@consiliency/agent-board-schema 1.27.0 → 1.35.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 +32 -0
- package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/package.json +1 -1
- package/schema-manifest.json +75 -3
- package/src/tooling.test.mjs +47 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_ledger_authority.test.sql +1081 -0
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signer_lifecycle.test.sql +29 -6
- package/tests/agent_board_source_strip_helper.test.sql +157 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/catalog/p_metadata_parameters.json +20 -0
- package/tests/fixtures/test-prereqs.sql +420 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
-- Consiliency/message-board#246 (change 2 of 3): the federated identity is derived
|
|
2
|
+
-- through ONE injective serialization, and the ambiguous one is gone.
|
|
3
|
+
--
|
|
4
|
+
-- WHAT WAS WRONG. Every federated site derived the owner slug as
|
|
5
|
+
--
|
|
6
|
+
-- 'fed-human-' || substr(encode(digest(convert_to(p_issuer || '|' || p_subject,
|
|
7
|
+
-- 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
8
|
+
--
|
|
9
|
+
-- A single-character separator with no length prefix and no escaping is NOT an
|
|
10
|
+
-- injective serialization: the delimiter can also occur inside either component, so
|
|
11
|
+
-- the concatenation does not determine the pair. Concretely, and measured on this
|
|
12
|
+
-- schema rather than asserted:
|
|
13
|
+
--
|
|
14
|
+
-- issuer='https://idp.example' subject='user|123' -> fed-human-7abc9cea64fb2629f7780b96c4570573
|
|
15
|
+
-- issuer='https://idp.example|user' subject='123' -> fed-human-7abc9cea64fb2629f7780b96c4570573
|
|
16
|
+
--
|
|
17
|
+
-- Two distinct principals, one slug. And the slug is not a label: it is fed to
|
|
18
|
+
-- _ensure_agent_auth_user, which turns it into 'agent+<slug>@agent-board.local' and
|
|
19
|
+
-- finds-or-creates the auth.users row. The ambiguous value IS the account key.
|
|
20
|
+
--
|
|
21
|
+
-- WHAT IS INJECTIVE ABOUT THE REPLACEMENT. The new serialization is the netstring
|
|
22
|
+
-- form
|
|
23
|
+
--
|
|
24
|
+
-- length(issuer) || ':' || issuer || ':' || length(subject) || ':' || subject
|
|
25
|
+
--
|
|
26
|
+
-- and its injectivity is a decoding argument, not a "looks different" argument:
|
|
27
|
+
-- reading left to right, the digits before the first ':' are a decimal count n1 (the
|
|
28
|
+
-- delimiter is unambiguous because a decimal count contains no ':'); consume exactly
|
|
29
|
+
-- the next n1 characters -- whatever they contain, including ':' or '|' -- and that
|
|
30
|
+
-- IS the issuer; require a ':'; repeat for the subject; require end of string. Every
|
|
31
|
+
-- serialized string therefore has exactly one preimage pair, so distinct pairs
|
|
32
|
+
-- produce distinct serializations, and convert_to(..., 'UTF8') is itself injective on
|
|
33
|
+
-- text. Under the old form no such decoder exists, which is the whole defect.
|
|
34
|
+
--
|
|
35
|
+
-- The 32-hex truncation is UNCHANGED and is a different property. Truncating SHA-256
|
|
36
|
+
-- to 128 bits bounds collisions at ~2^64 birthday work -- not constructible. The
|
|
37
|
+
-- defect being fixed here was constructible with a text editor. Widening the digest
|
|
38
|
+
-- would have moved every slug for a second, unrelated reason; it is deliberately not
|
|
39
|
+
-- bundled in.
|
|
40
|
+
--
|
|
41
|
+
-- ONE HELPER, NOT SIX COPIES. agent_board._federated_identity_slug(issuer, subject)
|
|
42
|
+
-- is the only place the serialization exists. Catalog-derived, at the vintage this
|
|
43
|
+
-- migration was written (2026-08-16), the live derivation sites were:
|
|
44
|
+
--
|
|
45
|
+
-- mqccjfqngptkemnchlko (canonical/prod, older bundle): 2 functions, 2 occurrences
|
|
46
|
+
-- enroll_federated_human, enroll_federated_identity
|
|
47
|
+
-- bxrbtugitzwgazmoslrx (Portal) and repo HEAD: 5 functions, 6 occurrences
|
|
48
|
+
-- enroll_federated_human, _enroll_federated_human_after_revocation_gate,
|
|
49
|
+
-- enroll_federated_identity (x2), _enroll_federated_identity_once,
|
|
50
|
+
-- onboard_federated_fleet_agent
|
|
51
|
+
--
|
|
52
|
+
-- The two lists differ because 20260903000100 / 20260906000100 / 20260915000100
|
|
53
|
+
-- restructured the call chain after prod's bundle was cut. Nobody hand-ports: prod
|
|
54
|
+
-- adopts the bundle IN ORDER, so it is already at the 5-function shape by the time
|
|
55
|
+
-- this migration applies. All 5 are rebuilt below from their catalog definitions
|
|
56
|
+
-- (pg_get_functiondef on a full replay), not from the migration files that first
|
|
57
|
+
-- introduced them -- the file that introduced a derivation is usually not the file
|
|
58
|
+
-- holding the live one.
|
|
59
|
+
--
|
|
60
|
+
-- THE HELPER RAISES ON A NULL OR EMPTY COMPONENT. These are two different cases and an
|
|
61
|
+
-- earlier version of this comment ran them together; separating them, because one of them
|
|
62
|
+
-- IS a behaviour change to a path that previously succeeded.
|
|
63
|
+
--
|
|
64
|
+
-- NULL is the harmless half. 'fed-human-' || NULL is NULL, _ensure_agent_auth_user(NULL)
|
|
65
|
+
-- builds 'agent+@agent-board.local', and every null-issuer caller converges on ONE
|
|
66
|
+
-- degenerate shared owner. Four of the five sites gate on identity_providers (which a NULL
|
|
67
|
+
-- issuer can never match) before deriving, so the raise is unreachable for them;
|
|
68
|
+
-- onboard_federated_fleet_agent derives BEFORE any issuer check, so there it fires earlier
|
|
69
|
+
-- than the delegate's own 22023 null-subject refusal. Nothing that succeeded stops
|
|
70
|
+
-- succeeding.
|
|
71
|
+
--
|
|
72
|
+
-- EMPTY STRING IS NOT THAT, and this migration DOES change it. The identity path's guard
|
|
73
|
+
-- is `if p_subject is null then raise` (enroll_federated_identity, live catalog) -- `is
|
|
74
|
+
-- null`, not `= ''`. So an allowlisted issuer presenting subject = '' passed every gate,
|
|
75
|
+
-- and under the old encoding derived issuer || '|' || '' -- an ISSUER-SPECIFIC slug, not
|
|
76
|
+
-- the degenerate shared one: two different issuers with an empty subject derived two
|
|
77
|
+
-- different owners. That was a genuine success and it now raises 22023. It is judged the
|
|
78
|
+
-- right refusal -- an empty subject identifies no principal, and an owner keyed on the
|
|
79
|
+
-- issuer alone is a whole-issuer account rather than a person's -- but it is a REFUSAL OF
|
|
80
|
+
-- SOMETHING THAT WORKED, not a no-op, and any adopter with such a row must be told rather
|
|
81
|
+
-- than discover it. Measured on the canonical project 2026-08-16: 0 agents reachable from
|
|
82
|
+
-- a fed-human owner carry an empty subject, so the live population is unaffected.
|
|
83
|
+
--
|
|
84
|
+
-- The backfill inherits the same distinction, and imperfectly: its recovery filter below
|
|
85
|
+
-- requires the stamp fields to be `is not null`, not non-empty, so a PRE-EXISTING
|
|
86
|
+
-- subject = '' stamp is accepted as a candidate and then raises inside the helper at the
|
|
87
|
+
-- v_new := line -- a correct refusal reached by an awkward route, because that raise
|
|
88
|
+
-- carries the helper's generic message and not the loop's uid-naming one. The population
|
|
89
|
+
-- for this is 0 on both projects; it is recorded rather than fixed because tightening the
|
|
90
|
+
-- filter is a behavioural change to a body four review seats have already cleared, and it
|
|
91
|
+
-- belongs in its own change with its own mutation.
|
|
92
|
+
--
|
|
93
|
+
-- NO DUAL DERIVATION, DELIBERATELY. There is no legacy arm, no fallback lookup, and
|
|
94
|
+
-- no legacy decoder left in the catalog. A permanent compatibility path would preserve
|
|
95
|
+
-- exactly the ambiguity this change exists to remove -- a caller who can still be
|
|
96
|
+
-- resolved by the old encoding is still a caller whose pair is unrecoverable. The old
|
|
97
|
+
-- expression appears exactly once more in this file, inside the backfill's DO block,
|
|
98
|
+
-- as the PROOF that each row being re-keyed is the row its recovered pair derives to.
|
|
99
|
+
-- That text is not a function; nothing in pg_proc retains it, which the pgTAP class
|
|
100
|
+
-- assertion checks catalog-side rather than by reading this file.
|
|
101
|
+
--
|
|
102
|
+
-- THE BACKFILL IS THE POINT, NOT A TIDY-UP. Changing the derivation without moving
|
|
103
|
+
-- auth.users.email ORPHANS the existing owner: the email still encodes the old slug,
|
|
104
|
+
-- so enroll_federated_identity's #244 resumption lookup (a pure lookup on
|
|
105
|
+
-- 'agent+<slug>@agent-board.local') misses, v_derived_owner_user_id comes back null,
|
|
106
|
+
-- the non-null owner_user_id conjunct fails, and the owner cannot resume. That reads
|
|
107
|
+
-- like a cosmetic relabel and is a break. Four surfaces carry the slug and all four
|
|
108
|
+
-- move together: auth.users.email, auth.users.raw_user_meta_data->>'slug',
|
|
109
|
+
-- auth.identities (provider_id and identity_data->>'sub'/'slug'), and
|
|
110
|
+
-- agent_board.agents.handle where the handle IS the slug (the human path's own agent
|
|
111
|
+
-- row; agents enrolled on the identity/fleet paths carry their own handles and only
|
|
112
|
+
-- reference the owner by uuid).
|
|
113
|
+
--
|
|
114
|
+
-- THE OWNER UUID NEVER CHANGES. The backfill rewrites the email that keys the lookup,
|
|
115
|
+
-- not the auth.users.id it resolves to. Every agents.owner_user_id, every
|
|
116
|
+
-- agents.auth_user_id, every issued session and every foreign key still points at the
|
|
117
|
+
-- same uuid. This is a re-key of the lookup value, not a re-mint of the principal.
|
|
118
|
+
--
|
|
119
|
+
-- POPULATION, RE-MEASURED READ-ONLY ON 2026-08-16 (management API, no writes):
|
|
120
|
+
-- mqccjfqngptkemnchlko : 1 auth.users row matching agent+fed-human-%@agent-board.local
|
|
121
|
+
-- uid bdd9671f-df9e-4ee5-8115-2a22b7daa441
|
|
122
|
+
-- slug 77213f39... -> 4fa7e7ad... under the new serialization
|
|
123
|
+
-- 1 agent on that auth_user_id, 6 agents owned by it,
|
|
124
|
+
-- 5 signing keys, 5 board memberships, ONE distinct
|
|
125
|
+
-- (issuer, subject) pair across all six stamps, and that pair
|
|
126
|
+
-- re-derives 77213f39... under the OLD encoding exactly.
|
|
127
|
+
-- bxrbtugitzwgazmoslrx : 0 rows. Portal's side of this change is code-only.
|
|
128
|
+
-- The count is taken on the OWNER UID, not on agents.handle: the resumption path
|
|
129
|
+
-- selects on auth_user_id, so a handle-keyed count is the wrong denominator.
|
|
130
|
+
-- The raw (issuer, subject) is a real person's identity and is deliberately NOT
|
|
131
|
+
-- recorded here; the slugs above are one-way digests of it.
|
|
132
|
+
--
|
|
133
|
+
-- THIS MIGRATION MUST BE APPLIED INSIDE A TRANSACTION, and the fail-closed argument below
|
|
134
|
+
-- is only true if it is. Every raise in the backfill assumes that aborting rolls the WHOLE
|
|
135
|
+
-- file back. Applied statement-at-a-time -- which is what `psql -v ON_ERROR_STOP=1 -f`
|
|
136
|
+
-- does, autocommit per statement, and it is how this repo's own replay harness runs
|
|
137
|
+
-- (scripts/run-agent-board-schema-tests.sh) -- an abort in the backfill leaves the six
|
|
138
|
+
-- CREATE OR REPLACE statements COMMITTED and every auth.users.email unmoved. That is
|
|
139
|
+
-- exactly the orphaned-owner state the TRAP paragraph above exists to prevent, reached by
|
|
140
|
+
-- the guard that was supposed to prevent it; and an enrollment of the same pair during
|
|
141
|
+
-- that window would mint a SECOND owner under the new email and then permanently trip the
|
|
142
|
+
-- collision guard on any retry. Supabase's migration runner wraps each file in a
|
|
143
|
+
-- transaction, which is why the replay is safe in practice -- but an operator applying
|
|
144
|
+
-- this file by hand must wrap it (BEGIN; \i <file>; COMMIT;) or run it through a runner
|
|
145
|
+
-- that does. This precondition is load-bearing and was previously unwritten.
|
|
146
|
+
--
|
|
147
|
+
-- FAIL CLOSED ON A ROW IT CANNOT PROVE. For each affected auth.users row the backfill
|
|
148
|
+
-- recovers the pair from the federation stamps of the agents that reference it (by
|
|
149
|
+
-- auth_user_id -- the human path -- or by owner_user_id -- the identity/fleet paths),
|
|
150
|
+
-- KEEPING ONLY pairs whose OLD derivation reproduces that row's current slug, and
|
|
151
|
+
-- requires exactly one DISTINCT such pair. Zero (no stamp, or no stamp that
|
|
152
|
+
-- reproduces) or more than one (a genuine collision) raises and aborts the migration
|
|
153
|
+
-- rather than guessing, because guessing here silently binds an account to the wrong
|
|
154
|
+
-- human. DISTINCT is load-bearing and is not count(*): production's row has six agents
|
|
155
|
+
-- carrying the same pair, and a count of rows would refuse it.
|
|
156
|
+
|
|
157
|
+
create or replace function agent_board._federated_identity_slug(
|
|
158
|
+
p_issuer text,
|
|
159
|
+
p_subject text
|
|
160
|
+
)
|
|
161
|
+
returns text
|
|
162
|
+
language plpgsql
|
|
163
|
+
immutable
|
|
164
|
+
set search_path = ''
|
|
165
|
+
as $fn$
|
|
166
|
+
begin
|
|
167
|
+
-- Fail closed rather than derive a degenerate shared slug from a missing component.
|
|
168
|
+
if p_issuer is null or p_issuer = '' or p_subject is null or p_subject = '' then
|
|
169
|
+
raise exception 'federated identity slug requires a non-empty issuer and subject'
|
|
170
|
+
using errcode = '22023';
|
|
171
|
+
end if;
|
|
172
|
+
|
|
173
|
+
-- Netstring serialization: <len>:<value>:<len>:<value>. Injective -- see the header.
|
|
174
|
+
-- The components are hashed VERBATIM; no trimming or case folding, because any
|
|
175
|
+
-- normalization here would silently move an existing identity.
|
|
176
|
+
return 'fed-human-' || substr(
|
|
177
|
+
encode(
|
|
178
|
+
extensions.digest(
|
|
179
|
+
convert_to(
|
|
180
|
+
length(p_issuer)::text || ':' || p_issuer || ':' ||
|
|
181
|
+
length(p_subject)::text || ':' || p_subject,
|
|
182
|
+
'UTF8'),
|
|
183
|
+
'sha256'),
|
|
184
|
+
'hex'),
|
|
185
|
+
1, 32);
|
|
186
|
+
end;
|
|
187
|
+
$fn$;
|
|
188
|
+
|
|
189
|
+
comment on function agent_board._federated_identity_slug(text, text) is
|
|
190
|
+
'message-board#246: the ONE injective (issuer, subject) -> fed-human-<32hex> serialization. Netstring length-prefixed, so the pair is recoverable from the preimage and distinct pairs cannot share a slug. Every federated derivation site calls this; the expression exists nowhere else. Raises 22023 on a null/empty component rather than deriving a shared degenerate slug.';
|
|
191
|
+
|
|
192
|
+
revoke all on function agent_board._federated_identity_slug(text, text) from public;
|
|
193
|
+
grant execute on function agent_board._federated_identity_slug(text, text) to service_role;
|
|
194
|
+
|
|
195
|
+
-- ---------------------------------------------------------------------------
|
|
196
|
+
-- The five live derivation sites, rebuilt from their catalog definitions with the
|
|
197
|
+
-- inline expression replaced by the helper call. create or replace (never drop)
|
|
198
|
+
-- so each function keeps its ACL and its COMMENT.
|
|
199
|
+
-- ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
CREATE OR REPLACE FUNCTION agent_board.enroll_federated_human(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_display_name text DEFAULT NULL::text)
|
|
202
|
+
RETURNS TABLE(agent_id uuid, auth_user_id uuid, handle text, kind text, status text, provider_id text, created_at timestamp with time zone)
|
|
203
|
+
LANGUAGE plpgsql
|
|
204
|
+
SECURITY DEFINER
|
|
205
|
+
SET search_path TO 'agent_board', 'public'
|
|
206
|
+
AS $function$
|
|
207
|
+
declare
|
|
208
|
+
v_provider_id agent_board.identity_providers.id%type;
|
|
209
|
+
v_default_board_id uuid;
|
|
210
|
+
v_locked_default_board_id uuid;
|
|
211
|
+
v_handle text;
|
|
212
|
+
v_uid uuid;
|
|
213
|
+
begin
|
|
214
|
+
if not agent_board._is_service_role() then
|
|
215
|
+
raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
|
|
216
|
+
end if;
|
|
217
|
+
if p_subject is null then
|
|
218
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
219
|
+
end if;
|
|
220
|
+
|
|
221
|
+
-- Snapshot the provider's auto-join target without a row lock so a non-null
|
|
222
|
+
-- target can be locked in canonical board -> provider order. Then lock the
|
|
223
|
+
-- provider row and require that the target did not change across the gap. A
|
|
224
|
+
-- concurrent NULL -> board designation is rejected for retry rather than
|
|
225
|
+
-- falling back to provider -> board and recreating the cross-route cycle.
|
|
226
|
+
select ip.id, ip.default_board_id
|
|
227
|
+
into v_provider_id, v_default_board_id
|
|
228
|
+
from agent_board.identity_providers ip
|
|
229
|
+
where ip.issuer = p_issuer
|
|
230
|
+
and ip.audience = p_audience
|
|
231
|
+
limit 1;
|
|
232
|
+
|
|
233
|
+
-- #193 PROVIDER-ABSENCE GATE, and it must come BEFORE the owner is provisioned
|
|
234
|
+
-- or locked. PostgreSQL cannot row-lock a row that does not exist, so an absent
|
|
235
|
+
-- provider left NOTHING to serialize on: this function used to skip the whole
|
|
236
|
+
-- board/provider ordering, lock the owner, and delegate -- and under READ
|
|
237
|
+
-- COMMITTED the delegate takes a FRESH snapshot, so a provider committed in that
|
|
238
|
+
-- gap became visible and its default board was auto-joined while this
|
|
239
|
+
-- transaction held auth.users. That is owner -> board, inverted against fleet
|
|
240
|
+
-- onboarding's board -> owner, and it deadlocked.
|
|
241
|
+
--
|
|
242
|
+
-- Rejecting here is a VALID serialization -- it orders this call before the
|
|
243
|
+
-- provider's creation, which is exactly what its own snapshot saw -- and it is
|
|
244
|
+
-- the same verdict _enroll_federated_human_after_revocation_gate step (4) would
|
|
245
|
+
-- have reached anyway for an allowlist miss, only now reached before any lock.
|
|
246
|
+
-- The message differs from the delegate's so a test can tell which gate fired.
|
|
247
|
+
-- NOTE: this is a permanent 42501, not a retryable 40001. A genuinely
|
|
248
|
+
-- unallowlisted issuer is a configuration error, and telling a caller to retry it
|
|
249
|
+
-- forever is worse than telling it no; the rare loser of a real provider-creation
|
|
250
|
+
-- race succeeds on its next /portal-session call.
|
|
251
|
+
if v_provider_id is null then
|
|
252
|
+
raise exception 'issuer/audience not in the identity-provider allowlist (rejected before any owner or board lock)'
|
|
253
|
+
using errcode = '42501';
|
|
254
|
+
end if;
|
|
255
|
+
|
|
256
|
+
if v_default_board_id is not null then
|
|
257
|
+
perform 1 from agent_board.boards b
|
|
258
|
+
where b.id = v_default_board_id
|
|
259
|
+
for update;
|
|
260
|
+
if not found then
|
|
261
|
+
raise exception 'federated human default board changed during enrollment'
|
|
262
|
+
using errcode = '40001';
|
|
263
|
+
end if;
|
|
264
|
+
end if;
|
|
265
|
+
|
|
266
|
+
-- Freeze the designation this transaction will delegate against. The provider row
|
|
267
|
+
-- is held FOR UPDATE through commit, and (issuer, audience) is UNIQUE, so the
|
|
268
|
+
-- delegate's own lookup cannot resolve to a different row or a changed
|
|
269
|
+
-- default_board_id than the one locked above.
|
|
270
|
+
select ip.default_board_id
|
|
271
|
+
into v_locked_default_board_id
|
|
272
|
+
from agent_board.identity_providers ip
|
|
273
|
+
where ip.id = v_provider_id
|
|
274
|
+
for update;
|
|
275
|
+
if not found or v_locked_default_board_id is distinct from v_default_board_id then
|
|
276
|
+
raise exception 'federated human default board changed during enrollment'
|
|
277
|
+
using errcode = '40001';
|
|
278
|
+
end if;
|
|
279
|
+
|
|
280
|
+
v_handle := agent_board._federated_identity_slug(p_issuer, p_subject);
|
|
281
|
+
v_uid := agent_board._ensure_agent_auth_user(v_handle);
|
|
282
|
+
|
|
283
|
+
perform 1 from auth.users u where u.id = v_uid for update;
|
|
284
|
+
if not found then
|
|
285
|
+
raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
|
|
286
|
+
end if;
|
|
287
|
+
if exists (
|
|
288
|
+
select 1
|
|
289
|
+
from agent_board.fleet_human_revocations r
|
|
290
|
+
where r.issuer = p_issuer
|
|
291
|
+
and r.subject = p_subject
|
|
292
|
+
) then
|
|
293
|
+
raise exception 'human identity is durably revoked' using errcode = '42501';
|
|
294
|
+
end if;
|
|
295
|
+
|
|
296
|
+
return query
|
|
297
|
+
select * from agent_board._enroll_federated_human_after_revocation_gate(
|
|
298
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
|
|
299
|
+
);
|
|
300
|
+
end;
|
|
301
|
+
$function$
|
|
302
|
+
|
|
303
|
+
;
|
|
304
|
+
|
|
305
|
+
CREATE OR REPLACE FUNCTION agent_board._enroll_federated_human_after_revocation_gate(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_display_name text DEFAULT NULL::text)
|
|
306
|
+
RETURNS TABLE(agent_id uuid, auth_user_id uuid, handle text, kind text, status text, provider_id text, created_at timestamp with time zone)
|
|
307
|
+
LANGUAGE plpgsql
|
|
308
|
+
SECURITY DEFINER
|
|
309
|
+
SET search_path TO 'agent_board', 'public'
|
|
310
|
+
AS $function$
|
|
311
|
+
declare
|
|
312
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
313
|
+
v_handle text;
|
|
314
|
+
v_uid uuid;
|
|
315
|
+
v_agent agent_board.agents%rowtype;
|
|
316
|
+
v_existing agent_board.agents%rowtype;
|
|
317
|
+
v_email text := nullif(trim(coalesce(p_human_identity_claim->>'email', '')), '');
|
|
318
|
+
v_email_verified boolean;
|
|
319
|
+
begin
|
|
320
|
+
-- Derive the verified-email boolean from the claim (never trust it as board-proven
|
|
321
|
+
-- possession; it only records what the Portal asserted). A non-boolean/absent value
|
|
322
|
+
-- collapses to false.
|
|
323
|
+
begin
|
|
324
|
+
v_email_verified := coalesce((p_human_identity_claim->>'email_verified')::boolean, false);
|
|
325
|
+
exception when others then
|
|
326
|
+
v_email_verified := false;
|
|
327
|
+
end;
|
|
328
|
+
|
|
329
|
+
-- (1) service_role-only (defense in depth on top of the execute-grant revocation).
|
|
330
|
+
if not agent_board._is_service_role() then
|
|
331
|
+
raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
|
|
332
|
+
end if;
|
|
333
|
+
|
|
334
|
+
-- (2) A verified subject + human identity claim are always present from the EF.
|
|
335
|
+
if p_subject is null then
|
|
336
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
337
|
+
end if;
|
|
338
|
+
if p_human_identity_claim is null then
|
|
339
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
340
|
+
end if;
|
|
341
|
+
|
|
342
|
+
-- (3) F-11: the synthetic-uid preimage is issuer||'|'||subject. Guard the separator so a
|
|
343
|
+
-- subject cannot inject a '|' to collide two distinct (issuer, subject) pairs onto one
|
|
344
|
+
-- owner uid. ALSO reject an email-shaped subject ('@'): the subject is stored VERBATIM in
|
|
345
|
+
-- agents.metadata.federation.subject, and the agents SELECT RLS is not row-scoped, so an
|
|
346
|
+
-- email `sub` would be a fleet-harvestable PII leak — the exact hazard the email is kept
|
|
347
|
+
-- out of agents.metadata to avoid. The operator's ENROLL_SUBJECT_CLAIM must resolve to an
|
|
348
|
+
-- opaque id; this is the fail-closed backstop, and the pgTAP PII guard asserts BOTH the
|
|
349
|
+
-- '@'-rejection and metadata carrying no '@'.
|
|
350
|
+
if position('|' in p_subject) > 0 then
|
|
351
|
+
raise exception 'verified subject must not contain a pipe separator character'
|
|
352
|
+
using errcode = '22023';
|
|
353
|
+
end if;
|
|
354
|
+
if position('@' in p_subject) > 0 then
|
|
355
|
+
raise exception 'verified subject must be an opaque id, not an email/@-bearing value'
|
|
356
|
+
using errcode = '22023';
|
|
357
|
+
end if;
|
|
358
|
+
|
|
359
|
+
-- (4) provider gate on (issuer, audience): must exist + supported + policy_approved.
|
|
360
|
+
-- Defense in depth atop the EF's signature verify (mirrors 20260719001200:154-181).
|
|
361
|
+
select *
|
|
362
|
+
into v_provider
|
|
363
|
+
from agent_board.identity_providers
|
|
364
|
+
where issuer = p_issuer
|
|
365
|
+
and audience = p_audience
|
|
366
|
+
limit 1;
|
|
367
|
+
if v_provider.id is null then
|
|
368
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
369
|
+
end if;
|
|
370
|
+
if not v_provider.supported then
|
|
371
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
372
|
+
end if;
|
|
373
|
+
if not v_provider.policy_approved then
|
|
374
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
375
|
+
end if;
|
|
376
|
+
|
|
377
|
+
-- (5) HUMAN-ONLY ceiling. allowed_agent_kinds is permissive on empty/null (the ceiling
|
|
378
|
+
-- is "unset"); reject ONLY when it is non-empty AND does not include 'human'.
|
|
379
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
380
|
+
and not ('human' = any (v_provider.allowed_agent_kinds)) then
|
|
381
|
+
raise exception 'provider ceiling does not permit the human kind' using errcode = '42501';
|
|
382
|
+
end if;
|
|
383
|
+
|
|
384
|
+
-- (6) SYNTHETIC uid — derived by agent_board._federated_identity_slug, the ONE
|
|
385
|
+
-- serialization every federated site shares (#246). "Byte-identical to
|
|
386
|
+
-- enroll_federated_identity" was a copied expression and a promise; it is now a single
|
|
387
|
+
-- call, so the sites cannot drift apart and the old line citation cannot go stale.
|
|
388
|
+
-- Idempotent per (issuer, sub) so re-provisioning converges to ONE owner auth.users row
|
|
389
|
+
-- = the shared IDLINK uid. Pass issuer/subject VERBATIM (no trim/normalize): the helper
|
|
390
|
+
-- length-prefixes them before hashing, so normalizing here would move the identity.
|
|
391
|
+
v_handle := agent_board._federated_identity_slug(p_issuer, p_subject);
|
|
392
|
+
v_uid := agent_board._ensure_agent_auth_user(v_handle);
|
|
393
|
+
|
|
394
|
+
-- (7) Idempotent + no-self-reactivation, keyed on auth_user_id = the synthetic uid (ANY
|
|
395
|
+
-- status). An ACTIVE row -> return it (idempotent). A NON-active row -> fail closed
|
|
396
|
+
-- (42501): a suspended/revoked human must NOT re-mint a session (mirrors
|
|
397
|
+
-- 20260726000100:78-81 and reinforces the F-13 revocation lever). F-5: assert the row is
|
|
398
|
+
-- human before returning it as a human session (a non-human agent on this owner uid must
|
|
399
|
+
-- never yield a "human" provision).
|
|
400
|
+
select *
|
|
401
|
+
into v_existing
|
|
402
|
+
from agent_board.agents a
|
|
403
|
+
where a.auth_user_id = v_uid
|
|
404
|
+
order by (a.status = 'active') desc, a.created_at asc
|
|
405
|
+
limit 1;
|
|
406
|
+
if found then
|
|
407
|
+
-- (7a) #246 STORED-IDENTITY EQUALITY. The select above keys on the DERIVED uid
|
|
408
|
+
-- and nothing else, and the derivation is not injective, so `found` does not
|
|
409
|
+
-- mean "the caller's own agent". Require the pair this row was stamped with at
|
|
410
|
+
-- creation to equal the pair the caller presented. coalesce(..., false): a row
|
|
411
|
+
-- with no stored stamp yields NULL, which must read as NOT-mine, not as
|
|
412
|
+
-- unchecked. First, before the status and kind branches, so a mismatching
|
|
413
|
+
-- caller learns only that it was refused.
|
|
414
|
+
if not coalesce(
|
|
415
|
+
v_existing.metadata #>> '{federation,issuer}' = p_issuer
|
|
416
|
+
and v_existing.metadata #>> '{federation,subject}' = p_subject, false) then
|
|
417
|
+
raise exception 'federated identity mismatch: the owner derived for this issuer/subject is already bound to a different verified identity; refusing to issue a session'
|
|
418
|
+
using errcode = '42501';
|
|
419
|
+
end if;
|
|
420
|
+
if v_existing.status = 'active' then
|
|
421
|
+
if v_existing.kind <> 'human' then
|
|
422
|
+
raise exception 'existing agent on this identity is not human; refusing human session'
|
|
423
|
+
using errcode = '42501';
|
|
424
|
+
end if;
|
|
425
|
+
v_agent := v_existing;
|
|
426
|
+
-- Refresh the verified-email snapshot on re-provision (MERGE, see step 9).
|
|
427
|
+
if v_email is not null then
|
|
428
|
+
update auth.users u
|
|
429
|
+
set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
|
|
430
|
+
|| jsonb_build_object(
|
|
431
|
+
'fedsession_email', v_email,
|
|
432
|
+
'fedsession_email_verified', v_email_verified,
|
|
433
|
+
'fedsession_email_provenance', 'portal-claim-unverified')
|
|
434
|
+
where u.id = v_uid;
|
|
435
|
+
end if;
|
|
436
|
+
-- (auto-join before the idempotent return — see step 11 for the rationale).
|
|
437
|
+
perform agent_board._fedsession_autojoin_default_board(v_provider, v_agent.id);
|
|
438
|
+
insert into agent_board.enrollment_federation_audit(
|
|
439
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
440
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
|
|
441
|
+
values (
|
|
442
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
443
|
+
v_agent.id, v_uid, '{}', '{}', 'fedsession');
|
|
444
|
+
return query
|
|
445
|
+
select v_agent.id, v_agent.auth_user_id, v_agent.handle,
|
|
446
|
+
v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
|
|
447
|
+
return;
|
|
448
|
+
else
|
|
449
|
+
raise exception 'a non-active identity already exists for this human; contact an operator'
|
|
450
|
+
using errcode = '42501';
|
|
451
|
+
end if;
|
|
452
|
+
end if;
|
|
453
|
+
|
|
454
|
+
-- (8) Create the kind='human' agent (self-owned: auth_user_id = owner_user_id = uid).
|
|
455
|
+
-- No agent_signing_keys row (humans hold no key). metadata.federation carries ONLY the
|
|
456
|
+
-- boolean email_verified — never the address (PII / fleet-readable RLS).
|
|
457
|
+
begin
|
|
458
|
+
insert into agent_board.agents(
|
|
459
|
+
agent_type,
|
|
460
|
+
kind,
|
|
461
|
+
handle,
|
|
462
|
+
display_name,
|
|
463
|
+
status,
|
|
464
|
+
auth_user_id,
|
|
465
|
+
owner_user_id,
|
|
466
|
+
metadata
|
|
467
|
+
)
|
|
468
|
+
values (
|
|
469
|
+
'human',
|
|
470
|
+
'human'::agent_board.agent_kind,
|
|
471
|
+
v_handle,
|
|
472
|
+
coalesce(nullif(trim(p_display_name), ''), 'Human'),
|
|
473
|
+
'active',
|
|
474
|
+
v_uid,
|
|
475
|
+
v_uid,
|
|
476
|
+
jsonb_build_object(
|
|
477
|
+
'federation', jsonb_build_object(
|
|
478
|
+
'mode', 'fedsession',
|
|
479
|
+
'issuer', p_issuer,
|
|
480
|
+
'audience', p_audience,
|
|
481
|
+
'subject', p_subject,
|
|
482
|
+
'providerId', v_provider.id,
|
|
483
|
+
'email_verified', v_email_verified,
|
|
484
|
+
'enrolledAt', now()
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
returning * into v_agent;
|
|
489
|
+
exception when unique_violation then
|
|
490
|
+
-- Concurrent first-provision race on the synthetic uid. F-5: re-select the ACTIVE row
|
|
491
|
+
-- and ASSERT it is human (fail closed otherwise) — never return a non-human agent as a
|
|
492
|
+
-- human session.
|
|
493
|
+
select *
|
|
494
|
+
into v_existing
|
|
495
|
+
from agent_board.agents a
|
|
496
|
+
where a.auth_user_id = v_uid
|
|
497
|
+
and a.status = 'active'
|
|
498
|
+
and a.kind = 'human'
|
|
499
|
+
limit 1;
|
|
500
|
+
if found then
|
|
501
|
+
-- (8a) #246 STORED-IDENTITY EQUALITY, second return site. Same gate, same
|
|
502
|
+
-- shape, same message as (7a): this handler returns an EXISTING row on the
|
|
503
|
+
-- derived uid, so it needs the same proof that the row is the caller's. The
|
|
504
|
+
-- interleaving that reaches it is concurrency-only -- (7)'s unfiltered select
|
|
505
|
+
-- intercepts any already-committed row on this uid -- which is why this site
|
|
506
|
+
-- is pinned structurally by the class assertion rather than differentially by
|
|
507
|
+
-- a single-session test.
|
|
508
|
+
if not coalesce(
|
|
509
|
+
v_existing.metadata #>> '{federation,issuer}' = p_issuer
|
|
510
|
+
and v_existing.metadata #>> '{federation,subject}' = p_subject, false) then
|
|
511
|
+
raise exception 'federated identity mismatch: the owner derived for this issuer/subject is already bound to a different verified identity; refusing to issue a session'
|
|
512
|
+
using errcode = '42501';
|
|
513
|
+
end if;
|
|
514
|
+
v_agent := v_existing;
|
|
515
|
+
else
|
|
516
|
+
raise exception 'could not provision federated human (race or non-human on owner uid), please retry'
|
|
517
|
+
using errcode = '40001';
|
|
518
|
+
end if;
|
|
519
|
+
end;
|
|
520
|
+
|
|
521
|
+
-- (9) F-12/F-4: store the verified email ONLY in auth.users.raw_user_meta_data
|
|
522
|
+
-- (service-role-only), MERGED so _ensure_agent_auth_user's {slug, issuer} is preserved,
|
|
523
|
+
-- tagged provenance='portal-claim-unverified'. The definer owner (postgres) owns auth.*.
|
|
524
|
+
if v_email is not null then
|
|
525
|
+
update auth.users u
|
|
526
|
+
set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
|
|
527
|
+
|| jsonb_build_object(
|
|
528
|
+
'fedsession_email', v_email,
|
|
529
|
+
'fedsession_email_verified', v_email_verified,
|
|
530
|
+
'fedsession_email_provenance', 'portal-claim-unverified')
|
|
531
|
+
where u.id = v_uid;
|
|
532
|
+
end if;
|
|
533
|
+
|
|
534
|
+
-- (10) FEDSESSION auto-join (new). Baseline default-board membership for a fresh fed-human.
|
|
535
|
+
perform agent_board._fedsession_autojoin_default_board(v_provider, v_agent.id);
|
|
536
|
+
|
|
537
|
+
-- (11) F-6: append the audit row (mode='fedsession'). No requested/granted scopes on the
|
|
538
|
+
-- human path (authority is membership/role, not enrollment scopes) -> empty arrays.
|
|
539
|
+
insert into agent_board.enrollment_federation_audit(
|
|
540
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
541
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
|
|
542
|
+
values (
|
|
543
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
544
|
+
v_agent.id, v_uid, '{}', '{}', 'fedsession');
|
|
545
|
+
|
|
546
|
+
return query
|
|
547
|
+
select v_agent.id, v_agent.auth_user_id, v_agent.handle,
|
|
548
|
+
v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
|
|
549
|
+
end;
|
|
550
|
+
$function$
|
|
551
|
+
|
|
552
|
+
;
|
|
553
|
+
|
|
554
|
+
CREATE OR REPLACE FUNCTION agent_board._enroll_federated_identity_once(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text)
|
|
555
|
+
RETURNS TABLE(agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, invite_redeemed boolean)
|
|
556
|
+
LANGUAGE plpgsql
|
|
557
|
+
SECURITY DEFINER
|
|
558
|
+
SET search_path TO 'agent_board', 'public'
|
|
559
|
+
AS $function$
|
|
560
|
+
declare
|
|
561
|
+
v_slug text;
|
|
562
|
+
v_kind text;
|
|
563
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
564
|
+
v_owner_user_id uuid;
|
|
565
|
+
v_auth_user_id uuid;
|
|
566
|
+
v_agent_handle text;
|
|
567
|
+
v_agent_id uuid;
|
|
568
|
+
v_signing_key_id uuid;
|
|
569
|
+
v_fingerprint text;
|
|
570
|
+
v_key_hex text;
|
|
571
|
+
v_raw_key bytea;
|
|
572
|
+
v_reencoded text;
|
|
573
|
+
v_key_id text := 'portal-enroll';
|
|
574
|
+
v_key_version int := 1;
|
|
575
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
576
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
577
|
+
v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
|
|
578
|
+
v_invite_redeemed boolean := false;
|
|
579
|
+
v_invite_board_id uuid;
|
|
580
|
+
v_existing_handle text;
|
|
581
|
+
begin
|
|
582
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
583
|
+
if not agent_board._is_service_role() then
|
|
584
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
585
|
+
end if;
|
|
586
|
+
|
|
587
|
+
-- A verified subject + human identity claim are always present from the EF.
|
|
588
|
+
if p_subject is null then
|
|
589
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
590
|
+
end if;
|
|
591
|
+
if p_human_identity_claim is null then
|
|
592
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
593
|
+
end if;
|
|
594
|
+
|
|
595
|
+
-- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
|
|
596
|
+
-- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
|
|
597
|
+
if p_pop_verified is not true then
|
|
598
|
+
raise exception 'proof-of-possession was not verified; enrollment refused'
|
|
599
|
+
using errcode = '42501';
|
|
600
|
+
end if;
|
|
601
|
+
|
|
602
|
+
-- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
|
|
603
|
+
-- base64url chars).
|
|
604
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
605
|
+
raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
|
|
606
|
+
end if;
|
|
607
|
+
if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
608
|
+
raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
|
|
609
|
+
using errcode = '22023';
|
|
610
|
+
end if;
|
|
611
|
+
|
|
612
|
+
-- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
|
|
613
|
+
v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
|
|
614
|
+
if octet_length(v_raw_key) <> 32 then
|
|
615
|
+
raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
|
|
616
|
+
using errcode = '22023';
|
|
617
|
+
end if;
|
|
618
|
+
v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
|
|
619
|
+
if v_reencoded is distinct from p_agent_public_key then
|
|
620
|
+
raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
|
|
621
|
+
using errcode = '22023';
|
|
622
|
+
end if;
|
|
623
|
+
|
|
624
|
+
-- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
|
|
625
|
+
if v_raw_key = decode(repeat('00', 32), 'hex') then
|
|
626
|
+
raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
|
|
627
|
+
using errcode = '22023';
|
|
628
|
+
end if;
|
|
629
|
+
|
|
630
|
+
-- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
|
|
631
|
+
-- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
|
|
632
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
633
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
634
|
+
raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
|
|
635
|
+
using errcode = '22023';
|
|
636
|
+
end if;
|
|
637
|
+
|
|
638
|
+
-- (3) act.sub is audit-only, but when present it must be a real value.
|
|
639
|
+
if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
|
|
640
|
+
raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
|
|
641
|
+
end if;
|
|
642
|
+
|
|
643
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
644
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
645
|
+
|
|
646
|
+
-- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
|
|
647
|
+
select *
|
|
648
|
+
into v_provider
|
|
649
|
+
from agent_board.identity_providers
|
|
650
|
+
where issuer = p_issuer
|
|
651
|
+
and audience = p_audience
|
|
652
|
+
limit 1;
|
|
653
|
+
if v_provider.id is null then
|
|
654
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
655
|
+
end if;
|
|
656
|
+
if not v_provider.supported then
|
|
657
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
658
|
+
end if;
|
|
659
|
+
if not v_provider.policy_approved then
|
|
660
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
661
|
+
end if;
|
|
662
|
+
|
|
663
|
+
-- (5) scope-ceiling + kind gate. 'system' is never federatable.
|
|
664
|
+
if v_kind = 'system' then
|
|
665
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
666
|
+
end if;
|
|
667
|
+
if v_kind not in ('human', 'agent') then
|
|
668
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
669
|
+
end if;
|
|
670
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
671
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
672
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
673
|
+
end if;
|
|
674
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
675
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
676
|
+
end if;
|
|
677
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
678
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
679
|
+
end if;
|
|
680
|
+
|
|
681
|
+
-- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound
|
|
682
|
+
-- HERE. #176: this is a BACKSTOP, not the resumption decision. enroll_federated_identity
|
|
683
|
+
-- is this function's only caller (pg_proc-verified) and it resolves an already-enrolled
|
|
684
|
+
-- fingerprint before ever reaching here, so a caller that lands on this raise reached the
|
|
685
|
+
-- internal creator directly. Resumption is deliberately NOT duplicated into this
|
|
686
|
+
-- function: it has no claim-projection equality gate, so an ownership decision made here
|
|
687
|
+
-- would rest on bare parameters rather than on the verified claim. What DID change is
|
|
688
|
+
-- that the raise is actionable -- it names the identity that already holds the key and
|
|
689
|
+
-- says enrollment need not be repeated.
|
|
690
|
+
--
|
|
691
|
+
-- The GATE is still the unchanged `exists` over agent_signing_keys alone. The handle is
|
|
692
|
+
-- looked up separately, for the MESSAGE only, through a join that a hypothetical
|
|
693
|
+
-- agent-less key would not satisfy: folding that join into the gate would have turned a
|
|
694
|
+
-- rendering nicety into a hole that lets such a key be re-bound.
|
|
695
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
|
|
696
|
+
select a.handle into v_existing_handle
|
|
697
|
+
from agent_board.agent_signing_keys k
|
|
698
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
699
|
+
where k.fingerprint = v_fingerprint
|
|
700
|
+
limit 1;
|
|
701
|
+
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)',
|
|
702
|
+
coalesce(v_existing_handle, '(unknown)')
|
|
703
|
+
using errcode = '23505';
|
|
704
|
+
end if;
|
|
705
|
+
|
|
706
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
707
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
708
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
709
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
710
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
711
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
712
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
713
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
714
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
715
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
716
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
717
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
718
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
719
|
+
if v_invite_code is not null then
|
|
720
|
+
select bit.board_id into v_invite_board_id
|
|
721
|
+
from agent_board.board_invite_tokens bit
|
|
722
|
+
where bit.code = v_invite_code
|
|
723
|
+
for update;
|
|
724
|
+
if v_invite_board_id is not null then
|
|
725
|
+
perform 1 from agent_board.boards b
|
|
726
|
+
where b.id = v_invite_board_id
|
|
727
|
+
for key share;
|
|
728
|
+
end if;
|
|
729
|
+
end if;
|
|
730
|
+
|
|
731
|
+
-- (7) HUMAN OWNER — established idempotently per (issuer, sub).
|
|
732
|
+
v_owner_user_id := agent_board._ensure_agent_auth_user(
|
|
733
|
+
agent_board._federated_identity_slug(p_issuer, p_subject));
|
|
734
|
+
|
|
735
|
+
-- (8) AGENT handle — unique per enrolled key.
|
|
736
|
+
v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
|
|
737
|
+
v_agent_handle := v_slug;
|
|
738
|
+
if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
|
|
739
|
+
v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
|
|
740
|
+
end if;
|
|
741
|
+
|
|
742
|
+
-- (9) the agent's OWN board principal (session-usable via its enrolled key).
|
|
743
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
|
|
744
|
+
|
|
745
|
+
-- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
|
|
746
|
+
insert into agent_board.agents(
|
|
747
|
+
agent_type,
|
|
748
|
+
kind,
|
|
749
|
+
handle,
|
|
750
|
+
display_name,
|
|
751
|
+
status,
|
|
752
|
+
auth_user_id,
|
|
753
|
+
owner_user_id,
|
|
754
|
+
metadata
|
|
755
|
+
)
|
|
756
|
+
values (
|
|
757
|
+
v_kind,
|
|
758
|
+
v_kind::agent_board.agent_kind,
|
|
759
|
+
v_agent_handle,
|
|
760
|
+
agent_board._default_provision_display_name(v_agent_handle),
|
|
761
|
+
'active',
|
|
762
|
+
v_auth_user_id,
|
|
763
|
+
v_owner_user_id,
|
|
764
|
+
jsonb_build_object(
|
|
765
|
+
'federation', jsonb_build_object(
|
|
766
|
+
'issuer', p_issuer,
|
|
767
|
+
'audience', p_audience,
|
|
768
|
+
'subject', p_subject,
|
|
769
|
+
'actorSub', p_actor_sub,
|
|
770
|
+
'providerId', v_provider.id,
|
|
771
|
+
'signingKeyFingerprint', v_fingerprint,
|
|
772
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
773
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
774
|
+
'popVerified', true,
|
|
775
|
+
'enrolledAt', now()
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
returning id into v_agent_id;
|
|
780
|
+
|
|
781
|
+
-- (11) enroll the public key as active + proven (satisfies active_requires_proof).
|
|
782
|
+
insert into agent_board.agent_signing_keys(
|
|
783
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
784
|
+
status, proof_state, metadata
|
|
785
|
+
)
|
|
786
|
+
values (
|
|
787
|
+
gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
|
|
788
|
+
p_agent_public_key_format, v_fingerprint,
|
|
789
|
+
'active', 'proven',
|
|
790
|
+
jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
|
|
791
|
+
)
|
|
792
|
+
returning id into v_signing_key_id;
|
|
793
|
+
|
|
794
|
+
-- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
|
|
795
|
+
insert into agent_board.enrollment_federation_audit(
|
|
796
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
797
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
798
|
+
)
|
|
799
|
+
values (
|
|
800
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
801
|
+
v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
-- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
|
|
805
|
+
-- membership for the freshly-created agent. Wrapped in a subtransaction so a
|
|
806
|
+
-- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
|
|
807
|
+
-- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
|
|
808
|
+
if v_invite_code is not null then
|
|
809
|
+
begin
|
|
810
|
+
perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
|
|
811
|
+
v_invite_redeemed := true;
|
|
812
|
+
exception when others then
|
|
813
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
814
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
815
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
816
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
817
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
818
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
819
|
+
if left(sqlstate, 2) = '40' then
|
|
820
|
+
raise;
|
|
821
|
+
end if;
|
|
822
|
+
v_invite_redeemed := false;
|
|
823
|
+
end;
|
|
824
|
+
end if;
|
|
825
|
+
|
|
826
|
+
-- Server-authoritative identity: the client mints /session with the RETURNED
|
|
827
|
+
-- key_id/key_version/agent_handle (its private key stays in the keystore).
|
|
828
|
+
return query
|
|
829
|
+
select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
|
|
830
|
+
v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
|
|
831
|
+
end;
|
|
832
|
+
$function$
|
|
833
|
+
|
|
834
|
+
;
|
|
835
|
+
|
|
836
|
+
CREATE OR REPLACE FUNCTION agent_board.enroll_federated_identity(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text)
|
|
837
|
+
RETURNS TABLE(agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, invite_redeemed boolean)
|
|
838
|
+
LANGUAGE plpgsql
|
|
839
|
+
SECURITY DEFINER
|
|
840
|
+
SET search_path TO 'agent_board', 'public'
|
|
841
|
+
AS $function$
|
|
842
|
+
#variable_conflict use_column
|
|
843
|
+
declare
|
|
844
|
+
v_fingerprint text;
|
|
845
|
+
v_existing record;
|
|
846
|
+
v_created record;
|
|
847
|
+
v_replay jsonb;
|
|
848
|
+
v_invite_redeemed boolean := false;
|
|
849
|
+
v_slug text;
|
|
850
|
+
v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
|
|
851
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
852
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
853
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
854
|
+
v_invite_board_id uuid;
|
|
855
|
+
v_claim_audience jsonb;
|
|
856
|
+
v_owner_user_id uuid;
|
|
857
|
+
-- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
|
|
858
|
+
-- (issuer, subject) derives to; `v_creation_federation` is the federation object the
|
|
859
|
+
-- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
|
|
860
|
+
-- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
|
|
861
|
+
v_derived_owner_user_id uuid;
|
|
862
|
+
v_creation_federation jsonb;
|
|
863
|
+
v_owned_by_caller boolean;
|
|
864
|
+
begin
|
|
865
|
+
if not agent_board._is_service_role() then
|
|
866
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
867
|
+
end if;
|
|
868
|
+
if p_pop_verified is not true then
|
|
869
|
+
raise exception 'proof-of-possession was not verified' using errcode = '42501';
|
|
870
|
+
end if;
|
|
871
|
+
if p_subject is null then
|
|
872
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
873
|
+
end if;
|
|
874
|
+
if p_human_identity_claim is null then
|
|
875
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
876
|
+
end if;
|
|
877
|
+
|
|
878
|
+
select * into v_provider from agent_board.identity_providers ip
|
|
879
|
+
where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
|
|
880
|
+
if v_provider.id is null then
|
|
881
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
882
|
+
end if;
|
|
883
|
+
|
|
884
|
+
v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
|
|
885
|
+
if p_human_identity_claim ->> 'iss' is distinct from p_issuer
|
|
886
|
+
or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
|
|
887
|
+
or v_claim_audience is null
|
|
888
|
+
or not (v_claim_audience ? p_audience) then
|
|
889
|
+
raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
|
|
890
|
+
end if;
|
|
891
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
|
|
892
|
+
or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
893
|
+
raise exception 'malformed agent public key' using errcode = '22023';
|
|
894
|
+
end if;
|
|
895
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
896
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
897
|
+
raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
|
|
898
|
+
end if;
|
|
899
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
900
|
+
|
|
901
|
+
if not v_provider.supported or not v_provider.policy_approved then
|
|
902
|
+
raise exception 'identity provider is not approved' using errcode = '42501';
|
|
903
|
+
end if;
|
|
904
|
+
if v_kind = 'system' then
|
|
905
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
906
|
+
end if;
|
|
907
|
+
if v_kind not in ('human', 'agent') then
|
|
908
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
909
|
+
end if;
|
|
910
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
911
|
+
and not (v_kind = any(v_provider.allowed_agent_kinds)) then
|
|
912
|
+
raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
|
|
913
|
+
end if;
|
|
914
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes)
|
|
915
|
+
or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
916
|
+
raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
|
|
917
|
+
end if;
|
|
918
|
+
|
|
919
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
|
|
920
|
+
select 1 from agent_board.board_invite_tokens bit
|
|
921
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
922
|
+
where bit.code = btrim(p_invite_code)
|
|
923
|
+
) then
|
|
924
|
+
raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
|
|
925
|
+
end if;
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
929
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
930
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
931
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
932
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
933
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
934
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
935
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
936
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
937
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
938
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
939
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
940
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
941
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
942
|
+
select bit.board_id into v_invite_board_id
|
|
943
|
+
from agent_board.board_invite_tokens bit
|
|
944
|
+
where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
|
|
945
|
+
for update;
|
|
946
|
+
if v_invite_board_id is not null then
|
|
947
|
+
perform 1 from agent_board.boards b
|
|
948
|
+
where b.id = v_invite_board_id
|
|
949
|
+
for key share;
|
|
950
|
+
end if;
|
|
951
|
+
end if;
|
|
952
|
+
|
|
953
|
+
perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
|
|
954
|
+
|
|
955
|
+
select a.owner_user_id into v_owner_user_id
|
|
956
|
+
from agent_board.agent_signing_keys k
|
|
957
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
958
|
+
where k.fingerprint = v_fingerprint;
|
|
959
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
960
|
+
agent_board._federated_identity_slug(p_issuer, p_subject)
|
|
961
|
+
));
|
|
962
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
963
|
+
if exists (
|
|
964
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
965
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
966
|
+
) then
|
|
967
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
968
|
+
end if;
|
|
969
|
+
|
|
970
|
+
-- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
|
|
971
|
+
-- SAME derivation _enroll_federated_identity_once step (7) used to mint the owner --
|
|
972
|
+
-- now literally the same function, agent_board._federated_identity_slug (#246), not a
|
|
973
|
+
-- second copy of the expression. Resolved by a PURE LOOKUP against the deterministic
|
|
974
|
+
-- email _ensure_agent_auth_user builds -- calling THAT function here would MINT an owner
|
|
975
|
+
-- row for whatever principal a squat attempt claimed. The slug helper is pure and mints
|
|
976
|
+
-- nothing, so computing the slug here is safe; only _ensure_agent_auth_user writes. Read-only: no FOR UPDATE, no row
|
|
977
|
+
-- lock, so the #194 board -> owner order above is untouched.
|
|
978
|
+
select u.id into v_derived_owner_user_id
|
|
979
|
+
from auth.users u
|
|
980
|
+
where lower(u.email) = format('agent+%s@agent-board.local',
|
|
981
|
+
agent_board._federated_identity_slug(p_issuer, p_subject))
|
|
982
|
+
limit 1;
|
|
983
|
+
|
|
984
|
+
select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
|
|
985
|
+
a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
|
|
986
|
+
k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
|
|
987
|
+
(select e.granted_scopes
|
|
988
|
+
from agent_board.enrollment_federation_audit e
|
|
989
|
+
where e.agent_id = a.id
|
|
990
|
+
and e.issuer = p_issuer
|
|
991
|
+
and e.subject = p_subject
|
|
992
|
+
and e.provider_id = v_provider.id) as granted_scopes
|
|
993
|
+
into v_existing
|
|
994
|
+
from agent_board.agent_signing_keys k
|
|
995
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
996
|
+
where k.fingerprint = v_fingerprint
|
|
997
|
+
for update of a, k;
|
|
998
|
+
|
|
999
|
+
if found then
|
|
1000
|
+
v_replay := v_existing.metadata #> '{federation,replay}';
|
|
1001
|
+
v_creation_federation := v_existing.metadata #> '{federation}';
|
|
1002
|
+
|
|
1003
|
+
-- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
|
|
1004
|
+
-- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
|
|
1005
|
+
-- creation-time federation stamp, (iii) the original enrollment's audit row. All
|
|
1006
|
+
-- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
|
|
1007
|
+
-- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
|
|
1008
|
+
-- read as not-owned rather than leaving the `if not` below untaken.
|
|
1009
|
+
v_owned_by_caller := coalesce(
|
|
1010
|
+
v_creation_federation ->> 'issuer' = p_issuer
|
|
1011
|
+
and v_creation_federation ->> 'subject' = p_subject
|
|
1012
|
+
and v_creation_federation ->> 'providerId' = v_provider.id
|
|
1013
|
+
and exists (
|
|
1014
|
+
select 1
|
|
1015
|
+
from agent_board.enrollment_federation_audit e
|
|
1016
|
+
where e.agent_id = v_existing.agent_id
|
|
1017
|
+
and e.issuer = p_issuer
|
|
1018
|
+
and e.subject = p_subject
|
|
1019
|
+
and e.provider_id = v_provider.id
|
|
1020
|
+
)
|
|
1021
|
+
-- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
|
|
1022
|
+
-- so its claim is stated positively rather than left as "unless". A row whose
|
|
1023
|
+
-- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
|
|
1024
|
+
-- backfill fixture is exactly that shape, and replaying such a row is released,
|
|
1025
|
+
-- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
|
|
1026
|
+
-- the binding that does the work. (ii) and (iii) already name the principal, and
|
|
1027
|
+
-- reaching them at all requires a claim the edge function VERIFIED for that same
|
|
1028
|
+
-- issuer and subject, so a null owner narrows corroboration without widening who
|
|
1029
|
+
-- may resume. A NON-NULL owner that disagrees with the derived one is a hard
|
|
1030
|
+
-- refusal, which is what keeps this from being a hole.
|
|
1031
|
+
and (
|
|
1032
|
+
v_existing.owner_user_id is null
|
|
1033
|
+
or (v_derived_owner_user_id is not null
|
|
1034
|
+
and v_existing.owner_user_id = v_derived_owner_user_id)
|
|
1035
|
+
), false);
|
|
1036
|
+
|
|
1037
|
+
-- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
|
|
1038
|
+
-- caller that does not own the key learns nothing else about it. No handle is named:
|
|
1039
|
+
-- telling a squatter which agent holds the key it just probed is the leak this
|
|
1040
|
+
-- ordering avoids. Everything below runs only for the proven owner.
|
|
1041
|
+
if not v_owned_by_caller then
|
|
1042
|
+
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'
|
|
1043
|
+
using errcode = '23505';
|
|
1044
|
+
end if;
|
|
1045
|
+
|
|
1046
|
+
-- (b) the owner is asking to resume, but the identity is not in a resumable state.
|
|
1047
|
+
-- Resumption returns an existing identity; it never revives a revoked, unproven or
|
|
1048
|
+
-- drifted one, and it never invents an audit trail that is not there.
|
|
1049
|
+
if v_existing.public_key is distinct from p_agent_public_key
|
|
1050
|
+
or v_existing.public_key_format is distinct from p_agent_public_key_format
|
|
1051
|
+
or v_existing.key_status <> 'active'
|
|
1052
|
+
or v_existing.proof_state <> 'proven'
|
|
1053
|
+
or v_existing.status <> 'active'
|
|
1054
|
+
or v_existing.granted_scopes is null then
|
|
1055
|
+
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',
|
|
1056
|
+
v_existing.handle using errcode = '23505';
|
|
1057
|
+
end if;
|
|
1058
|
+
|
|
1059
|
+
-- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
|
|
1060
|
+
-- contract exactly as before -- the check is unchanged, only its message improves to
|
|
1061
|
+
-- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
|
|
1062
|
+
-- (every identity created before 20260901000100, including the #176 victim) has no
|
|
1063
|
+
-- frozen contract to check and resumes on the ownership evidence alone; that absence
|
|
1064
|
+
-- used to be the raise that trapped it.
|
|
1065
|
+
if v_replay is not null
|
|
1066
|
+
and (v_replay ->> 'issuer' is distinct from p_issuer
|
|
1067
|
+
or v_replay -> 'audience' is distinct from v_claim_audience
|
|
1068
|
+
or v_replay ->> 'subject' is distinct from p_subject
|
|
1069
|
+
or v_replay ->> 'requestedSlug' is distinct from v_slug
|
|
1070
|
+
or v_replay ->> 'agentKind' is distinct from v_kind
|
|
1071
|
+
or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
|
|
1072
|
+
or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
|
|
1073
|
+
or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
|
|
1074
|
+
or v_replay ->> 'providerId' is distinct from v_provider.id) then
|
|
1075
|
+
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',
|
|
1076
|
+
v_existing.handle using errcode = '23505';
|
|
1077
|
+
end if;
|
|
1078
|
+
|
|
1079
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
1080
|
+
begin
|
|
1081
|
+
perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
|
|
1082
|
+
v_invite_redeemed := true;
|
|
1083
|
+
exception when others then
|
|
1084
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
1085
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
1086
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
1087
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
1088
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
1089
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
1090
|
+
if left(sqlstate, 2) = '40' then
|
|
1091
|
+
raise;
|
|
1092
|
+
end if;
|
|
1093
|
+
v_invite_redeemed := false;
|
|
1094
|
+
end;
|
|
1095
|
+
end if;
|
|
1096
|
+
|
|
1097
|
+
-- provider_id comes from STORED evidence, not the request: the replay stamp when
|
|
1098
|
+
-- there is one, otherwise the creation-time federation object. The ownership gate
|
|
1099
|
+
-- already pinned that value to v_provider.id, so the two agree by construction --
|
|
1100
|
+
-- but reading it from the row keeps the returned receipt sourced where every other
|
|
1101
|
+
-- returned field is.
|
|
1102
|
+
return query select v_existing.agent_id, v_existing.auth_user_id,
|
|
1103
|
+
coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
|
|
1104
|
+
v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
|
|
1105
|
+
v_existing.handle, v_invite_redeemed;
|
|
1106
|
+
return;
|
|
1107
|
+
end if;
|
|
1108
|
+
|
|
1109
|
+
select * into v_created from agent_board._enroll_federated_identity_once(
|
|
1110
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
1111
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
1112
|
+
p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
|
|
1113
|
+
v_requested_board_scopes, p_invite_code
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
update agent_board.agents a
|
|
1117
|
+
set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
|
|
1118
|
+
'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
|
|
1119
|
+
'providerId', v_created.provider_id,
|
|
1120
|
+
'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
|
|
1121
|
+
'requestedSlug', v_slug, 'agentKind', v_kind,
|
|
1122
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
1123
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
|
|
1124
|
+
), true)
|
|
1125
|
+
where a.id = v_created.agent_id;
|
|
1126
|
+
|
|
1127
|
+
return query select v_created.agent_id, v_created.auth_user_id,
|
|
1128
|
+
v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
|
|
1129
|
+
v_created.key_id, v_created.key_version, v_created.agent_handle,
|
|
1130
|
+
v_created.invite_redeemed;
|
|
1131
|
+
end;
|
|
1132
|
+
$function$
|
|
1133
|
+
|
|
1134
|
+
;
|
|
1135
|
+
|
|
1136
|
+
CREATE OR REPLACE FUNCTION agent_board.onboard_federated_fleet_agent(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text, p_board_id uuid DEFAULT NULL::uuid, p_machine_id uuid DEFAULT NULL::uuid, p_harness_kind text DEFAULT NULL::text, p_harness_label text DEFAULT NULL::text, p_repo_slug text DEFAULT NULL::text, p_repo_fingerprint text DEFAULT NULL::text, p_repo_scope_role text DEFAULT NULL::text, p_agent_role text DEFAULT NULL::text, p_expected_agent_handle text DEFAULT NULL::text)
|
|
1137
|
+
RETURNS TABLE(onboarding_contract_version text, agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, public_key_fingerprint text, board_id uuid, membership_role text, membership_state text, machine_id uuid, harness_registration_id uuid, repo_scope_id uuid, fleet_machine_agent_id uuid, binding_signing_key_id uuid, harness_kind text, harness_label text, repo_slug text, repo_fingerprint text, repo_scope_role text, agent_role text, expected_agent_handle text, identity_disposition text, membership_disposition text, harness_disposition text, repo_scope_disposition text, binding_disposition text)
|
|
1138
|
+
LANGUAGE plpgsql
|
|
1139
|
+
SECURITY DEFINER
|
|
1140
|
+
SET search_path TO 'agent_board', 'public'
|
|
1141
|
+
AS $function$
|
|
1142
|
+
#variable_conflict use_column
|
|
1143
|
+
declare
|
|
1144
|
+
v_inv agent_board.board_invite_tokens%rowtype;
|
|
1145
|
+
v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
|
|
1146
|
+
v_board agent_board.boards%rowtype;
|
|
1147
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
1148
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
1149
|
+
v_enroll record;
|
|
1150
|
+
v_membership record;
|
|
1151
|
+
v_harness agent_board.fleet_harness_registrations%rowtype;
|
|
1152
|
+
v_repo agent_board.fleet_repo_scopes%rowtype;
|
|
1153
|
+
v_binding agent_board.fleet_machine_agents%rowtype;
|
|
1154
|
+
v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
|
|
1155
|
+
v_label text := btrim(coalesce(p_harness_label, ''));
|
|
1156
|
+
v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
|
|
1157
|
+
v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
|
|
1158
|
+
v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
|
|
1159
|
+
v_existing_agent_id uuid;
|
|
1160
|
+
v_owner_user_id uuid;
|
|
1161
|
+
v_identity_disposition text;
|
|
1162
|
+
v_membership_disposition text;
|
|
1163
|
+
v_harness_disposition text;
|
|
1164
|
+
v_repo_disposition text;
|
|
1165
|
+
v_binding_disposition text;
|
|
1166
|
+
v_completed_replay boolean := false;
|
|
1167
|
+
begin
|
|
1168
|
+
if not agent_board._is_service_role() then
|
|
1169
|
+
raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
|
|
1170
|
+
end if;
|
|
1171
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
|
|
1172
|
+
raise exception 'bounded invite code is required' using errcode = '22023';
|
|
1173
|
+
end if;
|
|
1174
|
+
|
|
1175
|
+
select bit.* into v_inv
|
|
1176
|
+
from agent_board.board_invite_tokens bit
|
|
1177
|
+
where bit.code = btrim(p_invite_code)
|
|
1178
|
+
for update;
|
|
1179
|
+
if not found then
|
|
1180
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
1181
|
+
end if;
|
|
1182
|
+
select * into v_bounds
|
|
1183
|
+
from agent_board.fleet_onboarding_invite_bounds b
|
|
1184
|
+
where b.invite_id = v_inv.id
|
|
1185
|
+
for update;
|
|
1186
|
+
if not found then
|
|
1187
|
+
raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
|
|
1188
|
+
end if;
|
|
1189
|
+
|
|
1190
|
+
-- ADVISORY BEFORE BOARDS. set_board_authority_ceiling holds this lock and then
|
|
1191
|
+
-- reaches this same boards row through board_authority_ceilings' FK (FOR KEY SHARE
|
|
1192
|
+
-- on INSERT). Taking the boards lock first would invert the order against it and
|
|
1193
|
+
-- deadlock -- it did, until this line was hoisted above the select below.
|
|
1194
|
+
perform agent_board._lock_board_authority_ceiling(v_inv.board_id);
|
|
1195
|
+
|
|
1196
|
+
select * into v_board
|
|
1197
|
+
from agent_board.boards b
|
|
1198
|
+
where b.id = v_inv.board_id
|
|
1199
|
+
for update;
|
|
1200
|
+
if not found or v_board.status <> 'active' then
|
|
1201
|
+
raise exception 'fleet onboarding board is not usable' using errcode = '42501';
|
|
1202
|
+
end if;
|
|
1203
|
+
|
|
1204
|
+
if v_inv.board_id is distinct from p_board_id
|
|
1205
|
+
or v_bounds.machine_id is distinct from p_machine_id
|
|
1206
|
+
or v_bounds.harness_kind is distinct from v_kind
|
|
1207
|
+
or v_bounds.harness_label is distinct from v_label
|
|
1208
|
+
or v_bounds.repo_slug is distinct from v_repo_slug
|
|
1209
|
+
or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
|
|
1210
|
+
or v_bounds.repo_scope_role is distinct from p_repo_scope_role
|
|
1211
|
+
or v_bounds.agent_role is distinct from p_agent_role
|
|
1212
|
+
or v_bounds.expected_agent_handle is distinct from v_expected_handle then
|
|
1213
|
+
raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
|
|
1214
|
+
end if;
|
|
1215
|
+
if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
|
|
1216
|
+
or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
|
|
1217
|
+
raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
|
|
1218
|
+
end if;
|
|
1219
|
+
|
|
1220
|
+
select * into v_machine from agent_board.fleet_machines fm
|
|
1221
|
+
where fm.id = p_machine_id for update;
|
|
1222
|
+
if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
|
|
1223
|
+
or v_machine.lifecycle_state <> 'active' then
|
|
1224
|
+
raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
|
|
1225
|
+
end if;
|
|
1226
|
+
select * into v_domain from agent_board.fleet_trust_domains td
|
|
1227
|
+
where td.id = v_machine.trust_domain_id for update;
|
|
1228
|
+
if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
|
|
1229
|
+
or v_domain.lifecycle_state <> 'active' then
|
|
1230
|
+
raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
|
|
1231
|
+
end if;
|
|
1232
|
+
|
|
1233
|
+
-- Serialize cross-route identity classification before locking principals. For
|
|
1234
|
+
-- a replay, lock the creator and enrolled principal in UUID order so a human or
|
|
1235
|
+
-- agent subtree revocation that visits the same pair cannot invert their order.
|
|
1236
|
+
perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
|
|
1237
|
+
select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
|
|
1238
|
+
from agent_board.agent_signing_keys k
|
|
1239
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
1240
|
+
where k.fingerprint = p_agent_public_key_fingerprint;
|
|
1241
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
1242
|
+
agent_board._federated_identity_slug(p_issuer, p_subject)
|
|
1243
|
+
));
|
|
1244
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
1245
|
+
if exists (
|
|
1246
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
1247
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
1248
|
+
) then
|
|
1249
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
1250
|
+
end if;
|
|
1251
|
+
perform 1 from agent_board.agents a
|
|
1252
|
+
where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
|
|
1253
|
+
order by a.id for update;
|
|
1254
|
+
|
|
1255
|
+
-- Creator authority is part of the invite's live authorization, not merely a
|
|
1256
|
+
-- snapshot from mint time. Lock every membership row and the current ceiling
|
|
1257
|
+
-- before evaluating its role so revocation, eviction, or demotion must serialize
|
|
1258
|
+
-- on one side of this transaction.
|
|
1259
|
+
--
|
|
1260
|
+
-- The ceiling ROW lock below covers demotion of an EXISTING ceiling only. No row
|
|
1261
|
+
-- lock can cover the ABSENCE of a row, and absence is the normal default-owner
|
|
1262
|
+
-- state, so a concurrent set_board_authority_ceiling inserting 'observer' used to
|
|
1263
|
+
-- interleave: this transaction read "no ceiling", passed as owner, and committed a
|
|
1264
|
+
-- 'member' membership after the ceiling committed. The board-keyed advisory lock
|
|
1265
|
+
-- that serializes the ABSENCE is already held -- it is taken far above, BEFORE the
|
|
1266
|
+
-- boards row lock, because the ceiling writer reaches boards through its FK and the
|
|
1267
|
+
-- two must agree on order.
|
|
1268
|
+
perform 1 from agent_board.board_memberships bm
|
|
1269
|
+
where bm.board_id = v_inv.board_id
|
|
1270
|
+
and bm.agent_id = v_inv.created_by_agent_id
|
|
1271
|
+
order by bm.id for update;
|
|
1272
|
+
perform 1 from agent_board.board_authority_ceilings c
|
|
1273
|
+
where c.board_id = v_inv.board_id for update;
|
|
1274
|
+
|
|
1275
|
+
-- Concurrent legacy enrollment or onboarding on another machine has committed
|
|
1276
|
+
-- before this snapshot decides whether the identity was created here.
|
|
1277
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
|
|
1278
|
+
v_identity_disposition := 'reused';
|
|
1279
|
+
else
|
|
1280
|
+
v_identity_disposition := 'created';
|
|
1281
|
+
end if;
|
|
1282
|
+
|
|
1283
|
+
select * into v_enroll from agent_board.enroll_federated_identity(
|
|
1284
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
1285
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
1286
|
+
p_pop_verified, p_actor_sub, p_agent_kind, p_requested_scopes,
|
|
1287
|
+
p_requested_board_scopes, null
|
|
1288
|
+
);
|
|
1289
|
+
if v_enroll.agent_handle is distinct from v_expected_handle then
|
|
1290
|
+
raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
|
|
1291
|
+
end if;
|
|
1292
|
+
|
|
1293
|
+
-- A complete replay may bypass expiry/exhaustion only while its redemption and
|
|
1294
|
+
-- active membership remain true through commit. Lock both ledgers before the
|
|
1295
|
+
-- proof; the invite row already serializes first redemption for an absent row.
|
|
1296
|
+
perform 1 from agent_board.board_invite_redemptions red
|
|
1297
|
+
where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
|
|
1298
|
+
order by red.id for update;
|
|
1299
|
+
perform 1 from agent_board.board_memberships bm
|
|
1300
|
+
where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
|
|
1301
|
+
order by bm.id for update;
|
|
1302
|
+
|
|
1303
|
+
-- The released ROOMLINK core deliberately returns an existing redemption before
|
|
1304
|
+
-- checking current token authority. Fleet onboarding has a narrower replay
|
|
1305
|
+
-- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
|
|
1306
|
+
-- partial chain must pass every current invite gate before any missing suffix is
|
|
1307
|
+
-- written, and revocation/current creator authority always apply.
|
|
1308
|
+
select exists (
|
|
1309
|
+
select 1
|
|
1310
|
+
from agent_board.board_invite_redemptions red
|
|
1311
|
+
join agent_board.board_memberships mem on mem.id = red.membership_id
|
|
1312
|
+
join agent_board.fleet_harness_registrations h
|
|
1313
|
+
on h.machine_id = p_machine_id
|
|
1314
|
+
and h.harness_kind = v_kind
|
|
1315
|
+
and h.harness_label = v_label
|
|
1316
|
+
join agent_board.fleet_repo_scopes repo
|
|
1317
|
+
on repo.harness_registration_id = h.id
|
|
1318
|
+
and repo.repo_slug = v_repo_slug
|
|
1319
|
+
and repo.repo_fingerprint = v_repo_fingerprint
|
|
1320
|
+
and repo.scope_role = p_repo_scope_role
|
|
1321
|
+
join agent_board.fleet_machine_agents binding
|
|
1322
|
+
on binding.machine_id = p_machine_id
|
|
1323
|
+
and binding.agent_id = v_enroll.agent_id
|
|
1324
|
+
and binding.repo_scope_id = repo.id
|
|
1325
|
+
and binding.signing_key_id = v_enroll.signing_key_id
|
|
1326
|
+
and binding.agent_role = p_agent_role
|
|
1327
|
+
join agent_board.agents principal on principal.id = v_enroll.agent_id
|
|
1328
|
+
join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
|
|
1329
|
+
where red.invite_id = v_inv.id
|
|
1330
|
+
and red.agent_id = v_enroll.agent_id
|
|
1331
|
+
and mem.board_id = v_inv.board_id
|
|
1332
|
+
and mem.agent_id = v_enroll.agent_id
|
|
1333
|
+
and mem.role = 'member'
|
|
1334
|
+
and mem.membership_state in ('joined', 'subscribed')
|
|
1335
|
+
and h.lifecycle_state = 'active'
|
|
1336
|
+
and repo.lifecycle_state = 'active'
|
|
1337
|
+
and binding.lifecycle_state = 'active'
|
|
1338
|
+
and principal.status = 'active'
|
|
1339
|
+
and signing_key.status = 'active'
|
|
1340
|
+
and signing_key.proof_state = 'proven'
|
|
1341
|
+
and signing_key.revoked_at is null
|
|
1342
|
+
and not exists (
|
|
1343
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
1344
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
1345
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
1346
|
+
or (e.subject_kind = 'harness' and e.subject_id = h.id)
|
|
1347
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
|
|
1348
|
+
)
|
|
1349
|
+
) into v_completed_replay;
|
|
1350
|
+
|
|
1351
|
+
if v_inv.revoked_at is not null
|
|
1352
|
+
or not exists (
|
|
1353
|
+
select 1 from agent_board.agents a
|
|
1354
|
+
where a.id = v_inv.created_by_agent_id and a.status = 'active'
|
|
1355
|
+
)
|
|
1356
|
+
or not agent_board._grant_within_ceiling(
|
|
1357
|
+
v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
|
|
1358
|
+
)
|
|
1359
|
+
or (
|
|
1360
|
+
exists (
|
|
1361
|
+
select 1 from agent_board.board_memberships bm
|
|
1362
|
+
where bm.board_id = v_inv.board_id
|
|
1363
|
+
and bm.agent_id = v_enroll.agent_id
|
|
1364
|
+
and bm.membership_state = 'removed'
|
|
1365
|
+
)
|
|
1366
|
+
and not exists (
|
|
1367
|
+
select 1 from agent_board.board_memberships bm
|
|
1368
|
+
where bm.board_id = v_inv.board_id
|
|
1369
|
+
and bm.agent_id = v_enroll.agent_id
|
|
1370
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1371
|
+
)
|
|
1372
|
+
) then
|
|
1373
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
1374
|
+
end if;
|
|
1375
|
+
if not v_completed_replay
|
|
1376
|
+
and (
|
|
1377
|
+
v_inv.expires_at <= now()
|
|
1378
|
+
or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
|
|
1379
|
+
) then
|
|
1380
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
1381
|
+
end if;
|
|
1382
|
+
|
|
1383
|
+
if exists (
|
|
1384
|
+
select 1 from agent_board.board_invite_redemptions r
|
|
1385
|
+
where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
|
|
1386
|
+
) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
|
|
1387
|
+
|
|
1388
|
+
select * into v_membership
|
|
1389
|
+
from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
|
|
1390
|
+
if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
|
|
1391
|
+
raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
|
|
1392
|
+
end if;
|
|
1393
|
+
|
|
1394
|
+
perform 1 from agent_board.fleet_harness_registrations h
|
|
1395
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
1396
|
+
order by h.id for update;
|
|
1397
|
+
if exists (
|
|
1398
|
+
select 1 from agent_board.fleet_harness_registrations h
|
|
1399
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
1400
|
+
and h.lifecycle_state <> 'active'
|
|
1401
|
+
) then
|
|
1402
|
+
raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
|
|
1403
|
+
end if;
|
|
1404
|
+
select * into v_harness
|
|
1405
|
+
from agent_board.fleet_harness_registrations h
|
|
1406
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
1407
|
+
and h.lifecycle_state = 'active'
|
|
1408
|
+
order by h.id;
|
|
1409
|
+
if found then
|
|
1410
|
+
v_harness_disposition := 'reused';
|
|
1411
|
+
else
|
|
1412
|
+
insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
|
|
1413
|
+
values (p_machine_id, v_kind, v_label) returning * into v_harness;
|
|
1414
|
+
v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
1415
|
+
end if;
|
|
1416
|
+
|
|
1417
|
+
perform 1 from agent_board.fleet_repo_scopes r
|
|
1418
|
+
where r.harness_registration_id = v_harness.id
|
|
1419
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
1420
|
+
order by r.id for update;
|
|
1421
|
+
if exists (
|
|
1422
|
+
select 1 from agent_board.fleet_repo_scopes r
|
|
1423
|
+
where r.harness_registration_id = v_harness.id
|
|
1424
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
1425
|
+
and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
|
|
1426
|
+
) then
|
|
1427
|
+
raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
|
|
1428
|
+
end if;
|
|
1429
|
+
select * into v_repo
|
|
1430
|
+
from agent_board.fleet_repo_scopes r
|
|
1431
|
+
where r.harness_registration_id = v_harness.id
|
|
1432
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
1433
|
+
and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
|
|
1434
|
+
order by r.id;
|
|
1435
|
+
if found then
|
|
1436
|
+
v_repo_disposition := 'reused';
|
|
1437
|
+
else
|
|
1438
|
+
insert into agent_board.fleet_repo_scopes(
|
|
1439
|
+
harness_registration_id, repo_slug, repo_fingerprint, scope_role
|
|
1440
|
+
) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
|
|
1441
|
+
returning * into v_repo;
|
|
1442
|
+
v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
1443
|
+
end if;
|
|
1444
|
+
|
|
1445
|
+
-- Machine -> agent/key/binding lock order. Any tombstone for this natural key
|
|
1446
|
+
-- blocks replacement authority even though the active unique index would allow it.
|
|
1447
|
+
perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
|
|
1448
|
+
perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
|
|
1449
|
+
perform 1 from agent_board.fleet_machine_agents b
|
|
1450
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
1451
|
+
and b.repo_scope_id = v_repo.id
|
|
1452
|
+
order by b.id for update;
|
|
1453
|
+
if exists (
|
|
1454
|
+
select 1 from agent_board.fleet_machine_agents b
|
|
1455
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
1456
|
+
and b.repo_scope_id = v_repo.id
|
|
1457
|
+
and (
|
|
1458
|
+
b.lifecycle_state <> 'active'
|
|
1459
|
+
or b.signing_key_id is distinct from v_enroll.signing_key_id
|
|
1460
|
+
or b.agent_role is distinct from p_agent_role
|
|
1461
|
+
)
|
|
1462
|
+
) then
|
|
1463
|
+
raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
|
|
1464
|
+
end if;
|
|
1465
|
+
select * into v_binding
|
|
1466
|
+
from agent_board.fleet_machine_agents b
|
|
1467
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
1468
|
+
and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
|
|
1469
|
+
and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
|
|
1470
|
+
order by b.id;
|
|
1471
|
+
if found then
|
|
1472
|
+
v_binding_disposition := 'reused';
|
|
1473
|
+
else
|
|
1474
|
+
insert into agent_board.fleet_machine_agents(
|
|
1475
|
+
machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
|
|
1476
|
+
) values (
|
|
1477
|
+
p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
|
|
1478
|
+
) returning * into v_binding;
|
|
1479
|
+
v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
1480
|
+
end if;
|
|
1481
|
+
|
|
1482
|
+
if exists (
|
|
1483
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
1484
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
1485
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
1486
|
+
or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
|
|
1487
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
|
|
1488
|
+
) then
|
|
1489
|
+
raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
|
|
1490
|
+
end if;
|
|
1491
|
+
|
|
1492
|
+
return query select
|
|
1493
|
+
'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
|
|
1494
|
+
v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
|
|
1495
|
+
v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
|
|
1496
|
+
p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
|
|
1497
|
+
v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
|
|
1498
|
+
v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
|
|
1499
|
+
p_repo_scope_role, p_agent_role, v_expected_handle,
|
|
1500
|
+
v_identity_disposition, v_membership_disposition, v_harness_disposition,
|
|
1501
|
+
v_repo_disposition, v_binding_disposition;
|
|
1502
|
+
end;
|
|
1503
|
+
$function$
|
|
1504
|
+
|
|
1505
|
+
;
|
|
1506
|
+
|
|
1507
|
+
-- ---------------------------------------------------------------------------
|
|
1508
|
+
-- BACKFILL: move every existing federated owner identity onto the new slug.
|
|
1509
|
+
--
|
|
1510
|
+
-- The old slug is a one-way digest, so it cannot be inverted; the pair is
|
|
1511
|
+
-- recovered from the federation stamps the enrollment paths wrote at creation
|
|
1512
|
+
-- (metadata->'federation'), and each candidate pair must REPRODUCE the row's
|
|
1513
|
+
-- current slug under the OLD encoding before it is trusted. That reproduction
|
|
1514
|
+
-- test is the only place the old expression survives, and it survives as a
|
|
1515
|
+
-- proof, not as a derivation: nothing keyed by it is created here.
|
|
1516
|
+
-- ---------------------------------------------------------------------------
|
|
1517
|
+
do $backfill$
|
|
1518
|
+
declare
|
|
1519
|
+
v_row record;
|
|
1520
|
+
v_iss text;
|
|
1521
|
+
v_sub text;
|
|
1522
|
+
v_pairs int;
|
|
1523
|
+
v_old text;
|
|
1524
|
+
v_new text;
|
|
1525
|
+
v_moved int := 0;
|
|
1526
|
+
v_unproven int;
|
|
1527
|
+
begin
|
|
1528
|
+
for v_row in
|
|
1529
|
+
select u.id as uid,
|
|
1530
|
+
substring(lower(u.email) from '^agent\+(fed-human-[0-9a-f]{32})@agent-board\.local$') as slug
|
|
1531
|
+
from auth.users u
|
|
1532
|
+
where lower(u.email) like 'agent+fed-human-%@agent-board.local'
|
|
1533
|
+
order by u.id
|
|
1534
|
+
loop
|
|
1535
|
+
v_old := v_row.slug;
|
|
1536
|
+
if v_old is null then
|
|
1537
|
+
raise exception
|
|
1538
|
+
'#246 backfill: auth.users % carries a fed-human email that does not parse as a slug',
|
|
1539
|
+
v_row.uid using errcode = '22023';
|
|
1540
|
+
end if;
|
|
1541
|
+
|
|
1542
|
+
-- Recover the pair from the stamps of every agent that references this owner --
|
|
1543
|
+
-- by auth_user_id (the human path's own row) or owner_user_id (identity/fleet) --
|
|
1544
|
+
-- keeping only pairs that reproduce this row's slug under the OLD encoding.
|
|
1545
|
+
select count(*), min(t.iss), min(t.sub)
|
|
1546
|
+
into v_pairs, v_iss, v_sub
|
|
1547
|
+
from (
|
|
1548
|
+
select distinct
|
|
1549
|
+
a.metadata->'federation'->>'issuer' as iss,
|
|
1550
|
+
a.metadata->'federation'->>'subject' as sub
|
|
1551
|
+
from agent_board.agents a
|
|
1552
|
+
where (a.auth_user_id = v_row.uid or a.owner_user_id = v_row.uid)
|
|
1553
|
+
and a.metadata->'federation'->>'issuer' is not null
|
|
1554
|
+
and a.metadata->'federation'->>'subject' is not null
|
|
1555
|
+
and 'fed-human-' || substr(
|
|
1556
|
+
encode(extensions.digest(convert_to(
|
|
1557
|
+
(a.metadata->'federation'->>'issuer') || '|' ||
|
|
1558
|
+
(a.metadata->'federation'->>'subject'), 'UTF8'), 'sha256'), 'hex'),
|
|
1559
|
+
1, 32) = v_old
|
|
1560
|
+
) t;
|
|
1561
|
+
|
|
1562
|
+
-- DISTINCT pairs, not rows: production's owner carries six agents stamped with the
|
|
1563
|
+
-- same pair, and a row count would refuse it. 0 = unrecoverable, >1 = a real
|
|
1564
|
+
-- collision. Either way, refuse rather than bind an account to a guessed human.
|
|
1565
|
+
if v_pairs <> 1 then
|
|
1566
|
+
raise exception
|
|
1567
|
+
'#246 backfill: auth.users % (%) has % distinct stamped (issuer, subject) pairs reproducing its slug; exactly 1 required',
|
|
1568
|
+
v_row.uid, v_old, v_pairs using errcode = '22023';
|
|
1569
|
+
end if;
|
|
1570
|
+
|
|
1571
|
+
v_new := agent_board._federated_identity_slug(v_iss, v_sub);
|
|
1572
|
+
if v_new = v_old then
|
|
1573
|
+
continue;
|
|
1574
|
+
end if;
|
|
1575
|
+
|
|
1576
|
+
if exists (
|
|
1577
|
+
select 1 from auth.users u2
|
|
1578
|
+
where lower(u2.email) = 'agent+' || v_new || '@agent-board.local'
|
|
1579
|
+
and u2.id <> v_row.uid
|
|
1580
|
+
) then
|
|
1581
|
+
raise exception
|
|
1582
|
+
'#246 backfill: re-keying auth.users % to % would collide with an existing owner row',
|
|
1583
|
+
v_row.uid, v_new using errcode = '23505';
|
|
1584
|
+
end if;
|
|
1585
|
+
|
|
1586
|
+
-- (1) the account key itself, and (2) the slug _ensure_agent_auth_user recorded
|
|
1587
|
+
-- alongside it. The uuid is untouched: sessions, owner_user_id and every FK survive.
|
|
1588
|
+
update auth.users u
|
|
1589
|
+
set email = 'agent+' || v_new || '@agent-board.local',
|
|
1590
|
+
raw_user_meta_data = case
|
|
1591
|
+
when u.raw_user_meta_data->>'slug' = v_old
|
|
1592
|
+
then jsonb_set(u.raw_user_meta_data, '{slug}', to_jsonb(v_new))
|
|
1593
|
+
else u.raw_user_meta_data end,
|
|
1594
|
+
updated_at = now()
|
|
1595
|
+
where u.id = v_row.uid;
|
|
1596
|
+
|
|
1597
|
+
-- (3) the auth.identities row the same helper created, which keys on the slug in
|
|
1598
|
+
-- three places.
|
|
1599
|
+
update auth.identities i
|
|
1600
|
+
set provider_id = v_new,
|
|
1601
|
+
identity_data = i.identity_data
|
|
1602
|
+
|| case when i.identity_data->>'sub' = v_old
|
|
1603
|
+
then jsonb_build_object('sub', v_new) else '{}'::jsonb end
|
|
1604
|
+
|| case when i.identity_data->>'slug' = v_old
|
|
1605
|
+
then jsonb_build_object('slug', v_new) else '{}'::jsonb end,
|
|
1606
|
+
updated_at = now()
|
|
1607
|
+
where i.user_id = v_row.uid
|
|
1608
|
+
and i.provider = 'agent_board'
|
|
1609
|
+
and i.provider_id = v_old;
|
|
1610
|
+
|
|
1611
|
+
-- (4) the human path's own agent row, whose handle IS the slug. Agents enrolled on
|
|
1612
|
+
-- the identity/fleet paths keep their own handles and reference the owner by uuid.
|
|
1613
|
+
update agent_board.agents a
|
|
1614
|
+
set handle = v_new
|
|
1615
|
+
where a.handle = v_old;
|
|
1616
|
+
|
|
1617
|
+
v_moved := v_moved + 1;
|
|
1618
|
+
end loop;
|
|
1619
|
+
|
|
1620
|
+
-- Post-condition, asserted over the WHOLE population rather than the rows this loop
|
|
1621
|
+
-- happened to touch: every remaining fed-human owner email must be reproducible from
|
|
1622
|
+
-- a stamp under the NEW derivation. Old and new slugs are shape-identical
|
|
1623
|
+
-- (fed-human-<32hex>), so "no old-form rows remain" is not something a pattern can
|
|
1624
|
+
-- see; this is the check that can.
|
|
1625
|
+
select count(*) into v_unproven
|
|
1626
|
+
from auth.users u
|
|
1627
|
+
where lower(u.email) like 'agent+fed-human-%@agent-board.local'
|
|
1628
|
+
and not exists (
|
|
1629
|
+
select 1
|
|
1630
|
+
from agent_board.agents a
|
|
1631
|
+
where (a.auth_user_id = u.id or a.owner_user_id = u.id)
|
|
1632
|
+
and a.metadata->'federation'->>'issuer' is not null
|
|
1633
|
+
and a.metadata->'federation'->>'subject' is not null
|
|
1634
|
+
and agent_board._federated_identity_slug(
|
|
1635
|
+
a.metadata->'federation'->>'issuer',
|
|
1636
|
+
a.metadata->'federation'->>'subject')
|
|
1637
|
+
= substring(lower(u.email) from '^agent\+(fed-human-[0-9a-f]{32})@agent-board\.local$'));
|
|
1638
|
+
if v_unproven <> 0 then
|
|
1639
|
+
raise exception
|
|
1640
|
+
'#246 backfill: % federated owner row(s) are not reproducible under the new derivation after re-keying',
|
|
1641
|
+
v_unproven using errcode = '22023';
|
|
1642
|
+
end if;
|
|
1643
|
+
|
|
1644
|
+
raise notice '#246 backfill: re-keyed % federated owner identity row(s)', v_moved;
|
|
1645
|
+
end
|
|
1646
|
+
$backfill$;
|