@consiliency/agent-board-schema 1.24.0 → 1.27.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 +12 -0
- package/migrations/20260906000100_agent_board_enroll_resumption.sql +749 -0
- package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
- package/migrations/20260908000100_agent_board_signer_lifecycle_revalidation.sql +451 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +3 -3
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +129 -112
- package/tests/agent_board_claimkey.test.sql +25 -3
- package/tests/agent_board_consent_emission.test.sql +17 -13
- package/tests/agent_board_consent_gating.test.sql +55 -11
- package/tests/agent_board_delegation_policies.test.sql +3 -1
- package/tests/agent_board_enroll_resumption.test.sql +412 -0
- package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
- package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
- package/tests/agent_board_mint_harden.test.sql +39 -4
- package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
- package/tests/agent_board_rejectsem.test.sql +1 -1
- package/tests/agent_board_rolereset.test.sql +23 -2
- package/tests/agent_board_rowid.test.sql +24 -13
- package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
- package/tests/agent_board_signed_authority_provenance.test.sql +477 -0
- package/tests/agent_board_signer_lifecycle.test.sql +709 -0
- package/tests/agent_board_source_strip_helper.test.sql +285 -0
- package/tests/agent_board_wake_recovery.test.sql +58 -40
- package/tests/catalog/p_metadata_parameters.json +693 -0
- package/tests/fixtures/test-prereqs.sql +313 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +3 -3
- package/tests/isolation/expected/signed_grant_onboarding_deadlock.out +220 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +307 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +159 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +176 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
-- Consiliency/message-board#176 -- ENROLL RESUMPTION.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT HAPPENED, LIVE. An agent enrolled. `/enroll` SUCCEEDED: agent row, signing
|
|
4
|
+
-- key, human owner, proof_state=proven, all committed. Post-enroll provisioning then
|
|
5
|
+
-- failed (#173) and the client reported the WHOLE operation failed. Every retry after
|
|
6
|
+
-- that was refused with `23505 this signing key is already enrolled (duplicate
|
|
7
|
+
-- fingerprint)`, surfaced as an opaque 403. The agent held a valid identity for hours
|
|
8
|
+
-- while the tooling insisted it had none, and its loop could never succeed.
|
|
9
|
+
--
|
|
10
|
+
-- WHAT ALREADY EXISTS, AND WHY IT DID NOT SAVE THE VICTIM. 20260901000100 (merged the
|
|
11
|
+
-- day AFTER #176 was filed) wrapped the creator in a replay path that DOES return the
|
|
12
|
+
-- existing identity for an exact re-presentation. That path is gated on a
|
|
13
|
+
-- `metadata.federation.replay` STAMP the wrapper writes at creation time. Every
|
|
14
|
+
-- identity enrolled BEFORE that migration -- including the #176 victim -- has no stamp,
|
|
15
|
+
-- so `v_replay is null` sent it straight to
|
|
16
|
+
-- `23505 enrollment replay conflicts with the frozen identity contract`: a different
|
|
17
|
+
-- message for the same dead end. The trap is still shut for exactly the enrollments
|
|
18
|
+
-- that fell into it.
|
|
19
|
+
--
|
|
20
|
+
-- THE FIX, in one sentence: an already-enrolled key presented by the principal that
|
|
21
|
+
-- OWNS it is a RESUMPTION, not a violation. There is ONE resumption implementation and
|
|
22
|
+
-- it stays in the wrapper (agent_board.enroll_federated_identity). The internal creator
|
|
23
|
+
-- deliberately does NOT grow a second one -- a twin predicate is how this repo has
|
|
24
|
+
-- drifted before, and the creator lacks the wrapper's claim-projection equality gate,
|
|
25
|
+
-- so a resumption decided there would rest on bare parameters.
|
|
26
|
+
--
|
|
27
|
+
-- WHERE THE OWNERSHIP TEST GETS ITS PRINCIPAL, and why that source is EVIDENCE.
|
|
28
|
+
-- The DB session here is service_role (the /enroll edge function), which is the
|
|
29
|
+
-- verifier: it validated the Portal JWT signature and projected the verified claim.
|
|
30
|
+
-- `p_issuer`/`p_subject` are therefore trusted ONLY because the wrapper already
|
|
31
|
+
-- equality-gates them against the verified claim projection it was handed
|
|
32
|
+
-- (`p_human_identity_claim ->> 'iss'`, `->> subject_claim`, and the normalized `aud`
|
|
33
|
+
-- must all match, above -- that gate is unchanged and runs BEFORE anything here).
|
|
34
|
+
-- Ownership is then decided by comparing that gated principal against evidence WRITTEN
|
|
35
|
+
-- AT THE ORIGINAL ENROLLMENT, never against a caller argument describing who acted:
|
|
36
|
+
-- (i) agents.owner_user_id equals the auth user DERIVED from
|
|
37
|
+
-- sha256(issuer|subject) -- the same one-way derivation step (7) used to mint
|
|
38
|
+
-- it, so a different principal derives a different owner and cannot collide;
|
|
39
|
+
-- (ii) agents.metadata->'federation'->>'issuer'/'subject'/'providerId', written by
|
|
40
|
+
-- step (10) from the EF-verified inputs of the ORIGINAL enrollment;
|
|
41
|
+
-- (iii) a matching agent_board.enrollment_federation_audit row (agent, issuer,
|
|
42
|
+
-- subject, provider) -- the original enrollment's own audit record.
|
|
43
|
+
-- (ii) and (iii) are REQUIRED. (i) is required WHENEVER IT EXISTS: a non-null
|
|
44
|
+
-- owner_user_id that disagrees with the derived one is a hard refusal, and the single
|
|
45
|
+
-- exemption is a row with NO owner_user_id at all, which predates the owner model
|
|
46
|
+
-- entirely (the atomic-fleet backfill fixture is exactly that shape and replaying it is
|
|
47
|
+
-- released, asserted behaviour). Stated positively, because an exemption asserted only
|
|
48
|
+
-- by absence is how this repo has been bitten: such a row still proves ownership through
|
|
49
|
+
-- (ii) and (iii), which name the principal on their own, and reaching them at all
|
|
50
|
+
-- requires a claim the edge function VERIFIED for that same issuer and subject. The
|
|
51
|
+
-- exemption therefore narrows corroboration without widening who may resume, and it is
|
|
52
|
+
-- pinned in BOTH directions (a null-owner row resumes for its owner; the same row still
|
|
53
|
+
-- refuses a different subject).
|
|
54
|
+
-- The whole predicate is `coalesce(<conjunction>, false)`, because a NULL from any
|
|
55
|
+
-- missing evidence must read as NOT-OWNED, not as an untaken `if not`.
|
|
56
|
+
-- The derived-owner lookup is a pure SELECT against auth.users by the derived email --
|
|
57
|
+
-- it must NOT call _ensure_agent_auth_user, which would MINT an owner row for whatever
|
|
58
|
+
-- principal a squat attempt claimed.
|
|
59
|
+
--
|
|
60
|
+
-- THE THREE OUTCOMES, each with its own message:
|
|
61
|
+
-- (a) NOT owned by the caller -> raise 23505. This is the key-squat the check exists
|
|
62
|
+
-- to prevent and it is checked FIRST, so a non-owner learns nothing about the
|
|
63
|
+
-- key's state. The message names NO handle: telling a squatter which agent holds
|
|
64
|
+
-- the key it just probed is the leak this ordering avoids.
|
|
65
|
+
-- (b) owned, but the identity is not resumable (key or agent inactive, unproven,
|
|
66
|
+
-- public key/format drift, or an audit row recording no granted scopes) -> raise
|
|
67
|
+
-- 23505 naming the handle. Resumption never revives a revoked or unproven
|
|
68
|
+
-- identity, and the granted-scopes disjunct is what keeps this branch reachable
|
|
69
|
+
-- on its own rather than implied by (iii).
|
|
70
|
+
-- (c) owned, resumable, and a replay STAMP is present but a frozen field differs ->
|
|
71
|
+
-- raise 23505 naming the handle and saying enrollment need not be repeated. The
|
|
72
|
+
-- frozen contract is NOT loosened; only its message improves.
|
|
73
|
+
-- otherwise -> return the existing identity (idempotent success), which is the
|
|
74
|
+
-- resumption #176 asks for. `v_replay is null` (a pre-20260901000100 enrollment) is
|
|
75
|
+
-- no longer a raise: (i)-(iii) are the whole basis, and they are exactly the evidence
|
|
76
|
+
-- those rows do carry.
|
|
77
|
+
--
|
|
78
|
+
-- WHAT IS DELIBERATELY LEFT OUT. A legacy (unstamped) resumption does NOT write a
|
|
79
|
+
-- `federation.replay` stamp. The stamp's `requestedSlug` is not recoverable from stored
|
|
80
|
+
-- evidence -- only the RESULTING handle was persisted, and it may carry a collision
|
|
81
|
+
-- suffix -- so a stamp built here would have to take the slug from the CURRENT request,
|
|
82
|
+
-- which is not evidence of the original one and would freeze the contract around
|
|
83
|
+
-- whatever the retry happened to send. Unstamped rows therefore keep resuming on
|
|
84
|
+
-- (i)-(iii) every time. Consequence, stated plainly: for those rows a same-owner retry
|
|
85
|
+
-- with a different slug or scope request resumes instead of raising. It cannot escalate
|
|
86
|
+
-- -- `granted_scopes` is returned from the ORIGINAL audit row, never from the request --
|
|
87
|
+
-- and it is the recovery #176 exists to provide.
|
|
88
|
+
--
|
|
89
|
+
-- LOCK ORDER (#194) IS UNCHANGED, and this migration acquires nothing new. The two
|
|
90
|
+
-- additions are read-only: a `select ... from auth.users` by email (no FOR UPDATE, no
|
|
91
|
+
-- row lock) and a `select 1 from enrollment_federation_audit` existence probe. Both sit
|
|
92
|
+
-- AFTER the existing invite -> board -> fingerprint-advisory -> owner sequence, inside
|
|
93
|
+
-- the region that already holds `for update of a, k` on the agent and key. The whole
|
|
94
|
+
-- #194 header from 20260903000100 (board before owner; the FK this route never names;
|
|
95
|
+
-- the FOR KEY SHARE reasoning; the two advisory edges) still applies verbatim and is
|
|
96
|
+
-- reproduced at each of the two acquisition sites below.
|
|
97
|
+
--
|
|
98
|
+
-- The internal creator's step (6) still raises -- it is a backstop that the wrapper's
|
|
99
|
+
-- dedup makes unreachable in practice (the wrapper is its ONLY caller; verified from
|
|
100
|
+
-- pg_proc, and onboard_federated_fleet_agent calls the WRAPPER, not the creator) -- but
|
|
101
|
+
-- the raise is now ACTIONABLE: it names the existing handle and says enrollment need
|
|
102
|
+
-- not be repeated, which is the issue's minimum ask.
|
|
103
|
+
--
|
|
104
|
+
-- Both functions are recreated with CREATE OR REPLACE (ACL and COMMENT survive); the
|
|
105
|
+
-- grants and comments are restated below regardless, per the repo rule. The bodies are
|
|
106
|
+
-- byte-identical to 20260903000100 apart from the deltas described above.
|
|
107
|
+
|
|
108
|
+
-- 1. the internal one-time creator (renamed core).
|
|
109
|
+
create or replace function agent_board._enroll_federated_identity_once(
|
|
110
|
+
p_issuer text,
|
|
111
|
+
p_audience text,
|
|
112
|
+
p_subject text,
|
|
113
|
+
p_human_identity_claim jsonb,
|
|
114
|
+
p_slug text,
|
|
115
|
+
p_agent_public_key text,
|
|
116
|
+
p_agent_public_key_format text,
|
|
117
|
+
p_agent_public_key_fingerprint text,
|
|
118
|
+
p_pop_verified boolean,
|
|
119
|
+
p_actor_sub text default null,
|
|
120
|
+
p_agent_kind text default 'agent',
|
|
121
|
+
p_requested_scopes text[] default '{}',
|
|
122
|
+
p_requested_board_scopes text[] default '{}',
|
|
123
|
+
p_invite_code text default null
|
|
124
|
+
)
|
|
125
|
+
returns table(
|
|
126
|
+
agent_id uuid,
|
|
127
|
+
auth_user_id uuid,
|
|
128
|
+
provider_id text,
|
|
129
|
+
signing_key_id uuid,
|
|
130
|
+
granted_scopes text[],
|
|
131
|
+
key_id text,
|
|
132
|
+
key_version int,
|
|
133
|
+
agent_handle text,
|
|
134
|
+
invite_redeemed boolean
|
|
135
|
+
)
|
|
136
|
+
language plpgsql
|
|
137
|
+
security definer
|
|
138
|
+
set search_path = agent_board, public
|
|
139
|
+
as $$
|
|
140
|
+
declare
|
|
141
|
+
v_slug text;
|
|
142
|
+
v_kind text;
|
|
143
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
144
|
+
v_owner_user_id uuid;
|
|
145
|
+
v_auth_user_id uuid;
|
|
146
|
+
v_agent_handle text;
|
|
147
|
+
v_agent_id uuid;
|
|
148
|
+
v_signing_key_id uuid;
|
|
149
|
+
v_fingerprint text;
|
|
150
|
+
v_key_hex text;
|
|
151
|
+
v_raw_key bytea;
|
|
152
|
+
v_reencoded text;
|
|
153
|
+
v_key_id text := 'portal-enroll';
|
|
154
|
+
v_key_version int := 1;
|
|
155
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
156
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
157
|
+
v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
|
|
158
|
+
v_invite_redeemed boolean := false;
|
|
159
|
+
v_invite_board_id uuid;
|
|
160
|
+
v_existing_handle text;
|
|
161
|
+
begin
|
|
162
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
163
|
+
if not agent_board._is_service_role() then
|
|
164
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
165
|
+
end if;
|
|
166
|
+
|
|
167
|
+
-- A verified subject + human identity claim are always present from the EF.
|
|
168
|
+
if p_subject is null then
|
|
169
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
170
|
+
end if;
|
|
171
|
+
if p_human_identity_claim is null then
|
|
172
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
173
|
+
end if;
|
|
174
|
+
|
|
175
|
+
-- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
|
|
176
|
+
-- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
|
|
177
|
+
if p_pop_verified is not true then
|
|
178
|
+
raise exception 'proof-of-possession was not verified; enrollment refused'
|
|
179
|
+
using errcode = '42501';
|
|
180
|
+
end if;
|
|
181
|
+
|
|
182
|
+
-- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
|
|
183
|
+
-- base64url chars).
|
|
184
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
185
|
+
raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
|
|
186
|
+
end if;
|
|
187
|
+
if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
188
|
+
raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
|
|
189
|
+
using errcode = '22023';
|
|
190
|
+
end if;
|
|
191
|
+
|
|
192
|
+
-- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
|
|
193
|
+
v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
|
|
194
|
+
if octet_length(v_raw_key) <> 32 then
|
|
195
|
+
raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
|
|
196
|
+
using errcode = '22023';
|
|
197
|
+
end if;
|
|
198
|
+
v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
|
|
199
|
+
if v_reencoded is distinct from p_agent_public_key then
|
|
200
|
+
raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
|
|
201
|
+
using errcode = '22023';
|
|
202
|
+
end if;
|
|
203
|
+
|
|
204
|
+
-- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
|
|
205
|
+
if v_raw_key = decode(repeat('00', 32), 'hex') then
|
|
206
|
+
raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
|
|
207
|
+
using errcode = '22023';
|
|
208
|
+
end if;
|
|
209
|
+
|
|
210
|
+
-- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
|
|
211
|
+
-- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
|
|
212
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
213
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
214
|
+
raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
|
|
215
|
+
using errcode = '22023';
|
|
216
|
+
end if;
|
|
217
|
+
|
|
218
|
+
-- (3) act.sub is audit-only, but when present it must be a real value.
|
|
219
|
+
if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
|
|
220
|
+
raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
|
|
221
|
+
end if;
|
|
222
|
+
|
|
223
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
224
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
225
|
+
|
|
226
|
+
-- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
|
|
227
|
+
select *
|
|
228
|
+
into v_provider
|
|
229
|
+
from agent_board.identity_providers
|
|
230
|
+
where issuer = p_issuer
|
|
231
|
+
and audience = p_audience
|
|
232
|
+
limit 1;
|
|
233
|
+
if v_provider.id is null then
|
|
234
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
235
|
+
end if;
|
|
236
|
+
if not v_provider.supported then
|
|
237
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
238
|
+
end if;
|
|
239
|
+
if not v_provider.policy_approved then
|
|
240
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
241
|
+
end if;
|
|
242
|
+
|
|
243
|
+
-- (5) scope-ceiling + kind gate. 'system' is never federatable.
|
|
244
|
+
if v_kind = 'system' then
|
|
245
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
246
|
+
end if;
|
|
247
|
+
if v_kind not in ('human', 'agent') then
|
|
248
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
249
|
+
end if;
|
|
250
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
251
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
252
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
253
|
+
end if;
|
|
254
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
255
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
256
|
+
end if;
|
|
257
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
258
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
259
|
+
end if;
|
|
260
|
+
|
|
261
|
+
-- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound
|
|
262
|
+
-- HERE. #176: this is a BACKSTOP, not the resumption decision. enroll_federated_identity
|
|
263
|
+
-- is this function's only caller (pg_proc-verified) and it resolves an already-enrolled
|
|
264
|
+
-- fingerprint before ever reaching here, so a caller that lands on this raise reached the
|
|
265
|
+
-- internal creator directly. Resumption is deliberately NOT duplicated into this
|
|
266
|
+
-- function: it has no claim-projection equality gate, so an ownership decision made here
|
|
267
|
+
-- would rest on bare parameters rather than on the verified claim. What DID change is
|
|
268
|
+
-- that the raise is actionable -- it names the identity that already holds the key and
|
|
269
|
+
-- says enrollment need not be repeated.
|
|
270
|
+
--
|
|
271
|
+
-- The GATE is still the unchanged `exists` over agent_signing_keys alone. The handle is
|
|
272
|
+
-- looked up separately, for the MESSAGE only, through a join that a hypothetical
|
|
273
|
+
-- agent-less key would not satisfy: folding that join into the gate would have turned a
|
|
274
|
+
-- rendering nicety into a hole that lets such a key be re-bound.
|
|
275
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
|
|
276
|
+
select a.handle into v_existing_handle
|
|
277
|
+
from agent_board.agent_signing_keys k
|
|
278
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
279
|
+
where k.fingerprint = v_fingerprint
|
|
280
|
+
limit 1;
|
|
281
|
+
raise exception 'this signing key is already enrolled (duplicate fingerprint) as agent "%"; enrollment need not be repeated; call agent_board.enroll_federated_identity, which RESUMES an enrollment whose key is owned by the same verified (issuer, subject)',
|
|
282
|
+
coalesce(v_existing_handle, '(unknown)')
|
|
283
|
+
using errcode = '23505';
|
|
284
|
+
end if;
|
|
285
|
+
|
|
286
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
287
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
288
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
289
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
290
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
291
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
292
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
293
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
294
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
295
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
296
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
297
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
298
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
299
|
+
if v_invite_code is not null then
|
|
300
|
+
select bit.board_id into v_invite_board_id
|
|
301
|
+
from agent_board.board_invite_tokens bit
|
|
302
|
+
where bit.code = v_invite_code
|
|
303
|
+
for update;
|
|
304
|
+
if v_invite_board_id is not null then
|
|
305
|
+
perform 1 from agent_board.boards b
|
|
306
|
+
where b.id = v_invite_board_id
|
|
307
|
+
for key share;
|
|
308
|
+
end if;
|
|
309
|
+
end if;
|
|
310
|
+
|
|
311
|
+
-- (7) HUMAN OWNER — established idempotently per (issuer, sub).
|
|
312
|
+
v_owner_user_id := agent_board._ensure_agent_auth_user(
|
|
313
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
|
|
314
|
+
|
|
315
|
+
-- (8) AGENT handle — unique per enrolled key.
|
|
316
|
+
v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
|
|
317
|
+
v_agent_handle := v_slug;
|
|
318
|
+
if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
|
|
319
|
+
v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
|
|
320
|
+
end if;
|
|
321
|
+
|
|
322
|
+
-- (9) the agent's OWN board principal (session-usable via its enrolled key).
|
|
323
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
|
|
324
|
+
|
|
325
|
+
-- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
|
|
326
|
+
insert into agent_board.agents(
|
|
327
|
+
agent_type,
|
|
328
|
+
kind,
|
|
329
|
+
handle,
|
|
330
|
+
display_name,
|
|
331
|
+
status,
|
|
332
|
+
auth_user_id,
|
|
333
|
+
owner_user_id,
|
|
334
|
+
metadata
|
|
335
|
+
)
|
|
336
|
+
values (
|
|
337
|
+
v_kind,
|
|
338
|
+
v_kind::agent_board.agent_kind,
|
|
339
|
+
v_agent_handle,
|
|
340
|
+
agent_board._default_provision_display_name(v_agent_handle),
|
|
341
|
+
'active',
|
|
342
|
+
v_auth_user_id,
|
|
343
|
+
v_owner_user_id,
|
|
344
|
+
jsonb_build_object(
|
|
345
|
+
'federation', jsonb_build_object(
|
|
346
|
+
'issuer', p_issuer,
|
|
347
|
+
'audience', p_audience,
|
|
348
|
+
'subject', p_subject,
|
|
349
|
+
'actorSub', p_actor_sub,
|
|
350
|
+
'providerId', v_provider.id,
|
|
351
|
+
'signingKeyFingerprint', v_fingerprint,
|
|
352
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
353
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
354
|
+
'popVerified', true,
|
|
355
|
+
'enrolledAt', now()
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
returning id into v_agent_id;
|
|
360
|
+
|
|
361
|
+
-- (11) enroll the public key as active + proven (satisfies active_requires_proof).
|
|
362
|
+
insert into agent_board.agent_signing_keys(
|
|
363
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
364
|
+
status, proof_state, metadata
|
|
365
|
+
)
|
|
366
|
+
values (
|
|
367
|
+
gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
|
|
368
|
+
p_agent_public_key_format, v_fingerprint,
|
|
369
|
+
'active', 'proven',
|
|
370
|
+
jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
|
|
371
|
+
)
|
|
372
|
+
returning id into v_signing_key_id;
|
|
373
|
+
|
|
374
|
+
-- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
|
|
375
|
+
insert into agent_board.enrollment_federation_audit(
|
|
376
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
377
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
378
|
+
)
|
|
379
|
+
values (
|
|
380
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
381
|
+
v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
-- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
|
|
385
|
+
-- membership for the freshly-created agent. Wrapped in a subtransaction so a
|
|
386
|
+
-- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
|
|
387
|
+
-- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
|
|
388
|
+
if v_invite_code is not null then
|
|
389
|
+
begin
|
|
390
|
+
perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
|
|
391
|
+
v_invite_redeemed := true;
|
|
392
|
+
exception when others then
|
|
393
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
394
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
395
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
396
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
397
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
398
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
399
|
+
if left(sqlstate, 2) = '40' then
|
|
400
|
+
raise;
|
|
401
|
+
end if;
|
|
402
|
+
v_invite_redeemed := false;
|
|
403
|
+
end;
|
|
404
|
+
end if;
|
|
405
|
+
|
|
406
|
+
-- Server-authoritative identity: the client mints /session with the RETURNED
|
|
407
|
+
-- key_id/key_version/agent_handle (its private key stays in the keystore).
|
|
408
|
+
return query
|
|
409
|
+
select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
|
|
410
|
+
v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
|
|
411
|
+
end;
|
|
412
|
+
$$;
|
|
413
|
+
|
|
414
|
+
comment on function agent_board._enroll_federated_identity_once(
|
|
415
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
416
|
+
'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. CREATES ONLY: step (6) still refuses an already-enrolled fingerprint unconditionally and does NOT resume (#176) -- resumption has exactly one implementation and it lives in enroll_federated_identity, which is this function''s only caller and which gates the (issuer, subject) against the verified claim projection first. The step (6) raise names the agent that already holds the key and says enrollment need not be repeated.';
|
|
417
|
+
revoke all on function agent_board._enroll_federated_identity_once(
|
|
418
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
419
|
+
from public, anon, authenticated;
|
|
420
|
+
grant execute on function agent_board._enroll_federated_identity_once(
|
|
421
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
422
|
+
to service_role;
|
|
423
|
+
|
|
424
|
+
-- 2. the replay wrapper -- the route the Portal /enroll endpoint calls.
|
|
425
|
+
create or replace function agent_board.enroll_federated_identity(
|
|
426
|
+
p_issuer text,
|
|
427
|
+
p_audience text,
|
|
428
|
+
p_subject text,
|
|
429
|
+
p_human_identity_claim jsonb,
|
|
430
|
+
p_slug text,
|
|
431
|
+
p_agent_public_key text,
|
|
432
|
+
p_agent_public_key_format text,
|
|
433
|
+
p_agent_public_key_fingerprint text,
|
|
434
|
+
p_pop_verified boolean,
|
|
435
|
+
p_actor_sub text default null,
|
|
436
|
+
p_agent_kind text default 'agent',
|
|
437
|
+
p_requested_scopes text[] default '{}',
|
|
438
|
+
p_requested_board_scopes text[] default '{}',
|
|
439
|
+
p_invite_code text default null
|
|
440
|
+
)
|
|
441
|
+
returns table(
|
|
442
|
+
agent_id uuid,
|
|
443
|
+
auth_user_id uuid,
|
|
444
|
+
provider_id text,
|
|
445
|
+
signing_key_id uuid,
|
|
446
|
+
granted_scopes text[],
|
|
447
|
+
key_id text,
|
|
448
|
+
key_version int,
|
|
449
|
+
agent_handle text,
|
|
450
|
+
invite_redeemed boolean
|
|
451
|
+
)
|
|
452
|
+
language plpgsql
|
|
453
|
+
security definer
|
|
454
|
+
set search_path = agent_board, public
|
|
455
|
+
as $$
|
|
456
|
+
#variable_conflict use_column
|
|
457
|
+
declare
|
|
458
|
+
v_fingerprint text;
|
|
459
|
+
v_existing record;
|
|
460
|
+
v_created record;
|
|
461
|
+
v_replay jsonb;
|
|
462
|
+
v_invite_redeemed boolean := false;
|
|
463
|
+
v_slug text;
|
|
464
|
+
v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
|
|
465
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
466
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
467
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
468
|
+
v_invite_board_id uuid;
|
|
469
|
+
v_claim_audience jsonb;
|
|
470
|
+
v_owner_user_id uuid;
|
|
471
|
+
-- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
|
|
472
|
+
-- (issuer, subject) derives to; `v_creation_federation` is the federation object the
|
|
473
|
+
-- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
|
|
474
|
+
-- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
|
|
475
|
+
v_derived_owner_user_id uuid;
|
|
476
|
+
v_creation_federation jsonb;
|
|
477
|
+
v_owned_by_caller boolean;
|
|
478
|
+
begin
|
|
479
|
+
if not agent_board._is_service_role() then
|
|
480
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
481
|
+
end if;
|
|
482
|
+
if p_pop_verified is not true then
|
|
483
|
+
raise exception 'proof-of-possession was not verified' using errcode = '42501';
|
|
484
|
+
end if;
|
|
485
|
+
if p_subject is null then
|
|
486
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
487
|
+
end if;
|
|
488
|
+
if p_human_identity_claim is null then
|
|
489
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
490
|
+
end if;
|
|
491
|
+
|
|
492
|
+
select * into v_provider from agent_board.identity_providers ip
|
|
493
|
+
where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
|
|
494
|
+
if v_provider.id is null then
|
|
495
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
496
|
+
end if;
|
|
497
|
+
|
|
498
|
+
v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
|
|
499
|
+
if p_human_identity_claim ->> 'iss' is distinct from p_issuer
|
|
500
|
+
or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
|
|
501
|
+
or v_claim_audience is null
|
|
502
|
+
or not (v_claim_audience ? p_audience) then
|
|
503
|
+
raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
|
|
504
|
+
end if;
|
|
505
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
|
|
506
|
+
or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
507
|
+
raise exception 'malformed agent public key' using errcode = '22023';
|
|
508
|
+
end if;
|
|
509
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
510
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
511
|
+
raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
|
|
512
|
+
end if;
|
|
513
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
514
|
+
|
|
515
|
+
if not v_provider.supported or not v_provider.policy_approved then
|
|
516
|
+
raise exception 'identity provider is not approved' using errcode = '42501';
|
|
517
|
+
end if;
|
|
518
|
+
if v_kind = 'system' then
|
|
519
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
520
|
+
end if;
|
|
521
|
+
if v_kind not in ('human', 'agent') then
|
|
522
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
523
|
+
end if;
|
|
524
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
525
|
+
and not (v_kind = any(v_provider.allowed_agent_kinds)) then
|
|
526
|
+
raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
|
|
527
|
+
end if;
|
|
528
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes)
|
|
529
|
+
or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
530
|
+
raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
|
|
531
|
+
end if;
|
|
532
|
+
|
|
533
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
|
|
534
|
+
select 1 from agent_board.board_invite_tokens bit
|
|
535
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
536
|
+
where bit.code = btrim(p_invite_code)
|
|
537
|
+
) then
|
|
538
|
+
raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
|
|
539
|
+
end if;
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
543
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
544
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
545
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
546
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
547
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
548
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
549
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
550
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
551
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
552
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
553
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
554
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
555
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
556
|
+
select bit.board_id into v_invite_board_id
|
|
557
|
+
from agent_board.board_invite_tokens bit
|
|
558
|
+
where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
|
|
559
|
+
for update;
|
|
560
|
+
if v_invite_board_id is not null then
|
|
561
|
+
perform 1 from agent_board.boards b
|
|
562
|
+
where b.id = v_invite_board_id
|
|
563
|
+
for key share;
|
|
564
|
+
end if;
|
|
565
|
+
end if;
|
|
566
|
+
|
|
567
|
+
perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
|
|
568
|
+
|
|
569
|
+
select a.owner_user_id into v_owner_user_id
|
|
570
|
+
from agent_board.agent_signing_keys k
|
|
571
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
572
|
+
where k.fingerprint = v_fingerprint;
|
|
573
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
574
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
575
|
+
));
|
|
576
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
577
|
+
if exists (
|
|
578
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
579
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
580
|
+
) then
|
|
581
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
582
|
+
end if;
|
|
583
|
+
|
|
584
|
+
-- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
|
|
585
|
+
-- SAME one-way derivation _enroll_federated_identity_once step (7) used to mint the
|
|
586
|
+
-- owner. Resolved by a PURE LOOKUP against the deterministic email
|
|
587
|
+
-- _ensure_agent_auth_user builds -- calling that function here would MINT an owner row
|
|
588
|
+
-- for whatever principal a squat attempt claimed. Read-only: no FOR UPDATE, no row
|
|
589
|
+
-- lock, so the #194 board -> owner order above is untouched.
|
|
590
|
+
select u.id into v_derived_owner_user_id
|
|
591
|
+
from auth.users u
|
|
592
|
+
where lower(u.email) = format('agent+%s@agent-board.local',
|
|
593
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32))
|
|
594
|
+
limit 1;
|
|
595
|
+
|
|
596
|
+
select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
|
|
597
|
+
a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
|
|
598
|
+
k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
|
|
599
|
+
(select e.granted_scopes
|
|
600
|
+
from agent_board.enrollment_federation_audit e
|
|
601
|
+
where e.agent_id = a.id
|
|
602
|
+
and e.issuer = p_issuer
|
|
603
|
+
and e.subject = p_subject
|
|
604
|
+
and e.provider_id = v_provider.id) as granted_scopes
|
|
605
|
+
into v_existing
|
|
606
|
+
from agent_board.agent_signing_keys k
|
|
607
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
608
|
+
where k.fingerprint = v_fingerprint
|
|
609
|
+
for update of a, k;
|
|
610
|
+
|
|
611
|
+
if found then
|
|
612
|
+
v_replay := v_existing.metadata #> '{federation,replay}';
|
|
613
|
+
v_creation_federation := v_existing.metadata #> '{federation}';
|
|
614
|
+
|
|
615
|
+
-- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
|
|
616
|
+
-- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
|
|
617
|
+
-- creation-time federation stamp, (iii) the original enrollment's audit row. All
|
|
618
|
+
-- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
|
|
619
|
+
-- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
|
|
620
|
+
-- read as not-owned rather than leaving the `if not` below untaken.
|
|
621
|
+
v_owned_by_caller := coalesce(
|
|
622
|
+
v_creation_federation ->> 'issuer' = p_issuer
|
|
623
|
+
and v_creation_federation ->> 'subject' = p_subject
|
|
624
|
+
and v_creation_federation ->> 'providerId' = v_provider.id
|
|
625
|
+
and exists (
|
|
626
|
+
select 1
|
|
627
|
+
from agent_board.enrollment_federation_audit e
|
|
628
|
+
where e.agent_id = v_existing.agent_id
|
|
629
|
+
and e.issuer = p_issuer
|
|
630
|
+
and e.subject = p_subject
|
|
631
|
+
and e.provider_id = v_provider.id
|
|
632
|
+
)
|
|
633
|
+
-- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
|
|
634
|
+
-- so its claim is stated positively rather than left as "unless". A row whose
|
|
635
|
+
-- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
|
|
636
|
+
-- backfill fixture is exactly that shape, and replaying such a row is released,
|
|
637
|
+
-- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
|
|
638
|
+
-- the binding that does the work. (ii) and (iii) already name the principal, and
|
|
639
|
+
-- reaching them at all requires a claim the edge function VERIFIED for that same
|
|
640
|
+
-- issuer and subject, so a null owner narrows corroboration without widening who
|
|
641
|
+
-- may resume. A NON-NULL owner that disagrees with the derived one is a hard
|
|
642
|
+
-- refusal, which is what keeps this from being a hole.
|
|
643
|
+
and (
|
|
644
|
+
v_existing.owner_user_id is null
|
|
645
|
+
or (v_derived_owner_user_id is not null
|
|
646
|
+
and v_existing.owner_user_id = v_derived_owner_user_id)
|
|
647
|
+
), false);
|
|
648
|
+
|
|
649
|
+
-- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
|
|
650
|
+
-- caller that does not own the key learns nothing else about it. No handle is named:
|
|
651
|
+
-- telling a squatter which agent holds the key it just probed is the leak this
|
|
652
|
+
-- ordering avoids. Everything below runs only for the proven owner.
|
|
653
|
+
if not v_owned_by_caller then
|
|
654
|
+
raise exception 'this signing key is already enrolled to a different principal; enrollment is refused; generate a NEW key rather than re-presenting this one'
|
|
655
|
+
using errcode = '23505';
|
|
656
|
+
end if;
|
|
657
|
+
|
|
658
|
+
-- (b) the owner is asking to resume, but the identity is not in a resumable state.
|
|
659
|
+
-- Resumption returns an existing identity; it never revives a revoked, unproven or
|
|
660
|
+
-- drifted one, and it never invents an audit trail that is not there.
|
|
661
|
+
if v_existing.public_key is distinct from p_agent_public_key
|
|
662
|
+
or v_existing.public_key_format is distinct from p_agent_public_key_format
|
|
663
|
+
or v_existing.key_status <> 'active'
|
|
664
|
+
or v_existing.proof_state <> 'proven'
|
|
665
|
+
or v_existing.status <> 'active'
|
|
666
|
+
or v_existing.granted_scopes is null then
|
|
667
|
+
raise exception 'this signing key is already enrolled to you as agent "%", but that identity is not resumable (its key or agent is inactive or unproven, its stored public key differs, or its enrollment audit records no granted scopes); enrollment need not be repeated; report this handle to an operator',
|
|
668
|
+
v_existing.handle using errcode = '23505';
|
|
669
|
+
end if;
|
|
670
|
+
|
|
671
|
+
-- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
|
|
672
|
+
-- contract exactly as before -- the check is unchanged, only its message improves to
|
|
673
|
+
-- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
|
|
674
|
+
-- (every identity created before 20260901000100, including the #176 victim) has no
|
|
675
|
+
-- frozen contract to check and resumes on the ownership evidence alone; that absence
|
|
676
|
+
-- used to be the raise that trapped it.
|
|
677
|
+
if v_replay is not null
|
|
678
|
+
and (v_replay ->> 'issuer' is distinct from p_issuer
|
|
679
|
+
or v_replay -> 'audience' is distinct from v_claim_audience
|
|
680
|
+
or v_replay ->> 'subject' is distinct from p_subject
|
|
681
|
+
or v_replay ->> 'requestedSlug' is distinct from v_slug
|
|
682
|
+
or v_replay ->> 'agentKind' is distinct from v_kind
|
|
683
|
+
or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
|
|
684
|
+
or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
|
|
685
|
+
or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
|
|
686
|
+
or v_replay ->> 'providerId' is distinct from v_provider.id) then
|
|
687
|
+
raise exception 'this signing key is already enrolled to you as agent "%" (enrollment need not be repeated), but this request differs from that enrollment''s frozen identity contract (slug, agent kind, actor or requested scopes); re-send the original inputs, or use the existing identity as-is',
|
|
688
|
+
v_existing.handle using errcode = '23505';
|
|
689
|
+
end if;
|
|
690
|
+
|
|
691
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
692
|
+
begin
|
|
693
|
+
perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
|
|
694
|
+
v_invite_redeemed := true;
|
|
695
|
+
exception when others then
|
|
696
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
697
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
698
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
699
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
700
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
701
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
702
|
+
if left(sqlstate, 2) = '40' then
|
|
703
|
+
raise;
|
|
704
|
+
end if;
|
|
705
|
+
v_invite_redeemed := false;
|
|
706
|
+
end;
|
|
707
|
+
end if;
|
|
708
|
+
|
|
709
|
+
-- provider_id comes from STORED evidence, not the request: the replay stamp when
|
|
710
|
+
-- there is one, otherwise the creation-time federation object. The ownership gate
|
|
711
|
+
-- already pinned that value to v_provider.id, so the two agree by construction --
|
|
712
|
+
-- but reading it from the row keeps the returned receipt sourced where every other
|
|
713
|
+
-- returned field is.
|
|
714
|
+
return query select v_existing.agent_id, v_existing.auth_user_id,
|
|
715
|
+
coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
|
|
716
|
+
v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
|
|
717
|
+
v_existing.handle, v_invite_redeemed;
|
|
718
|
+
return;
|
|
719
|
+
end if;
|
|
720
|
+
|
|
721
|
+
select * into v_created from agent_board._enroll_federated_identity_once(
|
|
722
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
723
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
724
|
+
p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
|
|
725
|
+
v_requested_board_scopes, p_invite_code
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
update agent_board.agents a
|
|
729
|
+
set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
|
|
730
|
+
'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
|
|
731
|
+
'providerId', v_created.provider_id,
|
|
732
|
+
'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
|
|
733
|
+
'requestedSlug', v_slug, 'agentKind', v_kind,
|
|
734
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
735
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
|
|
736
|
+
), true)
|
|
737
|
+
where a.id = v_created.agent_id;
|
|
738
|
+
|
|
739
|
+
return query select v_created.agent_id, v_created.auth_user_id,
|
|
740
|
+
v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
|
|
741
|
+
v_created.key_id, v_created.key_version, v_created.agent_handle,
|
|
742
|
+
v_created.invite_redeemed;
|
|
743
|
+
end;
|
|
744
|
+
$$;
|
|
745
|
+
|
|
746
|
+
comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
747
|
+
'Service-role federated enrollment with exact fingerprint replay AND owner-scoped resumption (#176). An already-enrolled key re-presented by the principal that OWNS it RESUMES: the existing agent identity is returned so a client whose post-enroll provisioning failed can finish, rather than being refused forever. Ownership is decided against evidence written at the ORIGINAL enrollment: the creation-time federation stamp (issuer/subject/providerId) and the enrollment_federation_audit row are both REQUIRED, and agents.owner_user_id must equal the auth user derived from sha256(issuer|subject) whenever that column is populated (a null owner_user_id predates the owner model and is the one exemption; such a row still resumes only for the principal the stamp and audit row name). The caller''s (issuer, subject) is trusted only because it is equality-gated against the verified claim projection above; the conjunction is coalesced to false so unproven ownership is not-owned. A key held by a DIFFERENT principal still raises 23505 and the message names no handle. Stable owner, actor, slug, kind, and scope inputs stay frozen for a STAMPED enrollment (unchanged check, message now names the handle); an enrollment predating the stamp has no frozen contract and resumes on the ownership evidence alone, returning its ORIGINAL granted_scopes. 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); the resumption additions are read-only and acquire no lock. A serialization failure or deadlock during redemption propagates instead of being reported as invite_redeemed=false.';
|
|
748
|
+
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;
|
|
749
|
+
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;
|