@consiliency/agent-board-schema 1.22.0 → 1.24.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/README.md +75 -1
- package/fixtures/already-applied.json +40 -0
- package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/check.js +23 -0
- package/src/index.js +20 -0
- package/src/plan.js +79 -5
- package/src/tooling.test.mjs +237 -4
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +46 -2
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
- package/tests/agent_board_inbox_reads.test.sql +16 -19
- package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
- package/tests/agent_board_roomlink.test.sql +2 -2
- package/tests/agent_board_wake_recovery.test.sql +756 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
-- Consiliency/message-board#194 -- enroll_federated_identity took the human owner
|
|
2
|
+
-- BEFORE the board, inverting the canonical order the other two federated write
|
|
3
|
+
-- routes already use, and then SWALLOWED the resulting serialization failure.
|
|
4
|
+
--
|
|
5
|
+
-- THE CYCLE (reproduced on a private database, real `deadlock detected` output):
|
|
6
|
+
-- enroll_federated_identity auth.users FOR UPDATE -> boards FOR KEY SHARE
|
|
7
|
+
-- (:504) (via board_memberships_board_id_fkey,
|
|
8
|
+
-- inside the invite redemption)
|
|
9
|
+
-- enroll_federated_human boards FOR UPDATE -> auth.users FOR UPDATE
|
|
10
|
+
-- onboard_federated_fleet_agent
|
|
11
|
+
-- boards FOR UPDATE -> auth.users FOR UPDATE
|
|
12
|
+
-- Two of three routes already took board -> owner, so board -> owner is canonical
|
|
13
|
+
-- and this migration moves the third onto it rather than moving the other two.
|
|
14
|
+
--
|
|
15
|
+
-- WHY NO TEXTUAL CHECK FOUND IT: enroll_federated_identity never names `boards`.
|
|
16
|
+
-- Its boards lock arrives through an FK on a table it inserts into via a callee.
|
|
17
|
+
-- The class assertion added with this migration
|
|
18
|
+
-- (agent_board_federated_lock_order_class.test.sql) is therefore derived from
|
|
19
|
+
-- pg_constraint FK edges plus the pg_proc call graph, not from prosrc text: it
|
|
20
|
+
-- enumerates every agent_board function that acquires BOTH a boards row lock and
|
|
21
|
+
-- an auth.users row lock -- through an explicit lock OR through an INSERT into a
|
|
22
|
+
-- direct FK child of either table -- and requires the board acquisition first.
|
|
23
|
+
-- Against the pre-fix schema it flags exactly the two functions this migration
|
|
24
|
+
-- changes, and names them.
|
|
25
|
+
--
|
|
26
|
+
-- THE MASKING, which is the more serious half. The redemption sub-block caught
|
|
27
|
+
-- `others` and set invite_redeemed := false. That catches 40P01/40001, so under
|
|
28
|
+
-- contention the RPC returned SUCCESS with no board membership and no error to
|
|
29
|
+
-- the client (measured: uncontended invite_redeemed=t + membership row; under the
|
|
30
|
+
-- cycle invite_redeemed=f + none). Class 40 is now re-raised at BOTH sites -- the
|
|
31
|
+
-- wrapper's replay branch and _enroll_federated_identity_once step (13) -- while
|
|
32
|
+
-- every deterministic failure (bad/expired/exhausted/unauthorized code) keeps the
|
|
33
|
+
-- released soft-honor behaviour.
|
|
34
|
+
--
|
|
35
|
+
-- Both functions are recreated with CREATE OR REPLACE (ACL and COMMENT survive);
|
|
36
|
+
-- the grants and comments are restated below regardless, per the repo rule.
|
|
37
|
+
-- The bodies are otherwise byte-identical to their released sources
|
|
38
|
+
-- (20260901000100 for the wrapper, 20260822000100 for the renamed core).
|
|
39
|
+
--
|
|
40
|
+
-- WHY THE HOISTED LOCK IS *FOR KEY SHARE*. It stands in for an acquisition the
|
|
41
|
+
-- redemption makes through board_memberships_board_id_fkey, and that acquisition IS
|
|
42
|
+
-- FOR KEY SHARE. Taking the same mode early establishes the identical ordering
|
|
43
|
+
-- point -- KEY SHARE conflicts with the FOR UPDATE that enroll_federated_human and
|
|
44
|
+
-- onboard_federated_fleet_agent take on a boards row, which is the conflict the
|
|
45
|
+
-- ordering needs -- without serializing two concurrent enrollments that merely share
|
|
46
|
+
-- a board. A FOR UPDATE here (the first cut) would have been strictly stronger than
|
|
47
|
+
-- the thing it replaces and would have blocked every concurrent membership write on
|
|
48
|
+
-- that board for the length of an enrollment: throughput, not correctness, but not
|
|
49
|
+
-- a cost this migration needs to impose.
|
|
50
|
+
--
|
|
51
|
+
-- TWO ADVISORY-LOCK EDGES THIS ROUTE NOW HAS, recorded so the next person to move a
|
|
52
|
+
-- lock does not have to rediscover them:
|
|
53
|
+
-- * pg_advisory_xact_lock(hashtextextended(<key fingerprint>, 0)) is taken by BOTH
|
|
54
|
+
-- enroll_federated_identity (below) and onboard_federated_fleet_agent
|
|
55
|
+
-- (20260901000110:332). After this migration enroll runs boards -> fp-advisory,
|
|
56
|
+
-- and onboard runs boards -> ... -> fp-advisory: SAME direction, so the pair
|
|
57
|
+
-- holds. It holds because onboard takes its boards lock first for its own
|
|
58
|
+
-- reasons, not because anything enforced it -- moving either acquisition across
|
|
59
|
+
-- the other reopens a cycle.
|
|
60
|
+
-- * This route does NOT take _lock_board_authority_ceiling. It never acquires that
|
|
61
|
+
-- advisory lock at all, so taking boards without it cannot form the
|
|
62
|
+
-- advisory<->boards cycle 20260901000110's header describes; only
|
|
63
|
+
-- onboard_federated_fleet_agent and set_board_authority_ceiling participate in
|
|
64
|
+
-- that lock (enumerated from pg_proc).
|
|
65
|
+
|
|
66
|
+
-- 1. the internal one-time creator (renamed core).
|
|
67
|
+
create or replace function agent_board._enroll_federated_identity_once(
|
|
68
|
+
p_issuer text,
|
|
69
|
+
p_audience text,
|
|
70
|
+
p_subject text,
|
|
71
|
+
p_human_identity_claim jsonb,
|
|
72
|
+
p_slug text,
|
|
73
|
+
p_agent_public_key text,
|
|
74
|
+
p_agent_public_key_format text,
|
|
75
|
+
p_agent_public_key_fingerprint text,
|
|
76
|
+
p_pop_verified boolean,
|
|
77
|
+
p_actor_sub text default null,
|
|
78
|
+
p_agent_kind text default 'agent',
|
|
79
|
+
p_requested_scopes text[] default '{}',
|
|
80
|
+
p_requested_board_scopes text[] default '{}',
|
|
81
|
+
p_invite_code text default null
|
|
82
|
+
)
|
|
83
|
+
returns table(
|
|
84
|
+
agent_id uuid,
|
|
85
|
+
auth_user_id uuid,
|
|
86
|
+
provider_id text,
|
|
87
|
+
signing_key_id uuid,
|
|
88
|
+
granted_scopes text[],
|
|
89
|
+
key_id text,
|
|
90
|
+
key_version int,
|
|
91
|
+
agent_handle text,
|
|
92
|
+
invite_redeemed boolean
|
|
93
|
+
)
|
|
94
|
+
language plpgsql
|
|
95
|
+
security definer
|
|
96
|
+
set search_path = agent_board, public
|
|
97
|
+
as $$
|
|
98
|
+
declare
|
|
99
|
+
v_slug text;
|
|
100
|
+
v_kind text;
|
|
101
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
102
|
+
v_owner_user_id uuid;
|
|
103
|
+
v_auth_user_id uuid;
|
|
104
|
+
v_agent_handle text;
|
|
105
|
+
v_agent_id uuid;
|
|
106
|
+
v_signing_key_id uuid;
|
|
107
|
+
v_fingerprint text;
|
|
108
|
+
v_key_hex text;
|
|
109
|
+
v_raw_key bytea;
|
|
110
|
+
v_reencoded text;
|
|
111
|
+
v_key_id text := 'portal-enroll';
|
|
112
|
+
v_key_version int := 1;
|
|
113
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
114
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
115
|
+
v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
|
|
116
|
+
v_invite_redeemed boolean := false;
|
|
117
|
+
v_invite_board_id uuid;
|
|
118
|
+
begin
|
|
119
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
120
|
+
if not agent_board._is_service_role() then
|
|
121
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
122
|
+
end if;
|
|
123
|
+
|
|
124
|
+
-- A verified subject + human identity claim are always present from the EF.
|
|
125
|
+
if p_subject is null then
|
|
126
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
127
|
+
end if;
|
|
128
|
+
if p_human_identity_claim is null then
|
|
129
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
130
|
+
end if;
|
|
131
|
+
|
|
132
|
+
-- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
|
|
133
|
+
-- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
|
|
134
|
+
if p_pop_verified is not true then
|
|
135
|
+
raise exception 'proof-of-possession was not verified; enrollment refused'
|
|
136
|
+
using errcode = '42501';
|
|
137
|
+
end if;
|
|
138
|
+
|
|
139
|
+
-- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
|
|
140
|
+
-- base64url chars).
|
|
141
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
142
|
+
raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
|
|
143
|
+
end if;
|
|
144
|
+
if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
145
|
+
raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
|
|
146
|
+
using errcode = '22023';
|
|
147
|
+
end if;
|
|
148
|
+
|
|
149
|
+
-- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
|
|
150
|
+
v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
|
|
151
|
+
if octet_length(v_raw_key) <> 32 then
|
|
152
|
+
raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
|
|
153
|
+
using errcode = '22023';
|
|
154
|
+
end if;
|
|
155
|
+
v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
|
|
156
|
+
if v_reencoded is distinct from p_agent_public_key then
|
|
157
|
+
raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
|
|
158
|
+
using errcode = '22023';
|
|
159
|
+
end if;
|
|
160
|
+
|
|
161
|
+
-- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
|
|
162
|
+
if v_raw_key = decode(repeat('00', 32), 'hex') then
|
|
163
|
+
raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
|
|
164
|
+
using errcode = '22023';
|
|
165
|
+
end if;
|
|
166
|
+
|
|
167
|
+
-- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
|
|
168
|
+
-- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
|
|
169
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
170
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
171
|
+
raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
|
|
172
|
+
using errcode = '22023';
|
|
173
|
+
end if;
|
|
174
|
+
|
|
175
|
+
-- (3) act.sub is audit-only, but when present it must be a real value.
|
|
176
|
+
if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
|
|
177
|
+
raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
|
|
178
|
+
end if;
|
|
179
|
+
|
|
180
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
181
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
182
|
+
|
|
183
|
+
-- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
|
|
184
|
+
select *
|
|
185
|
+
into v_provider
|
|
186
|
+
from agent_board.identity_providers
|
|
187
|
+
where issuer = p_issuer
|
|
188
|
+
and audience = p_audience
|
|
189
|
+
limit 1;
|
|
190
|
+
if v_provider.id is null then
|
|
191
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
192
|
+
end if;
|
|
193
|
+
if not v_provider.supported then
|
|
194
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
195
|
+
end if;
|
|
196
|
+
if not v_provider.policy_approved then
|
|
197
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
198
|
+
end if;
|
|
199
|
+
|
|
200
|
+
-- (5) scope-ceiling + kind gate. 'system' is never federatable.
|
|
201
|
+
if v_kind = 'system' then
|
|
202
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
203
|
+
end if;
|
|
204
|
+
if v_kind not in ('human', 'agent') then
|
|
205
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
206
|
+
end if;
|
|
207
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
208
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
209
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
210
|
+
end if;
|
|
211
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
212
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
213
|
+
end if;
|
|
214
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
215
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
216
|
+
end if;
|
|
217
|
+
|
|
218
|
+
-- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
|
|
219
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
|
|
220
|
+
raise exception 'this signing key is already enrolled (duplicate fingerprint)'
|
|
221
|
+
using errcode = '23505';
|
|
222
|
+
end if;
|
|
223
|
+
|
|
224
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
225
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
226
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
227
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
228
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
229
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
230
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
231
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
232
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
233
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
234
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
235
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
236
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
237
|
+
if v_invite_code is not null then
|
|
238
|
+
select bit.board_id into v_invite_board_id
|
|
239
|
+
from agent_board.board_invite_tokens bit
|
|
240
|
+
where bit.code = v_invite_code
|
|
241
|
+
for update;
|
|
242
|
+
if v_invite_board_id is not null then
|
|
243
|
+
perform 1 from agent_board.boards b
|
|
244
|
+
where b.id = v_invite_board_id
|
|
245
|
+
for key share;
|
|
246
|
+
end if;
|
|
247
|
+
end if;
|
|
248
|
+
|
|
249
|
+
-- (7) HUMAN OWNER — established idempotently per (issuer, sub).
|
|
250
|
+
v_owner_user_id := agent_board._ensure_agent_auth_user(
|
|
251
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
|
|
252
|
+
|
|
253
|
+
-- (8) AGENT handle — unique per enrolled key.
|
|
254
|
+
v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
|
|
255
|
+
v_agent_handle := v_slug;
|
|
256
|
+
if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
|
|
257
|
+
v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
|
|
258
|
+
end if;
|
|
259
|
+
|
|
260
|
+
-- (9) the agent's OWN board principal (session-usable via its enrolled key).
|
|
261
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
|
|
262
|
+
|
|
263
|
+
-- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
|
|
264
|
+
insert into agent_board.agents(
|
|
265
|
+
agent_type,
|
|
266
|
+
kind,
|
|
267
|
+
handle,
|
|
268
|
+
display_name,
|
|
269
|
+
status,
|
|
270
|
+
auth_user_id,
|
|
271
|
+
owner_user_id,
|
|
272
|
+
metadata
|
|
273
|
+
)
|
|
274
|
+
values (
|
|
275
|
+
v_kind,
|
|
276
|
+
v_kind::agent_board.agent_kind,
|
|
277
|
+
v_agent_handle,
|
|
278
|
+
agent_board._default_provision_display_name(v_agent_handle),
|
|
279
|
+
'active',
|
|
280
|
+
v_auth_user_id,
|
|
281
|
+
v_owner_user_id,
|
|
282
|
+
jsonb_build_object(
|
|
283
|
+
'federation', jsonb_build_object(
|
|
284
|
+
'issuer', p_issuer,
|
|
285
|
+
'audience', p_audience,
|
|
286
|
+
'subject', p_subject,
|
|
287
|
+
'actorSub', p_actor_sub,
|
|
288
|
+
'providerId', v_provider.id,
|
|
289
|
+
'signingKeyFingerprint', v_fingerprint,
|
|
290
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
291
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
292
|
+
'popVerified', true,
|
|
293
|
+
'enrolledAt', now()
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
returning id into v_agent_id;
|
|
298
|
+
|
|
299
|
+
-- (11) enroll the public key as active + proven (satisfies active_requires_proof).
|
|
300
|
+
insert into agent_board.agent_signing_keys(
|
|
301
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
302
|
+
status, proof_state, metadata
|
|
303
|
+
)
|
|
304
|
+
values (
|
|
305
|
+
gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
|
|
306
|
+
p_agent_public_key_format, v_fingerprint,
|
|
307
|
+
'active', 'proven',
|
|
308
|
+
jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
|
|
309
|
+
)
|
|
310
|
+
returning id into v_signing_key_id;
|
|
311
|
+
|
|
312
|
+
-- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
|
|
313
|
+
insert into agent_board.enrollment_federation_audit(
|
|
314
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
315
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
316
|
+
)
|
|
317
|
+
values (
|
|
318
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
319
|
+
v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
-- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
|
|
323
|
+
-- membership for the freshly-created agent. Wrapped in a subtransaction so a
|
|
324
|
+
-- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
|
|
325
|
+
-- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
|
|
326
|
+
if v_invite_code is not null then
|
|
327
|
+
begin
|
|
328
|
+
perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
|
|
329
|
+
v_invite_redeemed := true;
|
|
330
|
+
exception when others then
|
|
331
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
332
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
333
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
334
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
335
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
336
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
337
|
+
if left(sqlstate, 2) = '40' then
|
|
338
|
+
raise;
|
|
339
|
+
end if;
|
|
340
|
+
v_invite_redeemed := false;
|
|
341
|
+
end;
|
|
342
|
+
end if;
|
|
343
|
+
|
|
344
|
+
-- Server-authoritative identity: the client mints /session with the RETURNED
|
|
345
|
+
-- key_id/key_version/agent_handle (its private key stays in the keystore).
|
|
346
|
+
return query
|
|
347
|
+
select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
|
|
348
|
+
v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
|
|
349
|
+
end;
|
|
350
|
+
$$;
|
|
351
|
+
|
|
352
|
+
comment on function agent_board._enroll_federated_identity_once(
|
|
353
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
354
|
+
'Internal one-time federated-identity creator (the released 20260724000200/20260822000100 implementation, renamed by 20260901000100). Call only through enroll_federated_identity, which holds the invite/board and owner locks. Takes the invite row and its board BEFORE the owner is provisioned so the board -> owner order holds even on a direct service_role call (#194); the optional trailing p_invite_code is still soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false), EXCEPT for SQLSTATE class 40, which propagates rather than being reported as a non-redemption.';
|
|
355
|
+
revoke all on function agent_board._enroll_federated_identity_once(
|
|
356
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
357
|
+
from public, anon, authenticated;
|
|
358
|
+
grant execute on function agent_board._enroll_federated_identity_once(
|
|
359
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
360
|
+
to service_role;
|
|
361
|
+
|
|
362
|
+
-- 2. the replay wrapper -- the route the Portal /enroll endpoint calls.
|
|
363
|
+
create or replace function agent_board.enroll_federated_identity(
|
|
364
|
+
p_issuer text,
|
|
365
|
+
p_audience text,
|
|
366
|
+
p_subject text,
|
|
367
|
+
p_human_identity_claim jsonb,
|
|
368
|
+
p_slug text,
|
|
369
|
+
p_agent_public_key text,
|
|
370
|
+
p_agent_public_key_format text,
|
|
371
|
+
p_agent_public_key_fingerprint text,
|
|
372
|
+
p_pop_verified boolean,
|
|
373
|
+
p_actor_sub text default null,
|
|
374
|
+
p_agent_kind text default 'agent',
|
|
375
|
+
p_requested_scopes text[] default '{}',
|
|
376
|
+
p_requested_board_scopes text[] default '{}',
|
|
377
|
+
p_invite_code text default null
|
|
378
|
+
)
|
|
379
|
+
returns table(
|
|
380
|
+
agent_id uuid,
|
|
381
|
+
auth_user_id uuid,
|
|
382
|
+
provider_id text,
|
|
383
|
+
signing_key_id uuid,
|
|
384
|
+
granted_scopes text[],
|
|
385
|
+
key_id text,
|
|
386
|
+
key_version int,
|
|
387
|
+
agent_handle text,
|
|
388
|
+
invite_redeemed boolean
|
|
389
|
+
)
|
|
390
|
+
language plpgsql
|
|
391
|
+
security definer
|
|
392
|
+
set search_path = agent_board, public
|
|
393
|
+
as $$
|
|
394
|
+
#variable_conflict use_column
|
|
395
|
+
declare
|
|
396
|
+
v_fingerprint text;
|
|
397
|
+
v_existing record;
|
|
398
|
+
v_created record;
|
|
399
|
+
v_replay jsonb;
|
|
400
|
+
v_invite_redeemed boolean := false;
|
|
401
|
+
v_slug text;
|
|
402
|
+
v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
|
|
403
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
404
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
405
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
406
|
+
v_invite_board_id uuid;
|
|
407
|
+
v_claim_audience jsonb;
|
|
408
|
+
v_owner_user_id uuid;
|
|
409
|
+
begin
|
|
410
|
+
if not agent_board._is_service_role() then
|
|
411
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
412
|
+
end if;
|
|
413
|
+
if p_pop_verified is not true then
|
|
414
|
+
raise exception 'proof-of-possession was not verified' using errcode = '42501';
|
|
415
|
+
end if;
|
|
416
|
+
if p_subject is null then
|
|
417
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
418
|
+
end if;
|
|
419
|
+
if p_human_identity_claim is null then
|
|
420
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
421
|
+
end if;
|
|
422
|
+
|
|
423
|
+
select * into v_provider from agent_board.identity_providers ip
|
|
424
|
+
where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
|
|
425
|
+
if v_provider.id is null then
|
|
426
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
427
|
+
end if;
|
|
428
|
+
|
|
429
|
+
v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
|
|
430
|
+
if p_human_identity_claim ->> 'iss' is distinct from p_issuer
|
|
431
|
+
or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
|
|
432
|
+
or v_claim_audience is null
|
|
433
|
+
or not (v_claim_audience ? p_audience) then
|
|
434
|
+
raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
|
|
435
|
+
end if;
|
|
436
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
|
|
437
|
+
or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
438
|
+
raise exception 'malformed agent public key' using errcode = '22023';
|
|
439
|
+
end if;
|
|
440
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
441
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
442
|
+
raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
|
|
443
|
+
end if;
|
|
444
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
445
|
+
|
|
446
|
+
if not v_provider.supported or not v_provider.policy_approved then
|
|
447
|
+
raise exception 'identity provider is not approved' using errcode = '42501';
|
|
448
|
+
end if;
|
|
449
|
+
if v_kind = 'system' then
|
|
450
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
451
|
+
end if;
|
|
452
|
+
if v_kind not in ('human', 'agent') then
|
|
453
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
454
|
+
end if;
|
|
455
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
456
|
+
and not (v_kind = any(v_provider.allowed_agent_kinds)) then
|
|
457
|
+
raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
|
|
458
|
+
end if;
|
|
459
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes)
|
|
460
|
+
or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
461
|
+
raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
|
|
462
|
+
end if;
|
|
463
|
+
|
|
464
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
|
|
465
|
+
select 1 from agent_board.board_invite_tokens bit
|
|
466
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
467
|
+
where bit.code = btrim(p_invite_code)
|
|
468
|
+
) then
|
|
469
|
+
raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
|
|
470
|
+
end if;
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
474
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
475
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
476
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
477
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
478
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
479
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
480
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
481
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
482
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
483
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
484
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
485
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
486
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
487
|
+
select bit.board_id into v_invite_board_id
|
|
488
|
+
from agent_board.board_invite_tokens bit
|
|
489
|
+
where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
|
|
490
|
+
for update;
|
|
491
|
+
if v_invite_board_id is not null then
|
|
492
|
+
perform 1 from agent_board.boards b
|
|
493
|
+
where b.id = v_invite_board_id
|
|
494
|
+
for key share;
|
|
495
|
+
end if;
|
|
496
|
+
end if;
|
|
497
|
+
|
|
498
|
+
perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
|
|
499
|
+
|
|
500
|
+
select a.owner_user_id into v_owner_user_id
|
|
501
|
+
from agent_board.agent_signing_keys k
|
|
502
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
503
|
+
where k.fingerprint = v_fingerprint;
|
|
504
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
505
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
506
|
+
));
|
|
507
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
508
|
+
if exists (
|
|
509
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
510
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
511
|
+
) then
|
|
512
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
513
|
+
end if;
|
|
514
|
+
|
|
515
|
+
select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
|
|
516
|
+
a.metadata, k.id as signing_key_id, k.key_id, k.key_version,
|
|
517
|
+
k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
|
|
518
|
+
(select e.granted_scopes
|
|
519
|
+
from agent_board.enrollment_federation_audit e
|
|
520
|
+
where e.agent_id = a.id
|
|
521
|
+
and e.issuer = p_issuer
|
|
522
|
+
and e.subject = p_subject
|
|
523
|
+
and e.provider_id = v_provider.id) as granted_scopes
|
|
524
|
+
into v_existing
|
|
525
|
+
from agent_board.agent_signing_keys k
|
|
526
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
527
|
+
where k.fingerprint = v_fingerprint
|
|
528
|
+
for update of a, k;
|
|
529
|
+
|
|
530
|
+
if found then
|
|
531
|
+
v_replay := v_existing.metadata #> '{federation,replay}';
|
|
532
|
+
if v_existing.public_key is distinct from p_agent_public_key
|
|
533
|
+
or v_existing.public_key_format is distinct from p_agent_public_key_format
|
|
534
|
+
or v_existing.key_status <> 'active'
|
|
535
|
+
or v_existing.proof_state <> 'proven'
|
|
536
|
+
or v_existing.status <> 'active'
|
|
537
|
+
or v_existing.granted_scopes is null
|
|
538
|
+
or v_replay is null
|
|
539
|
+
or v_replay ->> 'issuer' is distinct from p_issuer
|
|
540
|
+
or v_replay -> 'audience' is distinct from v_claim_audience
|
|
541
|
+
or v_replay ->> 'subject' is distinct from p_subject
|
|
542
|
+
or v_replay ->> 'requestedSlug' is distinct from v_slug
|
|
543
|
+
or v_replay ->> 'agentKind' is distinct from v_kind
|
|
544
|
+
or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
|
|
545
|
+
or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
|
|
546
|
+
or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
|
|
547
|
+
or v_replay ->> 'providerId' is distinct from v_provider.id then
|
|
548
|
+
raise exception 'enrollment replay conflicts with the frozen identity contract' using errcode = '23505';
|
|
549
|
+
end if;
|
|
550
|
+
|
|
551
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
552
|
+
begin
|
|
553
|
+
perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
|
|
554
|
+
v_invite_redeemed := true;
|
|
555
|
+
exception when others then
|
|
556
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
557
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
558
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
559
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
560
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
561
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
562
|
+
if left(sqlstate, 2) = '40' then
|
|
563
|
+
raise;
|
|
564
|
+
end if;
|
|
565
|
+
v_invite_redeemed := false;
|
|
566
|
+
end;
|
|
567
|
+
end if;
|
|
568
|
+
|
|
569
|
+
return query select v_existing.agent_id, v_existing.auth_user_id,
|
|
570
|
+
v_replay ->> 'providerId', v_existing.signing_key_id,
|
|
571
|
+
v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
|
|
572
|
+
v_existing.handle, v_invite_redeemed;
|
|
573
|
+
return;
|
|
574
|
+
end if;
|
|
575
|
+
|
|
576
|
+
select * into v_created from agent_board._enroll_federated_identity_once(
|
|
577
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
578
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
579
|
+
p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
|
|
580
|
+
v_requested_board_scopes, p_invite_code
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
update agent_board.agents a
|
|
584
|
+
set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
|
|
585
|
+
'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
|
|
586
|
+
'providerId', v_created.provider_id,
|
|
587
|
+
'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
|
|
588
|
+
'requestedSlug', v_slug, 'agentKind', v_kind,
|
|
589
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
590
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
|
|
591
|
+
), true)
|
|
592
|
+
where a.id = v_created.agent_id;
|
|
593
|
+
|
|
594
|
+
return query select v_created.agent_id, v_created.auth_user_id,
|
|
595
|
+
v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
|
|
596
|
+
v_created.key_id, v_created.key_version, v_created.agent_handle,
|
|
597
|
+
v_created.invite_redeemed;
|
|
598
|
+
end;
|
|
599
|
+
$$;
|
|
600
|
+
|
|
601
|
+
comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
602
|
+
'Service-role federated enrollment with exact fingerprint replay. Stable owner, actor, slug, kind, and scope inputs are frozen; rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes. Takes the invite row and its board (invite -> board) BEFORE the auth.users owner lock so every federated write route agrees on board -> owner (#194); a serialization failure or deadlock during redemption propagates instead of being reported as invite_redeemed=false.';
|
|
603
|
+
revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from public, anon, authenticated;
|
|
604
|
+
grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) to service_role;
|