@consiliency/agent-board-schema 1.58.0 → 1.59.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 +4 -0
- package/migrations/20261009000100_agent_board_hosted_followups.sql +665 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +6 -2
- package/tests/agent_board_hosted_delegates.test.sql +148 -4
- package/tests/catalog/function_execute_privileges.json +9 -0
- package/tests/catalog/p_metadata_parameters.json +4 -0
- package/tests/isolation/expected/hosted_delegate_add_tombstone_race.out +105 -0
- package/tests/isolation/expected/hosted_delegate_exchange_agent_revoke_race.out +66 -0
- package/tests/isolation/expected/hosted_delegate_tombstone_revoke_race.out +170 -0
- package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +102 -0
- package/tests/isolation/specs/hosted_delegate_exchange_agent_revoke_race.spec +92 -0
- package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +158 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
-- HOSTED follow-ups (Consiliency/message-board#591, items 4, 8 and 9): the owner's verified
|
|
2
|
+
-- (iss, sub) is recorded on every delegate instance and revoke_human_subtree derives a durable
|
|
3
|
+
-- marker from it; tombstone_delegate_instance takes the delegate's agents row before its
|
|
4
|
+
-- credentials, so it serialises with revoke_human_subtree and revoke_agent_subtree (both
|
|
5
|
+
-- agent-before-credentials) instead of deadlocking against them; the exchange takes the delegate's
|
|
6
|
+
-- agents row before the credentials for the same reason. schema_version stays 8.
|
|
7
|
+
--
|
|
8
|
+
-- THE CONTRACT is the HOSTED section of specs/agent-client-contract.md, amendment AB15.
|
|
9
|
+
--
|
|
10
|
+
-- Sources, each at its LATEST definition, read from the catalog of a replay of this tree
|
|
11
|
+
-- (pg_get_functiondef / md5(prosrc) compared with the file text before editing):
|
|
12
|
+
-- * exchange_delegate_credential, tombstone_delegate_instance -- 20261008000100
|
|
13
|
+
-- * revoke_human_subtree -- 20260901000120
|
|
14
|
+
-- Each is redefined below VERBATIM plus the lines marked `HOSTED #591`. create or replace keeps
|
|
15
|
+
-- the ACL and COMMENT; both are restated anyway so this file says what each function is.
|
|
16
|
+
--
|
|
17
|
+
-- SUPERSEDED COMMENT. add_delegate_to_board's body comment (20261008000100, section 9) says the
|
|
18
|
+
-- tombstone takes "its own owner-keyed advisory lock, then the instance row". That is still
|
|
19
|
+
-- true; the tombstone now takes the delegate's agents row (FOR NO KEY UPDATE, its status UPDATE)
|
|
20
|
+
-- right after the instance row instead of last. add_delegate_to_board's only lock on that row is
|
|
21
|
+
-- the foreign-key FOR KEY SHARE of the sink's insert, which FOR NO KEY UPDATE does not block, so
|
|
22
|
+
-- that comment's conclusion -- the instance row is the only lock the two contend on -- still
|
|
23
|
+
-- holds (measured: hosted_delegate_add_tombstone_race). add_delegate_to_board is not redefined.
|
|
24
|
+
|
|
25
|
+
-- =============================================================================
|
|
26
|
+
-- 1. The owner's verified identity on each instance (#591 item 4)
|
|
27
|
+
-- =============================================================================
|
|
28
|
+
-- revoke_human_subtree writes the durable marker fleet_human_revocations (issuer, subject) only
|
|
29
|
+
-- from enrollment_federation_audit. An owner whose only principals are hosted delegates has no
|
|
30
|
+
-- such row, so revoking it left no marker, and -- with no human agents row either -- a fresh first
|
|
31
|
+
-- exchange passed the exchange's owner_inactive check and created a new active delegate.
|
|
32
|
+
--
|
|
33
|
+
-- The marker is keyed by (issuer, subject) and the owner uid is a one-way derivation of them
|
|
34
|
+
-- (_federated_identity_slug, then the auth user of that slug), so revoke_human_subtree cannot
|
|
35
|
+
-- rebuild the pair from the uid. The exchange and the tombstone both have the verified pair; they
|
|
36
|
+
-- now record it. It is REVOCATION EVIDENCE, never identity: owner_mismatch still compares the uid,
|
|
37
|
+
-- and nothing compares these columns with a token.
|
|
38
|
+
--
|
|
39
|
+
-- NULLABLE, not NOT NULL. A 1.58.0 adopter may already have rows, and no backfill exists: the
|
|
40
|
+
-- subject is not derivable from the uid, and a delegate-only owner has no other row that carries
|
|
41
|
+
-- it. NOT NULL would therefore fail this migration on exactly the databases it must upgrade. The
|
|
42
|
+
-- two columns are paired (both or neither, neither empty), every write path of both RPCs fills
|
|
43
|
+
-- them, and both fill a legacy row's null pair when they write it (sound because owner_mismatch
|
|
44
|
+
-- has just proved the row's uid is the one this (iss, sub) derives): the exchange on a successful
|
|
45
|
+
-- exchange, the tombstone on any call, including a repeat of an existing tombstone (the only write
|
|
46
|
+
-- a legacy TOMBSTONED row can ever get, since the exchange refuses it before writing). A legacy row
|
|
47
|
+
-- not filled before its owner is revoked gives revoke_human_subtree nothing: stated in AB15.
|
|
48
|
+
alter table agent_board.delegate_instances
|
|
49
|
+
add column if not exists owner_issuer text,
|
|
50
|
+
add column if not exists owner_subject text;
|
|
51
|
+
|
|
52
|
+
alter table agent_board.delegate_instances
|
|
53
|
+
drop constraint if exists delegate_instances_owner_identity_shape;
|
|
54
|
+
alter table agent_board.delegate_instances
|
|
55
|
+
add constraint delegate_instances_owner_identity_shape check (
|
|
56
|
+
(owner_issuer is null and owner_subject is null)
|
|
57
|
+
or (owner_issuer is not null and owner_subject is not null
|
|
58
|
+
and owner_issuer <> '' and owner_subject <> '')
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
comment on column agent_board.delegate_instances.owner_issuer is
|
|
62
|
+
'HOSTED #591 (AB15): the verified issuer of the owner token that created or last wrote this instance, recorded by exchange_delegate_credential and tombstone_delegate_instance. Revocation evidence only: revoke_human_subtree derives a fleet_human_revocations marker from (owner_issuer, owner_subject). Paired with owner_subject; never changes once set (trg_delegate_instances_owner_identity_immutable). Null only on a row written before 1.59.0 and not filled since (any tombstone call for it, or a successful exchange of it, fills it).';
|
|
63
|
+
comment on column agent_board.delegate_instances.owner_subject is
|
|
64
|
+
'HOSTED #591 (AB15): the verified owner subject (the Portal sub) paired with owner_issuer. Revocation evidence only, never compared for identity: the owner identity is owner_user_id. Never changes once set.';
|
|
65
|
+
|
|
66
|
+
-- Once set, never changed. A null legacy pair may be filled once.
|
|
67
|
+
create or replace function agent_board._delegate_instances_owner_identity_immutable()
|
|
68
|
+
returns trigger
|
|
69
|
+
language plpgsql
|
|
70
|
+
set search_path = ''
|
|
71
|
+
as $fn$
|
|
72
|
+
begin
|
|
73
|
+
if old.owner_issuer is not null
|
|
74
|
+
and (new.owner_issuer is distinct from old.owner_issuer
|
|
75
|
+
or new.owner_subject is distinct from old.owner_subject) then
|
|
76
|
+
raise exception 'delegate_instances owner identity never changes once recorded'
|
|
77
|
+
using errcode = '23514';
|
|
78
|
+
end if;
|
|
79
|
+
return new;
|
|
80
|
+
end;
|
|
81
|
+
$fn$;
|
|
82
|
+
|
|
83
|
+
revoke all on function agent_board._delegate_instances_owner_identity_immutable() from public;
|
|
84
|
+
|
|
85
|
+
comment on function agent_board._delegate_instances_owner_identity_immutable() is
|
|
86
|
+
'HOSTED #591 (AB15): trigger function refusing any UPDATE that changes delegate_instances.owner_issuer or owner_subject once owner_issuer is set. A null legacy pair may be filled once. EXECUTE revoked from PUBLIC; a trigger function needs none at fire time.';
|
|
87
|
+
|
|
88
|
+
drop trigger if exists trg_delegate_instances_owner_identity_immutable on agent_board.delegate_instances;
|
|
89
|
+
create trigger trg_delegate_instances_owner_identity_immutable
|
|
90
|
+
before update of owner_issuer, owner_subject on agent_board.delegate_instances
|
|
91
|
+
for each row execute function agent_board._delegate_instances_owner_identity_immutable();
|
|
92
|
+
|
|
93
|
+
-- =============================================================================
|
|
94
|
+
-- 2. exchange_delegate_credential -- records the owner's (iss, sub) (#591 item 4)
|
|
95
|
+
-- =============================================================================
|
|
96
|
+
create or replace function agent_board.exchange_delegate_credential(p_claims jsonb)
|
|
97
|
+
returns table(agent_id uuid, auth_user_id uuid, credential_id uuid, expires_at timestamptz)
|
|
98
|
+
language plpgsql
|
|
99
|
+
security definer
|
|
100
|
+
set search_path = ''
|
|
101
|
+
as $fn$
|
|
102
|
+
#variable_conflict use_column
|
|
103
|
+
declare
|
|
104
|
+
v_iss text;
|
|
105
|
+
v_sub text;
|
|
106
|
+
v_slot text;
|
|
107
|
+
v_claim jsonb;
|
|
108
|
+
v_instance_id text;
|
|
109
|
+
v_grant_id text;
|
|
110
|
+
v_generation bigint;
|
|
111
|
+
v_client_id text;
|
|
112
|
+
v_change jsonb;
|
|
113
|
+
v_change_gen bigint;
|
|
114
|
+
v_owner uuid;
|
|
115
|
+
v_owner_slug text;
|
|
116
|
+
v_row agent_board.delegate_instances%rowtype;
|
|
117
|
+
v_first boolean;
|
|
118
|
+
v_live integer;
|
|
119
|
+
v_slug text;
|
|
120
|
+
v_auth uuid;
|
|
121
|
+
v_agent uuid;
|
|
122
|
+
v_status text;
|
|
123
|
+
v_credential uuid;
|
|
124
|
+
v_expires timestamptz;
|
|
125
|
+
begin
|
|
126
|
+
if not agent_board._is_service_role() then
|
|
127
|
+
raise exception 'exchange_delegate_credential is service_role-only' using errcode = '42501';
|
|
128
|
+
end if;
|
|
129
|
+
|
|
130
|
+
-- SHAPE. The Edge Function refuses every malformed token first; this is the database's own
|
|
131
|
+
-- refusal of the same shape, so a caller that skips it cannot write a malformed instance.
|
|
132
|
+
if jsonb_typeof(p_claims) is distinct from 'object'
|
|
133
|
+
or jsonb_typeof(p_claims -> 'iss') is distinct from 'string'
|
|
134
|
+
or jsonb_typeof(p_claims -> 'sub') is distinct from 'string'
|
|
135
|
+
or coalesce(p_claims ->> 'iss', '') = ''
|
|
136
|
+
or coalesce(p_claims ->> 'sub', '') = ''
|
|
137
|
+
or jsonb_typeof(p_claims -> 'slot_resource') not in ('string', 'null')
|
|
138
|
+
or jsonb_typeof(p_claims -> 'agent_board_delegate_instance') is distinct from 'object' then
|
|
139
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
140
|
+
end if;
|
|
141
|
+
v_iss := p_claims ->> 'iss';
|
|
142
|
+
v_sub := p_claims ->> 'sub';
|
|
143
|
+
v_slot := p_claims ->> 'slot_resource';
|
|
144
|
+
v_claim := p_claims -> 'agent_board_delegate_instance';
|
|
145
|
+
|
|
146
|
+
-- The instance claim: exactly these seven fields, each of its type.
|
|
147
|
+
if (select array_agg(k order by k) from jsonb_object_keys(v_claim) k)
|
|
148
|
+
is distinct from array['client_change', 'client_id', 'generation', 'grant_id', 'instance_id', 'owner_sub', 'status']
|
|
149
|
+
or jsonb_typeof(v_claim -> 'instance_id') is distinct from 'string'
|
|
150
|
+
or (v_claim ->> 'instance_id') !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
151
|
+
or jsonb_typeof(v_claim -> 'grant_id') is distinct from 'string'
|
|
152
|
+
or (v_claim ->> 'grant_id') !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
153
|
+
or jsonb_typeof(v_claim -> 'generation') is distinct from 'number'
|
|
154
|
+
or (v_claim ->> 'generation') !~ '^[1-9][0-9]{0,15}$'
|
|
155
|
+
or jsonb_typeof(v_claim -> 'owner_sub') is distinct from 'string'
|
|
156
|
+
or (v_claim ->> 'owner_sub') is distinct from v_sub
|
|
157
|
+
or jsonb_typeof(v_claim -> 'client_id') is distinct from 'string'
|
|
158
|
+
or char_length(v_claim ->> 'client_id') not between 1 and 512
|
|
159
|
+
or jsonb_typeof(v_claim -> 'status') is distinct from 'string'
|
|
160
|
+
or (v_claim ->> 'status') not in ('active', 'revoked')
|
|
161
|
+
or jsonb_typeof(v_claim -> 'client_change') not in ('object', 'null') then
|
|
162
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
163
|
+
end if;
|
|
164
|
+
v_generation := (v_claim ->> 'generation')::bigint;
|
|
165
|
+
if v_generation > 9007199254740991 then
|
|
166
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
167
|
+
end if;
|
|
168
|
+
v_instance_id := v_claim ->> 'instance_id';
|
|
169
|
+
v_grant_id := v_claim ->> 'grant_id';
|
|
170
|
+
v_client_id := v_claim ->> 'client_id';
|
|
171
|
+
|
|
172
|
+
v_change := case when jsonb_typeof(v_claim -> 'client_change') = 'object' then v_claim -> 'client_change' end;
|
|
173
|
+
if v_change is not null then
|
|
174
|
+
if (select array_agg(k order by k) from jsonb_object_keys(v_change) k)
|
|
175
|
+
is distinct from array['confirmed_at', 'confirmed_generation', 'from_client_id']
|
|
176
|
+
or jsonb_typeof(v_change -> 'from_client_id') is distinct from 'string'
|
|
177
|
+
or jsonb_typeof(v_change -> 'confirmed_at') is distinct from 'number'
|
|
178
|
+
or (v_change ->> 'confirmed_at') !~ '^[0-9]{1,16}$'
|
|
179
|
+
or jsonb_typeof(v_change -> 'confirmed_generation') is distinct from 'number'
|
|
180
|
+
or (v_change ->> 'confirmed_generation') !~ '^[1-9][0-9]{0,15}$'
|
|
181
|
+
or (v_change ->> 'confirmed_generation')::bigint > 9007199254740991 then
|
|
182
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
183
|
+
end if;
|
|
184
|
+
v_change_gen := (v_change ->> 'confirmed_generation')::bigint;
|
|
185
|
+
end if;
|
|
186
|
+
|
|
187
|
+
if v_slot is not null and v_slot !~ '^https://[^/?#@[:space:]]+(/[^?#[:space:]]*)?/s/[A-Za-z0-9_-]{22,64}$' then
|
|
188
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
189
|
+
end if;
|
|
190
|
+
|
|
191
|
+
-- A token the Portal itself reports revoked is refused, and changes nothing here: only a
|
|
192
|
+
-- Portal revocation (tombstone_delegate_instance) tombstones.
|
|
193
|
+
if v_claim ->> 'status' <> 'active' then
|
|
194
|
+
raise exception 'instance_revoked' using errcode = '42501';
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
-- THE OWNER LOCK, before anything else. A transaction-scoped advisory lock keyed by the
|
|
198
|
+
-- owner's identity serialises every exchange of one owner: it is what serialises D13's count
|
|
199
|
+
-- below, and tombstone_delegate_instance takes it too, in the same position, so the order is
|
|
200
|
+
-- always: owner lock, then the owner's auth.users row, then the instance row. (HOSTED #591: "the
|
|
201
|
+
-- owner's auth.users row" in that order is the exchange's alone; the tombstone takes the owner
|
|
202
|
+
-- lock and then the instance row, and no auth.users row: section 3.) It was first added for the
|
|
203
|
+
-- owner-provisioning race (two first exchanges each creating an auth user for
|
|
204
|
+
-- one owner, measured by hosted_delegate_first_exchange_race); that class is now closed for
|
|
205
|
+
-- every caller inside _ensure_agent_auth_user itself (section 5), which this lock alone could
|
|
206
|
+
-- not do: a first human enroll never takes it.
|
|
207
|
+
--
|
|
208
|
+
-- THE OWNER, derived exactly as enroll_federated_human derives the human's auth user, from
|
|
209
|
+
-- the verified issuer and subject. The raw sub is never compared for identity.
|
|
210
|
+
-- HOSTED #591: it IS stored, on the instance row, as revocation evidence (section 1).
|
|
211
|
+
v_owner_slug := agent_board._federated_identity_slug(v_iss, v_sub);
|
|
212
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
|
|
213
|
+
v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
|
|
214
|
+
|
|
215
|
+
-- THE OWNER MUST BE LIVE (fix N2). enroll_federated_human refuses a durably revoked human
|
|
216
|
+
-- (fleet_human_revocations (iss, sub)) and a non-active human row, and so does this: a
|
|
217
|
+
-- revoked human whose Portal still signs tokens must not bring up a NEW delegate instance, and
|
|
218
|
+
-- a suspended owner's existing delegates must not keep exchanging. Refused owner_inactive,
|
|
219
|
+
-- before the instance row is touched (so it wins over every instance-level code, including a
|
|
220
|
+
-- tombstone), and nothing is tombstoned.
|
|
221
|
+
--
|
|
222
|
+
-- UNDER THE OWNER'S auth.users ROW LOCK, which revoke_human_subtree takes FOR UPDATE first and
|
|
223
|
+
-- holds while it writes the marker and revokes every agent it then lists. FOR KEY SHARE
|
|
224
|
+
-- conflicts with that and with nothing an ordinary auth.users update takes. Without it an
|
|
225
|
+
-- exchange reads the marker before the revocation commits, and the revocation's agent list
|
|
226
|
+
-- (read once, at its loop) misses the delegate this exchange creates: an active delegate of a
|
|
227
|
+
-- revoked human, or a fresh credential for a delegate the revocation just revoked
|
|
228
|
+
-- (hosted_delegate_owner_revoke_race). revoke_human_subtree never takes the owner advisory
|
|
229
|
+
-- lock or an instance row, so no cycle closes. revoke_agent (suspension) does not take this
|
|
230
|
+
-- row and does not touch delegate credentials; an exchange racing it is simply ordered before
|
|
231
|
+
-- it.
|
|
232
|
+
--
|
|
233
|
+
-- The owner's human row is the agents row whose auth_user_id is the owner uid
|
|
234
|
+
-- (idx_agent_board_agents_auth_user_id_unique: at most one), the row enroll_federated_human
|
|
235
|
+
-- creates. None is not a refusal: an owner the board has never enrolled as a human is allowed
|
|
236
|
+
-- here, as before (it cannot add a delegate to a board without a session of its own).
|
|
237
|
+
perform 1 from auth.users u where u.id = v_owner for key share;
|
|
238
|
+
if exists (
|
|
239
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
240
|
+
where r.issuer = v_iss and r.subject = v_sub
|
|
241
|
+
)
|
|
242
|
+
or exists (
|
|
243
|
+
select 1 from agent_board.agents a
|
|
244
|
+
where a.auth_user_id = v_owner and a.status <> 'active'
|
|
245
|
+
) then
|
|
246
|
+
raise exception 'owner_inactive' using errcode = '42501';
|
|
247
|
+
end if;
|
|
248
|
+
|
|
249
|
+
-- INSERT-THEN-LOCK. A bare `select ... for update` locks nothing when the row does not yet
|
|
250
|
+
-- exist, so two first exchanges would both create an agent. Inserting first makes the
|
|
251
|
+
-- second block on the primary key until the first commits; both then lock the same row.
|
|
252
|
+
-- HOSTED #591: the owner's verified (iss, sub), recorded with the row (section 1).
|
|
253
|
+
insert into agent_board.delegate_instances(instance_id, owner_user_id, owner_issuer, owner_subject)
|
|
254
|
+
values (v_instance_id, v_owner, v_iss, v_sub)
|
|
255
|
+
on conflict (instance_id) do nothing;
|
|
256
|
+
|
|
257
|
+
select * into v_row
|
|
258
|
+
from agent_board.delegate_instances di
|
|
259
|
+
where di.instance_id = v_instance_id
|
|
260
|
+
for update;
|
|
261
|
+
|
|
262
|
+
-- A tombstone is permanent, whatever else the token says.
|
|
263
|
+
if v_row.tombstoned_at is not null then
|
|
264
|
+
raise exception 'instance_revoked' using errcode = '42501';
|
|
265
|
+
end if;
|
|
266
|
+
if v_row.owner_user_id is distinct from v_owner then
|
|
267
|
+
raise exception 'owner_mismatch' using errcode = '42501';
|
|
268
|
+
end if;
|
|
269
|
+
|
|
270
|
+
-- FIRST EXCHANGE is decided UNDER the lock, by whether the row has an agent yet -- never by
|
|
271
|
+
-- whether this transaction's insert happened: the loser of two concurrent first exchanges
|
|
272
|
+
-- finds the winner's agent after it blocks, and takes the ordinary path.
|
|
273
|
+
v_first := v_row.agent_id is null;
|
|
274
|
+
|
|
275
|
+
if not v_first then
|
|
276
|
+
-- HOSTED #591: FOR KEY SHARE on the delegate's agents row, BEFORE any credential. This is the
|
|
277
|
+
-- lock the credential insert below takes anyway (its foreign key), moved ahead of the
|
|
278
|
+
-- credential UPDATE that revokes lower generations. Taken after the credentials, it made an
|
|
279
|
+
-- exchange that supersedes a generation deadlock against revoke_agent_subtree of the same
|
|
280
|
+
-- delegate, which takes the agents row FOR UPDATE and then the credentials
|
|
281
|
+
-- (hosted_delegate_exchange_agent_revoke_race). It conflicts with nothing it did not already
|
|
282
|
+
-- conflict with.
|
|
283
|
+
select a.status into v_status from agent_board.agents a where a.id = v_row.agent_id for key share;
|
|
284
|
+
if v_status is distinct from 'active' then
|
|
285
|
+
-- Revoked by revoke_agent_subtree or revoke_human_subtree, or suspended. Refused, and
|
|
286
|
+
-- NOT tombstoned: only a Portal revocation tombstones.
|
|
287
|
+
raise exception 'agent_inactive' using errcode = '42501';
|
|
288
|
+
end if;
|
|
289
|
+
else
|
|
290
|
+
-- D13: at most 20 live instances per owner. The owner lock taken at the top serialises two
|
|
291
|
+
-- first exchanges of DIFFERENT instances of one owner (their instance rows are different
|
|
292
|
+
-- rows), so the count cannot be passed twice.
|
|
293
|
+
select count(*) into v_live
|
|
294
|
+
from agent_board.delegate_instances di
|
|
295
|
+
where di.owner_user_id = v_owner
|
|
296
|
+
and di.tombstoned_at is null
|
|
297
|
+
and di.instance_id <> v_instance_id;
|
|
298
|
+
if v_live >= 20 then
|
|
299
|
+
raise exception 'instance_cap' using errcode = '42501';
|
|
300
|
+
end if;
|
|
301
|
+
end if;
|
|
302
|
+
|
|
303
|
+
-- THE RESOURCE (D8), S = the recorded slot_resource.
|
|
304
|
+
if not v_first then
|
|
305
|
+
if v_row.slot_resource is not null then
|
|
306
|
+
if v_slot is distinct from v_row.slot_resource then
|
|
307
|
+
raise exception 'resource_mismatch' using errcode = '42501';
|
|
308
|
+
end if;
|
|
309
|
+
elsif v_slot is not null and not (v_generation > v_row.generation) then
|
|
310
|
+
-- The one move, base URL -> slot, needs a new grant (a strictly higher generation).
|
|
311
|
+
raise exception 'resource_mismatch' using errcode = '42501';
|
|
312
|
+
end if;
|
|
313
|
+
end if;
|
|
314
|
+
|
|
315
|
+
-- THE CLIENT AND GENERATION, r = recorded generation, g = the token's, C = the recorded
|
|
316
|
+
-- client_change_generation.
|
|
317
|
+
if not v_first then
|
|
318
|
+
if v_generation < v_row.generation then
|
|
319
|
+
raise exception 'generation_superseded' using errcode = '42501';
|
|
320
|
+
elsif v_generation = v_row.generation then
|
|
321
|
+
if v_grant_id is distinct from v_row.grant_id then
|
|
322
|
+
raise exception 'grant_conflict' using errcode = '42501';
|
|
323
|
+
end if;
|
|
324
|
+
if v_client_id is distinct from v_row.client_id
|
|
325
|
+
or v_change_gen is distinct from v_row.client_change_generation then
|
|
326
|
+
raise exception 'client_mismatch' using errcode = '42501';
|
|
327
|
+
end if;
|
|
328
|
+
elsif v_client_id is distinct from v_row.client_id then
|
|
329
|
+
-- g > r from another client: only with the owner's confirmation of a client change made
|
|
330
|
+
-- AFTER the last binding the board saw (r < confirmed_generation <= g). from_client_id
|
|
331
|
+
-- is recorded for audit but not compared: the board may have missed an intermediate
|
|
332
|
+
-- reconnect.
|
|
333
|
+
if v_change_gen is null
|
|
334
|
+
or not (v_row.generation < v_change_gen and v_change_gen <= v_generation) then
|
|
335
|
+
raise exception 'client_mismatch' using errcode = '42501';
|
|
336
|
+
end if;
|
|
337
|
+
end if;
|
|
338
|
+
end if;
|
|
339
|
+
|
|
340
|
+
-- FIRST USE: the instance's own auth user and agents row. One auth user per instance.
|
|
341
|
+
if v_first then
|
|
342
|
+
v_slug := agent_board._delegate_instance_slug(v_iss, v_instance_id);
|
|
343
|
+
-- A second agent on this auth user is impossible: idx_agent_board_agents_auth_user_id_unique
|
|
344
|
+
-- refuses the insert (23505), and the whole exchange with it.
|
|
345
|
+
v_auth := agent_board._ensure_agent_auth_user(v_slug);
|
|
346
|
+
insert into agent_board.agents(
|
|
347
|
+
agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id, is_delegate, metadata)
|
|
348
|
+
values (
|
|
349
|
+
'agent', 'agent', v_slug, 'Hosted assistant ' || left(v_instance_id, 8), 'active',
|
|
350
|
+
v_auth, v_owner, true,
|
|
351
|
+
jsonb_build_object('hosted_delegate', jsonb_build_object('instance_id', v_instance_id)))
|
|
352
|
+
returning id into v_agent;
|
|
353
|
+
else
|
|
354
|
+
v_agent := v_row.agent_id;
|
|
355
|
+
select a.auth_user_id into v_auth from agent_board.agents a where a.id = v_agent;
|
|
356
|
+
end if;
|
|
357
|
+
|
|
358
|
+
begin
|
|
359
|
+
update agent_board.delegate_instances di
|
|
360
|
+
set agent_id = v_agent,
|
|
361
|
+
client_id = v_client_id,
|
|
362
|
+
grant_id = v_grant_id,
|
|
363
|
+
generation = v_generation,
|
|
364
|
+
slot_resource = coalesce(di.slot_resource, v_slot),
|
|
365
|
+
client_change_generation = v_change_gen,
|
|
366
|
+
-- HOSTED #591: fills a legacy (pre-1.59.0) row's null pair; a recorded pair is kept.
|
|
367
|
+
-- Sound because owner_mismatch above proved this row's uid is the one (iss, sub)
|
|
368
|
+
-- derives.
|
|
369
|
+
owner_issuer = coalesce(di.owner_issuer, v_iss),
|
|
370
|
+
owner_subject = coalesce(di.owner_subject, v_sub),
|
|
371
|
+
updated_at = now()
|
|
372
|
+
where di.instance_id = v_instance_id;
|
|
373
|
+
exception when unique_violation then
|
|
374
|
+
-- D8: a slot belongs to one instance. Raising aborts the whole call, so nothing in this
|
|
375
|
+
-- transaction is kept.
|
|
376
|
+
raise exception 'slot_in_use' using errcode = '42501';
|
|
377
|
+
end;
|
|
378
|
+
|
|
379
|
+
-- A higher generation supersedes every lower one, in this transaction.
|
|
380
|
+
if not v_first and v_generation > v_row.generation then
|
|
381
|
+
update agent_board.agent_credentials ac
|
|
382
|
+
set revoked_at = now(),
|
|
383
|
+
updated_at = now(),
|
|
384
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
385
|
+
|| jsonb_build_object('revocationReason', 'generation_superseded', 'supersededByGeneration', v_generation)
|
|
386
|
+
where ac.agent_id = v_agent
|
|
387
|
+
and ac.revoked_at is null
|
|
388
|
+
and coalesce(ac.generation, 0) < v_generation;
|
|
389
|
+
end if;
|
|
390
|
+
|
|
391
|
+
-- The board credential row. No refresh token exists for it: the hash is random and never
|
|
392
|
+
-- disclosed, so the refresh path can never match it. expires_at <= now() + 900 s, and the
|
|
393
|
+
-- Edge Function mints exp = min(iat + 900, expires_at).
|
|
394
|
+
v_expires := now() + interval '900 seconds';
|
|
395
|
+
insert into agent_board.agent_credentials(
|
|
396
|
+
agent_id, refresh_token_hash, hash_version, credential_label, expires_at, metadata, grant_id, generation)
|
|
397
|
+
values (
|
|
398
|
+
v_agent,
|
|
399
|
+
extensions.digest(extensions.gen_random_bytes(32), 'sha256'),
|
|
400
|
+
1,
|
|
401
|
+
'hosted-delegate',
|
|
402
|
+
v_expires,
|
|
403
|
+
jsonb_build_object(
|
|
404
|
+
'source', 'exchange_delegate_credential',
|
|
405
|
+
'instance_id', v_instance_id,
|
|
406
|
+
'client_id', v_client_id,
|
|
407
|
+
'client_change', coalesce(v_change, 'null'::jsonb)),
|
|
408
|
+
v_grant_id,
|
|
409
|
+
v_generation)
|
|
410
|
+
returning id into v_credential;
|
|
411
|
+
|
|
412
|
+
return query select v_agent, v_auth, v_credential, v_expires;
|
|
413
|
+
end;
|
|
414
|
+
$fn$;
|
|
415
|
+
|
|
416
|
+
revoke all on function agent_board.exchange_delegate_credential(jsonb) from public, anon, authenticated;
|
|
417
|
+
grant execute on function agent_board.exchange_delegate_credential(jsonb) to service_role;
|
|
418
|
+
|
|
419
|
+
comment on function agent_board.exchange_delegate_credential(jsonb) is
|
|
420
|
+
'HOSTED (IF-0-HOSTED-7): turns a verified Portal delegate access token into a board credential row, in one transaction: under the owner lock and the owner''s auth.users row (FOR KEY SHARE), refuse a durably revoked or non-active owner (owner_inactive); insert-then-lock the instance; refuse a tombstone, another owner, an inactive agent, a 21st live instance (D13), a resource other than the recorded one (D8), a lower generation, an equal generation with another grant, and a client change without the owner''s confirmation; create the agent and auth user on first use; revoke lower generations; issue a credential with expires_at <= now()+900s. Records the owner''s verified (iss, sub) on the instance as revocation evidence (AB15; filled once on a legacy row, never changed). Returns (agent_id, auth_user_id, credential_id, expires_at). service_role only; called by the delegate-exchange Edge Function after it verified the token and rate-limited (D9).';
|
|
421
|
+
|
|
422
|
+
-- =============================================================================
|
|
423
|
+
-- 3. tombstone_delegate_instance -- records (iss, sub); owner row and agent row first (#591
|
|
424
|
+
-- items 4 and 8)
|
|
425
|
+
-- =============================================================================
|
|
426
|
+
create or replace function agent_board.tombstone_delegate_instance(
|
|
427
|
+
p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
|
|
428
|
+
returns table(instance_id text, tombstoned_at timestamptz, first boolean)
|
|
429
|
+
language plpgsql
|
|
430
|
+
security definer
|
|
431
|
+
set search_path = ''
|
|
432
|
+
as $fn$
|
|
433
|
+
#variable_conflict use_column
|
|
434
|
+
declare
|
|
435
|
+
v_owner uuid;
|
|
436
|
+
v_owner_slug text;
|
|
437
|
+
v_inserted boolean;
|
|
438
|
+
v_row agent_board.delegate_instances%rowtype;
|
|
439
|
+
v_board uuid;
|
|
440
|
+
begin
|
|
441
|
+
if not agent_board._is_service_role() then
|
|
442
|
+
raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
|
|
443
|
+
end if;
|
|
444
|
+
if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
445
|
+
or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
|
|
446
|
+
or p_reason is null
|
|
447
|
+
or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
|
|
448
|
+
raise exception 'revocation_malformed' using errcode = '22023';
|
|
449
|
+
end if;
|
|
450
|
+
|
|
451
|
+
-- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
|
|
452
|
+
-- same position: before the instance row), so a revocation and an exchange of a new owner's
|
|
453
|
+
-- instance never provision two auth users for one owner.
|
|
454
|
+
v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
|
|
455
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
|
|
456
|
+
v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
|
|
457
|
+
|
|
458
|
+
-- HOSTED #591: NO auth.users ROW LOCK here, deliberately, unlike the exchange. This function
|
|
459
|
+
-- reaches boards (update_board_membership below writes board_membership_events, whose foreign
|
|
460
|
+
-- key takes FOR KEY SHARE on the board), and every route that locks both a board and an owner
|
|
461
|
+
-- row must take the board first (agent_board_federated_lock_order_class EC-3):
|
|
462
|
+
-- enroll_federated_human holds its default board FOR UPDATE and then wants the owner's row FOR
|
|
463
|
+
-- UPDATE, so an owner-row lock taken here first closes a cycle with it. The deadlock against
|
|
464
|
+
-- revoke_human_subtree (#591 item 8) is closed by the agents-row order below instead
|
|
465
|
+
-- (hosted_delegate_tombstone_revoke_race P1, P2). What the owner-row lock would have added is
|
|
466
|
+
-- stated in AB15: see revoke_human_subtree (section 4).
|
|
467
|
+
|
|
468
|
+
-- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
|
|
469
|
+
-- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
|
|
470
|
+
-- HOSTED #591: with the owner's verified (iss, sub) (section 1).
|
|
471
|
+
insert into agent_board.delegate_instances as di(
|
|
472
|
+
instance_id, owner_user_id, owner_issuer, owner_subject, tombstoned_at, tombstone_reason)
|
|
473
|
+
values (p_instance_id, v_owner, p_issuer, p_owner_sub, now(), p_reason)
|
|
474
|
+
on conflict (instance_id) do nothing
|
|
475
|
+
returning true into v_inserted;
|
|
476
|
+
|
|
477
|
+
if v_inserted then
|
|
478
|
+
return query select p_instance_id, now(), true;
|
|
479
|
+
return;
|
|
480
|
+
end if;
|
|
481
|
+
|
|
482
|
+
select * into v_row
|
|
483
|
+
from agent_board.delegate_instances di
|
|
484
|
+
where di.instance_id = p_instance_id
|
|
485
|
+
for update;
|
|
486
|
+
|
|
487
|
+
if v_row.owner_user_id is distinct from v_owner then
|
|
488
|
+
raise exception 'owner_mismatch' using errcode = '42501';
|
|
489
|
+
end if;
|
|
490
|
+
|
|
491
|
+
-- Idempotent: the key is the instance, and a tombstone is permanent.
|
|
492
|
+
if v_row.tombstoned_at is not null then
|
|
493
|
+
-- HOSTED #591: a legacy (pre-1.59.0) tombstoned row has no owner pair, and nothing else ever
|
|
494
|
+
-- writes it again: the exchange refuses it (instance_revoked) before its UPDATE. So a repeated
|
|
495
|
+
-- revocation fills the pair here, and nothing else about the row changes. Sound for the same
|
|
496
|
+
-- reason as the fill below: owner_mismatch above proved this row's uid is the one
|
|
497
|
+
-- (p_issuer, p_owner_sub) derives. A recorded pair is never touched (the trigger refuses).
|
|
498
|
+
if v_row.owner_issuer is null then
|
|
499
|
+
update agent_board.delegate_instances di
|
|
500
|
+
set owner_issuer = p_issuer,
|
|
501
|
+
owner_subject = p_owner_sub
|
|
502
|
+
where di.instance_id = p_instance_id;
|
|
503
|
+
end if;
|
|
504
|
+
return query select v_row.instance_id, v_row.tombstoned_at, false;
|
|
505
|
+
return;
|
|
506
|
+
end if;
|
|
507
|
+
|
|
508
|
+
update agent_board.delegate_instances di
|
|
509
|
+
set tombstoned_at = now(),
|
|
510
|
+
tombstone_reason = p_reason,
|
|
511
|
+
-- HOSTED #591: fills a legacy row's null pair; sound because owner_mismatch above
|
|
512
|
+
-- proved this row's uid is the one (p_issuer, p_owner_sub) derives.
|
|
513
|
+
owner_issuer = coalesce(di.owner_issuer, p_issuer),
|
|
514
|
+
owner_subject = coalesce(di.owner_subject, p_owner_sub),
|
|
515
|
+
updated_at = now()
|
|
516
|
+
where di.instance_id = p_instance_id;
|
|
517
|
+
|
|
518
|
+
if v_row.agent_id is not null then
|
|
519
|
+
-- HOSTED #591 (item 8): THE DELEGATE'S agents ROW FIRST, before any credential. Every other
|
|
520
|
+
-- writer of a delegate's agents row and its credentials takes the agents row first
|
|
521
|
+
-- (_revoke_agent_principal, for revoke_agent_subtree and revoke_human_subtree: FOR UPDATE,
|
|
522
|
+
-- then credentials; revoke_agent: its UPDATE, then credentials). This UPDATE used to come
|
|
523
|
+
-- last, after the credentials and the memberships, so either subtree revoker interleaved
|
|
524
|
+
-- with a tombstone of the same delegate deadlocked (hosted_delegate_tombstone_revoke_race
|
|
525
|
+
-- P1, P2). It is the same statement and the same FOR NO KEY UPDATE lock the 1.58.0 function
|
|
526
|
+
-- took, moved ahead; it adds no lock. FOR NO KEY UPDATE does not block the FOR KEY SHARE of a
|
|
527
|
+
-- foreign-key check, so add_delegate_to_board's sink insert is not newly blocked (the two
|
|
528
|
+
-- already serialise on the instance row above). An agent already revoked and committed is
|
|
529
|
+
-- not matched and not locked; its revoker has finished, so there is nothing to order against.
|
|
530
|
+
update agent_board.agents a
|
|
531
|
+
set status = 'revoked', updated_at = now()
|
|
532
|
+
where a.id = v_row.agent_id
|
|
533
|
+
and a.status <> 'revoked';
|
|
534
|
+
|
|
535
|
+
update agent_board.agent_credentials ac
|
|
536
|
+
set revoked_at = coalesce(ac.revoked_at, now()),
|
|
537
|
+
updated_at = now(),
|
|
538
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
539
|
+
|| jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
|
|
540
|
+
where ac.agent_id = v_row.agent_id
|
|
541
|
+
and ac.revoked_at is null;
|
|
542
|
+
|
|
543
|
+
-- Every active membership ends through update_board_membership(..., 'left') under
|
|
544
|
+
-- service_role, so each ends with an append-only operator_removal event.
|
|
545
|
+
for v_board in
|
|
546
|
+
select bm.board_id
|
|
547
|
+
from agent_board.board_memberships bm
|
|
548
|
+
where bm.agent_id = v_row.agent_id
|
|
549
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
550
|
+
order by bm.board_id
|
|
551
|
+
loop
|
|
552
|
+
perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
|
|
553
|
+
end loop;
|
|
554
|
+
end if;
|
|
555
|
+
|
|
556
|
+
return query select p_instance_id, now(), true;
|
|
557
|
+
end;
|
|
558
|
+
$fn$;
|
|
559
|
+
|
|
560
|
+
revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
|
|
561
|
+
grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
|
|
562
|
+
|
|
563
|
+
comment on function agent_board.tombstone_delegate_instance(text, text, text, text) is
|
|
564
|
+
'HOSTED (IF-0-HOSTED-8, D10): tombstones a hosted delegate instance, idempotently and permanently. Before the first exchange it creates the tombstoned row; otherwise, in one transaction, it revokes every credential, ends every active membership through update_board_membership(..., ''left'') (operator_removal events) and moves the agent to revoked. A revocation naming an existing instance of another owner raises owner_mismatch. Lock order (AB15): the owner advisory lock, the instance row FOR UPDATE, the delegate''s agents row (its status UPDATE, FOR NO KEY UPDATE), then credentials and memberships; no auth.users row lock, because it reaches boards (board before owner, EC-3). Records the owner''s verified (iss, sub) on the instance as revocation evidence, filling a legacy row''s null pair on any call, including a repeat of an existing tombstone (which otherwise changes nothing). Returns (instance_id, tombstoned_at, first). service_role only; called by the delegate-revoke Edge Function with the verified revocation JWT''s iss, owner_sub and reason.';
|
|
565
|
+
|
|
566
|
+
-- =============================================================================
|
|
567
|
+
-- 4. revoke_human_subtree -- a marker from every recorded delegate owner identity (#591 item 4)
|
|
568
|
+
-- =============================================================================
|
|
569
|
+
create or replace function agent_board.revoke_human_subtree(
|
|
570
|
+
p_owner_user_id uuid,
|
|
571
|
+
p_revoked_by_agent_id uuid,
|
|
572
|
+
p_reason text default 'human revocation'
|
|
573
|
+
)
|
|
574
|
+
returns table(revoked_agent_count integer, revoked_key_count integer)
|
|
575
|
+
language plpgsql
|
|
576
|
+
security definer
|
|
577
|
+
set search_path = agent_board, public
|
|
578
|
+
as $$
|
|
579
|
+
declare
|
|
580
|
+
v_agent uuid;
|
|
581
|
+
v_agent_count integer := 0;
|
|
582
|
+
v_key_count integer := 0;
|
|
583
|
+
begin
|
|
584
|
+
if not agent_board._is_service_role() then
|
|
585
|
+
raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
|
|
586
|
+
end if;
|
|
587
|
+
|
|
588
|
+
perform 1 from auth.users u where u.id = p_owner_user_id for update;
|
|
589
|
+
if not found then
|
|
590
|
+
raise exception 'human owner not found' using errcode = 'P0002';
|
|
591
|
+
end if;
|
|
592
|
+
|
|
593
|
+
if exists (
|
|
594
|
+
select 1
|
|
595
|
+
from agent_board.agents a
|
|
596
|
+
where a.owner_user_id = p_owner_user_id
|
|
597
|
+
and jsonb_typeof(a.metadata -> 'federation') = 'object'
|
|
598
|
+
and not exists (
|
|
599
|
+
select 1
|
|
600
|
+
from agent_board.enrollment_federation_audit e
|
|
601
|
+
where e.agent_id = a.id
|
|
602
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
603
|
+
and nullif(btrim(e.subject), '') is not null
|
|
604
|
+
)
|
|
605
|
+
) then
|
|
606
|
+
raise exception 'stable federation evidence not found for every linked agent' using errcode = 'P0002';
|
|
607
|
+
end if;
|
|
608
|
+
|
|
609
|
+
-- HOSTED #591 (item 4): the markers come from TWO kinds of verified evidence: the federated
|
|
610
|
+
-- enrollment audit of an agent the human owns, and the owner (iss, sub) a hosted delegate
|
|
611
|
+
-- instance of this human recorded from the Portal token that created or last wrote it. An
|
|
612
|
+
-- owner whose only principals are hosted delegates has none of the first, and before this
|
|
613
|
+
-- got no marker, so a fresh first exchange passed owner_inactive after the revocation. The
|
|
614
|
+
-- instance rows are read unlocked. An exchange that creates one holds this owner's auth.users
|
|
615
|
+
-- row FOR KEY SHARE, which the FOR UPDATE above excludes. A tombstone does not (it reaches
|
|
616
|
+
-- boards, so it may not lock the owner first: section 3), so a pre-exchange tombstone that
|
|
617
|
+
-- commits after this read records a pair this revocation does not see. That misses a marker
|
|
618
|
+
-- only when nothing else gives one: the owner has no audit row, no human agents row (which
|
|
619
|
+
-- the loop below revokes, and which the exchange's owner_inactive reads), and no earlier
|
|
620
|
+
-- instance row with a pair -- i.e. its only evidence is pre-1.59.0 (AB15). A legacy instance
|
|
621
|
+
-- with no recorded pair gives nothing, and does NOT gate the revocation as missing audit
|
|
622
|
+
-- evidence does: refusing would leave that owner unrevocable, since only an exchange or a
|
|
623
|
+
-- tombstone call can fill the pair (AB15). A pair filled after this revocation commits marks
|
|
624
|
+
-- nothing: markers are written here only.
|
|
625
|
+
insert into agent_board.fleet_human_revocations(
|
|
626
|
+
issuer, subject, owner_user_id, revoked_by_agent_id, revocation_reason
|
|
627
|
+
)
|
|
628
|
+
select distinct
|
|
629
|
+
ev.issuer,
|
|
630
|
+
ev.subject,
|
|
631
|
+
p_owner_user_id, p_revoked_by_agent_id, p_reason
|
|
632
|
+
from (
|
|
633
|
+
select e.issuer, e.subject
|
|
634
|
+
from agent_board.enrollment_federation_audit e
|
|
635
|
+
join agent_board.agents a on a.id = e.agent_id
|
|
636
|
+
where a.owner_user_id = p_owner_user_id
|
|
637
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
638
|
+
and nullif(btrim(e.subject), '') is not null
|
|
639
|
+
union all
|
|
640
|
+
select di.owner_issuer, di.owner_subject
|
|
641
|
+
from agent_board.delegate_instances di
|
|
642
|
+
where di.owner_user_id = p_owner_user_id
|
|
643
|
+
and di.owner_issuer is not null
|
|
644
|
+
) ev
|
|
645
|
+
on conflict (issuer, subject) do nothing;
|
|
646
|
+
|
|
647
|
+
for v_agent in
|
|
648
|
+
select id
|
|
649
|
+
from agent_board.agents
|
|
650
|
+
where owner_user_id = p_owner_user_id
|
|
651
|
+
order by id
|
|
652
|
+
loop
|
|
653
|
+
v_key_count := v_key_count
|
|
654
|
+
+ agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
|
|
655
|
+
v_agent_count := v_agent_count + 1;
|
|
656
|
+
end loop;
|
|
657
|
+
|
|
658
|
+
return query select v_agent_count, v_key_count;
|
|
659
|
+
end;
|
|
660
|
+
$$;
|
|
661
|
+
|
|
662
|
+
comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
|
|
663
|
+
'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 from that audit evidence and from the owner (iss, sub) each of the human''s hosted delegate instances recorded (HOSTED #591, AB15; a legacy instance with none gives no marker and does not gate); then visits every owned agent in UUID order before the agent-before-key primitive. service_role-only.';
|
|
664
|
+
revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
|
|
665
|
+
grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;
|