@consiliency/agent-board-schema 1.22.0 → 1.23.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 +36 -0
- package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/package.json +1 -1
- package/schema-manifest.json +84 -3
- package/src/tooling.test.mjs +2 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +46 -2
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
- package/tests/agent_board_inbox_reads.test.sql +16 -19
- package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
- package/tests/agent_board_roomlink.test.sql +2 -2
- package/tests/agent_board_wake_recovery.test.sql +756 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
-- Consiliency/message-board#186 — make the ceiling-ABSENCE serialization EXPLICIT.
|
|
2
|
+
--
|
|
3
|
+
-- THE REPORTED DEFECT, AND WHY IT IS NOT REACHABLE TODAY. The #186 review held that
|
|
4
|
+
-- 20260901000100 locks the board's ceiling row before evaluating creator authority:
|
|
5
|
+
--
|
|
6
|
+
-- perform 1 from agent_board.board_authority_ceilings c
|
|
7
|
+
-- where c.board_id = v_inv.board_id for update;
|
|
8
|
+
--
|
|
9
|
+
-- and that this lock exists only when the row does. That part is true: absence is
|
|
10
|
+
-- the NORMAL default-owner state (_grant_within_ceiling coalesces a missing row to
|
|
11
|
+
-- rank 4 = owner), and PostgreSQL cannot row-lock a row that is not there. The
|
|
12
|
+
-- conclusion drawn from it — that a concurrent set_board_authority_ceiling
|
|
13
|
+
-- inserting 'observer' can interleave and let a 'member' membership commit under a
|
|
14
|
+
-- ceiling that forbids it — does NOT follow, and was refuted empirically before
|
|
15
|
+
-- this migration was written:
|
|
16
|
+
--
|
|
17
|
+
-- * board_authority_ceilings.board_id has an FK to boards. INSERTing a FIRST
|
|
18
|
+
-- ceiling row therefore takes a FOR KEY SHARE lock on that boards row.
|
|
19
|
+
-- * onboard_federated_fleet_agent already holds that same boards row FOR UPDATE
|
|
20
|
+
-- (`select * into v_board from agent_board.boards b where b.id = v_inv.board_id
|
|
21
|
+
-- for update`), acquired well BEFORE it reads the ceiling.
|
|
22
|
+
-- * FOR UPDATE conflicts with FOR KEY SHARE, so the two transactions already
|
|
23
|
+
-- mutually exclude. Verified with pg_locks: the ceiling INSERT waits on the
|
|
24
|
+
-- onboarding transaction id.
|
|
25
|
+
-- * The row-PRESENT case (ON CONFLICT DO UPDATE, no FK re-check, no parent lock)
|
|
26
|
+
-- is covered by the existing ceiling-row FOR UPDATE.
|
|
27
|
+
--
|
|
28
|
+
-- Removing the advisory lock added below, with everything else intact, changes
|
|
29
|
+
-- NOTHING observable in tests/isolation/specs/atomic_fleet_authority_races.spec —
|
|
30
|
+
-- confirmed by mutation. The advisory lock is therefore NOT the load-bearing
|
|
31
|
+
-- serializer today, and this migration must not be described as closing a live
|
|
32
|
+
-- hole. What is load-bearing is the boards FOR UPDATE, and it is load-bearing only
|
|
33
|
+
-- incidentally: nothing in 20260901000100 says the ceiling decision depends on a
|
|
34
|
+
-- lock taken 150 lines earlier for an unrelated reason (board liveness).
|
|
35
|
+
--
|
|
36
|
+
-- WHAT THIS MIGRATION IS FOR, THEN. It converts an incidental guarantee into a
|
|
37
|
+
-- stated one. A board-keyed pg_advisory_xact_lock, taken by BOTH sides before
|
|
38
|
+
-- either evaluates or writes the ceiling, is the correct instrument precisely
|
|
39
|
+
-- because the serialized thing is the ABSENCE of a row — it does not depend on an
|
|
40
|
+
-- FK's parent-lock side effect surviving a future refactor. Mutation-proven to be
|
|
41
|
+
-- genuinely redundant, not decorative: with the boards FOR UPDATE removed and this
|
|
42
|
+
-- lock retained the isolation spec still passes; with BOTH removed it fails, the
|
|
43
|
+
-- expired replay completing as reused/reused/reused under a committed observer
|
|
44
|
+
-- ceiling. Rejected alternatives:
|
|
45
|
+
-- * a sentinel ceiling row per board — changes default-owner semantics and needs
|
|
46
|
+
-- a backfill over every existing board;
|
|
47
|
+
-- * SERIALIZABLE — a repo-wide isolation posture change, out of scope.
|
|
48
|
+
--
|
|
49
|
+
-- The key derivation lives in ONE function (_lock_board_authority_ceiling) rather
|
|
50
|
+
-- than being written out at each site: two hand-written keys that must be
|
|
51
|
+
-- byte-identical to interlock are a silent-failure class, and a named helper also
|
|
52
|
+
-- lets a catalog assertion enumerate the participants.
|
|
53
|
+
--
|
|
54
|
+
-- LOCK ORDER — ADVISORY FIRST ON BOTH SIDES, and the reason is the FK above.
|
|
55
|
+
-- set_board_authority_ceiling takes the advisory lock, then reads board_memberships
|
|
56
|
+
-- (unlocked), then UPSERTS board_authority_ceilings — and that upsert is NOT a bare
|
|
57
|
+
-- write: on INSERT it takes FOR KEY SHARE on the parent boards row through
|
|
58
|
+
-- board_authority_ceilings_board_id_fkey. Its true order is therefore
|
|
59
|
+
-- advisory -> boards.
|
|
60
|
+
--
|
|
61
|
+
-- onboard_federated_fleet_agent must take the advisory lock in the same relative
|
|
62
|
+
-- position, i.e. BEFORE its `select * into v_board from agent_board.boards ... for
|
|
63
|
+
-- update`. The first cut of this migration did not: it left the advisory
|
|
64
|
+
-- acquisition ~70 lines below the boards lock, giving onboarding boards -> advisory
|
|
65
|
+
-- against the setter's advisory -> boards. That is an ABBA cycle, and it was a
|
|
66
|
+
-- deadlock this migration INTRODUCED — base 20260901000100 has no advisory lock
|
|
67
|
+
-- here and cannot exhibit it. Reproduced deterministically before the fix:
|
|
68
|
+
--
|
|
69
|
+
-- ERROR: deadlock detected
|
|
70
|
+
-- DETAIL: Process A waits for ExclusiveLock on advisory lock [...]; blocked by B.
|
|
71
|
+
-- Process B waits for ShareLock on transaction ...; blocked by A.
|
|
72
|
+
--
|
|
73
|
+
-- The lesson for the next reader: an `insert` into a child table is a lock
|
|
74
|
+
-- acquisition on the parent. Do not reason about lock order from the statements
|
|
75
|
+
-- that name a table explicitly.
|
|
76
|
+
--
|
|
77
|
+
-- KNOWN, DELIBERATE GAP. service_role retains a direct
|
|
78
|
+
-- insert/update grant on board_authority_ceilings (operator provisioning,
|
|
79
|
+
-- 20260717000400). A direct table write bypasses any function-level lock; only the
|
|
80
|
+
-- RPC path is serialized. Closing that would require a table trigger and is a
|
|
81
|
+
-- separate decision.
|
|
82
|
+
--
|
|
83
|
+
-- NOT recreated here (recorded so the class is not re-litigated). Enumerated from
|
|
84
|
+
-- pg_proc, not from memory: exactly four agent_board functions call
|
|
85
|
+
-- _grant_within_ceiling —
|
|
86
|
+
-- agent_board.onboard_federated_fleet_agent(...) <- serialized here
|
|
87
|
+
-- agent_board.grant_board_role(uuid,uuid,text,uuid)
|
|
88
|
+
-- agent_board.create_invite_link(uuid,text,text,integer,timestamptz,uuid,jsonb)
|
|
89
|
+
-- agent_board._redeem_unbounded_invite_for_agent(text,uuid)
|
|
90
|
+
-- The other three are UNCHANGED and are outside #186's scope. Whether they are
|
|
91
|
+
-- exposed to the absence race was NOT determined here: each would need its own
|
|
92
|
+
-- check for an equivalent incidental parent-row lock, and each recreation is a
|
|
93
|
+
-- separate blast radius. Do not read their absence from this migration as evidence
|
|
94
|
+
-- that they are safe. The catalog assertion in
|
|
95
|
+
-- agent_board_atomic_fleet_onboarding.test.sql pins this exact set from pg_proc,
|
|
96
|
+
-- so a NEW reader fails the suite and names itself rather than being silently
|
|
97
|
+
-- admitted to an unexamined class.
|
|
98
|
+
|
|
99
|
+
create or replace function agent_board._lock_board_authority_ceiling(p_board_id uuid)
|
|
100
|
+
returns void
|
|
101
|
+
language sql
|
|
102
|
+
volatile
|
|
103
|
+
as $$
|
|
104
|
+
select pg_advisory_xact_lock(
|
|
105
|
+
hashtextextended('agent_board.board_authority_ceilings:' || p_board_id::text, 0)
|
|
106
|
+
);
|
|
107
|
+
$$;
|
|
108
|
+
|
|
109
|
+
comment on function agent_board._lock_board_authority_ceiling(uuid) is
|
|
110
|
+
'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row: every RPC that writes the ceiling and every RPC that reads it for authorization takes this FIRST, before any boards row lock. Held to commit. Internal: it takes a lock with NO authorization check on an arbitrary board id, so it must never be grantable to authenticated.';
|
|
111
|
+
-- NOT grantable to authenticated. This helper performs zero authorization and takes
|
|
112
|
+
-- a transaction-scoped lock on any board id it is handed, so a direct grant is an
|
|
113
|
+
-- onboarding DoS: any authenticated principal, with no membership on the board, can
|
|
114
|
+
-- hold the key and block onboard_federated_fleet_agent for the life of its
|
|
115
|
+
-- transaction. Proven, and proven unnecessary — set_board_authority_ceiling still
|
|
116
|
+
-- works when called AS authenticated, because it is SECURITY DEFINER owned by the
|
|
117
|
+
-- schema owner and the helper resolves against the DEFINER's privileges, not the
|
|
118
|
+
-- caller's.
|
|
119
|
+
revoke all on function agent_board._lock_board_authority_ceiling(uuid) from public, anon, authenticated;
|
|
120
|
+
grant execute on function agent_board._lock_board_authority_ceiling(uuid) to service_role;
|
|
121
|
+
|
|
122
|
+
create or replace function agent_board.set_board_authority_ceiling(
|
|
123
|
+
p_board_id uuid,
|
|
124
|
+
p_max_grantable_role text
|
|
125
|
+
)
|
|
126
|
+
returns table(
|
|
127
|
+
ceiling_board_id uuid,
|
|
128
|
+
ceiling_max_grantable_role text
|
|
129
|
+
)
|
|
130
|
+
language plpgsql
|
|
131
|
+
security definer
|
|
132
|
+
set search_path = agent_board, public
|
|
133
|
+
as $$
|
|
134
|
+
declare
|
|
135
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
136
|
+
begin
|
|
137
|
+
if p_max_grantable_role not in ('owner', 'admin', 'member', 'observer') then
|
|
138
|
+
raise exception 'max_grantable_role must be one of owner/admin/member/observer' using errcode = '22023';
|
|
139
|
+
end if;
|
|
140
|
+
|
|
141
|
+
-- Same board-keyed advisory lock every ceiling READER for authorization takes.
|
|
142
|
+
-- Held to commit, it makes the insert of a first ceiling row serialize against a
|
|
143
|
+
-- concurrent authorization that would otherwise observe the pre-insert ABSENCE and
|
|
144
|
+
-- pass as default-owner. Taken before the owner gate so the gate and the write are
|
|
145
|
+
-- one critical section.
|
|
146
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
147
|
+
|
|
148
|
+
if v_actor is null
|
|
149
|
+
or not exists (
|
|
150
|
+
select 1
|
|
151
|
+
from agent_board.board_memberships bm
|
|
152
|
+
where bm.board_id = p_board_id
|
|
153
|
+
and bm.agent_id = v_actor
|
|
154
|
+
and bm.role = 'owner'
|
|
155
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
156
|
+
) then
|
|
157
|
+
raise exception 'only a board owner may set the board authority ceiling' using errcode = '42501';
|
|
158
|
+
end if;
|
|
159
|
+
|
|
160
|
+
insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
|
|
161
|
+
values (p_board_id, p_max_grantable_role)
|
|
162
|
+
on conflict (board_id) do update
|
|
163
|
+
set max_grantable_role = excluded.max_grantable_role,
|
|
164
|
+
updated_at = now();
|
|
165
|
+
|
|
166
|
+
return query
|
|
167
|
+
select p_board_id, p_max_grantable_role;
|
|
168
|
+
end;
|
|
169
|
+
$$;
|
|
170
|
+
|
|
171
|
+
comment on function agent_board.set_board_authority_ceiling(uuid, text) is
|
|
172
|
+
'Owner-gated setter for a board''s authority ceiling: takes the board-keyed _lock_board_authority_ceiling advisory lock, then upserts board_authority_ceilings only when current_agent_id() is an owner of the board (else raises 42501). Leaves _grant_within_ceiling semantics unchanged.';
|
|
173
|
+
|
|
174
|
+
revoke all on function agent_board.set_board_authority_ceiling(uuid, text) from public, anon;
|
|
175
|
+
grant execute on function agent_board.set_board_authority_ceiling(uuid, text) to authenticated, service_role;
|
|
176
|
+
|
|
177
|
+
create or replace function agent_board.onboard_federated_fleet_agent(
|
|
178
|
+
p_issuer text,
|
|
179
|
+
p_audience text,
|
|
180
|
+
p_subject text,
|
|
181
|
+
p_human_identity_claim jsonb,
|
|
182
|
+
p_slug text,
|
|
183
|
+
p_agent_public_key text,
|
|
184
|
+
p_agent_public_key_format text,
|
|
185
|
+
p_agent_public_key_fingerprint text,
|
|
186
|
+
p_pop_verified boolean,
|
|
187
|
+
p_actor_sub text default null,
|
|
188
|
+
p_agent_kind text default 'agent',
|
|
189
|
+
p_requested_scopes text[] default '{}',
|
|
190
|
+
p_requested_board_scopes text[] default '{}',
|
|
191
|
+
p_invite_code text default null,
|
|
192
|
+
p_board_id uuid default null,
|
|
193
|
+
p_machine_id uuid default null,
|
|
194
|
+
p_harness_kind text default null,
|
|
195
|
+
p_harness_label text default null,
|
|
196
|
+
p_repo_slug text default null,
|
|
197
|
+
p_repo_fingerprint text default null,
|
|
198
|
+
p_repo_scope_role text default null,
|
|
199
|
+
p_agent_role text default null,
|
|
200
|
+
p_expected_agent_handle text default null
|
|
201
|
+
)
|
|
202
|
+
returns table(
|
|
203
|
+
onboarding_contract_version text,
|
|
204
|
+
agent_id uuid,
|
|
205
|
+
auth_user_id uuid,
|
|
206
|
+
provider_id text,
|
|
207
|
+
signing_key_id uuid,
|
|
208
|
+
granted_scopes text[],
|
|
209
|
+
key_id text,
|
|
210
|
+
key_version int,
|
|
211
|
+
agent_handle text,
|
|
212
|
+
public_key_fingerprint text,
|
|
213
|
+
board_id uuid,
|
|
214
|
+
membership_role text,
|
|
215
|
+
membership_state text,
|
|
216
|
+
machine_id uuid,
|
|
217
|
+
harness_registration_id uuid,
|
|
218
|
+
repo_scope_id uuid,
|
|
219
|
+
fleet_machine_agent_id uuid,
|
|
220
|
+
binding_signing_key_id uuid,
|
|
221
|
+
harness_kind text,
|
|
222
|
+
harness_label text,
|
|
223
|
+
repo_slug text,
|
|
224
|
+
repo_fingerprint text,
|
|
225
|
+
repo_scope_role text,
|
|
226
|
+
agent_role text,
|
|
227
|
+
expected_agent_handle text,
|
|
228
|
+
identity_disposition text,
|
|
229
|
+
membership_disposition text,
|
|
230
|
+
harness_disposition text,
|
|
231
|
+
repo_scope_disposition text,
|
|
232
|
+
binding_disposition text
|
|
233
|
+
)
|
|
234
|
+
language plpgsql
|
|
235
|
+
security definer
|
|
236
|
+
set search_path = agent_board, public
|
|
237
|
+
as $$
|
|
238
|
+
#variable_conflict use_column
|
|
239
|
+
declare
|
|
240
|
+
v_inv agent_board.board_invite_tokens%rowtype;
|
|
241
|
+
v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
|
|
242
|
+
v_board agent_board.boards%rowtype;
|
|
243
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
244
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
245
|
+
v_enroll record;
|
|
246
|
+
v_membership record;
|
|
247
|
+
v_harness agent_board.fleet_harness_registrations%rowtype;
|
|
248
|
+
v_repo agent_board.fleet_repo_scopes%rowtype;
|
|
249
|
+
v_binding agent_board.fleet_machine_agents%rowtype;
|
|
250
|
+
v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
|
|
251
|
+
v_label text := btrim(coalesce(p_harness_label, ''));
|
|
252
|
+
v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
|
|
253
|
+
v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
|
|
254
|
+
v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
|
|
255
|
+
v_existing_agent_id uuid;
|
|
256
|
+
v_owner_user_id uuid;
|
|
257
|
+
v_identity_disposition text;
|
|
258
|
+
v_membership_disposition text;
|
|
259
|
+
v_harness_disposition text;
|
|
260
|
+
v_repo_disposition text;
|
|
261
|
+
v_binding_disposition text;
|
|
262
|
+
v_completed_replay boolean := false;
|
|
263
|
+
begin
|
|
264
|
+
if not agent_board._is_service_role() then
|
|
265
|
+
raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
|
|
266
|
+
end if;
|
|
267
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
|
|
268
|
+
raise exception 'bounded invite code is required' using errcode = '22023';
|
|
269
|
+
end if;
|
|
270
|
+
|
|
271
|
+
select bit.* into v_inv
|
|
272
|
+
from agent_board.board_invite_tokens bit
|
|
273
|
+
where bit.code = btrim(p_invite_code)
|
|
274
|
+
for update;
|
|
275
|
+
if not found then
|
|
276
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
277
|
+
end if;
|
|
278
|
+
select * into v_bounds
|
|
279
|
+
from agent_board.fleet_onboarding_invite_bounds b
|
|
280
|
+
where b.invite_id = v_inv.id
|
|
281
|
+
for update;
|
|
282
|
+
if not found then
|
|
283
|
+
raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
|
|
284
|
+
end if;
|
|
285
|
+
|
|
286
|
+
-- ADVISORY BEFORE BOARDS. set_board_authority_ceiling holds this lock and then
|
|
287
|
+
-- reaches this same boards row through board_authority_ceilings' FK (FOR KEY SHARE
|
|
288
|
+
-- on INSERT). Taking the boards lock first would invert the order against it and
|
|
289
|
+
-- deadlock -- it did, until this line was hoisted above the select below.
|
|
290
|
+
perform agent_board._lock_board_authority_ceiling(v_inv.board_id);
|
|
291
|
+
|
|
292
|
+
select * into v_board
|
|
293
|
+
from agent_board.boards b
|
|
294
|
+
where b.id = v_inv.board_id
|
|
295
|
+
for update;
|
|
296
|
+
if not found or v_board.status <> 'active' then
|
|
297
|
+
raise exception 'fleet onboarding board is not usable' using errcode = '42501';
|
|
298
|
+
end if;
|
|
299
|
+
|
|
300
|
+
if v_inv.board_id is distinct from p_board_id
|
|
301
|
+
or v_bounds.machine_id is distinct from p_machine_id
|
|
302
|
+
or v_bounds.harness_kind is distinct from v_kind
|
|
303
|
+
or v_bounds.harness_label is distinct from v_label
|
|
304
|
+
or v_bounds.repo_slug is distinct from v_repo_slug
|
|
305
|
+
or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
|
|
306
|
+
or v_bounds.repo_scope_role is distinct from p_repo_scope_role
|
|
307
|
+
or v_bounds.agent_role is distinct from p_agent_role
|
|
308
|
+
or v_bounds.expected_agent_handle is distinct from v_expected_handle then
|
|
309
|
+
raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
|
|
310
|
+
end if;
|
|
311
|
+
if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
|
|
312
|
+
or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
|
|
313
|
+
raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
|
|
314
|
+
end if;
|
|
315
|
+
|
|
316
|
+
select * into v_machine from agent_board.fleet_machines fm
|
|
317
|
+
where fm.id = p_machine_id for update;
|
|
318
|
+
if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
|
|
319
|
+
or v_machine.lifecycle_state <> 'active' then
|
|
320
|
+
raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
|
|
321
|
+
end if;
|
|
322
|
+
select * into v_domain from agent_board.fleet_trust_domains td
|
|
323
|
+
where td.id = v_machine.trust_domain_id for update;
|
|
324
|
+
if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
|
|
325
|
+
or v_domain.lifecycle_state <> 'active' then
|
|
326
|
+
raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
|
|
327
|
+
end if;
|
|
328
|
+
|
|
329
|
+
-- Serialize cross-route identity classification before locking principals. For
|
|
330
|
+
-- a replay, lock the creator and enrolled principal in UUID order so a human or
|
|
331
|
+
-- agent subtree revocation that visits the same pair cannot invert their order.
|
|
332
|
+
perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
|
|
333
|
+
select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
|
|
334
|
+
from agent_board.agent_signing_keys k
|
|
335
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
336
|
+
where k.fingerprint = p_agent_public_key_fingerprint;
|
|
337
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
338
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
339
|
+
));
|
|
340
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
341
|
+
if exists (
|
|
342
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
343
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
344
|
+
) then
|
|
345
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
346
|
+
end if;
|
|
347
|
+
perform 1 from agent_board.agents a
|
|
348
|
+
where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
|
|
349
|
+
order by a.id for update;
|
|
350
|
+
|
|
351
|
+
-- Creator authority is part of the invite's live authorization, not merely a
|
|
352
|
+
-- snapshot from mint time. Lock every membership row and the current ceiling
|
|
353
|
+
-- before evaluating its role so revocation, eviction, or demotion must serialize
|
|
354
|
+
-- on one side of this transaction.
|
|
355
|
+
--
|
|
356
|
+
-- The ceiling ROW lock below covers demotion of an EXISTING ceiling only. No row
|
|
357
|
+
-- lock can cover the ABSENCE of a row, and absence is the normal default-owner
|
|
358
|
+
-- state, so a concurrent set_board_authority_ceiling inserting 'observer' used to
|
|
359
|
+
-- interleave: this transaction read "no ceiling", passed as owner, and committed a
|
|
360
|
+
-- 'member' membership after the ceiling committed. The board-keyed advisory lock
|
|
361
|
+
-- that serializes the ABSENCE is already held -- it is taken far above, BEFORE the
|
|
362
|
+
-- boards row lock, because the ceiling writer reaches boards through its FK and the
|
|
363
|
+
-- two must agree on order.
|
|
364
|
+
perform 1 from agent_board.board_memberships bm
|
|
365
|
+
where bm.board_id = v_inv.board_id
|
|
366
|
+
and bm.agent_id = v_inv.created_by_agent_id
|
|
367
|
+
order by bm.id for update;
|
|
368
|
+
perform 1 from agent_board.board_authority_ceilings c
|
|
369
|
+
where c.board_id = v_inv.board_id for update;
|
|
370
|
+
|
|
371
|
+
-- Concurrent legacy enrollment or onboarding on another machine has committed
|
|
372
|
+
-- before this snapshot decides whether the identity was created here.
|
|
373
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
|
|
374
|
+
v_identity_disposition := 'reused';
|
|
375
|
+
else
|
|
376
|
+
v_identity_disposition := 'created';
|
|
377
|
+
end if;
|
|
378
|
+
|
|
379
|
+
select * into v_enroll from agent_board.enroll_federated_identity(
|
|
380
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
381
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
382
|
+
p_pop_verified, p_actor_sub, p_agent_kind, p_requested_scopes,
|
|
383
|
+
p_requested_board_scopes, null
|
|
384
|
+
);
|
|
385
|
+
if v_enroll.agent_handle is distinct from v_expected_handle then
|
|
386
|
+
raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
|
|
387
|
+
end if;
|
|
388
|
+
|
|
389
|
+
-- A complete replay may bypass expiry/exhaustion only while its redemption and
|
|
390
|
+
-- active membership remain true through commit. Lock both ledgers before the
|
|
391
|
+
-- proof; the invite row already serializes first redemption for an absent row.
|
|
392
|
+
perform 1 from agent_board.board_invite_redemptions red
|
|
393
|
+
where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
|
|
394
|
+
order by red.id for update;
|
|
395
|
+
perform 1 from agent_board.board_memberships bm
|
|
396
|
+
where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
|
|
397
|
+
order by bm.id for update;
|
|
398
|
+
|
|
399
|
+
-- The released ROOMLINK core deliberately returns an existing redemption before
|
|
400
|
+
-- checking current token authority. Fleet onboarding has a narrower replay
|
|
401
|
+
-- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
|
|
402
|
+
-- partial chain must pass every current invite gate before any missing suffix is
|
|
403
|
+
-- written, and revocation/current creator authority always apply.
|
|
404
|
+
select exists (
|
|
405
|
+
select 1
|
|
406
|
+
from agent_board.board_invite_redemptions red
|
|
407
|
+
join agent_board.board_memberships mem on mem.id = red.membership_id
|
|
408
|
+
join agent_board.fleet_harness_registrations h
|
|
409
|
+
on h.machine_id = p_machine_id
|
|
410
|
+
and h.harness_kind = v_kind
|
|
411
|
+
and h.harness_label = v_label
|
|
412
|
+
join agent_board.fleet_repo_scopes repo
|
|
413
|
+
on repo.harness_registration_id = h.id
|
|
414
|
+
and repo.repo_slug = v_repo_slug
|
|
415
|
+
and repo.repo_fingerprint = v_repo_fingerprint
|
|
416
|
+
and repo.scope_role = p_repo_scope_role
|
|
417
|
+
join agent_board.fleet_machine_agents binding
|
|
418
|
+
on binding.machine_id = p_machine_id
|
|
419
|
+
and binding.agent_id = v_enroll.agent_id
|
|
420
|
+
and binding.repo_scope_id = repo.id
|
|
421
|
+
and binding.signing_key_id = v_enroll.signing_key_id
|
|
422
|
+
and binding.agent_role = p_agent_role
|
|
423
|
+
join agent_board.agents principal on principal.id = v_enroll.agent_id
|
|
424
|
+
join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
|
|
425
|
+
where red.invite_id = v_inv.id
|
|
426
|
+
and red.agent_id = v_enroll.agent_id
|
|
427
|
+
and mem.board_id = v_inv.board_id
|
|
428
|
+
and mem.agent_id = v_enroll.agent_id
|
|
429
|
+
and mem.role = 'member'
|
|
430
|
+
and mem.membership_state in ('joined', 'subscribed')
|
|
431
|
+
and h.lifecycle_state = 'active'
|
|
432
|
+
and repo.lifecycle_state = 'active'
|
|
433
|
+
and binding.lifecycle_state = 'active'
|
|
434
|
+
and principal.status = 'active'
|
|
435
|
+
and signing_key.status = 'active'
|
|
436
|
+
and signing_key.proof_state = 'proven'
|
|
437
|
+
and signing_key.revoked_at is null
|
|
438
|
+
and not exists (
|
|
439
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
440
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
441
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
442
|
+
or (e.subject_kind = 'harness' and e.subject_id = h.id)
|
|
443
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
|
|
444
|
+
)
|
|
445
|
+
) into v_completed_replay;
|
|
446
|
+
|
|
447
|
+
if v_inv.revoked_at is not null
|
|
448
|
+
or not exists (
|
|
449
|
+
select 1 from agent_board.agents a
|
|
450
|
+
where a.id = v_inv.created_by_agent_id and a.status = 'active'
|
|
451
|
+
)
|
|
452
|
+
or not agent_board._grant_within_ceiling(
|
|
453
|
+
v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
|
|
454
|
+
)
|
|
455
|
+
or (
|
|
456
|
+
exists (
|
|
457
|
+
select 1 from agent_board.board_memberships bm
|
|
458
|
+
where bm.board_id = v_inv.board_id
|
|
459
|
+
and bm.agent_id = v_enroll.agent_id
|
|
460
|
+
and bm.membership_state = 'removed'
|
|
461
|
+
)
|
|
462
|
+
and not exists (
|
|
463
|
+
select 1 from agent_board.board_memberships bm
|
|
464
|
+
where bm.board_id = v_inv.board_id
|
|
465
|
+
and bm.agent_id = v_enroll.agent_id
|
|
466
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
467
|
+
)
|
|
468
|
+
) then
|
|
469
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
470
|
+
end if;
|
|
471
|
+
if not v_completed_replay
|
|
472
|
+
and (
|
|
473
|
+
v_inv.expires_at <= now()
|
|
474
|
+
or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
|
|
475
|
+
) then
|
|
476
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
477
|
+
end if;
|
|
478
|
+
|
|
479
|
+
if exists (
|
|
480
|
+
select 1 from agent_board.board_invite_redemptions r
|
|
481
|
+
where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
|
|
482
|
+
) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
|
|
483
|
+
|
|
484
|
+
select * into v_membership
|
|
485
|
+
from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
|
|
486
|
+
if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
|
|
487
|
+
raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
|
|
488
|
+
end if;
|
|
489
|
+
|
|
490
|
+
perform 1 from agent_board.fleet_harness_registrations h
|
|
491
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
492
|
+
order by h.id for update;
|
|
493
|
+
if exists (
|
|
494
|
+
select 1 from agent_board.fleet_harness_registrations h
|
|
495
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
496
|
+
and h.lifecycle_state <> 'active'
|
|
497
|
+
) then
|
|
498
|
+
raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
|
|
499
|
+
end if;
|
|
500
|
+
select * into v_harness
|
|
501
|
+
from agent_board.fleet_harness_registrations h
|
|
502
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
503
|
+
and h.lifecycle_state = 'active'
|
|
504
|
+
order by h.id;
|
|
505
|
+
if found then
|
|
506
|
+
v_harness_disposition := 'reused';
|
|
507
|
+
else
|
|
508
|
+
insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
|
|
509
|
+
values (p_machine_id, v_kind, v_label) returning * into v_harness;
|
|
510
|
+
v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
511
|
+
end if;
|
|
512
|
+
|
|
513
|
+
perform 1 from agent_board.fleet_repo_scopes r
|
|
514
|
+
where r.harness_registration_id = v_harness.id
|
|
515
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
516
|
+
order by r.id for update;
|
|
517
|
+
if exists (
|
|
518
|
+
select 1 from agent_board.fleet_repo_scopes r
|
|
519
|
+
where r.harness_registration_id = v_harness.id
|
|
520
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
521
|
+
and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
|
|
522
|
+
) then
|
|
523
|
+
raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
|
|
524
|
+
end if;
|
|
525
|
+
select * into v_repo
|
|
526
|
+
from agent_board.fleet_repo_scopes r
|
|
527
|
+
where r.harness_registration_id = v_harness.id
|
|
528
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
529
|
+
and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
|
|
530
|
+
order by r.id;
|
|
531
|
+
if found then
|
|
532
|
+
v_repo_disposition := 'reused';
|
|
533
|
+
else
|
|
534
|
+
insert into agent_board.fleet_repo_scopes(
|
|
535
|
+
harness_registration_id, repo_slug, repo_fingerprint, scope_role
|
|
536
|
+
) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
|
|
537
|
+
returning * into v_repo;
|
|
538
|
+
v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
539
|
+
end if;
|
|
540
|
+
|
|
541
|
+
-- Machine -> agent/key/binding lock order. Any tombstone for this natural key
|
|
542
|
+
-- blocks replacement authority even though the active unique index would allow it.
|
|
543
|
+
perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
|
|
544
|
+
perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
|
|
545
|
+
perform 1 from agent_board.fleet_machine_agents b
|
|
546
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
547
|
+
and b.repo_scope_id = v_repo.id
|
|
548
|
+
order by b.id for update;
|
|
549
|
+
if exists (
|
|
550
|
+
select 1 from agent_board.fleet_machine_agents b
|
|
551
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
552
|
+
and b.repo_scope_id = v_repo.id
|
|
553
|
+
and (
|
|
554
|
+
b.lifecycle_state <> 'active'
|
|
555
|
+
or b.signing_key_id is distinct from v_enroll.signing_key_id
|
|
556
|
+
or b.agent_role is distinct from p_agent_role
|
|
557
|
+
)
|
|
558
|
+
) then
|
|
559
|
+
raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
|
|
560
|
+
end if;
|
|
561
|
+
select * into v_binding
|
|
562
|
+
from agent_board.fleet_machine_agents b
|
|
563
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
564
|
+
and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
|
|
565
|
+
and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
|
|
566
|
+
order by b.id;
|
|
567
|
+
if found then
|
|
568
|
+
v_binding_disposition := 'reused';
|
|
569
|
+
else
|
|
570
|
+
insert into agent_board.fleet_machine_agents(
|
|
571
|
+
machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
|
|
572
|
+
) values (
|
|
573
|
+
p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
|
|
574
|
+
) returning * into v_binding;
|
|
575
|
+
v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
576
|
+
end if;
|
|
577
|
+
|
|
578
|
+
if exists (
|
|
579
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
580
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
581
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
582
|
+
or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
|
|
583
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
|
|
584
|
+
) then
|
|
585
|
+
raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
|
|
586
|
+
end if;
|
|
587
|
+
|
|
588
|
+
return query select
|
|
589
|
+
'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
|
|
590
|
+
v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
|
|
591
|
+
v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
|
|
592
|
+
p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
|
|
593
|
+
v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
|
|
594
|
+
v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
|
|
595
|
+
p_repo_scope_role, p_agent_role, v_expected_handle,
|
|
596
|
+
v_identity_disposition, v_membership_disposition, v_harness_disposition,
|
|
597
|
+
v_repo_disposition, v_binding_disposition;
|
|
598
|
+
end;
|
|
599
|
+
$$;
|
|
600
|
+
|
|
601
|
+
comment on 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) is
|
|
602
|
+
'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.';
|
|
603
|
+
revoke all on 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) from public, anon, authenticated;
|
|
604
|
+
grant execute on 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) to service_role;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Consiliency/message-board#162 follow-up: total-human revocation must have stable
|
|
2
|
+
-- evidence for EVERY federation-linked principal before it mutates the subtree.
|
|
3
|
+
-- Requiring only one evidenced principal for the owner lets a mixed owner pass the
|
|
4
|
+
-- gate while an unaudited sibling receives no durable issuer/subject marker.
|
|
5
|
+
|
|
6
|
+
create or replace function agent_board.revoke_human_subtree(
|
|
7
|
+
p_owner_user_id uuid,
|
|
8
|
+
p_revoked_by_agent_id uuid,
|
|
9
|
+
p_reason text default 'human revocation'
|
|
10
|
+
)
|
|
11
|
+
returns table(revoked_agent_count integer, revoked_key_count integer)
|
|
12
|
+
language plpgsql
|
|
13
|
+
security definer
|
|
14
|
+
set search_path = agent_board, public
|
|
15
|
+
as $$
|
|
16
|
+
declare
|
|
17
|
+
v_agent uuid;
|
|
18
|
+
v_agent_count integer := 0;
|
|
19
|
+
v_key_count integer := 0;
|
|
20
|
+
begin
|
|
21
|
+
if not agent_board._is_service_role() then
|
|
22
|
+
raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
|
|
23
|
+
end if;
|
|
24
|
+
|
|
25
|
+
perform 1 from auth.users u where u.id = p_owner_user_id for update;
|
|
26
|
+
if not found then
|
|
27
|
+
raise exception 'human owner not found' using errcode = 'P0002';
|
|
28
|
+
end if;
|
|
29
|
+
|
|
30
|
+
if exists (
|
|
31
|
+
select 1
|
|
32
|
+
from agent_board.agents a
|
|
33
|
+
where a.owner_user_id = p_owner_user_id
|
|
34
|
+
and jsonb_typeof(a.metadata -> 'federation') = 'object'
|
|
35
|
+
and not exists (
|
|
36
|
+
select 1
|
|
37
|
+
from agent_board.enrollment_federation_audit e
|
|
38
|
+
where e.agent_id = a.id
|
|
39
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
40
|
+
and nullif(btrim(e.subject), '') is not null
|
|
41
|
+
)
|
|
42
|
+
) then
|
|
43
|
+
raise exception 'stable federation evidence not found for every linked agent' using errcode = 'P0002';
|
|
44
|
+
end if;
|
|
45
|
+
|
|
46
|
+
insert into agent_board.fleet_human_revocations(
|
|
47
|
+
issuer, subject, owner_user_id, revoked_by_agent_id, revocation_reason
|
|
48
|
+
)
|
|
49
|
+
select distinct
|
|
50
|
+
e.issuer,
|
|
51
|
+
e.subject,
|
|
52
|
+
p_owner_user_id, p_revoked_by_agent_id, p_reason
|
|
53
|
+
from agent_board.enrollment_federation_audit e
|
|
54
|
+
join agent_board.agents a on a.id = e.agent_id
|
|
55
|
+
where a.owner_user_id = p_owner_user_id
|
|
56
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
57
|
+
and nullif(btrim(e.subject), '') is not null
|
|
58
|
+
on conflict (issuer, subject) do nothing;
|
|
59
|
+
|
|
60
|
+
for v_agent in
|
|
61
|
+
select id
|
|
62
|
+
from agent_board.agents
|
|
63
|
+
where owner_user_id = p_owner_user_id
|
|
64
|
+
order by id
|
|
65
|
+
loop
|
|
66
|
+
v_key_count := v_key_count
|
|
67
|
+
+ agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
|
|
68
|
+
v_agent_count := v_agent_count + 1;
|
|
69
|
+
end loop;
|
|
70
|
+
|
|
71
|
+
return query select v_agent_count, v_key_count;
|
|
72
|
+
end;
|
|
73
|
+
$$;
|
|
74
|
+
|
|
75
|
+
comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
|
|
76
|
+
'TOTAL human revocation. Locks the human owner; requires append-only stable federation evidence for every federation-linked owned agent before any mutation; derives durable issuer/subject markers only from that audit evidence; then visits every owned agent in UUID order before the agent-before-key primitive. service_role-only.';
|
|
77
|
+
revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
|
|
78
|
+
grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;
|