@consiliency/agent-board-schema 1.53.0 → 1.55.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 +615 -607
- package/migrations/20261004000100_agent_board_callback_consumer_intent.sql +52 -0
- package/migrations/20261005000100_agent_board_enroll_token_single_use.sql +886 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +5 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +35 -35
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +5 -5
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +32 -32
- package/tests/agent_board_enroll_federated_identity.test.sql +11 -10
- package/tests/agent_board_enroll_pop_binding.test.sql +12 -12
- package/tests/agent_board_enroll_resumption.test.sql +1 -1
- package/tests/agent_board_enroll_token_single_use.test.sql +538 -0
- package/tests/agent_board_enrollment_audit_content.test.sql +3 -3
- package/tests/agent_board_federated_lock_order_class.test.sql +5 -5
- package/tests/agent_board_fedsession.test.sql +1 -1
- package/tests/agent_board_fk_lock_edges.test.sql +23 -0
- package/tests/agent_board_identity_providers.test.sql +3 -2
- package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
- package/tests/agent_board_roomlink.test.sql +2 -2
- package/tests/agent_board_write_enforcement_class.test.sql +5 -2
- package/tests/catalog/function_execute_privileges.json +12 -3
- package/tests/catalog/p_metadata_parameters.json +6 -2
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +1 -1
- package/tests/isolation/expected/atomic_fleet_authority_races.out +8 -8
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +2 -2
- package/tests/isolation/expected/atomic_fleet_board_archive.out +2 -2
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +2 -2
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +2 -2
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +22 -22
- package/tests/isolation/expected/atomic_fleet_onboarding.out +2 -2
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +1 -1
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +1 -1
- package/tests/isolation/expected/enroll_token_fleet_abort.out +81 -0
- package/tests/isolation/expected/enroll_token_pk_backstop.out +36 -0
- package/tests/isolation/expected/enroll_token_placement.out +86 -0
- package/tests/isolation/expected/enroll_token_rollback_unstrands.out +59 -0
- package/tests/isolation/expected/enroll_token_route_race_fleet_first.out +72 -0
- package/tests/isolation/expected/enroll_token_route_race_identity_first.out +72 -0
- package/tests/isolation/expected/enroll_token_same_key_fleet_then_identity.out +73 -0
- package/tests/isolation/expected/enroll_token_same_key_identity_then_fleet.out +73 -0
- package/tests/isolation/expected/enroll_token_same_key_resume.out +59 -0
- package/tests/isolation/expected/enroll_token_same_token_race.out +58 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +2 -2
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +1 -1
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +2 -2
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +2 -2
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +1 -1
- package/tests/isolation/specs/enroll_token_fleet_abort.spec +138 -0
- package/tests/isolation/specs/enroll_token_pk_backstop.spec +37 -0
- package/tests/isolation/specs/enroll_token_placement.spec +136 -0
- package/tests/isolation/specs/enroll_token_rollback_unstrands.spec +71 -0
- package/tests/isolation/specs/enroll_token_route_race_fleet_first.spec +119 -0
- package/tests/isolation/specs/enroll_token_route_race_identity_first.spec +120 -0
- package/tests/isolation/specs/enroll_token_same_key_fleet_then_identity.spec +112 -0
- package/tests/isolation/specs/enroll_token_same_key_identity_then_fleet.spec +113 -0
- package/tests/isolation/specs/enroll_token_same_key_resume.spec +70 -0
- package/tests/isolation/specs/enroll_token_same_token_race.spec +80 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +1 -1
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
-- Consiliency/message-board#521: a verified Portal access token enrolls AT MOST ONE key.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT WAS WRONG. /enroll RS256-verifies a Portal access token and checks an Ed25519
|
|
4
|
+
-- proof-of-possession: the NEW key's signature over the token string. Nothing recorded
|
|
5
|
+
-- that a token had been used. The PoP does not stop reuse -- whoever holds the token can
|
|
6
|
+
-- generate a fresh keypair and sign the same token again -- and the existing replay
|
|
7
|
+
-- mechanism (enroll_federated_identity's #176 resumption) is keyed on the KEY, so a fresh
|
|
8
|
+
-- key per request walks straight past it. One held token therefore enrolled an unbounded
|
|
9
|
+
-- number of agents under the same human until it expired: an undesigned delegation path
|
|
10
|
+
-- with none of the bounds the designed one (mint_delegated_child, capped by
|
|
11
|
+
-- delegation_policies) carries.
|
|
12
|
+
--
|
|
13
|
+
-- THE INVARIANT. A verified token binds to at most one agent key, created OR resumed,
|
|
14
|
+
-- enforced atomically in the database:
|
|
15
|
+
--
|
|
16
|
+
-- * agent_board.enroll_token_uses records (token_sha256 -> key fingerprint). The
|
|
17
|
+
-- fingerprint is known before any write, so no reservation state is needed and the
|
|
18
|
+
-- agent is recoverable through agent_signing_keys.fingerprint. No FK: revoking or
|
|
19
|
+
-- deleting a key must never un-consume a token.
|
|
20
|
+
-- * agent_board._enroll_token_bind is the ONLY writer. Same token + same fingerprint
|
|
21
|
+
-- is a no-op and the caller continues into its UNCHANGED body; same token + a
|
|
22
|
+
-- different fingerprint raises SQLSTATE MBT01, which the Edge Function maps to
|
|
23
|
+
-- 409 token_already_used. MBT01 is outside every SQLSTATE this schema already uses
|
|
24
|
+
-- (23505 already means "key enrolled / not resumable / frozen contract"), and it is
|
|
25
|
+
-- not PTxxx, which PostgREST would turn into an HTTP status.
|
|
26
|
+
-- * It is called from EXACTLY ONE site: enroll_federated_identity, immediately after the
|
|
27
|
+
-- owner lock and the human-revocation gate and BEFORE the found/not-found split, so
|
|
28
|
+
-- it covers creation (-> _enroll_federated_identity_once) AND resumption. There is no
|
|
29
|
+
-- early return: a same-key retry falls through into the existing (a)/(b)/(c) gates
|
|
30
|
+
-- and redemption exactly as before. onboard_federated_fleet_agent reaches it through
|
|
31
|
+
-- its nested enroll_federated_identity call and only forwards the two arguments; a
|
|
32
|
+
-- second call site there would be a no-op under fall-through, and a mutation removing
|
|
33
|
+
-- either site could then redden nothing.
|
|
34
|
+
--
|
|
35
|
+
-- WHY NO NEW LOCK. Same token => same verified (issuer, subject) => same derived owner O,
|
|
36
|
+
-- and every path that reaches the bind already holds O FOR UPDATE (the identity route at
|
|
37
|
+
-- its owner lock; the fleet route at its own owner lock before the nested call, then
|
|
38
|
+
-- re-entrantly). The owner is coalesce(existing key's owner, derived owner): that is O for
|
|
39
|
+
-- a new key, a key the caller owns, and a legacy null-owner key. The only other case is a
|
|
40
|
+
-- key owned by another principal, which raises at resumption gate (a) in the same
|
|
41
|
+
-- transaction. Two same-token requests are therefore already serialised by an existing
|
|
42
|
+
-- row lock before the bind, and the second one's bind statement sees the first one's
|
|
43
|
+
-- committed row. The primary key is the backstop: were that argument ever broken, the
|
|
44
|
+
-- second insert waits on the first's uncommitted key and fails 23505 -- closed, loud.
|
|
45
|
+
-- Placing the bind ABOVE the owner lock would deadlock the identity route against the
|
|
46
|
+
-- fleet route (fleet holds O, then reaches the token; identity would hold the token, then
|
|
47
|
+
-- wait for O). Placement after the owner lock is what makes the loser a clean MBT01.
|
|
48
|
+
--
|
|
49
|
+
-- THE TOKEN IDENTITY is lowercase hex sha256 over the RS256 SIGNING INPUT
|
|
50
|
+
-- (header "." payload), computed by the Edge Function, which also refuses non-canonical
|
|
51
|
+
-- token encodings. RS256 is deterministic and the signing input is fixed by the
|
|
52
|
+
-- signature, so the hash is invariant under any re-encoding of the signature segment.
|
|
53
|
+
-- The expiry is the token's verified `exp`.
|
|
54
|
+
--
|
|
55
|
+
-- BOUNDED TABLE. Each bind prunes up to 50 rows whose token expired more than an hour
|
|
56
|
+
-- ago. Deleting a row is safe ONLY because an expired token can never pass again: the
|
|
57
|
+
-- Edge Function rejects exp <= now, and the bind itself refuses p_token_expires_at <=
|
|
58
|
+
-- clock_timestamp(). The hour absorbs Edge-Function/database clock skew.
|
|
59
|
+
--
|
|
60
|
+
-- BOTH SIDES READ clock_timestamp(), NEVER now(). now() is fixed at TRANSACTION START, and a
|
|
61
|
+
-- request can start, wait on the owner lock for as long as another transaction holds it, and
|
|
62
|
+
-- only then bind. With now() on the refusal, a request that started before exp and waited
|
|
63
|
+
-- past exp + 1 hour while another enrollment pruned the token's row would find the row gone,
|
|
64
|
+
-- pass its stale expiry check, and bind a SECOND key. The bind runs after the owner lock is
|
|
65
|
+
-- held, so clock_timestamp() there is the time the decision is actually made; and the prune
|
|
66
|
+
-- deletes only rows with exp < clock_timestamp() - 1 hour, each of which the refusal above
|
|
67
|
+
-- already rejects at any later clock_timestamp(). (Review finding, #524 round 1.)
|
|
68
|
+
--
|
|
69
|
+
-- SIGNATURES. enroll_federated_identity and onboard_federated_fleet_agent gain
|
|
70
|
+
-- (p_token_sha256 text, p_token_expires_at timestamptz) immediately after p_pop_verified,
|
|
71
|
+
-- with NO defaults, and the old signatures are DROPPED -- `create or replace` with a
|
|
72
|
+
-- changed argument list would leave the unguarded overload callable. An old Edge Function
|
|
73
|
+
-- calling with the old named arguments therefore resolves to no function (PGRST202) and
|
|
74
|
+
-- fails closed rather than falling through to an unguarded path.
|
|
75
|
+
-- _enroll_federated_identity_once is unchanged in signature and body; its service_role
|
|
76
|
+
-- EXECUTE grant is revoked. service_role has no direct INSERT on agent_signing_keys or
|
|
77
|
+
-- agents, so that grant was a real, unguarded creation path, and its only caller is a
|
|
78
|
+
-- postgres-owned definer that needs no grant.
|
|
79
|
+
--
|
|
80
|
+
-- DROP DISCARDS THE ACL AND THE COMMENT, and a recreated function grants EXECUTE to
|
|
81
|
+
-- PUBLIC by default, so every revoke/grant/comment is restated below in this file.
|
|
82
|
+
--
|
|
83
|
+
-- THIS MIGRATION MUST BE APPLIED INSIDE ONE TRANSACTION. The live apply path is one
|
|
84
|
+
-- Management-API request per migration, which is one implicit transaction, so the drop,
|
|
85
|
+
-- the create, the ACL and the comment commit together and no caller ever sees a missing
|
|
86
|
+
-- function. Applied statement-at-a-time (the local replay harness) there is a
|
|
87
|
+
-- drop-to-create gap on a scratch database; that gap is an AVAILABILITY gap only --
|
|
88
|
+
-- both recreated bodies raise 42501 on `not _is_service_role()` before anything else, so
|
|
89
|
+
-- the transient PUBLIC EXECUTE is not exploitable on its own.
|
|
90
|
+
--
|
|
91
|
+
-- The function bodies below are pg_get_functiondef of the live definers on a fresh bundle
|
|
92
|
+
-- built from this tree (20260916000100, written in UPPERCASE), NOT copies from a
|
|
93
|
+
-- migration file, plus exactly: the two parameters, the bind call (enroll_federated_identity
|
|
94
|
+
-- only), and onboard_federated_fleet_agent forwarding the two arguments to its nested call.
|
|
95
|
+
|
|
96
|
+
create table if not exists agent_board.enroll_token_uses (
|
|
97
|
+
token_sha256 text primary key check (token_sha256 ~ '^[0-9a-f]{64}$'),
|
|
98
|
+
agent_public_key_fingerprint text not null,
|
|
99
|
+
token_expires_at timestamptz not null,
|
|
100
|
+
created_at timestamptz not null default now()
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
create index if not exists enroll_token_uses_token_expires_at_idx
|
|
104
|
+
on agent_board.enroll_token_uses (token_expires_at);
|
|
105
|
+
|
|
106
|
+
alter table agent_board.enroll_token_uses enable row level security;
|
|
107
|
+
revoke all on table agent_board.enroll_token_uses from public, anon, authenticated, service_role;
|
|
108
|
+
|
|
109
|
+
comment on table agent_board.enroll_token_uses is
|
|
110
|
+
'One row per verified Portal access token that has enrolled or resumed a key (#521): token_sha256 is lowercase hex sha256 of the RS256 signing input (header.payload), bound to exactly one agent_public_key_fingerprint. Written ONLY by _enroll_token_bind, called ONLY from enroll_federated_identity. No FK to keys or agents by design: revoking or deleting a key must not un-consume a token. RLS on with zero policies and no API-role grants. Rows are pruned by _enroll_token_bind once token_expires_at is more than an hour past, which is safe only because an expired token can never pass /enroll or the bind again.';
|
|
111
|
+
|
|
112
|
+
create or replace function agent_board._enroll_token_bind(
|
|
113
|
+
p_token_sha256 text,
|
|
114
|
+
p_token_expires_at timestamptz,
|
|
115
|
+
p_fingerprint text
|
|
116
|
+
)
|
|
117
|
+
returns void
|
|
118
|
+
language plpgsql
|
|
119
|
+
security definer
|
|
120
|
+
set search_path = agent_board, public
|
|
121
|
+
as $$
|
|
122
|
+
declare
|
|
123
|
+
v_bound_fingerprint text;
|
|
124
|
+
begin
|
|
125
|
+
-- Not reachable through the API once the revoke below applies. This guard covers the one
|
|
126
|
+
-- gap the revoke cannot: applied statement-at-a-time (the local replay harness), this
|
|
127
|
+
-- CREATE commits with PUBLIC EXECUTE before the next statement revokes it.
|
|
128
|
+
if not agent_board._is_service_role() then
|
|
129
|
+
raise exception 'service_role required for _enroll_token_bind' using errcode = '42501';
|
|
130
|
+
end if;
|
|
131
|
+
if p_token_sha256 is null or p_token_sha256 !~ '^[0-9a-f]{64}$' then
|
|
132
|
+
raise exception 'enroll token hash is missing or malformed' using errcode = '22023';
|
|
133
|
+
end if;
|
|
134
|
+
if p_token_expires_at is null then
|
|
135
|
+
raise exception 'enroll token expiry is required' using errcode = '22023';
|
|
136
|
+
end if;
|
|
137
|
+
-- Defence in depth, and what makes the prune below safe from the database side: a
|
|
138
|
+
-- token whose row could already have been pruned is refused here, not re-bound.
|
|
139
|
+
-- clock_timestamp(), not now(): this runs after the caller's owner-lock WAIT, and now()
|
|
140
|
+
-- is the transaction's START time (see the header).
|
|
141
|
+
if p_token_expires_at <= clock_timestamp() then
|
|
142
|
+
raise exception 'enroll token has expired' using errcode = '22023';
|
|
143
|
+
end if;
|
|
144
|
+
if p_fingerprint is null then
|
|
145
|
+
raise exception 'agent public key fingerprint is required' using errcode = '22023';
|
|
146
|
+
end if;
|
|
147
|
+
|
|
148
|
+
select u.agent_public_key_fingerprint into v_bound_fingerprint
|
|
149
|
+
from agent_board.enroll_token_uses u
|
|
150
|
+
where u.token_sha256 = p_token_sha256;
|
|
151
|
+
|
|
152
|
+
if found then
|
|
153
|
+
-- Same key: a no-op. The caller continues into its unchanged body, which re-runs
|
|
154
|
+
-- every existing gate; nothing returns early from here.
|
|
155
|
+
if v_bound_fingerprint is distinct from p_fingerprint then
|
|
156
|
+
raise exception 'enroll token already bound to a different key' using errcode = 'MBT01';
|
|
157
|
+
end if;
|
|
158
|
+
else
|
|
159
|
+
-- A plain insert, deliberately WITHOUT `on conflict`. The caller's owner lock
|
|
160
|
+
-- serialises same-token requests, so a conflict here needs two requests holding
|
|
161
|
+
-- DIFFERENT owner locks -- reachable only when one presents a key owned by another
|
|
162
|
+
-- principal, which resumption gate (a) refuses in the same transaction anyway. When
|
|
163
|
+
-- it does happen it must surface as a loud 23505, never pass silently. It is not
|
|
164
|
+
-- claimed to be impossible.
|
|
165
|
+
insert into agent_board.enroll_token_uses (token_sha256, agent_public_key_fingerprint, token_expires_at)
|
|
166
|
+
values (p_token_sha256, p_fingerprint, p_token_expires_at);
|
|
167
|
+
end if;
|
|
168
|
+
|
|
169
|
+
-- Bounded prune. DELETE has no LIMIT, so it is keyed through the primary key;
|
|
170
|
+
-- `skip locked` keeps concurrent enrollments from queueing on each other's cleanup.
|
|
171
|
+
delete from agent_board.enroll_token_uses d
|
|
172
|
+
where d.token_sha256 in (
|
|
173
|
+
select u.token_sha256
|
|
174
|
+
from agent_board.enroll_token_uses u
|
|
175
|
+
where u.token_expires_at < clock_timestamp() - interval '1 hour'
|
|
176
|
+
order by u.token_expires_at
|
|
177
|
+
limit 50
|
|
178
|
+
for update skip locked
|
|
179
|
+
);
|
|
180
|
+
end;
|
|
181
|
+
$$;
|
|
182
|
+
|
|
183
|
+
revoke all on function agent_board._enroll_token_bind(text, timestamptz, text) from public, anon, authenticated, service_role;
|
|
184
|
+
|
|
185
|
+
comment on function agent_board._enroll_token_bind(text, timestamptz, text) is
|
|
186
|
+
'Binds a verified Portal access token (lowercase hex sha256 of its RS256 signing input) to exactly one agent key fingerprint (#521). Same token + same fingerprint: no-op, the caller continues into its unchanged body. Same token + a different fingerprint: raises SQLSTATE MBT01 (the Edge Function maps it to 409 token_already_used). Not found: a plain insert WITHOUT on conflict -- the caller''s owner lock serialises same-token requests, and the primary key is the fail-closed backstop (23505). Raises 22023 on a null or malformed hash, a null expiry, an expiry <= clock_timestamp() (never now(): the bind runs after an owner-lock wait), or a null fingerprint; raises 42501 unless the caller is service_role. Prunes up to 50 rows expired more than an hour before clock_timestamp(). Called ONLY from enroll_federated_identity, after its owner lock and revocation gate; not EXECUTE-able by any API role.';
|
|
187
|
+
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
-- Drop the token-less signatures. Nothing may keep resolving to them.
|
|
190
|
+
-- ---------------------------------------------------------------------------
|
|
191
|
+
drop function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text);
|
|
192
|
+
drop function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text);
|
|
193
|
+
|
|
194
|
+
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_token_sha256 text, p_token_expires_at timestamp with time zone, 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)
|
|
195
|
+
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)
|
|
196
|
+
LANGUAGE plpgsql
|
|
197
|
+
SECURITY DEFINER
|
|
198
|
+
SET search_path TO 'agent_board', 'public'
|
|
199
|
+
AS $function$
|
|
200
|
+
#variable_conflict use_column
|
|
201
|
+
declare
|
|
202
|
+
v_fingerprint text;
|
|
203
|
+
v_existing record;
|
|
204
|
+
v_created record;
|
|
205
|
+
v_replay jsonb;
|
|
206
|
+
v_invite_redeemed boolean := false;
|
|
207
|
+
v_slug text;
|
|
208
|
+
v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
|
|
209
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
210
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
211
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
212
|
+
v_invite_board_id uuid;
|
|
213
|
+
v_claim_audience jsonb;
|
|
214
|
+
v_owner_user_id uuid;
|
|
215
|
+
-- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
|
|
216
|
+
-- (issuer, subject) derives to; `v_creation_federation` is the federation object the
|
|
217
|
+
-- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
|
|
218
|
+
-- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
|
|
219
|
+
v_derived_owner_user_id uuid;
|
|
220
|
+
v_creation_federation jsonb;
|
|
221
|
+
v_owned_by_caller boolean;
|
|
222
|
+
begin
|
|
223
|
+
if not agent_board._is_service_role() then
|
|
224
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
225
|
+
end if;
|
|
226
|
+
if p_pop_verified is not true then
|
|
227
|
+
raise exception 'proof-of-possession was not verified' using errcode = '42501';
|
|
228
|
+
end if;
|
|
229
|
+
if p_subject is null then
|
|
230
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
231
|
+
end if;
|
|
232
|
+
if p_human_identity_claim is null then
|
|
233
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
234
|
+
end if;
|
|
235
|
+
|
|
236
|
+
select * into v_provider from agent_board.identity_providers ip
|
|
237
|
+
where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
|
|
238
|
+
if v_provider.id is null then
|
|
239
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
240
|
+
end if;
|
|
241
|
+
|
|
242
|
+
v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
|
|
243
|
+
if p_human_identity_claim ->> 'iss' is distinct from p_issuer
|
|
244
|
+
or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
|
|
245
|
+
or v_claim_audience is null
|
|
246
|
+
or not (v_claim_audience ? p_audience) then
|
|
247
|
+
raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
|
|
248
|
+
end if;
|
|
249
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
|
|
250
|
+
or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
251
|
+
raise exception 'malformed agent public key' using errcode = '22023';
|
|
252
|
+
end if;
|
|
253
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
254
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
255
|
+
raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
|
|
256
|
+
end if;
|
|
257
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
258
|
+
|
|
259
|
+
if not v_provider.supported or not v_provider.policy_approved then
|
|
260
|
+
raise exception 'identity provider is not approved' using errcode = '42501';
|
|
261
|
+
end if;
|
|
262
|
+
if v_kind = 'system' then
|
|
263
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
264
|
+
end if;
|
|
265
|
+
if v_kind not in ('human', 'agent') then
|
|
266
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
267
|
+
end if;
|
|
268
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
269
|
+
and not (v_kind = any(v_provider.allowed_agent_kinds)) then
|
|
270
|
+
raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
|
|
271
|
+
end if;
|
|
272
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes)
|
|
273
|
+
or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
274
|
+
raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
|
|
275
|
+
end if;
|
|
276
|
+
|
|
277
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
|
|
278
|
+
select 1 from agent_board.board_invite_tokens bit
|
|
279
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
280
|
+
where bit.code = btrim(p_invite_code)
|
|
281
|
+
) then
|
|
282
|
+
raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
|
|
283
|
+
end if;
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
-- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
|
|
287
|
+
-- never names. The redemption below inserts board_memberships, which takes FOR
|
|
288
|
+
-- KEY SHARE on the invite's agent_board.boards row through
|
|
289
|
+
-- board_memberships_board_id_fkey. That acquisition used to land AFTER the
|
|
290
|
+
-- auth.users owner lock, inverting against enroll_federated_human and
|
|
291
|
+
-- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
|
|
292
|
+
-- resulting cycle was reproduced as `deadlock detected ... while locking tuple
|
|
293
|
+
-- in relation "boards"`. Acquire the invite row and its board here, in the same
|
|
294
|
+
-- relative order _redeem_unbounded_invite_for_agent and
|
|
295
|
+
-- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
|
|
296
|
+
-- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
|
|
297
|
+
-- revoked, expired or exhausted code still falls through to redemption, which
|
|
298
|
+
-- stays the sole authority on invite usability (soft-honor contract).
|
|
299
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
300
|
+
select bit.board_id into v_invite_board_id
|
|
301
|
+
from agent_board.board_invite_tokens bit
|
|
302
|
+
where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
|
|
303
|
+
for update;
|
|
304
|
+
if v_invite_board_id is not null then
|
|
305
|
+
perform 1 from agent_board.boards b
|
|
306
|
+
where b.id = v_invite_board_id
|
|
307
|
+
for key share;
|
|
308
|
+
end if;
|
|
309
|
+
end if;
|
|
310
|
+
|
|
311
|
+
perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
|
|
312
|
+
|
|
313
|
+
select a.owner_user_id into v_owner_user_id
|
|
314
|
+
from agent_board.agent_signing_keys k
|
|
315
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
316
|
+
where k.fingerprint = v_fingerprint;
|
|
317
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
318
|
+
agent_board._federated_identity_slug(p_issuer, p_subject)
|
|
319
|
+
));
|
|
320
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
321
|
+
if exists (
|
|
322
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
323
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
324
|
+
) then
|
|
325
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
326
|
+
end if;
|
|
327
|
+
|
|
328
|
+
-- #521 THE TOKEN BINDING -- one site, here, for BOTH branches below. The owner lock
|
|
329
|
+
-- above already serialises every request carrying this token (same token => same
|
|
330
|
+
-- verified (issuer, subject) => same owner), so the bind needs no lock of its own; and
|
|
331
|
+
-- it sits AFTER that lock deliberately -- above it, the identity route would hold the
|
|
332
|
+
-- token and wait for the owner while the fleet route holds the owner and waits for the
|
|
333
|
+
-- token. A different key under a used token raises MBT01 here. The same key falls
|
|
334
|
+
-- THROUGH: nothing returns early, so resumption gates (a)/(b)/(c) and redemption below
|
|
335
|
+
-- run exactly as they did before this call existed.
|
|
336
|
+
perform agent_board._enroll_token_bind(p_token_sha256, p_token_expires_at, v_fingerprint);
|
|
337
|
+
|
|
338
|
+
-- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
|
|
339
|
+
-- SAME derivation _enroll_federated_identity_once step (7) used to mint the owner --
|
|
340
|
+
-- now literally the same function, agent_board._federated_identity_slug (#246), not a
|
|
341
|
+
-- second copy of the expression. Resolved by a PURE LOOKUP against the deterministic
|
|
342
|
+
-- email _ensure_agent_auth_user builds -- calling THAT function here would MINT an owner
|
|
343
|
+
-- row for whatever principal a squat attempt claimed. The slug helper is pure and mints
|
|
344
|
+
-- nothing, so computing the slug here is safe; only _ensure_agent_auth_user writes. Read-only: no FOR UPDATE, no row
|
|
345
|
+
-- lock, so the #194 board -> owner order above is untouched.
|
|
346
|
+
select u.id into v_derived_owner_user_id
|
|
347
|
+
from auth.users u
|
|
348
|
+
where lower(u.email) = format('agent+%s@agent-board.local',
|
|
349
|
+
agent_board._federated_identity_slug(p_issuer, p_subject))
|
|
350
|
+
limit 1;
|
|
351
|
+
|
|
352
|
+
select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
|
|
353
|
+
a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
|
|
354
|
+
k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
|
|
355
|
+
(select e.granted_scopes
|
|
356
|
+
from agent_board.enrollment_federation_audit e
|
|
357
|
+
where e.agent_id = a.id
|
|
358
|
+
and e.issuer = p_issuer
|
|
359
|
+
and e.subject = p_subject
|
|
360
|
+
and e.provider_id = v_provider.id) as granted_scopes
|
|
361
|
+
into v_existing
|
|
362
|
+
from agent_board.agent_signing_keys k
|
|
363
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
364
|
+
where k.fingerprint = v_fingerprint
|
|
365
|
+
for update of a, k;
|
|
366
|
+
|
|
367
|
+
if found then
|
|
368
|
+
v_replay := v_existing.metadata #> '{federation,replay}';
|
|
369
|
+
v_creation_federation := v_existing.metadata #> '{federation}';
|
|
370
|
+
|
|
371
|
+
-- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
|
|
372
|
+
-- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
|
|
373
|
+
-- creation-time federation stamp, (iii) the original enrollment's audit row. All
|
|
374
|
+
-- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
|
|
375
|
+
-- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
|
|
376
|
+
-- read as not-owned rather than leaving the `if not` below untaken.
|
|
377
|
+
v_owned_by_caller := coalesce(
|
|
378
|
+
v_creation_federation ->> 'issuer' = p_issuer
|
|
379
|
+
and v_creation_federation ->> 'subject' = p_subject
|
|
380
|
+
and v_creation_federation ->> 'providerId' = v_provider.id
|
|
381
|
+
and exists (
|
|
382
|
+
select 1
|
|
383
|
+
from agent_board.enrollment_federation_audit e
|
|
384
|
+
where e.agent_id = v_existing.agent_id
|
|
385
|
+
and e.issuer = p_issuer
|
|
386
|
+
and e.subject = p_subject
|
|
387
|
+
and e.provider_id = v_provider.id
|
|
388
|
+
)
|
|
389
|
+
-- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
|
|
390
|
+
-- so its claim is stated positively rather than left as "unless". A row whose
|
|
391
|
+
-- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
|
|
392
|
+
-- backfill fixture is exactly that shape, and replaying such a row is released,
|
|
393
|
+
-- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
|
|
394
|
+
-- the binding that does the work. (ii) and (iii) already name the principal, and
|
|
395
|
+
-- reaching them at all requires a claim the edge function VERIFIED for that same
|
|
396
|
+
-- issuer and subject, so a null owner narrows corroboration without widening who
|
|
397
|
+
-- may resume. A NON-NULL owner that disagrees with the derived one is a hard
|
|
398
|
+
-- refusal, which is what keeps this from being a hole.
|
|
399
|
+
and (
|
|
400
|
+
v_existing.owner_user_id is null
|
|
401
|
+
or (v_derived_owner_user_id is not null
|
|
402
|
+
and v_existing.owner_user_id = v_derived_owner_user_id)
|
|
403
|
+
), false);
|
|
404
|
+
|
|
405
|
+
-- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
|
|
406
|
+
-- caller that does not own the key learns nothing else about it. No handle is named:
|
|
407
|
+
-- telling a squatter which agent holds the key it just probed is the leak this
|
|
408
|
+
-- ordering avoids. Everything below runs only for the proven owner.
|
|
409
|
+
if not v_owned_by_caller then
|
|
410
|
+
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'
|
|
411
|
+
using errcode = '23505';
|
|
412
|
+
end if;
|
|
413
|
+
|
|
414
|
+
-- (b) the owner is asking to resume, but the identity is not in a resumable state.
|
|
415
|
+
-- Resumption returns an existing identity; it never revives a revoked, unproven or
|
|
416
|
+
-- drifted one, and it never invents an audit trail that is not there.
|
|
417
|
+
if v_existing.public_key is distinct from p_agent_public_key
|
|
418
|
+
or v_existing.public_key_format is distinct from p_agent_public_key_format
|
|
419
|
+
or v_existing.key_status <> 'active'
|
|
420
|
+
or v_existing.proof_state <> 'proven'
|
|
421
|
+
or v_existing.status <> 'active'
|
|
422
|
+
or v_existing.granted_scopes is null then
|
|
423
|
+
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',
|
|
424
|
+
v_existing.handle using errcode = '23505';
|
|
425
|
+
end if;
|
|
426
|
+
|
|
427
|
+
-- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
|
|
428
|
+
-- contract exactly as before -- the check is unchanged, only its message improves to
|
|
429
|
+
-- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
|
|
430
|
+
-- (every identity created before 20260901000100, including the #176 victim) has no
|
|
431
|
+
-- frozen contract to check and resumes on the ownership evidence alone; that absence
|
|
432
|
+
-- used to be the raise that trapped it.
|
|
433
|
+
if v_replay is not null
|
|
434
|
+
and (v_replay ->> 'issuer' is distinct from p_issuer
|
|
435
|
+
or v_replay -> 'audience' is distinct from v_claim_audience
|
|
436
|
+
or v_replay ->> 'subject' is distinct from p_subject
|
|
437
|
+
or v_replay ->> 'requestedSlug' is distinct from v_slug
|
|
438
|
+
or v_replay ->> 'agentKind' is distinct from v_kind
|
|
439
|
+
or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
|
|
440
|
+
or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
|
|
441
|
+
or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
|
|
442
|
+
or v_replay ->> 'providerId' is distinct from v_provider.id) then
|
|
443
|
+
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',
|
|
444
|
+
v_existing.handle using errcode = '23505';
|
|
445
|
+
end if;
|
|
446
|
+
|
|
447
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
448
|
+
begin
|
|
449
|
+
perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
|
|
450
|
+
v_invite_redeemed := true;
|
|
451
|
+
exception when others then
|
|
452
|
+
-- #194: a serialization failure is NOT "the invite was not redeemed".
|
|
453
|
+
-- Swallowing class 40 here reported enrollment SUCCESS to the caller with
|
|
454
|
+
-- invite_redeemed=false and no membership row, while the deadlock counter
|
|
455
|
+
-- ticked and no error reached the client. Re-raise the whole class
|
|
456
|
+
-- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
|
|
457
|
+
-- and keep the soft-honor contract for every deterministic failure.
|
|
458
|
+
if left(sqlstate, 2) = '40' then
|
|
459
|
+
raise;
|
|
460
|
+
end if;
|
|
461
|
+
v_invite_redeemed := false;
|
|
462
|
+
end;
|
|
463
|
+
end if;
|
|
464
|
+
|
|
465
|
+
-- provider_id comes from STORED evidence, not the request: the replay stamp when
|
|
466
|
+
-- there is one, otherwise the creation-time federation object. The ownership gate
|
|
467
|
+
-- already pinned that value to v_provider.id, so the two agree by construction --
|
|
468
|
+
-- but reading it from the row keeps the returned receipt sourced where every other
|
|
469
|
+
-- returned field is.
|
|
470
|
+
return query select v_existing.agent_id, v_existing.auth_user_id,
|
|
471
|
+
coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
|
|
472
|
+
v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
|
|
473
|
+
v_existing.handle, v_invite_redeemed;
|
|
474
|
+
return;
|
|
475
|
+
end if;
|
|
476
|
+
|
|
477
|
+
select * into v_created from agent_board._enroll_federated_identity_once(
|
|
478
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
479
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
480
|
+
p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
|
|
481
|
+
v_requested_board_scopes, p_invite_code
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
update agent_board.agents a
|
|
485
|
+
set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
|
|
486
|
+
'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
|
|
487
|
+
'providerId', v_created.provider_id,
|
|
488
|
+
'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
|
|
489
|
+
'requestedSlug', v_slug, 'agentKind', v_kind,
|
|
490
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
491
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
|
|
492
|
+
), true)
|
|
493
|
+
where a.id = v_created.agent_id;
|
|
494
|
+
|
|
495
|
+
return query select v_created.agent_id, v_created.auth_user_id,
|
|
496
|
+
v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
|
|
497
|
+
v_created.key_id, v_created.key_version, v_created.agent_handle,
|
|
498
|
+
v_created.invite_redeemed;
|
|
499
|
+
end;
|
|
500
|
+
$function$;
|
|
501
|
+
|
|
502
|
+
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_token_sha256 text, p_token_expires_at timestamp with time zone, 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)
|
|
503
|
+
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)
|
|
504
|
+
LANGUAGE plpgsql
|
|
505
|
+
SECURITY DEFINER
|
|
506
|
+
SET search_path TO 'agent_board', 'public'
|
|
507
|
+
AS $function$
|
|
508
|
+
#variable_conflict use_column
|
|
509
|
+
declare
|
|
510
|
+
v_inv agent_board.board_invite_tokens%rowtype;
|
|
511
|
+
v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
|
|
512
|
+
v_board agent_board.boards%rowtype;
|
|
513
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
514
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
515
|
+
v_enroll record;
|
|
516
|
+
v_membership record;
|
|
517
|
+
v_harness agent_board.fleet_harness_registrations%rowtype;
|
|
518
|
+
v_repo agent_board.fleet_repo_scopes%rowtype;
|
|
519
|
+
v_binding agent_board.fleet_machine_agents%rowtype;
|
|
520
|
+
v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
|
|
521
|
+
v_label text := btrim(coalesce(p_harness_label, ''));
|
|
522
|
+
v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
|
|
523
|
+
v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
|
|
524
|
+
v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
|
|
525
|
+
v_existing_agent_id uuid;
|
|
526
|
+
v_owner_user_id uuid;
|
|
527
|
+
v_identity_disposition text;
|
|
528
|
+
v_membership_disposition text;
|
|
529
|
+
v_harness_disposition text;
|
|
530
|
+
v_repo_disposition text;
|
|
531
|
+
v_binding_disposition text;
|
|
532
|
+
v_completed_replay boolean := false;
|
|
533
|
+
begin
|
|
534
|
+
if not agent_board._is_service_role() then
|
|
535
|
+
raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
|
|
536
|
+
end if;
|
|
537
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
|
|
538
|
+
raise exception 'bounded invite code is required' using errcode = '22023';
|
|
539
|
+
end if;
|
|
540
|
+
|
|
541
|
+
select bit.* into v_inv
|
|
542
|
+
from agent_board.board_invite_tokens bit
|
|
543
|
+
where bit.code = btrim(p_invite_code)
|
|
544
|
+
for update;
|
|
545
|
+
if not found then
|
|
546
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
547
|
+
end if;
|
|
548
|
+
select * into v_bounds
|
|
549
|
+
from agent_board.fleet_onboarding_invite_bounds b
|
|
550
|
+
where b.invite_id = v_inv.id
|
|
551
|
+
for update;
|
|
552
|
+
if not found then
|
|
553
|
+
raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
|
|
554
|
+
end if;
|
|
555
|
+
|
|
556
|
+
-- ADVISORY BEFORE BOARDS. set_board_authority_ceiling holds this lock and then
|
|
557
|
+
-- reaches this same boards row through board_authority_ceilings' FK (FOR KEY SHARE
|
|
558
|
+
-- on INSERT). Taking the boards lock first would invert the order against it and
|
|
559
|
+
-- deadlock -- it did, until this line was hoisted above the select below.
|
|
560
|
+
perform agent_board._lock_board_authority_ceiling(v_inv.board_id);
|
|
561
|
+
|
|
562
|
+
select * into v_board
|
|
563
|
+
from agent_board.boards b
|
|
564
|
+
where b.id = v_inv.board_id
|
|
565
|
+
for update;
|
|
566
|
+
if not found or v_board.status <> 'active' then
|
|
567
|
+
raise exception 'fleet onboarding board is not usable' using errcode = '42501';
|
|
568
|
+
end if;
|
|
569
|
+
|
|
570
|
+
if v_inv.board_id is distinct from p_board_id
|
|
571
|
+
or v_bounds.machine_id is distinct from p_machine_id
|
|
572
|
+
or v_bounds.harness_kind is distinct from v_kind
|
|
573
|
+
or v_bounds.harness_label is distinct from v_label
|
|
574
|
+
or v_bounds.repo_slug is distinct from v_repo_slug
|
|
575
|
+
or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
|
|
576
|
+
or v_bounds.repo_scope_role is distinct from p_repo_scope_role
|
|
577
|
+
or v_bounds.agent_role is distinct from p_agent_role
|
|
578
|
+
or v_bounds.expected_agent_handle is distinct from v_expected_handle then
|
|
579
|
+
raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
|
|
580
|
+
end if;
|
|
581
|
+
if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
|
|
582
|
+
or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
|
|
583
|
+
raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
|
|
584
|
+
end if;
|
|
585
|
+
|
|
586
|
+
select * into v_machine from agent_board.fleet_machines fm
|
|
587
|
+
where fm.id = p_machine_id for update;
|
|
588
|
+
if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
|
|
589
|
+
or v_machine.lifecycle_state <> 'active' then
|
|
590
|
+
raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
|
|
591
|
+
end if;
|
|
592
|
+
select * into v_domain from agent_board.fleet_trust_domains td
|
|
593
|
+
where td.id = v_machine.trust_domain_id for update;
|
|
594
|
+
if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
|
|
595
|
+
or v_domain.lifecycle_state <> 'active' then
|
|
596
|
+
raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
|
|
597
|
+
end if;
|
|
598
|
+
|
|
599
|
+
-- Serialize cross-route identity classification before locking principals. For
|
|
600
|
+
-- a replay, lock the creator and enrolled principal in UUID order so a human or
|
|
601
|
+
-- agent subtree revocation that visits the same pair cannot invert their order.
|
|
602
|
+
perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
|
|
603
|
+
select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
|
|
604
|
+
from agent_board.agent_signing_keys k
|
|
605
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
606
|
+
where k.fingerprint = p_agent_public_key_fingerprint;
|
|
607
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
608
|
+
agent_board._federated_identity_slug(p_issuer, p_subject)
|
|
609
|
+
));
|
|
610
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
611
|
+
if exists (
|
|
612
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
613
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
614
|
+
) then
|
|
615
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
616
|
+
end if;
|
|
617
|
+
perform 1 from agent_board.agents a
|
|
618
|
+
where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
|
|
619
|
+
order by a.id for update;
|
|
620
|
+
|
|
621
|
+
-- Creator authority is part of the invite's live authorization, not merely a
|
|
622
|
+
-- snapshot from mint time. Lock every membership row and the current ceiling
|
|
623
|
+
-- before evaluating its role so revocation, eviction, or demotion must serialize
|
|
624
|
+
-- on one side of this transaction.
|
|
625
|
+
--
|
|
626
|
+
-- The ceiling ROW lock below covers demotion of an EXISTING ceiling only. No row
|
|
627
|
+
-- lock can cover the ABSENCE of a row, and absence is the normal default-owner
|
|
628
|
+
-- state, so a concurrent set_board_authority_ceiling inserting 'observer' used to
|
|
629
|
+
-- interleave: this transaction read "no ceiling", passed as owner, and committed a
|
|
630
|
+
-- 'member' membership after the ceiling committed. The board-keyed advisory lock
|
|
631
|
+
-- that serializes the ABSENCE is already held -- it is taken far above, BEFORE the
|
|
632
|
+
-- boards row lock, because the ceiling writer reaches boards through its FK and the
|
|
633
|
+
-- two must agree on order.
|
|
634
|
+
perform 1 from agent_board.board_memberships bm
|
|
635
|
+
where bm.board_id = v_inv.board_id
|
|
636
|
+
and bm.agent_id = v_inv.created_by_agent_id
|
|
637
|
+
order by bm.id for update;
|
|
638
|
+
perform 1 from agent_board.board_authority_ceilings c
|
|
639
|
+
where c.board_id = v_inv.board_id for update;
|
|
640
|
+
|
|
641
|
+
-- Concurrent legacy enrollment or onboarding on another machine has committed
|
|
642
|
+
-- before this snapshot decides whether the identity was created here.
|
|
643
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
|
|
644
|
+
v_identity_disposition := 'reused';
|
|
645
|
+
else
|
|
646
|
+
v_identity_disposition := 'created';
|
|
647
|
+
end if;
|
|
648
|
+
|
|
649
|
+
select * into v_enroll from agent_board.enroll_federated_identity(
|
|
650
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
651
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
652
|
+
p_pop_verified, p_token_sha256, p_token_expires_at, p_actor_sub, p_agent_kind, p_requested_scopes,
|
|
653
|
+
p_requested_board_scopes, null
|
|
654
|
+
);
|
|
655
|
+
if v_enroll.agent_handle is distinct from v_expected_handle then
|
|
656
|
+
raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
|
|
657
|
+
end if;
|
|
658
|
+
|
|
659
|
+
-- A complete replay may bypass expiry/exhaustion only while its redemption and
|
|
660
|
+
-- active membership remain true through commit. Lock both ledgers before the
|
|
661
|
+
-- proof; the invite row already serializes first redemption for an absent row.
|
|
662
|
+
perform 1 from agent_board.board_invite_redemptions red
|
|
663
|
+
where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
|
|
664
|
+
order by red.id for update;
|
|
665
|
+
perform 1 from agent_board.board_memberships bm
|
|
666
|
+
where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
|
|
667
|
+
order by bm.id for update;
|
|
668
|
+
|
|
669
|
+
-- The released ROOMLINK core deliberately returns an existing redemption before
|
|
670
|
+
-- checking current token authority. Fleet onboarding has a narrower replay
|
|
671
|
+
-- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
|
|
672
|
+
-- partial chain must pass every current invite gate before any missing suffix is
|
|
673
|
+
-- written, and revocation/current creator authority always apply.
|
|
674
|
+
select exists (
|
|
675
|
+
select 1
|
|
676
|
+
from agent_board.board_invite_redemptions red
|
|
677
|
+
join agent_board.board_memberships mem on mem.id = red.membership_id
|
|
678
|
+
join agent_board.fleet_harness_registrations h
|
|
679
|
+
on h.machine_id = p_machine_id
|
|
680
|
+
and h.harness_kind = v_kind
|
|
681
|
+
and h.harness_label = v_label
|
|
682
|
+
join agent_board.fleet_repo_scopes repo
|
|
683
|
+
on repo.harness_registration_id = h.id
|
|
684
|
+
and repo.repo_slug = v_repo_slug
|
|
685
|
+
and repo.repo_fingerprint = v_repo_fingerprint
|
|
686
|
+
and repo.scope_role = p_repo_scope_role
|
|
687
|
+
join agent_board.fleet_machine_agents binding
|
|
688
|
+
on binding.machine_id = p_machine_id
|
|
689
|
+
and binding.agent_id = v_enroll.agent_id
|
|
690
|
+
and binding.repo_scope_id = repo.id
|
|
691
|
+
and binding.signing_key_id = v_enroll.signing_key_id
|
|
692
|
+
and binding.agent_role = p_agent_role
|
|
693
|
+
join agent_board.agents principal on principal.id = v_enroll.agent_id
|
|
694
|
+
join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
|
|
695
|
+
where red.invite_id = v_inv.id
|
|
696
|
+
and red.agent_id = v_enroll.agent_id
|
|
697
|
+
and mem.board_id = v_inv.board_id
|
|
698
|
+
and mem.agent_id = v_enroll.agent_id
|
|
699
|
+
and mem.role = 'member'
|
|
700
|
+
and mem.membership_state in ('joined', 'subscribed')
|
|
701
|
+
and h.lifecycle_state = 'active'
|
|
702
|
+
and repo.lifecycle_state = 'active'
|
|
703
|
+
and binding.lifecycle_state = 'active'
|
|
704
|
+
and principal.status = 'active'
|
|
705
|
+
and signing_key.status = 'active'
|
|
706
|
+
and signing_key.proof_state = 'proven'
|
|
707
|
+
and signing_key.revoked_at is null
|
|
708
|
+
and not exists (
|
|
709
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
710
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
711
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
712
|
+
or (e.subject_kind = 'harness' and e.subject_id = h.id)
|
|
713
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
|
|
714
|
+
)
|
|
715
|
+
) into v_completed_replay;
|
|
716
|
+
|
|
717
|
+
if v_inv.revoked_at is not null
|
|
718
|
+
or not exists (
|
|
719
|
+
select 1 from agent_board.agents a
|
|
720
|
+
where a.id = v_inv.created_by_agent_id and a.status = 'active'
|
|
721
|
+
)
|
|
722
|
+
or not agent_board._grant_within_ceiling(
|
|
723
|
+
v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
|
|
724
|
+
)
|
|
725
|
+
or (
|
|
726
|
+
exists (
|
|
727
|
+
select 1 from agent_board.board_memberships bm
|
|
728
|
+
where bm.board_id = v_inv.board_id
|
|
729
|
+
and bm.agent_id = v_enroll.agent_id
|
|
730
|
+
and bm.membership_state = 'removed'
|
|
731
|
+
)
|
|
732
|
+
and not exists (
|
|
733
|
+
select 1 from agent_board.board_memberships bm
|
|
734
|
+
where bm.board_id = v_inv.board_id
|
|
735
|
+
and bm.agent_id = v_enroll.agent_id
|
|
736
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
737
|
+
)
|
|
738
|
+
) then
|
|
739
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
740
|
+
end if;
|
|
741
|
+
if not v_completed_replay
|
|
742
|
+
and (
|
|
743
|
+
v_inv.expires_at <= now()
|
|
744
|
+
or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
|
|
745
|
+
) then
|
|
746
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
747
|
+
end if;
|
|
748
|
+
|
|
749
|
+
if exists (
|
|
750
|
+
select 1 from agent_board.board_invite_redemptions r
|
|
751
|
+
where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
|
|
752
|
+
) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
|
|
753
|
+
|
|
754
|
+
select * into v_membership
|
|
755
|
+
from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
|
|
756
|
+
if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
|
|
757
|
+
raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
|
|
758
|
+
end if;
|
|
759
|
+
|
|
760
|
+
perform 1 from agent_board.fleet_harness_registrations h
|
|
761
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
762
|
+
order by h.id for update;
|
|
763
|
+
if exists (
|
|
764
|
+
select 1 from agent_board.fleet_harness_registrations h
|
|
765
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
766
|
+
and h.lifecycle_state <> 'active'
|
|
767
|
+
) then
|
|
768
|
+
raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
|
|
769
|
+
end if;
|
|
770
|
+
select * into v_harness
|
|
771
|
+
from agent_board.fleet_harness_registrations h
|
|
772
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
773
|
+
and h.lifecycle_state = 'active'
|
|
774
|
+
order by h.id;
|
|
775
|
+
if found then
|
|
776
|
+
v_harness_disposition := 'reused';
|
|
777
|
+
else
|
|
778
|
+
insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
|
|
779
|
+
values (p_machine_id, v_kind, v_label) returning * into v_harness;
|
|
780
|
+
v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
781
|
+
end if;
|
|
782
|
+
|
|
783
|
+
perform 1 from agent_board.fleet_repo_scopes r
|
|
784
|
+
where r.harness_registration_id = v_harness.id
|
|
785
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
786
|
+
order by r.id for update;
|
|
787
|
+
if exists (
|
|
788
|
+
select 1 from agent_board.fleet_repo_scopes r
|
|
789
|
+
where r.harness_registration_id = v_harness.id
|
|
790
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
791
|
+
and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
|
|
792
|
+
) then
|
|
793
|
+
raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
|
|
794
|
+
end if;
|
|
795
|
+
select * into v_repo
|
|
796
|
+
from agent_board.fleet_repo_scopes r
|
|
797
|
+
where r.harness_registration_id = v_harness.id
|
|
798
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
799
|
+
and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
|
|
800
|
+
order by r.id;
|
|
801
|
+
if found then
|
|
802
|
+
v_repo_disposition := 'reused';
|
|
803
|
+
else
|
|
804
|
+
insert into agent_board.fleet_repo_scopes(
|
|
805
|
+
harness_registration_id, repo_slug, repo_fingerprint, scope_role
|
|
806
|
+
) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
|
|
807
|
+
returning * into v_repo;
|
|
808
|
+
v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
809
|
+
end if;
|
|
810
|
+
|
|
811
|
+
-- Machine -> agent/key/binding lock order. Any tombstone for this natural key
|
|
812
|
+
-- blocks replacement authority even though the active unique index would allow it.
|
|
813
|
+
perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
|
|
814
|
+
perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
|
|
815
|
+
perform 1 from agent_board.fleet_machine_agents b
|
|
816
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
817
|
+
and b.repo_scope_id = v_repo.id
|
|
818
|
+
order by b.id for update;
|
|
819
|
+
if exists (
|
|
820
|
+
select 1 from agent_board.fleet_machine_agents b
|
|
821
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
822
|
+
and b.repo_scope_id = v_repo.id
|
|
823
|
+
and (
|
|
824
|
+
b.lifecycle_state <> 'active'
|
|
825
|
+
or b.signing_key_id is distinct from v_enroll.signing_key_id
|
|
826
|
+
or b.agent_role is distinct from p_agent_role
|
|
827
|
+
)
|
|
828
|
+
) then
|
|
829
|
+
raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
|
|
830
|
+
end if;
|
|
831
|
+
select * into v_binding
|
|
832
|
+
from agent_board.fleet_machine_agents b
|
|
833
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
834
|
+
and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
|
|
835
|
+
and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
|
|
836
|
+
order by b.id;
|
|
837
|
+
if found then
|
|
838
|
+
v_binding_disposition := 'reused';
|
|
839
|
+
else
|
|
840
|
+
insert into agent_board.fleet_machine_agents(
|
|
841
|
+
machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
|
|
842
|
+
) values (
|
|
843
|
+
p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
|
|
844
|
+
) returning * into v_binding;
|
|
845
|
+
v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
846
|
+
end if;
|
|
847
|
+
|
|
848
|
+
if exists (
|
|
849
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
850
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
851
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
852
|
+
or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
|
|
853
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
|
|
854
|
+
) then
|
|
855
|
+
raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
|
|
856
|
+
end if;
|
|
857
|
+
|
|
858
|
+
return query select
|
|
859
|
+
'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
|
|
860
|
+
v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
|
|
861
|
+
v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
|
|
862
|
+
p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
|
|
863
|
+
v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
|
|
864
|
+
v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
|
|
865
|
+
p_repo_scope_role, p_agent_role, v_expected_handle,
|
|
866
|
+
v_identity_disposition, v_membership_disposition, v_harness_disposition,
|
|
867
|
+
v_repo_disposition, v_binding_disposition;
|
|
868
|
+
end;
|
|
869
|
+
$function$;
|
|
870
|
+
|
|
871
|
+
revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) from public, anon, authenticated;
|
|
872
|
+
grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) to service_role;
|
|
873
|
+
comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) is
|
|
874
|
+
'Service-role federated enrollment with exact fingerprint replay AND owner-scoped resumption (#176). An already-enrolled key re-presented by the principal that OWNS it RESUMES: the existing agent identity is returned so a client whose post-enroll provisioning failed can finish, rather than being refused forever. Ownership is decided against evidence written at the ORIGINAL enrollment: the creation-time federation stamp (issuer/subject/providerId) and the enrollment_federation_audit row are both REQUIRED, and agents.owner_user_id must equal the auth user derived from sha256(issuer|subject) whenever that column is populated (a null owner_user_id predates the owner model and is the one exemption; such a row still resumes only for the principal the stamp and audit row name). The caller''s (issuer, subject) is trusted only because it is equality-gated against the verified claim projection above; the conjunction is coalesced to false so unproven ownership is not-owned. A key held by a DIFFERENT principal still raises 23505 and the message names no handle. Stable owner, actor, slug, kind, and scope inputs stay frozen for a STAMPED enrollment (unchanged check, message now names the handle); an enrollment predating the stamp has no frozen contract and resumes on the ownership evidence alone, returning its ORIGINAL granted_scopes. Rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes. Takes the invite row and its board (invite -> board) BEFORE the auth.users owner lock so every federated write route agrees on board -> owner (#194); the resumption additions are read-only and acquire no lock. A serialization failure or deadlock during redemption propagates instead of being reported as invite_redeemed=false. #521: p_token_sha256 (lowercase hex sha256 of the verified Portal token''s RS256 signing input) and p_token_expires_at (its verified exp) are REQUIRED and have no default; immediately after the owner lock and the revocation gate, and before the found/not-found split, _enroll_token_bind binds the token to this key''s fingerprint, so a token enrolls or resumes AT MOST ONE key. A different key under a used token raises SQLSTATE MBT01 and nothing is created; the same key falls through into the unchanged resumption gates. This is the ONLY call site of _enroll_token_bind and the only caller of _enroll_federated_identity_once.';
|
|
875
|
+
|
|
876
|
+
revoke all on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) from public, anon, authenticated;
|
|
877
|
+
grant execute on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) to service_role;
|
|
878
|
+
comment on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) is
|
|
879
|
+
'Service-role-only atomic fleet onboarding. Locks invite, machine, domain, principal, key, and binding in canonical order; takes the board-keyed _lock_board_authority_ceiling advisory lock so the ceiling''s ABSENCE serializes against a concurrent set_board_authority_ceiling before creator authority is evaluated; validates immutable creator bounds; enrolls/replays identity; redeems member ROOMLINK; and creates/reuses the exact harness, repo, and key-bound machine-agent chain in one transaction. #521: p_token_sha256 and p_token_expires_at are REQUIRED (no default) and are only forwarded to the nested enroll_federated_identity call, which binds the token to the key after this function''s owner lock; a used token presented with a different key raises MBT01 there and the whole onboarding rolls back.';
|
|
880
|
+
|
|
881
|
+
-- _enroll_federated_identity_once: signature and body unchanged. Its only caller is the
|
|
882
|
+
-- postgres-owned definer above, so the service_role grant was a bypass of the token bind
|
|
883
|
+
-- and nothing else.
|
|
884
|
+
revoke execute on function agent_board._enroll_federated_identity_once(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from service_role;
|
|
885
|
+
comment on function agent_board._enroll_federated_identity_once(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
886
|
+
'Internal one-time federated-identity creator (the released 20260724000200/20260822000100 implementation, renamed by 20260901000100). Call only through enroll_federated_identity, which holds the invite/board and owner locks. Takes the invite row and its board BEFORE the owner is provisioned so the board -> owner order holds even on a direct service_role call (#194); the optional trailing p_invite_code is still soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false), EXCEPT for SQLSTATE class 40, which propagates rather than being reported as a non-redemption. CREATES ONLY: step (6) still refuses an already-enrolled fingerprint unconditionally and does NOT resume (#176) -- resumption has exactly one implementation and it lives in enroll_federated_identity, which is this function''s only caller and which gates the (issuer, subject) against the verified claim projection first. The step (6) raise names the agent that already holds the key and says enrollment need not be repeated. #521: not EXECUTE-able by service_role (or any API role). The Portal-token binding lives in its only caller, enroll_federated_identity, and a direct service_role call would have been an unguarded creation path, since service_role holds no direct INSERT on agent_signing_keys or agents.';
|