@consiliency/agent-board-schema 1.48.0 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +16 -0
- package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
- package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
- package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
- package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
- package/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/tooling.test.mjs +33 -2
- package/tests/agent_board_board_claim_policy.test.sql +261 -1
- package/tests/agent_board_claim_subject_gate.test.sql +11 -2
- package/tests/agent_board_claimkey.test.sql +17 -2
- package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
- package/tests/agent_board_fk_lock_edges.test.sql +20 -0
- package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
- package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
- package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
- package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
- package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
- package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
- package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
- package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
-- Consiliency/message-board#405 — the board policy lock helper is granted to NOBODY.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS CLOSES, measured rather than argued. 20260930000100 revoked
|
|
4
|
+
-- `_claimgate_lock_board_policy` from public/anon/authenticated and GRANTED it to `service_role`,
|
|
5
|
+
-- on the reasoning (stated in its own comment) that the serialisation "cannot be reached from a
|
|
6
|
+
-- session". `service_role` IS a session role. With that grant in place a single service_role
|
|
7
|
+
-- transaction assembles the exact inversion claimpolicy_claimrow_lock_order.spec constructs by
|
|
8
|
+
-- hand:
|
|
9
|
+
--
|
|
10
|
+
-- BEGIN; select agent_board.renew_claim(<token>, …); -- claims ROW LOCK, no board lock
|
|
11
|
+
-- select agent_board._claimgate_lock_board_policy(<board>); -- board lock, AFTER it
|
|
12
|
+
--
|
|
13
|
+
-- and a concurrent `set_board_claim_policy` on that board (board lock, then #400's cap-reset
|
|
14
|
+
-- UPDATE onto the row session 1 holds) closes the cycle. Reproduced against this bundle as
|
|
15
|
+
-- `ERROR: deadlock detected` before this migration existed.
|
|
16
|
+
--
|
|
17
|
+
-- WHY renew_claim STILL TAKES THE ROW LOCK FOR service_role, which is the part that makes the
|
|
18
|
+
-- composition work: its `update agent_board.claims` sits OUTSIDE the ownerless block, so
|
|
19
|
+
-- `_is_service_role()` short-circuits the BOARD lock while the ROW lock is taken regardless. The
|
|
20
|
+
-- caller therefore holds a claims row lock and no board lock — precisely the state the inversion
|
|
21
|
+
-- needs — and only needs one more granted call to want the board lock.
|
|
22
|
+
--
|
|
23
|
+
-- WHY REVOKING IS SAFE, derived from the catalog rather than assumed. The helper is NOT
|
|
24
|
+
-- `SECURITY DEFINER`; its three callers (`acquire_claim`, `renew_claim`, `set_board_claim_policy`)
|
|
25
|
+
-- ARE, and are owned by the same role that owns the helper. A SECURITY DEFINER function executes
|
|
26
|
+
-- as its owner, and an owner needs no grant on its own function, so the definer path is unaffected
|
|
27
|
+
-- by any grant at all. CPR-22 asserts the empty ACL; CPR-22a asserts the definer path still
|
|
28
|
+
-- reaches the lock AND that the advisory lock is genuinely held afterwards, so the negative cannot
|
|
29
|
+
-- pass by the enforcement having been taken offline.
|
|
30
|
+
--
|
|
31
|
+
-- NOTHING CALLS IT DIRECTLY. Derived from the tree on this branch, not from a live database: the
|
|
32
|
+
-- only references to `_claimgate_lock_board_policy` outside its defining migration are in this
|
|
33
|
+
-- package's pgTAP file, the isolation-spec COMMENTS, and the p_metadata catalog snapshot. No
|
|
34
|
+
-- TypeScript package (client/cli/mcp/runtime) and no Edge Function names it, in any casing.
|
|
35
|
+
--
|
|
36
|
+
-- WHY A NEW MIGRATION RATHER THAN EDITING 20260930000100's GRANT LINE. That migration is this
|
|
37
|
+
-- PR's own and still unmerged, so amending it would be tidier history — but this repository cannot
|
|
38
|
+
-- prove that no database has already applied it (the canary project tracks this branch), and a
|
|
39
|
+
-- bundle is replayed by digest rather than re-derived. A forward revoke is correct whether or not
|
|
40
|
+
-- the grant was ever applied, and `revoke` on an absent grant is a no-op. Replay-safe either way.
|
|
41
|
+
--
|
|
42
|
+
-- NOT `DROP FUNCTION`: that would discard the ACL *and* the COMMENT and re-grant EXECUTE to PUBLIC
|
|
43
|
+
-- by default (AGENTS.md, "Schema-change mechanics that bite"). The function body is unchanged here;
|
|
44
|
+
-- only its ACL and its comment move.
|
|
45
|
+
|
|
46
|
+
-- THE REVOKE IS DERIVED, ACROSS EVERY OVERLOAD, AND CASCADES. Three separate holes closed here,
|
|
47
|
+
-- each of which would leave this migration's own comment asserting something false:
|
|
48
|
+
--
|
|
49
|
+
-- 1. NAMED ROLES ARE A HAND LIST. `revoke ... from public, anon, authenticated, service_role`
|
|
50
|
+
-- leaves a grant to any fifth role standing. That is not hypothetical bookkeeping — this bundle
|
|
51
|
+
-- is REPLAYED ONTO ADOPTER DATABASES we do not control, where an operator may have granted this
|
|
52
|
+
-- helper to a role we have never heard of.
|
|
53
|
+
--
|
|
54
|
+
-- 2. ONE SIGNATURE IS A HAND LIST TOO. Keying the sweep to `(uuid)` lets a `(text)` overload carry
|
|
55
|
+
-- a grant straight through it. The property wanted is about the NAME: nothing in agent_board
|
|
56
|
+
-- called _claimgate_lock_board_policy should be executable by a non-owner, whatever its
|
|
57
|
+
-- argument types. Every overload is swept.
|
|
58
|
+
--
|
|
59
|
+
-- 3. `REVOKE` DEFAULTS TO `RESTRICT`, AND RESTRICT FAILS ON A DEPENDENT GRANT. If a grantee holds
|
|
60
|
+
-- EXECUTE **WITH GRANT OPTION** and has granted it onward, a plain REVOKE ERRORS ("dependent
|
|
61
|
+
-- privileges exist") — and on an adopter database that is a FAILED MIGRATION, not a tidier ACL,
|
|
62
|
+
-- with the empty ACL not achieved either. `CASCADE` is chosen deliberately over enumerating
|
|
63
|
+
-- dependencies: the whole claim of this migration is that NOTHING non-owner holds EXECUTE, so a
|
|
64
|
+
-- downstream grant is exactly as unwanted as the grant it hangs from, and handling dependencies
|
|
65
|
+
-- individually would only reach the same end state by a longer route. Stated rather than
|
|
66
|
+
-- slipped in, because CASCADE silently removes grants a third party made: an operator who
|
|
67
|
+
-- deliberately delegated this helper onward will find that delegation gone after upgrading.
|
|
68
|
+
-- That is the intended outcome — the helper is not meant to be reachable from any session — but
|
|
69
|
+
-- it is a real effect on someone else's database and belongs in the record.
|
|
70
|
+
--
|
|
71
|
+
-- 4. `ON FUNCTION` IS THE WRONG VERB FOR A ROUTINE, AND RAISES. The scan above selects by NAME,
|
|
72
|
+
-- which admits any pg_proc row — a legal same-name PROCEDURE or AGGREGATE included. `REVOKE …
|
|
73
|
+
-- ON FUNCTION` against a procedure fails with `is not a function`, and it would fail at the
|
|
74
|
+
-- MATERIALISATION statement, before the sweep runs at all: a second adopter-upgrade abort from
|
|
75
|
+
-- the same source as the RESTRICT one, a query written for functions applied to routines.
|
|
76
|
+
-- Measured on this bundle: `on function` against a procedure errors, `on routine` succeeds
|
|
77
|
+
-- against a procedure AND an aggregate. So both statements say ROUTINE and there is no prokind
|
|
78
|
+
-- filter — operating on routines consistently is the choice, rather than narrowing the scan to
|
|
79
|
+
-- prokind='f' and leaving a same-name procedure's grant standing. Window functions ('w') cannot
|
|
80
|
+
-- be created without C-level support and are untested here; ROUTINE is the widest verb available
|
|
81
|
+
-- and no narrower verb would cover more.
|
|
82
|
+
--
|
|
83
|
+
-- The OWNER is excluded throughout: EXECUTE is implicit in ownership, cannot be revoked, and is
|
|
84
|
+
-- exactly the path the three SECURITY DEFINER callers use.
|
|
85
|
+
--
|
|
86
|
+
-- A missing function RAISES rather than silently sweeping nothing: this migration exists to
|
|
87
|
+
-- establish an ACL, and doing that to a function that is not there is a failure, not a success.
|
|
88
|
+
-- Likewise an unresolvable grantee OID. Both differ from CPR-22, which RENDERS those cases instead
|
|
89
|
+
-- — a raise inside a pgTAP assertion aborts the file and emits no `not ok`, whereas here failing
|
|
90
|
+
-- closed is right, because skipping silently would leave a live grant standing.
|
|
91
|
+
do $$
|
|
92
|
+
declare
|
|
93
|
+
v_overloads int := 0;
|
|
94
|
+
v_who text;
|
|
95
|
+
f record;
|
|
96
|
+
r record;
|
|
97
|
+
begin
|
|
98
|
+
for f in
|
|
99
|
+
select p.oid, p.oid::regprocedure::text as sig
|
|
100
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
101
|
+
where n.nspname = 'agent_board'
|
|
102
|
+
and p.proname = '_claimgate_lock_board_policy'
|
|
103
|
+
loop
|
|
104
|
+
v_overloads := v_overloads + 1;
|
|
105
|
+
|
|
106
|
+
-- MATERIALISE THE ACL FIRST. While `proacl` is NULL the function carries DEFAULT privileges,
|
|
107
|
+
-- and the default for a function is EXECUTE to PUBLIC — but aclexplode(NULL) yields no rows,
|
|
108
|
+
-- so the sweep below would find nothing to revoke on a database where no grant was ever
|
|
109
|
+
-- written explicitly. This statement both removes that implicit PUBLIC grant and populates
|
|
110
|
+
-- proacl so the sweep can see the rest.
|
|
111
|
+
execute format('revoke all on routine %s from public cascade', f.sig);
|
|
112
|
+
|
|
113
|
+
for r in
|
|
114
|
+
select distinct a.grantee
|
|
115
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
116
|
+
where p.oid = f.oid
|
|
117
|
+
and a.privilege_type = 'EXECUTE'
|
|
118
|
+
and a.grantee <> p.proowner
|
|
119
|
+
loop
|
|
120
|
+
if r.grantee = 0 then
|
|
121
|
+
v_who := 'public';
|
|
122
|
+
else
|
|
123
|
+
select quote_ident(rolname) into v_who from pg_roles where oid = r.grantee;
|
|
124
|
+
end if;
|
|
125
|
+
if v_who is null then
|
|
126
|
+
raise exception
|
|
127
|
+
'EXECUTE grantee % on % has no pg_roles row — cannot revoke it, and reporting an empty ACL would be false',
|
|
128
|
+
r.grantee, f.sig
|
|
129
|
+
using errcode = '42704';
|
|
130
|
+
end if;
|
|
131
|
+
execute format('revoke all on routine %s from %s cascade', f.sig, v_who);
|
|
132
|
+
end loop;
|
|
133
|
+
end loop;
|
|
134
|
+
|
|
135
|
+
if v_overloads = 0 then
|
|
136
|
+
raise exception
|
|
137
|
+
'no function named agent_board._claimgate_lock_board_policy — refusing to report an empty ACL for a function that does not exist'
|
|
138
|
+
using errcode = '42883';
|
|
139
|
+
end if;
|
|
140
|
+
end $$;
|
|
141
|
+
|
|
142
|
+
-- The comment 20260930000100 attached asserts the service_role grant this migration removes, and
|
|
143
|
+
-- cites CPR-14 for the ACL — CPR-14 is the claims row-lock class assertion; CPR-22 is the ACL one.
|
|
144
|
+
-- Both corrected here, in the same migration that makes the first sentence true.
|
|
145
|
+
comment on function agent_board._claimgate_lock_board_policy(uuid) is
|
|
146
|
+
'#405: the board-scoped serialisation point for claim policy. Takes a pg_advisory_xact_lock on '
|
|
147
|
+
'hashtextextended(''agent_board.board_claim_policy:''||board_id) so set_board_claim_policy''s '
|
|
148
|
+
'flip and every ownerless enforcement decision on that board are totally ordered, and RAISES '
|
|
149
|
+
'0A000 unless transaction_isolation is ''read committed'' — under repeatable read or '
|
|
150
|
+
'serializable the wait does not advance the caller''s snapshot, so the lock would be silently '
|
|
151
|
+
'ineffective; under read uncommitted the lock still works (PostgreSQL runs it as read '
|
|
152
|
+
'committed) and the refusal is a deliberate one-accepted-spelling contract rather than a '
|
|
153
|
+
'correctness requirement. A null board is isolation-checked and takes no lock. '
|
|
154
|
+
'EXECUTE is granted to NO role (#405 follow-up): the helper is not SECURITY DEFINER, its three '
|
|
155
|
+
'callers are and share its owner, so they reach it by ownership. The grant that existed briefly '
|
|
156
|
+
'let a service_role session compose renew_claim''s claims row lock with a direct call to this '
|
|
157
|
+
'helper and deadlock against the policy writer. CPR-22 pins the empty ACL; CPR-22a pins that '
|
|
158
|
+
'the definer path still works.';
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
-- F1 (code-review-2026-09-01): the per-key pending-challenge cap was a lockout lever.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT. `/session` step "challenge" is verify_jwt=false: it needs only an agent
|
|
4
|
+
-- handle and a key id, both directory-visible, and each request inserts a challenge row
|
|
5
|
+
-- through service_role. `issue_signing_key_challenge` capped pending challenges at 16
|
|
6
|
+
-- per KEY and RAISED `challenge_pending_cap` at the cap. So any caller who could reach
|
|
7
|
+
-- the endpoint could hold a victim's sixteen slots with one request every five minutes;
|
|
8
|
+
-- every legitimate challenge for that key then failed. The client turned the 403 into a
|
|
9
|
+
-- non-retryable SessionError and STOPPED its background refresh loop, so the outage
|
|
10
|
+
-- outlived the flood and needed a restart to clear. The same shape occurred by accident
|
|
11
|
+
-- after sixteen crashed mints inside one five-minute challenge lifetime.
|
|
12
|
+
--
|
|
13
|
+
-- THE CHANGE. Forward-only `create or replace` of two functions, both reproducing their
|
|
14
|
+
-- current body verbatim with one edit each:
|
|
15
|
+
--
|
|
16
|
+
-- 1. issue_signing_key_challenge — at the cap, SUPERSEDE the oldest pending challenge
|
|
17
|
+
-- and issue, instead of refusing. The resource bound is unchanged (at most 16
|
|
18
|
+
-- pending rows per key, still); what changes is that arrival is never refused.
|
|
19
|
+
-- 2. mint_fleet_agent_session — report a superseded challenge as `challenge_superseded`
|
|
20
|
+
-- rather than `nonce_replay`, so an eviction is retryable without teaching a client
|
|
21
|
+
-- to retry through a genuine replay.
|
|
22
|
+
--
|
|
23
|
+
-- WHY SUPERSEDE RATHER THAN NARROW THE CAP. The cap's purpose is to bound rows per key;
|
|
24
|
+
-- refusing was how it was spelled, not what it was for. Making the cap per-source instead
|
|
25
|
+
-- does NOT close this: sixteen requests over five minutes fit under any per-source limit
|
|
26
|
+
-- a real fleet can use, and a caller with several sources is not bounded per key at all.
|
|
27
|
+
-- The per-source rate cap added to the handler in this same change bounds the COST of
|
|
28
|
+
-- driving the queue; superseding is what removes the denial.
|
|
29
|
+
--
|
|
30
|
+
-- WHAT IS CLOSED, AND THE RESIDUAL THAT IS NOT. What this closes is the HOLDABLE lockout:
|
|
31
|
+
-- fill-and-walk-away no longer denies anything, and an outage can no longer outlive the
|
|
32
|
+
-- attack. What it does NOT close is eviction under a SUSTAINED flood. A challenge is the
|
|
33
|
+
-- NEWEST of its key when issued, so sixteen later issues rotate it out and its mint then
|
|
34
|
+
-- raises challenge_superseded. Per source that is bounded by the `session:` rate cap;
|
|
35
|
+
-- IN AGGREGATE IT IS UNBOUNDED, because a per-key aggregate bound is precisely the
|
|
36
|
+
-- holdable lever this migration removes -- the two cannot both exist. N sources rotate the
|
|
37
|
+
-- queue in 1/N of the per-source time, so a minter loses its challenge once its
|
|
38
|
+
-- issue->mint gap exceeds ~32/N seconds at the default 30/min.
|
|
39
|
+
--
|
|
40
|
+
-- That residual is DoS-class, not stored state: it costs the attacker continuous traffic and
|
|
41
|
+
-- stops when the traffic stops. NOTE WHAT IS NOT CLAIMED: "the victim's recovery is one
|
|
42
|
+
-- retry" is true of a ONE-SHOT evictor (driven end-to-end by F1-F in
|
|
43
|
+
-- session.integration.test.ts) and NOT of a sustained flood, where the retry re-enters on
|
|
44
|
+
-- the same odds and the retry count is geometric in the ratio of the client's issue->mint
|
|
45
|
+
-- window W to the flood's rotation period. W is asserted NOWHERE here and is dominated by
|
|
46
|
+
-- the signer -- an OS-keychain prompt makes it seconds, at which point a handful of sources
|
|
47
|
+
-- suffices. An earlier version of this header stated the one-retry recovery flatly.
|
|
48
|
+
-- It is pinned by assertions 19-20 of
|
|
49
|
+
-- agent_board_signing_challenge_pending_cap.test.sql rather than left as prose, because a
|
|
50
|
+
-- sentence is not re-checked (rule 5). ACCEPTED FOR THIS RELEASE AND TRACKED BY
|
|
51
|
+
-- message-board#423, which owns whether an eventual-progress mechanism is wanted; this
|
|
52
|
+
-- migration deliberately does not attempt one. An earlier version of this header called the race
|
|
53
|
+
-- "rate-bounded in attempts": true per source, FALSE in aggregate. Deleted rather than
|
|
54
|
+
-- softened -- it was the sentence that was wrong.
|
|
55
|
+
--
|
|
56
|
+
-- `create or replace` preserves both functions' ACLs; both are restated below anyway,
|
|
57
|
+
-- with their COMMENTs, because a SECURITY DEFINER function grants EXECUTE to PUBLIC by
|
|
58
|
+
-- default and this file must be self-contained if either is ever dropped and re-run.
|
|
59
|
+
|
|
60
|
+
create or replace function agent_board.issue_signing_key_challenge(
|
|
61
|
+
p_agent_id uuid,
|
|
62
|
+
p_key_id text,
|
|
63
|
+
p_expires_in interval default interval '5 minutes'
|
|
64
|
+
)
|
|
65
|
+
returns table (
|
|
66
|
+
challenge_id uuid,
|
|
67
|
+
agent_id uuid,
|
|
68
|
+
key_id text,
|
|
69
|
+
key_version integer,
|
|
70
|
+
fingerprint text,
|
|
71
|
+
challenge text,
|
|
72
|
+
expires_at timestamptz
|
|
73
|
+
)
|
|
74
|
+
language plpgsql
|
|
75
|
+
security definer
|
|
76
|
+
set search_path = agent_board, public
|
|
77
|
+
as $$
|
|
78
|
+
declare
|
|
79
|
+
v_actor uuid;
|
|
80
|
+
v_key agent_board.agent_signing_keys%rowtype;
|
|
81
|
+
v_challenge_id uuid := gen_random_uuid();
|
|
82
|
+
v_nonce text := encode(extensions.gen_random_bytes(24), 'base64');
|
|
83
|
+
v_expires_at timestamptz;
|
|
84
|
+
v_pending integer;
|
|
85
|
+
v_evicted uuid;
|
|
86
|
+
-- Frozen pending-challenge cap per signing key (IF-0-MBHARDEN-1a). N=16.
|
|
87
|
+
c_pending_cap constant integer := 16;
|
|
88
|
+
begin
|
|
89
|
+
if p_expires_in is null or p_expires_in <= interval '0 seconds' or p_expires_in > interval '15 minutes' then
|
|
90
|
+
raise exception 'challenge expiry must be between 0 and 15 minutes' using errcode = '22023';
|
|
91
|
+
end if;
|
|
92
|
+
|
|
93
|
+
v_actor := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
94
|
+
|
|
95
|
+
select *
|
|
96
|
+
into v_key
|
|
97
|
+
from agent_board.agent_signing_keys ask
|
|
98
|
+
where ask.agent_id = v_actor
|
|
99
|
+
and ask.key_id = p_key_id
|
|
100
|
+
for update;
|
|
101
|
+
|
|
102
|
+
if not found then
|
|
103
|
+
raise exception 'signing key not found' using errcode = 'P0002';
|
|
104
|
+
end if;
|
|
105
|
+
if v_key.status in ('revoked', 'compromised') then
|
|
106
|
+
raise exception 'key_revoked: signing key is revoked' using errcode = '22023';
|
|
107
|
+
end if;
|
|
108
|
+
if v_key.expires_at is not null and v_key.expires_at <= now() then
|
|
109
|
+
update agent_board.agent_signing_keys
|
|
110
|
+
set status = 'expired',
|
|
111
|
+
proof_state = 'expired',
|
|
112
|
+
updated_at = now()
|
|
113
|
+
where id = v_key.id;
|
|
114
|
+
raise exception 'key_expired: signing key is expired' using errcode = '22023';
|
|
115
|
+
end if;
|
|
116
|
+
|
|
117
|
+
-- Pending-challenge cap: at most N=16 unexpired, unconsumed, still-pending
|
|
118
|
+
-- challenges per key. Serialized by the `for update` above, so the count is
|
|
119
|
+
-- race-free and the eviction below cannot double-fire.
|
|
120
|
+
--
|
|
121
|
+
-- AT THE CAP THIS SUPERSEDES, IT DOES NOT REFUSE (review finding F1). The
|
|
122
|
+
-- previous behaviour raised challenge_pending_cap, which made a per-key RESOURCE
|
|
123
|
+
-- BOUND into a per-key LOCKOUT LEVER: /session step "challenge" is
|
|
124
|
+
-- verify_jwt=false and needs only a directory-visible handle and key id, so any
|
|
125
|
+
-- caller could hold all sixteen slots with one request every five minutes and the
|
|
126
|
+
-- key's legitimate owner could never obtain a challenge again. The client turned
|
|
127
|
+
-- the resulting 403 into a non-retryable error and stopped its refresh loop, so
|
|
128
|
+
-- the outage outlived the flood.
|
|
129
|
+
--
|
|
130
|
+
-- WHAT SUPERSEDING COSTS. It creates an eviction the refusal did not have: sixteen
|
|
131
|
+
-- later issues rotate any pending challenge out, so a sustained flood evicts a slow
|
|
132
|
+
-- minter for as long as it runs. Bounded per source by the `session:` rate cap,
|
|
133
|
+
-- UNBOUNDED in aggregate -- an aggregate per-key bound is the holdable lever this
|
|
134
|
+
-- removes. DoS-class rather than stored state: it stops when the traffic stops, and
|
|
135
|
+
-- the consequence is one retry rather than a stopped client. Pinned by assertions
|
|
136
|
+
-- 19-20 of agent_board_signing_challenge_pending_cap.test.sql.
|
|
137
|
+
--
|
|
138
|
+
-- The eviction is deliberately made LOUD instead of
|
|
139
|
+
-- cheap: the row is marked (below) and mint_fleet_agent_session reports
|
|
140
|
+
-- challenge_superseded, distinct from the nonce_replay a genuine replay raises, so
|
|
141
|
+
-- the client can retry the one without learning to ignore the other.
|
|
142
|
+
--
|
|
143
|
+
-- WHY OLDEST. The oldest pending challenge is the one most likely to be abandoned,
|
|
144
|
+
-- and taking it puts a freshly-issued challenge LAST in the eviction order — a
|
|
145
|
+
-- challenge is minted within milliseconds of issue, so the legitimate flow is the
|
|
146
|
+
-- one this ordering protects. `order by created_at` with `id` as the tiebreak keeps
|
|
147
|
+
-- the choice deterministic when two rows share a timestamp.
|
|
148
|
+
--
|
|
149
|
+
-- WHY `SKIP LOCKED`, WHICH IS NOT OPTIONAL. This function holds a FOR UPDATE on the
|
|
150
|
+
-- agent_signing_keys row and then writes an agent_signing_key_challenges row;
|
|
151
|
+
-- mint_fleet_agent_session takes those two in the OPPOSITE order (challenge row FOR
|
|
152
|
+
-- UPDATE at 20260718000600:73-76, then key row FOR UPDATE at :95-100). A plain
|
|
153
|
+
-- eviction would therefore deadlock against a concurrent mint of the very row being
|
|
154
|
+
-- evicted — an ABBA this change would have INTRODUCED, and exactly the traffic it
|
|
155
|
+
-- makes common: floods driving evictions while legitimate agents mint. SKIP LOCKED
|
|
156
|
+
-- removes the cycle by never waiting: a challenge row a mint already holds is passed
|
|
157
|
+
-- over for the next-oldest. It also happens to be the kinder choice, since the row a
|
|
158
|
+
-- mint is holding is the one row we most want not to evict. The pair is pinned as a
|
|
159
|
+
-- class by agent_board_fk_lock_edges.test.sql FKL-10, which named this inversion when
|
|
160
|
+
-- the first version of this migration omitted the clause.
|
|
161
|
+
select count(*)
|
|
162
|
+
into v_pending
|
|
163
|
+
from agent_board.agent_signing_key_challenges c
|
|
164
|
+
where c.signing_key_id = v_key.id
|
|
165
|
+
and c.proof_state = 'pending'
|
|
166
|
+
and c.consumed_at is null
|
|
167
|
+
and c.expires_at > now();
|
|
168
|
+
|
|
169
|
+
if v_pending >= c_pending_cap then
|
|
170
|
+
update agent_board.agent_signing_key_challenges
|
|
171
|
+
set proof_state = 'expired',
|
|
172
|
+
consumed_at = now(),
|
|
173
|
+
updated_at = now(),
|
|
174
|
+
metadata = metadata || jsonb_build_object(
|
|
175
|
+
'superseded_at', now(),
|
|
176
|
+
'superseded_by', v_challenge_id
|
|
177
|
+
)
|
|
178
|
+
where id = (
|
|
179
|
+
select c.id
|
|
180
|
+
from agent_board.agent_signing_key_challenges c
|
|
181
|
+
where c.signing_key_id = v_key.id
|
|
182
|
+
and c.proof_state = 'pending'
|
|
183
|
+
and c.consumed_at is null
|
|
184
|
+
and c.expires_at > now()
|
|
185
|
+
order by c.created_at, c.id
|
|
186
|
+
for update skip locked
|
|
187
|
+
limit 1
|
|
188
|
+
)
|
|
189
|
+
returning id into v_evicted;
|
|
190
|
+
|
|
191
|
+
-- No evictable row. Under SKIP LOCKED this is reachable without the bound being
|
|
192
|
+
-- wrong: every one of the sixteen pending rows can be row-locked by a concurrent
|
|
193
|
+
-- mint of that same key. Issuing anyway would grow the pending set past the cap,
|
|
194
|
+
-- so refuse — the pre-change behaviour, now confined to a window in which sixteen
|
|
195
|
+
-- mints for one key are in flight at once, and retryable at the client rather than
|
|
196
|
+
-- terminal. ('expired'/consumed_at is the same closed-out shape the consumed-shape
|
|
197
|
+
-- CHECK requires, and reuses the existing proof_state vocabulary rather than adding
|
|
198
|
+
-- a value; the metadata marker is what distinguishes an eviction from a lapse.)
|
|
199
|
+
if v_evicted is null then
|
|
200
|
+
raise exception 'challenge_pending_cap: too many pending signing-key challenges for this key (max %)', c_pending_cap
|
|
201
|
+
using errcode = '22023';
|
|
202
|
+
end if;
|
|
203
|
+
end if;
|
|
204
|
+
|
|
205
|
+
v_expires_at := now() + p_expires_in;
|
|
206
|
+
|
|
207
|
+
insert into agent_board.agent_signing_key_challenges(
|
|
208
|
+
id,
|
|
209
|
+
signing_key_id,
|
|
210
|
+
agent_id,
|
|
211
|
+
challenge_nonce,
|
|
212
|
+
challenge_bytes,
|
|
213
|
+
expires_at,
|
|
214
|
+
metadata
|
|
215
|
+
)
|
|
216
|
+
values (
|
|
217
|
+
v_challenge_id,
|
|
218
|
+
v_key.id,
|
|
219
|
+
v_actor,
|
|
220
|
+
v_nonce,
|
|
221
|
+
'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
|
|
222
|
+
v_expires_at,
|
|
223
|
+
jsonb_build_object('keyId', v_key.key_id, 'keyVersion', v_key.key_version)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
update agent_board.agent_signing_keys
|
|
227
|
+
set proof_state = 'challenge_issued',
|
|
228
|
+
updated_at = now()
|
|
229
|
+
where id = v_key.id
|
|
230
|
+
and proof_state <> 'proven';
|
|
231
|
+
|
|
232
|
+
return query
|
|
233
|
+
select
|
|
234
|
+
v_challenge_id,
|
|
235
|
+
v_actor,
|
|
236
|
+
v_key.key_id,
|
|
237
|
+
v_key.key_version,
|
|
238
|
+
v_key.fingerprint,
|
|
239
|
+
'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
|
|
240
|
+
v_expires_at;
|
|
241
|
+
end;
|
|
242
|
+
$$;
|
|
243
|
+
comment on function agent_board.issue_signing_key_challenge(uuid, text, interval) is
|
|
244
|
+
'Issues a one-time PoP challenge for an agent signing key. Bounds the key to at most 16 concurrently-pending challenges; AT the bound it supersedes the OLDEST pending challenge (proof_state=''expired'', consumed_at set, metadata.superseded_by naming the new challenge) and issues, rather than refusing — refusing made the bound a lockout lever on the unauthenticated /session surface (F1). Serialized by the FOR UPDATE on the key row. authenticated + service_role.';
|
|
245
|
+
|
|
246
|
+
revoke all on function agent_board.issue_signing_key_challenge(uuid, text, interval) from public, anon;
|
|
247
|
+
grant execute on function agent_board.issue_signing_key_challenge(uuid, text, interval) to authenticated, service_role;
|
|
248
|
+
|
|
249
|
+
create or replace function agent_board.mint_fleet_agent_session(
|
|
250
|
+
p_challenge_id uuid,
|
|
251
|
+
p_nonce text,
|
|
252
|
+
p_signing_key_id uuid,
|
|
253
|
+
p_agent_id uuid,
|
|
254
|
+
p_max_ttl_seconds integer default 900
|
|
255
|
+
)
|
|
256
|
+
returns table(session_id text, expires_at timestamptz)
|
|
257
|
+
language plpgsql
|
|
258
|
+
security definer
|
|
259
|
+
set search_path = agent_board, public
|
|
260
|
+
as $$
|
|
261
|
+
declare
|
|
262
|
+
v_challenge agent_board.agent_signing_key_challenges%rowtype;
|
|
263
|
+
v_key agent_board.agent_signing_keys%rowtype;
|
|
264
|
+
v_auth_user_id uuid;
|
|
265
|
+
v_parent_agent_id uuid;
|
|
266
|
+
v_ttl integer := least(coalesce(p_max_ttl_seconds, 900), 900);
|
|
267
|
+
v_session_id text := 'fas_' || replace(gen_random_uuid()::text, '-', '');
|
|
268
|
+
v_expires_at timestamptz;
|
|
269
|
+
begin
|
|
270
|
+
if v_ttl <= 0 then
|
|
271
|
+
raise exception 'max_ttl_seconds must be positive' using errcode = '22023';
|
|
272
|
+
end if;
|
|
273
|
+
|
|
274
|
+
-- (i) challenge: exists, bound to this agent + signing key, unconsumed, unexpired.
|
|
275
|
+
select * into v_challenge
|
|
276
|
+
from agent_board.agent_signing_key_challenges c
|
|
277
|
+
where c.id = p_challenge_id
|
|
278
|
+
for update;
|
|
279
|
+
if not found then
|
|
280
|
+
raise exception 'signing key challenge not found' using errcode = 'P0002';
|
|
281
|
+
end if;
|
|
282
|
+
if v_challenge.agent_id <> p_agent_id or v_challenge.signing_key_id <> p_signing_key_id then
|
|
283
|
+
raise exception 'actor_mismatch: challenge is not bound to this agent and signing key' using errcode = '42501';
|
|
284
|
+
end if;
|
|
285
|
+
-- An EVICTED challenge is not a replayed one. issue_signing_key_challenge closes
|
|
286
|
+
-- the oldest pending row out ('expired' + consumed_at) when a new challenge arrives
|
|
287
|
+
-- at the per-key cap; without this branch that row would arrive here and be reported
|
|
288
|
+
-- as nonce_replay, which is a security signal the client must never learn to retry
|
|
289
|
+
-- through. Named separately so the client can retry exactly this one. Checked BEFORE
|
|
290
|
+
-- the generic branch because the evicted row satisfies it.
|
|
291
|
+
if v_challenge.metadata ? 'superseded_by' then
|
|
292
|
+
raise exception 'challenge_superseded: this challenge was superseded by a newer one for the same key; request a fresh challenge'
|
|
293
|
+
using errcode = '22023';
|
|
294
|
+
end if;
|
|
295
|
+
if v_challenge.proof_state <> 'pending' or v_challenge.consumed_at is not null then
|
|
296
|
+
raise exception 'nonce_replay: signing key challenge already consumed' using errcode = '23505';
|
|
297
|
+
end if;
|
|
298
|
+
if v_challenge.expires_at <= now() then
|
|
299
|
+
raise exception 'timestamp_out_of_window: signing key challenge expired' using errcode = '22023';
|
|
300
|
+
end if;
|
|
301
|
+
-- #10 nonce binding (belt-and-suspenders on top of the challenge_id binding): the
|
|
302
|
+
-- caller must present the exact stored challenge_nonce for this challenge row.
|
|
303
|
+
if p_nonce is null or p_nonce <> v_challenge.challenge_nonce then
|
|
304
|
+
raise exception 'nonce_mismatch: presented nonce does not match the bound challenge' using errcode = '42501';
|
|
305
|
+
end if;
|
|
306
|
+
|
|
307
|
+
-- (ii) signing key: bound to this agent, active, unrevoked.
|
|
308
|
+
select * into v_key
|
|
309
|
+
from agent_board.agent_signing_keys ask
|
|
310
|
+
where ask.id = p_signing_key_id
|
|
311
|
+
and ask.agent_id = p_agent_id
|
|
312
|
+
for update;
|
|
313
|
+
if not found then
|
|
314
|
+
raise exception 'signing key not found for agent' using errcode = 'P0002';
|
|
315
|
+
end if;
|
|
316
|
+
if v_key.status <> 'active' or v_key.revoked_at is not null then
|
|
317
|
+
raise exception 'key_revoked: signing key is not active' using errcode = '42501';
|
|
318
|
+
end if;
|
|
319
|
+
-- #12 key expiry: today only the challenge RPC checks agent_signing_keys.expires_at;
|
|
320
|
+
-- the mint must reject an expired key as well.
|
|
321
|
+
if v_key.expires_at is not null and v_key.expires_at <= now() then
|
|
322
|
+
raise exception 'key_expired: signing key is past its expires_at' using errcode = '42501';
|
|
323
|
+
end if;
|
|
324
|
+
|
|
325
|
+
-- (iii) whole principal active (self + every ancestor up to lineage root).
|
|
326
|
+
if not agent_board._agent_principal_active(p_agent_id) then
|
|
327
|
+
raise exception 'principal_inactive: agent principal is not fully active' using errcode = '42501';
|
|
328
|
+
end if;
|
|
329
|
+
|
|
330
|
+
-- (iv) #4 governing delegation-grant liveness for root_minted children. A child
|
|
331
|
+
-- that holds a delegation_grants row may mint ONLY while that grant is unrevoked
|
|
332
|
+
-- and unexpired (grant_expires_at > now()). independent_auth principals hold no
|
|
333
|
+
-- grant row and skip this gate. This binds the hours-scale grant clock to the mint
|
|
334
|
+
-- so an expired/revoked grant cannot keep re-minting <=900s tokens indefinitely.
|
|
335
|
+
if exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_agent_id) then
|
|
336
|
+
if not exists (
|
|
337
|
+
select 1
|
|
338
|
+
from agent_board.delegation_grants g
|
|
339
|
+
where g.child_agent_id = p_agent_id
|
|
340
|
+
and g.revoked_at is null
|
|
341
|
+
and g.grant_expires_at > now()
|
|
342
|
+
) then
|
|
343
|
+
raise exception 'grant_expired: the governing delegation grant is revoked or expired' using errcode = '42501';
|
|
344
|
+
end if;
|
|
345
|
+
end if;
|
|
346
|
+
|
|
347
|
+
-- Scope to the agent's authenticated principal. A fleet_agent_session must bind to
|
|
348
|
+
-- an auth-bound agent (no auth_user_id column on nonfleet_sessions; this is a mint
|
|
349
|
+
-- invariant flowed to the EF token sub, not a stored value).
|
|
350
|
+
select a.auth_user_id, a.parent_agent_id
|
|
351
|
+
into v_auth_user_id, v_parent_agent_id
|
|
352
|
+
from agent_board.agents a
|
|
353
|
+
where a.id = p_agent_id;
|
|
354
|
+
if v_auth_user_id is null then
|
|
355
|
+
raise exception 'agent has no auth_user_id principal to scope the session' using errcode = '42501';
|
|
356
|
+
end if;
|
|
357
|
+
|
|
358
|
+
-- Consume the challenge (one-time).
|
|
359
|
+
update agent_board.agent_signing_key_challenges
|
|
360
|
+
set proof_state = 'consumed',
|
|
361
|
+
consumed_at = now(),
|
|
362
|
+
updated_at = now()
|
|
363
|
+
where id = p_challenge_id;
|
|
364
|
+
|
|
365
|
+
v_expires_at := now() + make_interval(secs => v_ttl);
|
|
366
|
+
|
|
367
|
+
insert into agent_board.nonfleet_sessions(
|
|
368
|
+
id, session_id, identity_class, agent_id, parent_agent_id,
|
|
369
|
+
max_ttl_seconds, issued_at, expires_at
|
|
370
|
+
)
|
|
371
|
+
values (
|
|
372
|
+
v_session_id, v_session_id, 'fleet_agent_session', p_agent_id, v_parent_agent_id,
|
|
373
|
+
v_ttl, now(), v_expires_at
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return query select v_session_id, v_expires_at;
|
|
377
|
+
end;
|
|
378
|
+
$$;
|
|
379
|
+
comment on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) is
|
|
380
|
+
'Whole-principal PoP session mint: re-validates challenge (incl. bound nonce) + active/unrevoked/unexpired signing key + full-lineage liveness + governing delegation-grant liveness, consumes the challenge, and inserts a <=900s fleet_agent_session. A challenge superseded by issue_signing_key_challenge raises challenge_superseded (retryable: request a fresh challenge), distinct from the nonce_replay a genuine replay raises. service_role-only.';
|
|
381
|
+
|
|
382
|
+
revoke all on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) from public, anon, authenticated;
|
|
383
|
+
grant execute on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) to service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.51.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 150,
|
|
50
|
+
"aggregateSha256": "94b8945ab506a02a31ffd0ed6daad37d5655f209214558ca868dbb22693fb57d",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1362,6 +1362,42 @@
|
|
|
1362
1362
|
"sourceRef": "message-board",
|
|
1363
1363
|
"bytes": 36921,
|
|
1364
1364
|
"sha256": "9982efff25378703a744187b15081b5cd53ab38fa6ac7d2950092a471780169e"
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
"order": 146,
|
|
1368
|
+
"id": "20260929000100_agent_board_advisory_revert_cap_reset",
|
|
1369
|
+
"filename": "20260929000100_agent_board_advisory_revert_cap_reset.sql",
|
|
1370
|
+
"origin": "message-board-forward",
|
|
1371
|
+
"sourceRef": "message-board",
|
|
1372
|
+
"bytes": 24052,
|
|
1373
|
+
"sha256": "f2bd4e9e06f5c348aba379a004a010d8dba29a84f66b1b4569313fad4a0046ba"
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
"order": 147,
|
|
1377
|
+
"id": "20260930000100_agent_board_claim_policy_race",
|
|
1378
|
+
"filename": "20260930000100_agent_board_claim_policy_race.sql",
|
|
1379
|
+
"origin": "message-board-forward",
|
|
1380
|
+
"sourceRef": "message-board",
|
|
1381
|
+
"bytes": 58999,
|
|
1382
|
+
"sha256": "1893b57ccb643a8293c0dc801d2d66d20fe1d71a2b7dc95cd93f64ced39b472d"
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
"order": 148,
|
|
1386
|
+
"id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
|
|
1387
|
+
"filename": "20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql",
|
|
1388
|
+
"origin": "message-board-forward",
|
|
1389
|
+
"sourceRef": "message-board",
|
|
1390
|
+
"bytes": 10193,
|
|
1391
|
+
"sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
"order": 149,
|
|
1395
|
+
"id": "20261002000100_agent_board_session_challenge_supersede",
|
|
1396
|
+
"filename": "20261002000100_agent_board_session_challenge_supersede.sql",
|
|
1397
|
+
"origin": "message-board-forward",
|
|
1398
|
+
"sourceRef": "message-board",
|
|
1399
|
+
"bytes": 19273,
|
|
1400
|
+
"sha256": "f10d8f539538fcf63d5b3538650160e7a4f9e345ccb7476b21756675f2b6ab80"
|
|
1365
1401
|
}
|
|
1366
1402
|
],
|
|
1367
1403
|
"excludedBridge": {
|