@consiliency/agent-board-schema 1.50.0 → 1.52.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 +4 -0
- package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +10 -2
- package/tests/agent_board_agent_liveness.test.sql +3 -1
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +33 -36
- package/tests/agent_board_authbound_boundary.test.sql +4 -3
- package/tests/agent_board_board_claim_policy.test.sql +17 -6
- package/tests/agent_board_claim_subject_gate.test.sql +7 -8
- package/tests/agent_board_claimkey.test.sql +9 -6
- package/tests/agent_board_claimpolicy_race.test.sql +1466 -342
- package/tests/agent_board_consent_emission.test.sql +5 -4
- package/tests/agent_board_consent_gating.test.sql +20 -9
- package/tests/agent_board_enroll_resumption.test.sql +1 -1
- package/tests/agent_board_enrollment_audit_content.test.sql +5 -4
- package/tests/agent_board_federated_identity_injective_slug.test.sql +3 -1
- package/tests/agent_board_federated_lock_order_class.test.sql +69 -52
- package/tests/agent_board_fedsession.test.sql +3 -1
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -4
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +18 -6
- package/tests/agent_board_fk_lock_edges.test.sql +84 -5
- package/tests/agent_board_fleet_domain_reclaim.test.sql +2 -4
- package/tests/agent_board_fleet_machine_hijack.test.sql +2 -4
- package/tests/agent_board_fleet_machine_reactivation.test.sql +5 -1
- package/tests/agent_board_fleet_machine_reclaim.test.sql +2 -4
- package/tests/agent_board_invite_no_downgrade.test.sql +24 -18
- package/tests/agent_board_ledger_authority.test.sql +29 -21
- package/tests/agent_board_mint_harden.test.sql +5 -5
- package/tests/agent_board_outbox_wake_ordering.test.sql +4 -3
- package/tests/agent_board_ownerless_renew_quota.test.sql +7 -2
- package/tests/agent_board_portal_offline_invariant.test.sql +5 -4
- package/tests/agent_board_receipt_subject_gate.test.sql +7 -8
- package/tests/agent_board_rolereset.test.sql +5 -3
- package/tests/agent_board_rowid.test.sql +34 -15
- package/tests/agent_board_signer_lifecycle.test.sql +24 -13
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
- package/tests/agent_board_source_strip_helper.test.sql +2 -1
- package/tests/agent_board_task_event_origin_posture.test.sql +21 -9
- package/tests/agent_board_wake_recovery.test.sql +53 -26
- package/tests/agent_board_write_enforcement_class.test.sql +130 -12
- package/tests/catalog/function_execute_privileges.json +1923 -0
- package/tests/fixtures/test-prereqs.sql +644 -237
- package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
- package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
|
@@ -596,6 +596,10 @@
|
|
|
596
596
|
{
|
|
597
597
|
"id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
|
|
598
598
|
"sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
"id": "20261002000100_agent_board_session_challenge_supersede",
|
|
602
|
+
"sha256": "f10d8f539538fcf63d5b3538650160e7a4f9e345ccb7476b21756675f2b6ab80"
|
|
599
603
|
}
|
|
600
604
|
]
|
|
601
605
|
}
|
|
@@ -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.52.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.52.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,
|
|
@@ -1389,6 +1389,15 @@
|
|
|
1389
1389
|
"sourceRef": "message-board",
|
|
1390
1390
|
"bytes": 10193,
|
|
1391
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"
|
|
1392
1401
|
}
|
|
1393
1402
|
],
|
|
1394
1403
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 =
|
|
41
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 150)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(150);
|
|
44
44
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
45
45
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
46
46
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -327,6 +327,14 @@ describe('manifest integrity', () => {
|
|
|
327
327
|
// wait would not advance the snapshot and the lock would be silently inert.
|
|
328
328
|
'20260930000100_agent_board_claim_policy_race.sql',
|
|
329
329
|
'20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql',
|
|
330
|
+
// F1: the per-key pending-challenge cap SUPERSEDES the oldest pending challenge
|
|
331
|
+
// instead of raising challenge_pending_cap. Refusing at the cap turned a resource
|
|
332
|
+
// bound into a lockout lever on the unauthenticated /session surface -- sixteen
|
|
333
|
+
// requests naming a directory-visible handle and key id denied that key every
|
|
334
|
+
// further challenge, and the client stopped its refresh loop on the 403. The bound
|
|
335
|
+
// is unchanged; mint_fleet_agent_session now names an eviction challenge_superseded
|
|
336
|
+
// so it is retryable without making nonce_replay retryable with it.
|
|
337
|
+
'20261002000100_agent_board_session_challenge_supersede.sql',
|
|
330
338
|
]) {
|
|
331
339
|
expect(names.has(f)).toBe(true);
|
|
332
340
|
}
|
|
@@ -263,7 +263,9 @@ select is(
|
|
|
263
263
|
(select string_agg(c.relname, ', ' order by c.relname)
|
|
264
264
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
265
265
|
where n.nspname = 'agent_board' and c.relkind = 'v'
|
|
266
|
-
|
|
266
|
+
-- literals KEPT on purpose: v_ops_table_growth names presence as a row-count subject
|
|
267
|
+
-- inside a string literal, and it is in the expected set below
|
|
268
|
+
and agent_board_test.stripped_viewdef(c.oid, true) ~ agent_board_test.qname_re('presence')),
|
|
267
269
|
'v_agent_dashboard, v_agent_liveness, v_ops_presence_heartbeats, v_ops_table_growth',
|
|
268
270
|
'AL-11 CLASS: exactly these four agent_board views read presence — a new one fails here and names itself, so its join direction gets stated instead of assumed');
|
|
269
271
|
|