@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,1496 @@
|
|
|
1
|
+
-- Consiliency/message-board#162: atomic, bounded fleet onboarding.
|
|
2
|
+
--
|
|
3
|
+
-- A fleet invite is not merely a ROOMLINK bearer. Its immutable server-side
|
|
4
|
+
-- bounds are creator-authorized at mint time and the service-role-only composed
|
|
5
|
+
-- RPC validates those exact bytes before creating any identity or fleet row.
|
|
6
|
+
|
|
7
|
+
create table agent_board.fleet_onboarding_invite_bounds (
|
|
8
|
+
invite_id uuid primary key references agent_board.board_invite_tokens(id) on delete cascade,
|
|
9
|
+
machine_id uuid not null references agent_board.fleet_machines(id) on delete restrict,
|
|
10
|
+
harness_kind text not null,
|
|
11
|
+
harness_label text not null,
|
|
12
|
+
repo_slug text not null,
|
|
13
|
+
repo_fingerprint text not null,
|
|
14
|
+
repo_scope_role text not null,
|
|
15
|
+
agent_role text not null,
|
|
16
|
+
expected_agent_handle text not null,
|
|
17
|
+
created_by_agent_id uuid not null references agent_board.agents(id) on delete restrict,
|
|
18
|
+
created_at timestamptz not null default now(),
|
|
19
|
+
constraint fleet_onboarding_bounds_harness_kind_shape check (
|
|
20
|
+
harness_kind = lower(harness_kind)
|
|
21
|
+
and harness_kind = btrim(harness_kind)
|
|
22
|
+
and length(harness_kind) between 1 and 64
|
|
23
|
+
and harness_kind ~ '^[a-z0-9][a-z0-9_.-]*$'
|
|
24
|
+
),
|
|
25
|
+
constraint fleet_onboarding_bounds_text_shape check (
|
|
26
|
+
harness_label = btrim(harness_label) and length(harness_label) between 1 and 128
|
|
27
|
+
and repo_slug = btrim(repo_slug) and length(repo_slug) between 1 and 255
|
|
28
|
+
and repo_fingerprint = btrim(repo_fingerprint) and length(repo_fingerprint) between 1 and 255
|
|
29
|
+
and expected_agent_handle = btrim(expected_agent_handle) and length(expected_agent_handle) between 1 and 255
|
|
30
|
+
),
|
|
31
|
+
constraint fleet_onboarding_bounds_role_pair check (
|
|
32
|
+
(agent_role = 'phase_worker' and repo_scope_role = 'worker')
|
|
33
|
+
or (agent_role = 'reviewer' and repo_scope_role = 'reviewer')
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
comment on table agent_board.fleet_onboarding_invite_bounds is
|
|
38
|
+
'Immutable creator-authorized bounds for atomic fleet onboarding. A bounded invite is reserved from generic ROOMLINK redemption and legacy enrollment.';
|
|
39
|
+
|
|
40
|
+
alter table agent_board.fleet_onboarding_invite_bounds enable row level security;
|
|
41
|
+
revoke all on agent_board.fleet_onboarding_invite_bounds from public, anon, authenticated;
|
|
42
|
+
grant select on agent_board.fleet_onboarding_invite_bounds to service_role;
|
|
43
|
+
|
|
44
|
+
create table agent_board.fleet_human_revocations (
|
|
45
|
+
issuer text not null,
|
|
46
|
+
subject text not null,
|
|
47
|
+
owner_user_id uuid references auth.users(id) on delete set null,
|
|
48
|
+
revoked_by_agent_id uuid not null references agent_board.agents(id) on delete restrict,
|
|
49
|
+
revocation_reason text not null,
|
|
50
|
+
effective_at timestamptz not null default now(),
|
|
51
|
+
primary key (issuer, subject)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
comment on table agent_board.fleet_human_revocations is
|
|
55
|
+
'Durable total-revocation marker keyed by stable federation principal. The optional owner UUID may be nulled by auth-user deletion without erasing the marker.';
|
|
56
|
+
|
|
57
|
+
alter table agent_board.fleet_human_revocations enable row level security;
|
|
58
|
+
revoke all on agent_board.fleet_human_revocations from public, anon, authenticated;
|
|
59
|
+
grant select on agent_board.fleet_human_revocations to service_role;
|
|
60
|
+
|
|
61
|
+
create function agent_board._protect_federation_contract()
|
|
62
|
+
returns trigger
|
|
63
|
+
language plpgsql
|
|
64
|
+
set search_path = agent_board, public
|
|
65
|
+
as $$
|
|
66
|
+
begin
|
|
67
|
+
if not agent_board._is_service_role()
|
|
68
|
+
and new.metadata #> '{federation}' is distinct from old.metadata #> '{federation}' then
|
|
69
|
+
raise exception 'authenticated agents may not mutate reserved federation metadata' using errcode = '42501';
|
|
70
|
+
end if;
|
|
71
|
+
if not agent_board._is_service_role()
|
|
72
|
+
and old.metadata #> '{federation,replay}' is not null
|
|
73
|
+
and new.handle is distinct from old.handle then
|
|
74
|
+
raise exception 'federated replay handle is immutable' using errcode = '42501';
|
|
75
|
+
end if;
|
|
76
|
+
return new;
|
|
77
|
+
end;
|
|
78
|
+
$$;
|
|
79
|
+
|
|
80
|
+
create trigger agents_protect_federation_contract
|
|
81
|
+
before update of handle, metadata on agent_board.agents
|
|
82
|
+
for each row execute function agent_board._protect_federation_contract();
|
|
83
|
+
|
|
84
|
+
revoke all on function agent_board._protect_federation_contract() from public, anon, authenticated;
|
|
85
|
+
|
|
86
|
+
create function agent_board._fleet_onboarding_bounds_immutable()
|
|
87
|
+
returns trigger
|
|
88
|
+
language plpgsql
|
|
89
|
+
set search_path = agent_board, public
|
|
90
|
+
as $$
|
|
91
|
+
begin
|
|
92
|
+
raise exception 'fleet onboarding invite bounds are immutable' using errcode = '55000';
|
|
93
|
+
end;
|
|
94
|
+
$$;
|
|
95
|
+
|
|
96
|
+
create trigger fleet_onboarding_invite_bounds_immutable
|
|
97
|
+
before update on agent_board.fleet_onboarding_invite_bounds
|
|
98
|
+
for each row execute function agent_board._fleet_onboarding_bounds_immutable();
|
|
99
|
+
|
|
100
|
+
revoke all on function agent_board._fleet_onboarding_bounds_immutable() from public, anon, authenticated;
|
|
101
|
+
|
|
102
|
+
create function agent_board.create_fleet_onboarding_invite(
|
|
103
|
+
p_board_id uuid,
|
|
104
|
+
p_machine_id uuid,
|
|
105
|
+
p_harness_kind text,
|
|
106
|
+
p_harness_label text,
|
|
107
|
+
p_repo_slug text,
|
|
108
|
+
p_repo_fingerprint text,
|
|
109
|
+
p_repo_scope_role text,
|
|
110
|
+
p_agent_role text,
|
|
111
|
+
p_expected_agent_handle text,
|
|
112
|
+
p_max_uses int default 1,
|
|
113
|
+
p_expires_at timestamptz default null,
|
|
114
|
+
p_created_by_agent_id uuid default null,
|
|
115
|
+
p_metadata jsonb default '{}'::jsonb
|
|
116
|
+
)
|
|
117
|
+
returns table(
|
|
118
|
+
invite_id uuid,
|
|
119
|
+
board_id uuid,
|
|
120
|
+
code text,
|
|
121
|
+
preset_role text,
|
|
122
|
+
preset_membership_state text,
|
|
123
|
+
max_uses int,
|
|
124
|
+
uses_count int,
|
|
125
|
+
expires_at timestamptz,
|
|
126
|
+
created_at timestamptz,
|
|
127
|
+
machine_id uuid,
|
|
128
|
+
harness_kind text,
|
|
129
|
+
harness_label text,
|
|
130
|
+
repo_slug text,
|
|
131
|
+
repo_fingerprint text,
|
|
132
|
+
repo_scope_role text,
|
|
133
|
+
agent_role text,
|
|
134
|
+
expected_agent_handle text
|
|
135
|
+
)
|
|
136
|
+
language plpgsql
|
|
137
|
+
security definer
|
|
138
|
+
set search_path = agent_board, public
|
|
139
|
+
as $$
|
|
140
|
+
#variable_conflict use_column
|
|
141
|
+
declare
|
|
142
|
+
v_creator uuid;
|
|
143
|
+
v_board agent_board.boards%rowtype;
|
|
144
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
145
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
146
|
+
v_inv record;
|
|
147
|
+
v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
|
|
148
|
+
v_label text := btrim(coalesce(p_harness_label, ''));
|
|
149
|
+
v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
|
|
150
|
+
v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
|
|
151
|
+
v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
|
|
152
|
+
begin
|
|
153
|
+
v_creator := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
154
|
+
|
|
155
|
+
if p_board_id is null or p_machine_id is null then
|
|
156
|
+
raise exception 'board and machine are required' using errcode = '22023';
|
|
157
|
+
end if;
|
|
158
|
+
if v_kind !~ '^[a-z0-9][a-z0-9_.-]{0,63}$'
|
|
159
|
+
or length(v_label) not between 1 and 128
|
|
160
|
+
or length(v_repo_slug) not between 1 and 255
|
|
161
|
+
or length(v_repo_fingerprint) not between 1 and 255
|
|
162
|
+
or length(v_expected_handle) not between 1 and 255 then
|
|
163
|
+
raise exception 'invalid fleet onboarding bounds' using errcode = '22023';
|
|
164
|
+
end if;
|
|
165
|
+
if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
|
|
166
|
+
or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
|
|
167
|
+
raise exception 'invalid fleet onboarding role pair' using errcode = '22023';
|
|
168
|
+
end if;
|
|
169
|
+
if p_max_uses is null or p_max_uses < 1 then
|
|
170
|
+
raise exception 'fleet onboarding invites require a positive use cap' using errcode = '22023';
|
|
171
|
+
end if;
|
|
172
|
+
|
|
173
|
+
select * into v_board
|
|
174
|
+
from agent_board.boards b
|
|
175
|
+
where b.id = p_board_id
|
|
176
|
+
for update;
|
|
177
|
+
if not found or v_board.status <> 'active' then
|
|
178
|
+
raise exception 'fleet onboarding board is not usable' using errcode = '42501';
|
|
179
|
+
end if;
|
|
180
|
+
|
|
181
|
+
select * into v_machine
|
|
182
|
+
from agent_board.fleet_machines fm
|
|
183
|
+
where fm.id = p_machine_id
|
|
184
|
+
for update;
|
|
185
|
+
if not found or v_machine.owner_agent_id <> v_creator or v_machine.lifecycle_state <> 'active' then
|
|
186
|
+
raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
|
|
187
|
+
end if;
|
|
188
|
+
|
|
189
|
+
select * into v_domain
|
|
190
|
+
from agent_board.fleet_trust_domains td
|
|
191
|
+
where td.id = v_machine.trust_domain_id
|
|
192
|
+
for update;
|
|
193
|
+
if not found or v_domain.owner_agent_id <> v_creator or v_domain.lifecycle_state <> 'active' then
|
|
194
|
+
raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
select * into v_inv
|
|
198
|
+
from agent_board.create_invite_link(
|
|
199
|
+
p_board_id, 'member', 'joined', p_max_uses, p_expires_at,
|
|
200
|
+
v_creator, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('purpose', 'fleet_onboarding.v1')
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
insert into agent_board.fleet_onboarding_invite_bounds(
|
|
204
|
+
invite_id, machine_id, harness_kind, harness_label, repo_slug,
|
|
205
|
+
repo_fingerprint, repo_scope_role, agent_role, expected_agent_handle,
|
|
206
|
+
created_by_agent_id
|
|
207
|
+
) values (
|
|
208
|
+
v_inv.invite_id, p_machine_id, v_kind, v_label, v_repo_slug,
|
|
209
|
+
v_repo_fingerprint, p_repo_scope_role, p_agent_role, v_expected_handle,
|
|
210
|
+
v_creator
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return query select
|
|
214
|
+
v_inv.invite_id, v_inv.board_id, v_inv.code, v_inv.preset_role,
|
|
215
|
+
v_inv.preset_membership_state, v_inv.max_uses, v_inv.uses_count,
|
|
216
|
+
v_inv.expires_at, v_inv.created_at, p_machine_id, v_kind, v_label,
|
|
217
|
+
v_repo_slug, v_repo_fingerprint, p_repo_scope_role, p_agent_role,
|
|
218
|
+
v_expected_handle;
|
|
219
|
+
end;
|
|
220
|
+
$$;
|
|
221
|
+
|
|
222
|
+
comment on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) is
|
|
223
|
+
'Mints a member-only ROOMLINK and immutable creator-owned machine/harness/repo/role/handle bounds in one transaction. The bearer code is returned only to the authorized creator.';
|
|
224
|
+
revoke all on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) from public, anon;
|
|
225
|
+
grant execute on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) to authenticated, service_role;
|
|
226
|
+
|
|
227
|
+
-- Keep the released ROOMLINK core byte-for-byte and put class selection in two
|
|
228
|
+
-- non-interchangeable wrappers. The renamed primitive remains service-role-only.
|
|
229
|
+
alter function agent_board._redeem_invite_for_agent(text, uuid)
|
|
230
|
+
rename to _redeem_unbounded_invite_for_agent;
|
|
231
|
+
revoke all on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) from public, anon, authenticated;
|
|
232
|
+
grant execute on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) to service_role;
|
|
233
|
+
|
|
234
|
+
create function agent_board._redeem_invite_for_agent(p_code text, p_agent_id uuid)
|
|
235
|
+
returns table(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamptz)
|
|
236
|
+
language plpgsql
|
|
237
|
+
security definer
|
|
238
|
+
set search_path = agent_board, public
|
|
239
|
+
as $$
|
|
240
|
+
begin
|
|
241
|
+
if exists (
|
|
242
|
+
select 1
|
|
243
|
+
from agent_board.board_invite_tokens bit
|
|
244
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
245
|
+
where bit.code = p_code
|
|
246
|
+
) then
|
|
247
|
+
raise exception 'invite is reserved for atomic fleet onboarding' using errcode = '42501';
|
|
248
|
+
end if;
|
|
249
|
+
return query select * from agent_board._redeem_unbounded_invite_for_agent(p_code, p_agent_id);
|
|
250
|
+
end;
|
|
251
|
+
$$;
|
|
252
|
+
|
|
253
|
+
create function agent_board._redeem_fleet_invite_for_agent(p_code text, p_agent_id uuid)
|
|
254
|
+
returns table(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamptz)
|
|
255
|
+
language plpgsql
|
|
256
|
+
security definer
|
|
257
|
+
set search_path = agent_board, public
|
|
258
|
+
as $$
|
|
259
|
+
begin
|
|
260
|
+
if not exists (
|
|
261
|
+
select 1
|
|
262
|
+
from agent_board.board_invite_tokens bit
|
|
263
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
264
|
+
where bit.code = p_code
|
|
265
|
+
) then
|
|
266
|
+
raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
|
|
267
|
+
end if;
|
|
268
|
+
return query select * from agent_board._redeem_unbounded_invite_for_agent(p_code, p_agent_id);
|
|
269
|
+
end;
|
|
270
|
+
$$;
|
|
271
|
+
|
|
272
|
+
comment on function agent_board._redeem_invite_for_agent(text, uuid) is
|
|
273
|
+
'Generic ROOMLINK redemption guard. Fleet-bounded invites are categorically rejected before the released redemption core can write.';
|
|
274
|
+
comment on function agent_board._redeem_fleet_invite_for_agent(text, uuid) is
|
|
275
|
+
'Fleet-only ROOMLINK redemption guard used exclusively by onboard_federated_fleet_agent.';
|
|
276
|
+
revoke all on function agent_board._redeem_invite_for_agent(text, uuid) from public, anon, authenticated;
|
|
277
|
+
revoke all on function agent_board._redeem_fleet_invite_for_agent(text, uuid) from public, anon, authenticated;
|
|
278
|
+
grant execute on function agent_board._redeem_invite_for_agent(text, uuid) to service_role;
|
|
279
|
+
grant execute on function agent_board._redeem_fleet_invite_for_agent(text, uuid) to service_role;
|
|
280
|
+
|
|
281
|
+
-- Preserve the released enrollment implementation as the one-time creator and
|
|
282
|
+
-- wrap it with a serialized exact-key replay contract.
|
|
283
|
+
alter function agent_board.enroll_federated_identity(
|
|
284
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
285
|
+
rename to _enroll_federated_identity_once;
|
|
286
|
+
revoke all on function agent_board._enroll_federated_identity_once(
|
|
287
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
288
|
+
from public, anon, authenticated;
|
|
289
|
+
grant execute on function agent_board._enroll_federated_identity_once(
|
|
290
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
291
|
+
to service_role;
|
|
292
|
+
|
|
293
|
+
create function agent_board._normalized_federation_audience(p_claim jsonb)
|
|
294
|
+
returns jsonb
|
|
295
|
+
language plpgsql
|
|
296
|
+
immutable
|
|
297
|
+
strict
|
|
298
|
+
set search_path = agent_board, public
|
|
299
|
+
as $$
|
|
300
|
+
declare
|
|
301
|
+
v_aud jsonb := p_claim -> 'aud';
|
|
302
|
+
v_result jsonb;
|
|
303
|
+
begin
|
|
304
|
+
if jsonb_typeof(v_aud) = 'string' then
|
|
305
|
+
if length(btrim(v_aud #>> '{}')) = 0 then return null; end if;
|
|
306
|
+
return jsonb_build_array(v_aud #>> '{}');
|
|
307
|
+
end if;
|
|
308
|
+
if jsonb_typeof(v_aud) <> 'array'
|
|
309
|
+
or jsonb_array_length(v_aud) = 0
|
|
310
|
+
or exists (select 1 from jsonb_array_elements(v_aud) e where jsonb_typeof(e) <> 'string') then
|
|
311
|
+
return null;
|
|
312
|
+
end if;
|
|
313
|
+
select jsonb_agg(value order by value)
|
|
314
|
+
into v_result
|
|
315
|
+
from (select distinct btrim(value) as value from jsonb_array_elements_text(v_aud)) normalized
|
|
316
|
+
where length(value) > 0;
|
|
317
|
+
if jsonb_array_length(v_result) <> jsonb_array_length(v_aud) then return null; end if;
|
|
318
|
+
return v_result;
|
|
319
|
+
end;
|
|
320
|
+
$$;
|
|
321
|
+
revoke all on function agent_board._normalized_federation_audience(jsonb) from public, anon, authenticated;
|
|
322
|
+
grant execute on function agent_board._normalized_federation_audience(jsonb) to service_role;
|
|
323
|
+
|
|
324
|
+
-- Shipped backfill: only rows with a complete, unambiguous legacy federation
|
|
325
|
+
-- record, exactly one proven key, and exactly one immutable audit claim gain replay
|
|
326
|
+
-- authority. Any historically agent-writable replay object is overwritten. Stable
|
|
327
|
+
-- identity and requested scopes come from the audit/key evidence; fields absent
|
|
328
|
+
-- from that append-only record are pinned to least privilege. The original audit
|
|
329
|
+
-- claim remains untouched.
|
|
330
|
+
update agent_board.agents
|
|
331
|
+
set metadata = metadata #- '{federation,replay}'::text[]
|
|
332
|
+
where (metadata #> '{federation}') ? 'replay';
|
|
333
|
+
|
|
334
|
+
with legacy as (
|
|
335
|
+
select a.id,
|
|
336
|
+
e.human_identity_claim,
|
|
337
|
+
e.issuer,
|
|
338
|
+
e.subject,
|
|
339
|
+
e.provider_id,
|
|
340
|
+
e.requested_scopes,
|
|
341
|
+
agent_board._normalized_federation_audience(e.human_identity_claim) as audience
|
|
342
|
+
from agent_board.agents a
|
|
343
|
+
join agent_board.enrollment_federation_audit e on e.agent_id = a.id
|
|
344
|
+
join agent_board.identity_providers ip on ip.id = e.provider_id
|
|
345
|
+
where (select count(*) from agent_board.enrollment_federation_audit ea where ea.agent_id = a.id) = 1
|
|
346
|
+
and (select count(*) from agent_board.agent_signing_keys k where k.agent_id = a.id) = 1
|
|
347
|
+
and e.issuer is not null
|
|
348
|
+
and e.audience is not null
|
|
349
|
+
and e.subject is not null
|
|
350
|
+
and ip.issuer = e.issuer
|
|
351
|
+
and ip.audience = e.audience
|
|
352
|
+
and e.human_identity_claim ->> 'iss' = e.issuer
|
|
353
|
+
and e.human_identity_claim ->> ip.subject_claim = e.subject
|
|
354
|
+
and agent_board._normalized_federation_audience(e.human_identity_claim) ? e.audience
|
|
355
|
+
and exists (
|
|
356
|
+
select 1 from agent_board.agent_signing_keys k
|
|
357
|
+
where k.agent_id = a.id
|
|
358
|
+
and k.status = 'active' and k.proof_state = 'proven'
|
|
359
|
+
and k.fingerprint = a.metadata #>> '{federation,signingKeyFingerprint}'
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
update agent_board.agents a
|
|
363
|
+
set metadata = jsonb_set(
|
|
364
|
+
a.metadata,
|
|
365
|
+
'{federation,replay}',
|
|
366
|
+
jsonb_build_object(
|
|
367
|
+
'issuer', legacy.issuer,
|
|
368
|
+
'audience', legacy.audience,
|
|
369
|
+
'subject', legacy.subject,
|
|
370
|
+
'providerId', legacy.provider_id,
|
|
371
|
+
'actorSub', 'null'::jsonb,
|
|
372
|
+
'requestedSlug', a.handle,
|
|
373
|
+
'agentKind', a.agent_type,
|
|
374
|
+
'requestedScopes', to_jsonb(coalesce(legacy.requested_scopes, '{}'::text[])),
|
|
375
|
+
'requestedBoardScopes', '[]'::jsonb
|
|
376
|
+
),
|
|
377
|
+
true
|
|
378
|
+
)
|
|
379
|
+
from legacy
|
|
380
|
+
where a.id = legacy.id
|
|
381
|
+
and legacy.issuer = a.metadata #>> '{federation,issuer}'
|
|
382
|
+
and legacy.subject = a.metadata #>> '{federation,subject}'
|
|
383
|
+
and legacy.provider_id = a.metadata #>> '{federation,providerId}'
|
|
384
|
+
and legacy.audience ? (a.metadata #>> '{federation,audience}')
|
|
385
|
+
and legacy.requested_scopes is not null;
|
|
386
|
+
|
|
387
|
+
create function agent_board.enroll_federated_identity(
|
|
388
|
+
p_issuer text,
|
|
389
|
+
p_audience text,
|
|
390
|
+
p_subject text,
|
|
391
|
+
p_human_identity_claim jsonb,
|
|
392
|
+
p_slug text,
|
|
393
|
+
p_agent_public_key text,
|
|
394
|
+
p_agent_public_key_format text,
|
|
395
|
+
p_agent_public_key_fingerprint text,
|
|
396
|
+
p_pop_verified boolean,
|
|
397
|
+
p_actor_sub text default null,
|
|
398
|
+
p_agent_kind text default 'agent',
|
|
399
|
+
p_requested_scopes text[] default '{}',
|
|
400
|
+
p_requested_board_scopes text[] default '{}',
|
|
401
|
+
p_invite_code text default null
|
|
402
|
+
)
|
|
403
|
+
returns table(
|
|
404
|
+
agent_id uuid,
|
|
405
|
+
auth_user_id uuid,
|
|
406
|
+
provider_id text,
|
|
407
|
+
signing_key_id uuid,
|
|
408
|
+
granted_scopes text[],
|
|
409
|
+
key_id text,
|
|
410
|
+
key_version int,
|
|
411
|
+
agent_handle text,
|
|
412
|
+
invite_redeemed boolean
|
|
413
|
+
)
|
|
414
|
+
language plpgsql
|
|
415
|
+
security definer
|
|
416
|
+
set search_path = agent_board, public
|
|
417
|
+
as $$
|
|
418
|
+
#variable_conflict use_column
|
|
419
|
+
declare
|
|
420
|
+
v_fingerprint text;
|
|
421
|
+
v_existing record;
|
|
422
|
+
v_created record;
|
|
423
|
+
v_replay jsonb;
|
|
424
|
+
v_invite_redeemed boolean := false;
|
|
425
|
+
v_slug text;
|
|
426
|
+
v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
|
|
427
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
428
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
429
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
430
|
+
v_claim_audience jsonb;
|
|
431
|
+
v_owner_user_id uuid;
|
|
432
|
+
begin
|
|
433
|
+
if not agent_board._is_service_role() then
|
|
434
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
435
|
+
end if;
|
|
436
|
+
if p_pop_verified is not true then
|
|
437
|
+
raise exception 'proof-of-possession was not verified' using errcode = '42501';
|
|
438
|
+
end if;
|
|
439
|
+
if p_subject is null then
|
|
440
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
441
|
+
end if;
|
|
442
|
+
if p_human_identity_claim is null then
|
|
443
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
444
|
+
end if;
|
|
445
|
+
|
|
446
|
+
select * into v_provider from agent_board.identity_providers ip
|
|
447
|
+
where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
|
|
448
|
+
if v_provider.id is null then
|
|
449
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
450
|
+
end if;
|
|
451
|
+
|
|
452
|
+
v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
|
|
453
|
+
if p_human_identity_claim ->> 'iss' is distinct from p_issuer
|
|
454
|
+
or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
|
|
455
|
+
or v_claim_audience is null
|
|
456
|
+
or not (v_claim_audience ? p_audience) then
|
|
457
|
+
raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
|
|
458
|
+
end if;
|
|
459
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
|
|
460
|
+
or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
461
|
+
raise exception 'malformed agent public key' using errcode = '22023';
|
|
462
|
+
end if;
|
|
463
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
464
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
465
|
+
raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
|
|
466
|
+
end if;
|
|
467
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
468
|
+
|
|
469
|
+
if not v_provider.supported or not v_provider.policy_approved then
|
|
470
|
+
raise exception 'identity provider is not approved' using errcode = '42501';
|
|
471
|
+
end if;
|
|
472
|
+
if v_kind = 'system' then
|
|
473
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
474
|
+
end if;
|
|
475
|
+
if v_kind not in ('human', 'agent') then
|
|
476
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
477
|
+
end if;
|
|
478
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
479
|
+
and not (v_kind = any(v_provider.allowed_agent_kinds)) then
|
|
480
|
+
raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
|
|
481
|
+
end if;
|
|
482
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes)
|
|
483
|
+
or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
484
|
+
raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
|
|
485
|
+
end if;
|
|
486
|
+
|
|
487
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
|
|
488
|
+
select 1 from agent_board.board_invite_tokens bit
|
|
489
|
+
join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
|
|
490
|
+
where bit.code = btrim(p_invite_code)
|
|
491
|
+
) then
|
|
492
|
+
raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
|
|
493
|
+
end if;
|
|
494
|
+
|
|
495
|
+
perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
|
|
496
|
+
|
|
497
|
+
select a.owner_user_id into v_owner_user_id
|
|
498
|
+
from agent_board.agent_signing_keys k
|
|
499
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
500
|
+
where k.fingerprint = v_fingerprint;
|
|
501
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
502
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
503
|
+
));
|
|
504
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
505
|
+
if exists (
|
|
506
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
507
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
508
|
+
) then
|
|
509
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
510
|
+
end if;
|
|
511
|
+
|
|
512
|
+
select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
|
|
513
|
+
a.metadata, k.id as signing_key_id, k.key_id, k.key_version,
|
|
514
|
+
k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
|
|
515
|
+
(select e.granted_scopes
|
|
516
|
+
from agent_board.enrollment_federation_audit e
|
|
517
|
+
where e.agent_id = a.id
|
|
518
|
+
and e.issuer = p_issuer
|
|
519
|
+
and e.subject = p_subject
|
|
520
|
+
and e.provider_id = v_provider.id) as granted_scopes
|
|
521
|
+
into v_existing
|
|
522
|
+
from agent_board.agent_signing_keys k
|
|
523
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
524
|
+
where k.fingerprint = v_fingerprint
|
|
525
|
+
for update of a, k;
|
|
526
|
+
|
|
527
|
+
if found then
|
|
528
|
+
v_replay := v_existing.metadata #> '{federation,replay}';
|
|
529
|
+
if v_existing.public_key is distinct from p_agent_public_key
|
|
530
|
+
or v_existing.public_key_format is distinct from p_agent_public_key_format
|
|
531
|
+
or v_existing.key_status <> 'active'
|
|
532
|
+
or v_existing.proof_state <> 'proven'
|
|
533
|
+
or v_existing.status <> 'active'
|
|
534
|
+
or v_existing.granted_scopes is null
|
|
535
|
+
or v_replay is null
|
|
536
|
+
or v_replay ->> 'issuer' is distinct from p_issuer
|
|
537
|
+
or v_replay -> 'audience' is distinct from v_claim_audience
|
|
538
|
+
or v_replay ->> 'subject' is distinct from p_subject
|
|
539
|
+
or v_replay ->> 'requestedSlug' is distinct from v_slug
|
|
540
|
+
or v_replay ->> 'agentKind' is distinct from v_kind
|
|
541
|
+
or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
|
|
542
|
+
or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
|
|
543
|
+
or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
|
|
544
|
+
or v_replay ->> 'providerId' is distinct from v_provider.id then
|
|
545
|
+
raise exception 'enrollment replay conflicts with the frozen identity contract' using errcode = '23505';
|
|
546
|
+
end if;
|
|
547
|
+
|
|
548
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
|
|
549
|
+
begin
|
|
550
|
+
perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
|
|
551
|
+
v_invite_redeemed := true;
|
|
552
|
+
exception when others then
|
|
553
|
+
v_invite_redeemed := false;
|
|
554
|
+
end;
|
|
555
|
+
end if;
|
|
556
|
+
|
|
557
|
+
return query select v_existing.agent_id, v_existing.auth_user_id,
|
|
558
|
+
v_replay ->> 'providerId', v_existing.signing_key_id,
|
|
559
|
+
v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
|
|
560
|
+
v_existing.handle, v_invite_redeemed;
|
|
561
|
+
return;
|
|
562
|
+
end if;
|
|
563
|
+
|
|
564
|
+
select * into v_created from agent_board._enroll_federated_identity_once(
|
|
565
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
566
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
567
|
+
p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
|
|
568
|
+
v_requested_board_scopes, p_invite_code
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
update agent_board.agents a
|
|
572
|
+
set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
|
|
573
|
+
'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
|
|
574
|
+
'providerId', v_created.provider_id,
|
|
575
|
+
'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
|
|
576
|
+
'requestedSlug', v_slug, 'agentKind', v_kind,
|
|
577
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
578
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
|
|
579
|
+
), true)
|
|
580
|
+
where a.id = v_created.agent_id;
|
|
581
|
+
|
|
582
|
+
return query select v_created.agent_id, v_created.auth_user_id,
|
|
583
|
+
v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
|
|
584
|
+
v_created.key_id, v_created.key_version, v_created.agent_handle,
|
|
585
|
+
v_created.invite_redeemed;
|
|
586
|
+
end;
|
|
587
|
+
$$;
|
|
588
|
+
|
|
589
|
+
comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
590
|
+
'Service-role federated enrollment with exact fingerprint replay. Stable owner, actor, slug, kind, and scope inputs are frozen; rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes.';
|
|
591
|
+
revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from public, anon, authenticated;
|
|
592
|
+
grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) to service_role;
|
|
593
|
+
|
|
594
|
+
create function agent_board.onboard_federated_fleet_agent(
|
|
595
|
+
p_issuer text,
|
|
596
|
+
p_audience text,
|
|
597
|
+
p_subject text,
|
|
598
|
+
p_human_identity_claim jsonb,
|
|
599
|
+
p_slug text,
|
|
600
|
+
p_agent_public_key text,
|
|
601
|
+
p_agent_public_key_format text,
|
|
602
|
+
p_agent_public_key_fingerprint text,
|
|
603
|
+
p_pop_verified boolean,
|
|
604
|
+
p_actor_sub text default null,
|
|
605
|
+
p_agent_kind text default 'agent',
|
|
606
|
+
p_requested_scopes text[] default '{}',
|
|
607
|
+
p_requested_board_scopes text[] default '{}',
|
|
608
|
+
p_invite_code text default null,
|
|
609
|
+
p_board_id uuid default null,
|
|
610
|
+
p_machine_id uuid default null,
|
|
611
|
+
p_harness_kind text default null,
|
|
612
|
+
p_harness_label text default null,
|
|
613
|
+
p_repo_slug text default null,
|
|
614
|
+
p_repo_fingerprint text default null,
|
|
615
|
+
p_repo_scope_role text default null,
|
|
616
|
+
p_agent_role text default null,
|
|
617
|
+
p_expected_agent_handle text default null
|
|
618
|
+
)
|
|
619
|
+
returns table(
|
|
620
|
+
onboarding_contract_version text,
|
|
621
|
+
agent_id uuid,
|
|
622
|
+
auth_user_id uuid,
|
|
623
|
+
provider_id text,
|
|
624
|
+
signing_key_id uuid,
|
|
625
|
+
granted_scopes text[],
|
|
626
|
+
key_id text,
|
|
627
|
+
key_version int,
|
|
628
|
+
agent_handle text,
|
|
629
|
+
public_key_fingerprint text,
|
|
630
|
+
board_id uuid,
|
|
631
|
+
membership_role text,
|
|
632
|
+
membership_state text,
|
|
633
|
+
machine_id uuid,
|
|
634
|
+
harness_registration_id uuid,
|
|
635
|
+
repo_scope_id uuid,
|
|
636
|
+
fleet_machine_agent_id uuid,
|
|
637
|
+
binding_signing_key_id uuid,
|
|
638
|
+
harness_kind text,
|
|
639
|
+
harness_label text,
|
|
640
|
+
repo_slug text,
|
|
641
|
+
repo_fingerprint text,
|
|
642
|
+
repo_scope_role text,
|
|
643
|
+
agent_role text,
|
|
644
|
+
expected_agent_handle text,
|
|
645
|
+
identity_disposition text,
|
|
646
|
+
membership_disposition text,
|
|
647
|
+
harness_disposition text,
|
|
648
|
+
repo_scope_disposition text,
|
|
649
|
+
binding_disposition text
|
|
650
|
+
)
|
|
651
|
+
language plpgsql
|
|
652
|
+
security definer
|
|
653
|
+
set search_path = agent_board, public
|
|
654
|
+
as $$
|
|
655
|
+
#variable_conflict use_column
|
|
656
|
+
declare
|
|
657
|
+
v_inv agent_board.board_invite_tokens%rowtype;
|
|
658
|
+
v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
|
|
659
|
+
v_board agent_board.boards%rowtype;
|
|
660
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
661
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
662
|
+
v_enroll record;
|
|
663
|
+
v_membership record;
|
|
664
|
+
v_harness agent_board.fleet_harness_registrations%rowtype;
|
|
665
|
+
v_repo agent_board.fleet_repo_scopes%rowtype;
|
|
666
|
+
v_binding agent_board.fleet_machine_agents%rowtype;
|
|
667
|
+
v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
|
|
668
|
+
v_label text := btrim(coalesce(p_harness_label, ''));
|
|
669
|
+
v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
|
|
670
|
+
v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
|
|
671
|
+
v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
|
|
672
|
+
v_existing_agent_id uuid;
|
|
673
|
+
v_owner_user_id uuid;
|
|
674
|
+
v_identity_disposition text;
|
|
675
|
+
v_membership_disposition text;
|
|
676
|
+
v_harness_disposition text;
|
|
677
|
+
v_repo_disposition text;
|
|
678
|
+
v_binding_disposition text;
|
|
679
|
+
v_completed_replay boolean := false;
|
|
680
|
+
begin
|
|
681
|
+
if not agent_board._is_service_role() then
|
|
682
|
+
raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
|
|
683
|
+
end if;
|
|
684
|
+
if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
|
|
685
|
+
raise exception 'bounded invite code is required' using errcode = '22023';
|
|
686
|
+
end if;
|
|
687
|
+
|
|
688
|
+
select bit.* into v_inv
|
|
689
|
+
from agent_board.board_invite_tokens bit
|
|
690
|
+
where bit.code = btrim(p_invite_code)
|
|
691
|
+
for update;
|
|
692
|
+
if not found then
|
|
693
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
694
|
+
end if;
|
|
695
|
+
select * into v_bounds
|
|
696
|
+
from agent_board.fleet_onboarding_invite_bounds b
|
|
697
|
+
where b.invite_id = v_inv.id
|
|
698
|
+
for update;
|
|
699
|
+
if not found then
|
|
700
|
+
raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
|
|
701
|
+
end if;
|
|
702
|
+
|
|
703
|
+
select * into v_board
|
|
704
|
+
from agent_board.boards b
|
|
705
|
+
where b.id = v_inv.board_id
|
|
706
|
+
for update;
|
|
707
|
+
if not found or v_board.status <> 'active' then
|
|
708
|
+
raise exception 'fleet onboarding board is not usable' using errcode = '42501';
|
|
709
|
+
end if;
|
|
710
|
+
|
|
711
|
+
if v_inv.board_id is distinct from p_board_id
|
|
712
|
+
or v_bounds.machine_id is distinct from p_machine_id
|
|
713
|
+
or v_bounds.harness_kind is distinct from v_kind
|
|
714
|
+
or v_bounds.harness_label is distinct from v_label
|
|
715
|
+
or v_bounds.repo_slug is distinct from v_repo_slug
|
|
716
|
+
or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
|
|
717
|
+
or v_bounds.repo_scope_role is distinct from p_repo_scope_role
|
|
718
|
+
or v_bounds.agent_role is distinct from p_agent_role
|
|
719
|
+
or v_bounds.expected_agent_handle is distinct from v_expected_handle then
|
|
720
|
+
raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
|
|
721
|
+
end if;
|
|
722
|
+
if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
|
|
723
|
+
or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
|
|
724
|
+
raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
|
|
725
|
+
end if;
|
|
726
|
+
|
|
727
|
+
select * into v_machine from agent_board.fleet_machines fm
|
|
728
|
+
where fm.id = p_machine_id for update;
|
|
729
|
+
if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
|
|
730
|
+
or v_machine.lifecycle_state <> 'active' then
|
|
731
|
+
raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
|
|
732
|
+
end if;
|
|
733
|
+
select * into v_domain from agent_board.fleet_trust_domains td
|
|
734
|
+
where td.id = v_machine.trust_domain_id for update;
|
|
735
|
+
if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
|
|
736
|
+
or v_domain.lifecycle_state <> 'active' then
|
|
737
|
+
raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
|
|
738
|
+
end if;
|
|
739
|
+
|
|
740
|
+
-- Serialize cross-route identity classification before locking principals. For
|
|
741
|
+
-- a replay, lock the creator and enrolled principal in UUID order so a human or
|
|
742
|
+
-- agent subtree revocation that visits the same pair cannot invert their order.
|
|
743
|
+
perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
|
|
744
|
+
select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
|
|
745
|
+
from agent_board.agent_signing_keys k
|
|
746
|
+
join agent_board.agents a on a.id = k.agent_id
|
|
747
|
+
where k.fingerprint = p_agent_public_key_fingerprint;
|
|
748
|
+
v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
|
|
749
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
|
|
750
|
+
));
|
|
751
|
+
perform 1 from auth.users u where u.id = v_owner_user_id for update;
|
|
752
|
+
if exists (
|
|
753
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
754
|
+
where r.issuer = p_issuer and r.subject = p_subject
|
|
755
|
+
) then
|
|
756
|
+
raise exception 'human owner has been revoked' using errcode = '42501';
|
|
757
|
+
end if;
|
|
758
|
+
perform 1 from agent_board.agents a
|
|
759
|
+
where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
|
|
760
|
+
order by a.id for update;
|
|
761
|
+
|
|
762
|
+
-- Creator authority is part of the invite's live authorization, not merely a
|
|
763
|
+
-- snapshot from mint time. Lock every membership row and the current ceiling
|
|
764
|
+
-- before evaluating its role so revocation, eviction, or demotion must serialize
|
|
765
|
+
-- on one side of this transaction. Absence retains default-owner semantics.
|
|
766
|
+
perform 1 from agent_board.board_memberships bm
|
|
767
|
+
where bm.board_id = v_inv.board_id
|
|
768
|
+
and bm.agent_id = v_inv.created_by_agent_id
|
|
769
|
+
order by bm.id for update;
|
|
770
|
+
perform 1 from agent_board.board_authority_ceilings c
|
|
771
|
+
where c.board_id = v_inv.board_id for update;
|
|
772
|
+
|
|
773
|
+
-- Concurrent legacy enrollment or onboarding on another machine has committed
|
|
774
|
+
-- before this snapshot decides whether the identity was created here.
|
|
775
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
|
|
776
|
+
v_identity_disposition := 'reused';
|
|
777
|
+
else
|
|
778
|
+
v_identity_disposition := 'created';
|
|
779
|
+
end if;
|
|
780
|
+
|
|
781
|
+
select * into v_enroll from agent_board.enroll_federated_identity(
|
|
782
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
|
|
783
|
+
p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
|
|
784
|
+
p_pop_verified, p_actor_sub, p_agent_kind, p_requested_scopes,
|
|
785
|
+
p_requested_board_scopes, null
|
|
786
|
+
);
|
|
787
|
+
if v_enroll.agent_handle is distinct from v_expected_handle then
|
|
788
|
+
raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
|
|
789
|
+
end if;
|
|
790
|
+
|
|
791
|
+
-- A complete replay may bypass expiry/exhaustion only while its redemption and
|
|
792
|
+
-- active membership remain true through commit. Lock both ledgers before the
|
|
793
|
+
-- proof; the invite row already serializes first redemption for an absent row.
|
|
794
|
+
perform 1 from agent_board.board_invite_redemptions red
|
|
795
|
+
where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
|
|
796
|
+
order by red.id for update;
|
|
797
|
+
perform 1 from agent_board.board_memberships bm
|
|
798
|
+
where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
|
|
799
|
+
order by bm.id for update;
|
|
800
|
+
|
|
801
|
+
-- The released ROOMLINK core deliberately returns an existing redemption before
|
|
802
|
+
-- checking current token authority. Fleet onboarding has a narrower replay
|
|
803
|
+
-- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
|
|
804
|
+
-- partial chain must pass every current invite gate before any missing suffix is
|
|
805
|
+
-- written, and revocation/current creator authority always apply.
|
|
806
|
+
select exists (
|
|
807
|
+
select 1
|
|
808
|
+
from agent_board.board_invite_redemptions red
|
|
809
|
+
join agent_board.board_memberships mem on mem.id = red.membership_id
|
|
810
|
+
join agent_board.fleet_harness_registrations h
|
|
811
|
+
on h.machine_id = p_machine_id
|
|
812
|
+
and h.harness_kind = v_kind
|
|
813
|
+
and h.harness_label = v_label
|
|
814
|
+
join agent_board.fleet_repo_scopes repo
|
|
815
|
+
on repo.harness_registration_id = h.id
|
|
816
|
+
and repo.repo_slug = v_repo_slug
|
|
817
|
+
and repo.repo_fingerprint = v_repo_fingerprint
|
|
818
|
+
and repo.scope_role = p_repo_scope_role
|
|
819
|
+
join agent_board.fleet_machine_agents binding
|
|
820
|
+
on binding.machine_id = p_machine_id
|
|
821
|
+
and binding.agent_id = v_enroll.agent_id
|
|
822
|
+
and binding.repo_scope_id = repo.id
|
|
823
|
+
and binding.signing_key_id = v_enroll.signing_key_id
|
|
824
|
+
and binding.agent_role = p_agent_role
|
|
825
|
+
join agent_board.agents principal on principal.id = v_enroll.agent_id
|
|
826
|
+
join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
|
|
827
|
+
where red.invite_id = v_inv.id
|
|
828
|
+
and red.agent_id = v_enroll.agent_id
|
|
829
|
+
and mem.board_id = v_inv.board_id
|
|
830
|
+
and mem.agent_id = v_enroll.agent_id
|
|
831
|
+
and mem.role = 'member'
|
|
832
|
+
and mem.membership_state in ('joined', 'subscribed')
|
|
833
|
+
and h.lifecycle_state = 'active'
|
|
834
|
+
and repo.lifecycle_state = 'active'
|
|
835
|
+
and binding.lifecycle_state = 'active'
|
|
836
|
+
and principal.status = 'active'
|
|
837
|
+
and signing_key.status = 'active'
|
|
838
|
+
and signing_key.proof_state = 'proven'
|
|
839
|
+
and signing_key.revoked_at is null
|
|
840
|
+
and not exists (
|
|
841
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
842
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
843
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
844
|
+
or (e.subject_kind = 'harness' and e.subject_id = h.id)
|
|
845
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
|
|
846
|
+
)
|
|
847
|
+
) into v_completed_replay;
|
|
848
|
+
|
|
849
|
+
if v_inv.revoked_at is not null
|
|
850
|
+
or not exists (
|
|
851
|
+
select 1 from agent_board.agents a
|
|
852
|
+
where a.id = v_inv.created_by_agent_id and a.status = 'active'
|
|
853
|
+
)
|
|
854
|
+
or not agent_board._grant_within_ceiling(
|
|
855
|
+
v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
|
|
856
|
+
)
|
|
857
|
+
or (
|
|
858
|
+
exists (
|
|
859
|
+
select 1 from agent_board.board_memberships bm
|
|
860
|
+
where bm.board_id = v_inv.board_id
|
|
861
|
+
and bm.agent_id = v_enroll.agent_id
|
|
862
|
+
and bm.membership_state = 'removed'
|
|
863
|
+
)
|
|
864
|
+
and not exists (
|
|
865
|
+
select 1 from agent_board.board_memberships bm
|
|
866
|
+
where bm.board_id = v_inv.board_id
|
|
867
|
+
and bm.agent_id = v_enroll.agent_id
|
|
868
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
869
|
+
)
|
|
870
|
+
) then
|
|
871
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
872
|
+
end if;
|
|
873
|
+
if not v_completed_replay
|
|
874
|
+
and (
|
|
875
|
+
v_inv.expires_at <= now()
|
|
876
|
+
or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
|
|
877
|
+
) then
|
|
878
|
+
raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
|
|
879
|
+
end if;
|
|
880
|
+
|
|
881
|
+
if exists (
|
|
882
|
+
select 1 from agent_board.board_invite_redemptions r
|
|
883
|
+
where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
|
|
884
|
+
) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
|
|
885
|
+
|
|
886
|
+
select * into v_membership
|
|
887
|
+
from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
|
|
888
|
+
if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
|
|
889
|
+
raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
|
|
890
|
+
end if;
|
|
891
|
+
|
|
892
|
+
perform 1 from agent_board.fleet_harness_registrations h
|
|
893
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
894
|
+
order by h.id for update;
|
|
895
|
+
if exists (
|
|
896
|
+
select 1 from agent_board.fleet_harness_registrations h
|
|
897
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
898
|
+
and h.lifecycle_state <> 'active'
|
|
899
|
+
) then
|
|
900
|
+
raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
|
|
901
|
+
end if;
|
|
902
|
+
select * into v_harness
|
|
903
|
+
from agent_board.fleet_harness_registrations h
|
|
904
|
+
where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
|
|
905
|
+
and h.lifecycle_state = 'active'
|
|
906
|
+
order by h.id;
|
|
907
|
+
if found then
|
|
908
|
+
v_harness_disposition := 'reused';
|
|
909
|
+
else
|
|
910
|
+
insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
|
|
911
|
+
values (p_machine_id, v_kind, v_label) returning * into v_harness;
|
|
912
|
+
v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
913
|
+
end if;
|
|
914
|
+
|
|
915
|
+
perform 1 from agent_board.fleet_repo_scopes r
|
|
916
|
+
where r.harness_registration_id = v_harness.id
|
|
917
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
918
|
+
order by r.id for update;
|
|
919
|
+
if exists (
|
|
920
|
+
select 1 from agent_board.fleet_repo_scopes r
|
|
921
|
+
where r.harness_registration_id = v_harness.id
|
|
922
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
923
|
+
and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
|
|
924
|
+
) then
|
|
925
|
+
raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
|
|
926
|
+
end if;
|
|
927
|
+
select * into v_repo
|
|
928
|
+
from agent_board.fleet_repo_scopes r
|
|
929
|
+
where r.harness_registration_id = v_harness.id
|
|
930
|
+
and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
|
|
931
|
+
and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
|
|
932
|
+
order by r.id;
|
|
933
|
+
if found then
|
|
934
|
+
v_repo_disposition := 'reused';
|
|
935
|
+
else
|
|
936
|
+
insert into agent_board.fleet_repo_scopes(
|
|
937
|
+
harness_registration_id, repo_slug, repo_fingerprint, scope_role
|
|
938
|
+
) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
|
|
939
|
+
returning * into v_repo;
|
|
940
|
+
v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
941
|
+
end if;
|
|
942
|
+
|
|
943
|
+
-- Machine -> agent/key/binding lock order. Any tombstone for this natural key
|
|
944
|
+
-- blocks replacement authority even though the active unique index would allow it.
|
|
945
|
+
perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
|
|
946
|
+
perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
|
|
947
|
+
perform 1 from agent_board.fleet_machine_agents b
|
|
948
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
949
|
+
and b.repo_scope_id = v_repo.id
|
|
950
|
+
order by b.id for update;
|
|
951
|
+
if exists (
|
|
952
|
+
select 1 from agent_board.fleet_machine_agents b
|
|
953
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
954
|
+
and b.repo_scope_id = v_repo.id
|
|
955
|
+
and (
|
|
956
|
+
b.lifecycle_state <> 'active'
|
|
957
|
+
or b.signing_key_id is distinct from v_enroll.signing_key_id
|
|
958
|
+
or b.agent_role is distinct from p_agent_role
|
|
959
|
+
)
|
|
960
|
+
) then
|
|
961
|
+
raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
|
|
962
|
+
end if;
|
|
963
|
+
select * into v_binding
|
|
964
|
+
from agent_board.fleet_machine_agents b
|
|
965
|
+
where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
|
|
966
|
+
and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
|
|
967
|
+
and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
|
|
968
|
+
order by b.id;
|
|
969
|
+
if found then
|
|
970
|
+
v_binding_disposition := 'reused';
|
|
971
|
+
else
|
|
972
|
+
insert into agent_board.fleet_machine_agents(
|
|
973
|
+
machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
|
|
974
|
+
) values (
|
|
975
|
+
p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
|
|
976
|
+
) returning * into v_binding;
|
|
977
|
+
v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
|
|
978
|
+
end if;
|
|
979
|
+
|
|
980
|
+
if exists (
|
|
981
|
+
select 1 from agent_board.fleet_revocation_events e
|
|
982
|
+
where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
|
|
983
|
+
or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
|
|
984
|
+
or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
|
|
985
|
+
or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
|
|
986
|
+
) then
|
|
987
|
+
raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
|
|
988
|
+
end if;
|
|
989
|
+
|
|
990
|
+
return query select
|
|
991
|
+
'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
|
|
992
|
+
v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
|
|
993
|
+
v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
|
|
994
|
+
p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
|
|
995
|
+
v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
|
|
996
|
+
v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
|
|
997
|
+
p_repo_scope_role, p_agent_role, v_expected_handle,
|
|
998
|
+
v_identity_disposition, v_membership_disposition, v_harness_disposition,
|
|
999
|
+
v_repo_disposition, v_binding_disposition;
|
|
1000
|
+
end;
|
|
1001
|
+
$$;
|
|
1002
|
+
|
|
1003
|
+
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
|
|
1004
|
+
'Service-role-only atomic fleet onboarding. Locks invite, machine, domain, principal, key, and binding in canonical order; 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.';
|
|
1005
|
+
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;
|
|
1006
|
+
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;
|
|
1007
|
+
|
|
1008
|
+
create function agent_board.revoke_fleet_machine_agent_binding(
|
|
1009
|
+
p_binding_id uuid,
|
|
1010
|
+
p_revoked_by_agent_id uuid default null,
|
|
1011
|
+
p_revocation_reason text default null,
|
|
1012
|
+
p_metadata jsonb default '{}'::jsonb
|
|
1013
|
+
)
|
|
1014
|
+
returns table(binding_id uuid, lifecycle_state text, revoked_at timestamptz)
|
|
1015
|
+
language plpgsql
|
|
1016
|
+
security definer
|
|
1017
|
+
set search_path = agent_board, public
|
|
1018
|
+
as $$
|
|
1019
|
+
#variable_conflict use_column
|
|
1020
|
+
declare
|
|
1021
|
+
v_actor uuid;
|
|
1022
|
+
v_machine_id uuid;
|
|
1023
|
+
v_binding agent_board.fleet_machine_agents%rowtype;
|
|
1024
|
+
begin
|
|
1025
|
+
v_actor := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
|
|
1026
|
+
if nullif(btrim(coalesce(p_revocation_reason, '')), '') is null then
|
|
1027
|
+
raise exception 'revocation reason is required' using errcode = '22023';
|
|
1028
|
+
end if;
|
|
1029
|
+
select b.machine_id into v_machine_id from agent_board.fleet_machine_agents b where b.id = p_binding_id;
|
|
1030
|
+
if v_machine_id is null then raise exception 'binding not found' using errcode = 'P0002'; end if;
|
|
1031
|
+
perform 1 from agent_board.fleet_machines m
|
|
1032
|
+
where m.id = v_machine_id and (agent_board._is_service_role() or m.owner_agent_id = v_actor)
|
|
1033
|
+
for update;
|
|
1034
|
+
if not found then raise exception 'binding revocation is not authorized' using errcode = '42501'; end if;
|
|
1035
|
+
select * into v_binding from agent_board.fleet_machine_agents b where b.id = p_binding_id for update;
|
|
1036
|
+
if v_binding.lifecycle_state = 'revoked' then
|
|
1037
|
+
return query select v_binding.id, v_binding.lifecycle_state, v_binding.revoked_at;
|
|
1038
|
+
return;
|
|
1039
|
+
end if;
|
|
1040
|
+
update agent_board.fleet_machine_agents b
|
|
1041
|
+
set lifecycle_state = 'revoked', revoked_at = now(), updated_at = now(),
|
|
1042
|
+
metadata = b.metadata || jsonb_build_object(
|
|
1043
|
+
'bindingRevocation', jsonb_build_object(
|
|
1044
|
+
'revokedByAgentId', v_actor, 'reason', btrim(p_revocation_reason),
|
|
1045
|
+
'metadata', coalesce(p_metadata, '{}'::jsonb), 'revokedAt', now()
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
where b.id = p_binding_id returning * into v_binding;
|
|
1049
|
+
return query select v_binding.id, v_binding.lifecycle_state, v_binding.revoked_at;
|
|
1050
|
+
end;
|
|
1051
|
+
$$;
|
|
1052
|
+
|
|
1053
|
+
comment on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) is
|
|
1054
|
+
'Sole writer for permanent exact fleet-machine-agent binding revocation. Locks the owning machine before the binding, rechecks owner/service authority, and never deletes or reactivates the row.';
|
|
1055
|
+
revoke all on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) from public, anon;
|
|
1056
|
+
grant execute on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
1057
|
+
|
|
1058
|
+
-- Refresh the public fleet revocation writer so onboarding and revocation share a
|
|
1059
|
+
-- single lock order. Machine-derived subjects already locked their machine first;
|
|
1060
|
+
-- agent subjects now lock all existing bound machines before the principal and
|
|
1061
|
+
-- bindings, while signing-key subjects lock their owning principal before the key.
|
|
1062
|
+
create or replace function agent_board.revoke_fleet_subject(
|
|
1063
|
+
p_subject_kind text,
|
|
1064
|
+
p_subject_id uuid,
|
|
1065
|
+
p_revoked_by_agent_id uuid,
|
|
1066
|
+
p_revocation_reason text,
|
|
1067
|
+
p_metadata jsonb default '{}'::jsonb
|
|
1068
|
+
)
|
|
1069
|
+
returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
|
|
1070
|
+
language plpgsql
|
|
1071
|
+
security definer
|
|
1072
|
+
set search_path = agent_board, public
|
|
1073
|
+
as $$
|
|
1074
|
+
declare
|
|
1075
|
+
v_event agent_board.fleet_revocation_events%rowtype;
|
|
1076
|
+
v_actor_agent_id uuid;
|
|
1077
|
+
v_lock_machine_id uuid;
|
|
1078
|
+
v_lock_agent_id uuid;
|
|
1079
|
+
begin
|
|
1080
|
+
if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
|
|
1081
|
+
raise exception 'unsupported fleet revocation subject' using errcode = '22023';
|
|
1082
|
+
end if;
|
|
1083
|
+
|
|
1084
|
+
if p_subject_kind in ('machine', 'harness', 'repo_scope') then
|
|
1085
|
+
v_lock_machine_id := case
|
|
1086
|
+
when p_subject_kind = 'machine' then p_subject_id
|
|
1087
|
+
when p_subject_kind = 'harness' then (
|
|
1088
|
+
select fhr.machine_id
|
|
1089
|
+
from agent_board.fleet_harness_registrations fhr
|
|
1090
|
+
where fhr.id = p_subject_id)
|
|
1091
|
+
when p_subject_kind = 'repo_scope' then (
|
|
1092
|
+
select fhr.machine_id
|
|
1093
|
+
from agent_board.fleet_repo_scopes frs
|
|
1094
|
+
join agent_board.fleet_harness_registrations fhr
|
|
1095
|
+
on fhr.id = frs.harness_registration_id
|
|
1096
|
+
where frs.id = p_subject_id)
|
|
1097
|
+
end;
|
|
1098
|
+
if v_lock_machine_id is not null then
|
|
1099
|
+
perform 1
|
|
1100
|
+
from agent_board.fleet_machines fm
|
|
1101
|
+
where fm.id = v_lock_machine_id
|
|
1102
|
+
for update;
|
|
1103
|
+
end if;
|
|
1104
|
+
elsif p_subject_kind = 'agent' then
|
|
1105
|
+
for v_lock_machine_id in
|
|
1106
|
+
select distinct fma.machine_id
|
|
1107
|
+
from agent_board.fleet_machine_agents fma
|
|
1108
|
+
where fma.agent_id = p_subject_id
|
|
1109
|
+
order by fma.machine_id
|
|
1110
|
+
loop
|
|
1111
|
+
perform 1
|
|
1112
|
+
from agent_board.fleet_machines fm
|
|
1113
|
+
where fm.id = v_lock_machine_id
|
|
1114
|
+
for update;
|
|
1115
|
+
end loop;
|
|
1116
|
+
perform 1 from agent_board.agents a where a.id = p_subject_id for update;
|
|
1117
|
+
perform 1
|
|
1118
|
+
from agent_board.fleet_machine_agents fma
|
|
1119
|
+
where fma.agent_id = p_subject_id
|
|
1120
|
+
order by fma.id
|
|
1121
|
+
for update;
|
|
1122
|
+
elsif p_subject_kind = 'signing_key' then
|
|
1123
|
+
select ask.agent_id
|
|
1124
|
+
into v_lock_agent_id
|
|
1125
|
+
from agent_board.agent_signing_keys ask
|
|
1126
|
+
where ask.id = p_subject_id;
|
|
1127
|
+
if v_lock_agent_id is not null then
|
|
1128
|
+
perform 1 from agent_board.agents a where a.id = v_lock_agent_id for update;
|
|
1129
|
+
end if;
|
|
1130
|
+
perform 1 from agent_board.agent_signing_keys ask where ask.id = p_subject_id for update;
|
|
1131
|
+
end if;
|
|
1132
|
+
|
|
1133
|
+
if p_subject_kind = 'trust_domain' then
|
|
1134
|
+
perform 1
|
|
1135
|
+
from agent_board.fleet_trust_domains ftd
|
|
1136
|
+
where ftd.id = p_subject_id
|
|
1137
|
+
for update;
|
|
1138
|
+
end if;
|
|
1139
|
+
|
|
1140
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(
|
|
1141
|
+
p_revoked_by_agent_id,
|
|
1142
|
+
'p_revoked_by_agent_id'
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
if not agent_board._is_service_role() then
|
|
1146
|
+
if p_subject_kind = 'trust_domain' and not exists (
|
|
1147
|
+
select 1 from agent_board.fleet_trust_domains ftd
|
|
1148
|
+
where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
|
|
1149
|
+
) then
|
|
1150
|
+
raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
|
|
1151
|
+
elsif p_subject_kind = 'machine' and not exists (
|
|
1152
|
+
select 1 from agent_board.fleet_machines fm
|
|
1153
|
+
where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1154
|
+
) then
|
|
1155
|
+
raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
|
|
1156
|
+
elsif p_subject_kind = 'harness' and not exists (
|
|
1157
|
+
select 1
|
|
1158
|
+
from agent_board.fleet_harness_registrations fhr
|
|
1159
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
1160
|
+
where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1161
|
+
) then
|
|
1162
|
+
raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
|
|
1163
|
+
elsif p_subject_kind = 'repo_scope' and not exists (
|
|
1164
|
+
select 1
|
|
1165
|
+
from agent_board.fleet_repo_scopes frs
|
|
1166
|
+
join agent_board.fleet_harness_registrations fhr
|
|
1167
|
+
on fhr.id = frs.harness_registration_id
|
|
1168
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
1169
|
+
where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1170
|
+
) then
|
|
1171
|
+
raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
|
|
1172
|
+
elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
|
|
1173
|
+
raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
|
|
1174
|
+
elsif p_subject_kind = 'signing_key' and not exists (
|
|
1175
|
+
select 1 from agent_board.agent_signing_keys ask
|
|
1176
|
+
where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
|
|
1177
|
+
) then
|
|
1178
|
+
raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
|
|
1179
|
+
elsif p_subject_kind = 'credential' and not exists (
|
|
1180
|
+
select 1 from agent_board.agent_credentials ac
|
|
1181
|
+
where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
|
|
1182
|
+
) then
|
|
1183
|
+
raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
|
|
1184
|
+
end if;
|
|
1185
|
+
end if;
|
|
1186
|
+
|
|
1187
|
+
select *
|
|
1188
|
+
into v_event
|
|
1189
|
+
from agent_board.fleet_revocation_events fre
|
|
1190
|
+
where fre.subject_kind = p_subject_kind
|
|
1191
|
+
and fre.subject_id = p_subject_id
|
|
1192
|
+
limit 1;
|
|
1193
|
+
|
|
1194
|
+
if v_event.id is null then
|
|
1195
|
+
insert into agent_board.fleet_revocation_events(
|
|
1196
|
+
subject_kind,
|
|
1197
|
+
subject_id,
|
|
1198
|
+
trust_domain_id,
|
|
1199
|
+
machine_id,
|
|
1200
|
+
harness_registration_id,
|
|
1201
|
+
repo_scope_id,
|
|
1202
|
+
agent_id,
|
|
1203
|
+
signing_key_id,
|
|
1204
|
+
revoked_by_agent_id,
|
|
1205
|
+
revocation_reason,
|
|
1206
|
+
metadata
|
|
1207
|
+
)
|
|
1208
|
+
values (
|
|
1209
|
+
p_subject_kind,
|
|
1210
|
+
p_subject_id,
|
|
1211
|
+
case when p_subject_kind = 'trust_domain' then p_subject_id end,
|
|
1212
|
+
case when p_subject_kind = 'machine' then p_subject_id end,
|
|
1213
|
+
case when p_subject_kind = 'harness' then p_subject_id end,
|
|
1214
|
+
case when p_subject_kind = 'repo_scope' then p_subject_id end,
|
|
1215
|
+
case when p_subject_kind = 'agent' then p_subject_id end,
|
|
1216
|
+
case when p_subject_kind = 'signing_key' then p_subject_id end,
|
|
1217
|
+
p_revoked_by_agent_id,
|
|
1218
|
+
trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1219
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
1220
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
1221
|
+
)
|
|
1222
|
+
returning * into v_event;
|
|
1223
|
+
else
|
|
1224
|
+
update agent_board.fleet_revocation_events fre
|
|
1225
|
+
set effective_at = now(),
|
|
1226
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1227
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1228
|
+
metadata = coalesce(p_metadata, '{}'::jsonb)
|
|
1229
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
1230
|
+
where fre.id = v_event.id
|
|
1231
|
+
returning * into v_event;
|
|
1232
|
+
end if;
|
|
1233
|
+
|
|
1234
|
+
if p_subject_kind = 'trust_domain' then
|
|
1235
|
+
update agent_board.fleet_trust_domains
|
|
1236
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1237
|
+
where id = p_subject_id;
|
|
1238
|
+
elsif p_subject_kind = 'machine' then
|
|
1239
|
+
update agent_board.fleet_machines
|
|
1240
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1241
|
+
where id = p_subject_id;
|
|
1242
|
+
elsif p_subject_kind = 'harness' then
|
|
1243
|
+
update agent_board.fleet_harness_registrations
|
|
1244
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1245
|
+
where id = p_subject_id;
|
|
1246
|
+
elsif p_subject_kind = 'repo_scope' then
|
|
1247
|
+
update agent_board.fleet_repo_scopes
|
|
1248
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1249
|
+
where id = p_subject_id;
|
|
1250
|
+
elsif p_subject_kind = 'agent' then
|
|
1251
|
+
update agent_board.fleet_machine_agents
|
|
1252
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1253
|
+
where agent_id = p_subject_id;
|
|
1254
|
+
elsif p_subject_kind = 'signing_key' then
|
|
1255
|
+
update agent_board.agent_signing_keys
|
|
1256
|
+
set status = case when status = 'compromised' then status else 'revoked' end,
|
|
1257
|
+
proof_state = 'revoked',
|
|
1258
|
+
revoked_at = coalesce(revoked_at, v_event.effective_at),
|
|
1259
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1260
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1261
|
+
updated_at = now()
|
|
1262
|
+
where id = p_subject_id;
|
|
1263
|
+
end if;
|
|
1264
|
+
|
|
1265
|
+
return query select v_event.id, v_event.subject_kind, v_event.effective_at;
|
|
1266
|
+
end;
|
|
1267
|
+
$$;
|
|
1268
|
+
|
|
1269
|
+
revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
|
|
1270
|
+
grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
1271
|
+
|
|
1272
|
+
comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
|
|
1273
|
+
'Records effective fleet revocation while sharing atomic onboarding lock order: machine-derived subjects lock the machine first, agent revocation locks affected machines in UUID order before the agent and bindings, and signing-key revocation locks agent before key.';
|
|
1274
|
+
|
|
1275
|
+
-- Refresh the subtree primitive and its public entrypoints. The primitive locks
|
|
1276
|
+
-- agent before keys; both entrypoints visit agents in UUID order.
|
|
1277
|
+
create or replace function agent_board._revoke_agent_principal(
|
|
1278
|
+
p_agent_id uuid,
|
|
1279
|
+
p_revoked_by_agent_id uuid,
|
|
1280
|
+
p_reason text
|
|
1281
|
+
)
|
|
1282
|
+
returns integer
|
|
1283
|
+
language plpgsql
|
|
1284
|
+
security definer
|
|
1285
|
+
set search_path = agent_board, public
|
|
1286
|
+
as $$
|
|
1287
|
+
declare
|
|
1288
|
+
v_key record;
|
|
1289
|
+
v_key_count integer := 0;
|
|
1290
|
+
v_agent_flipped boolean := false;
|
|
1291
|
+
begin
|
|
1292
|
+
perform 1 from agent_board.agents a where a.id = p_agent_id for update;
|
|
1293
|
+
|
|
1294
|
+
for v_key in
|
|
1295
|
+
select id
|
|
1296
|
+
from agent_board.agent_signing_keys
|
|
1297
|
+
where agent_id = p_agent_id
|
|
1298
|
+
and status not in ('revoked', 'compromised')
|
|
1299
|
+
order by id
|
|
1300
|
+
for update
|
|
1301
|
+
loop
|
|
1302
|
+
update agent_board.agent_signing_keys
|
|
1303
|
+
set status = 'revoked',
|
|
1304
|
+
proof_state = 'revoked',
|
|
1305
|
+
revoked_at = now(),
|
|
1306
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1307
|
+
revocation_reason = p_reason,
|
|
1308
|
+
updated_at = now()
|
|
1309
|
+
where id = v_key.id;
|
|
1310
|
+
|
|
1311
|
+
insert into agent_board.fleet_revocation_events(
|
|
1312
|
+
subject_kind, subject_id, signing_key_id, agent_id,
|
|
1313
|
+
revoked_by_agent_id, revocation_reason, metadata
|
|
1314
|
+
)
|
|
1315
|
+
values (
|
|
1316
|
+
'signing_key', v_key.id, v_key.id, p_agent_id,
|
|
1317
|
+
p_revoked_by_agent_id, p_reason,
|
|
1318
|
+
jsonb_build_object('source', 'revoke_agent_subtree')
|
|
1319
|
+
)
|
|
1320
|
+
on conflict (subject_kind, subject_id) do update
|
|
1321
|
+
set revoked_by_agent_id = excluded.revoked_by_agent_id,
|
|
1322
|
+
revocation_reason = excluded.revocation_reason,
|
|
1323
|
+
signing_key_id = excluded.signing_key_id,
|
|
1324
|
+
agent_id = excluded.agent_id,
|
|
1325
|
+
effective_at = now(),
|
|
1326
|
+
metadata = excluded.metadata;
|
|
1327
|
+
|
|
1328
|
+
v_key_count := v_key_count + 1;
|
|
1329
|
+
end loop;
|
|
1330
|
+
|
|
1331
|
+
update agent_board.agents
|
|
1332
|
+
set status = 'revoked', updated_at = now()
|
|
1333
|
+
where id = p_agent_id and status <> 'revoked';
|
|
1334
|
+
if found then
|
|
1335
|
+
v_agent_flipped := true;
|
|
1336
|
+
end if;
|
|
1337
|
+
|
|
1338
|
+
update agent_board.delegation_grants
|
|
1339
|
+
set revoked_at = now()
|
|
1340
|
+
where child_agent_id = p_agent_id and revoked_at is null;
|
|
1341
|
+
|
|
1342
|
+
update agent_board.agent_credentials ac
|
|
1343
|
+
set revoked_at = coalesce(ac.revoked_at, now()),
|
|
1344
|
+
updated_at = now(),
|
|
1345
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
1346
|
+
|| jsonb_build_object(
|
|
1347
|
+
'revokedByAgentId', p_revoked_by_agent_id,
|
|
1348
|
+
'revocationReason', p_reason,
|
|
1349
|
+
'source', 'revoke_agent_subtree'
|
|
1350
|
+
)
|
|
1351
|
+
where ac.agent_id = p_agent_id and ac.revoked_at is null;
|
|
1352
|
+
|
|
1353
|
+
insert into agent_board.fleet_revocation_events(
|
|
1354
|
+
subject_kind, subject_id, agent_id,
|
|
1355
|
+
revoked_by_agent_id, revocation_reason, metadata
|
|
1356
|
+
)
|
|
1357
|
+
values (
|
|
1358
|
+
'agent', p_agent_id, p_agent_id,
|
|
1359
|
+
p_revoked_by_agent_id, p_reason,
|
|
1360
|
+
jsonb_build_object('source', 'revoke_agent_subtree', 'statusFlipped', v_agent_flipped)
|
|
1361
|
+
)
|
|
1362
|
+
on conflict (subject_kind, subject_id) do update
|
|
1363
|
+
set revoked_by_agent_id = excluded.revoked_by_agent_id,
|
|
1364
|
+
revocation_reason = excluded.revocation_reason,
|
|
1365
|
+
agent_id = excluded.agent_id,
|
|
1366
|
+
effective_at = now(),
|
|
1367
|
+
metadata = excluded.metadata;
|
|
1368
|
+
|
|
1369
|
+
return v_key_count;
|
|
1370
|
+
end;
|
|
1371
|
+
$$;
|
|
1372
|
+
|
|
1373
|
+
comment on function agent_board._revoke_agent_principal(uuid, uuid, text) is
|
|
1374
|
+
'Per-principal revocation primitive. Locks agent before signing keys in UUID order, then preserves the existing key, status, grant, credential, and audit effects.';
|
|
1375
|
+
revoke all on function agent_board._revoke_agent_principal(uuid, uuid, text) from public, anon, authenticated;
|
|
1376
|
+
grant execute on function agent_board._revoke_agent_principal(uuid, uuid, text) to service_role;
|
|
1377
|
+
|
|
1378
|
+
create or replace function agent_board.revoke_agent_subtree(
|
|
1379
|
+
p_root_agent_id uuid,
|
|
1380
|
+
p_revoked_by_agent_id uuid,
|
|
1381
|
+
p_reason text default 'root revocation'
|
|
1382
|
+
)
|
|
1383
|
+
returns table(revoked_agent_count integer, revoked_key_count integer)
|
|
1384
|
+
language plpgsql
|
|
1385
|
+
security definer
|
|
1386
|
+
set search_path = agent_board, public
|
|
1387
|
+
as $$
|
|
1388
|
+
declare
|
|
1389
|
+
v_agent uuid;
|
|
1390
|
+
v_agent_count integer := 0;
|
|
1391
|
+
v_key_count integer := 0;
|
|
1392
|
+
begin
|
|
1393
|
+
if not agent_board._is_service_role() then
|
|
1394
|
+
raise exception 'revoke_agent_subtree is service_role-only' using errcode = '42501';
|
|
1395
|
+
end if;
|
|
1396
|
+
if not exists (select 1 from agent_board.agents where id = p_root_agent_id) then
|
|
1397
|
+
raise exception 'root agent not found' using errcode = 'P0002';
|
|
1398
|
+
end if;
|
|
1399
|
+
|
|
1400
|
+
for v_agent in
|
|
1401
|
+
with recursive subtree(agent_id) as (
|
|
1402
|
+
select p_root_agent_id
|
|
1403
|
+
union
|
|
1404
|
+
select g.child_agent_id
|
|
1405
|
+
from agent_board.delegation_grants g
|
|
1406
|
+
join subtree s on g.parent_agent_id = s.agent_id
|
|
1407
|
+
)
|
|
1408
|
+
select agent_id from subtree order by agent_id
|
|
1409
|
+
loop
|
|
1410
|
+
v_key_count := v_key_count
|
|
1411
|
+
+ agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
|
|
1412
|
+
v_agent_count := v_agent_count + 1;
|
|
1413
|
+
end loop;
|
|
1414
|
+
|
|
1415
|
+
return query select v_agent_count, v_key_count;
|
|
1416
|
+
end;
|
|
1417
|
+
$$;
|
|
1418
|
+
|
|
1419
|
+
comment on function agent_board.revoke_agent_subtree(uuid, uuid, text) is
|
|
1420
|
+
'DOWN-walking refresh-checked cascade. Visits the root and every grant-derived descendant in UUID order before the agent-before-key primitive. service_role-only.';
|
|
1421
|
+
revoke all on function agent_board.revoke_agent_subtree(uuid, uuid, text) from public, anon, authenticated;
|
|
1422
|
+
grant execute on function agent_board.revoke_agent_subtree(uuid, uuid, text) to service_role;
|
|
1423
|
+
|
|
1424
|
+
create or replace function agent_board.revoke_human_subtree(
|
|
1425
|
+
p_owner_user_id uuid,
|
|
1426
|
+
p_revoked_by_agent_id uuid,
|
|
1427
|
+
p_reason text default 'human revocation'
|
|
1428
|
+
)
|
|
1429
|
+
returns table(revoked_agent_count integer, revoked_key_count integer)
|
|
1430
|
+
language plpgsql
|
|
1431
|
+
security definer
|
|
1432
|
+
set search_path = agent_board, public
|
|
1433
|
+
as $$
|
|
1434
|
+
declare
|
|
1435
|
+
v_agent uuid;
|
|
1436
|
+
v_agent_count integer := 0;
|
|
1437
|
+
v_key_count integer := 0;
|
|
1438
|
+
begin
|
|
1439
|
+
if not agent_board._is_service_role() then
|
|
1440
|
+
raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
|
|
1441
|
+
end if;
|
|
1442
|
+
|
|
1443
|
+
perform 1 from auth.users u where u.id = p_owner_user_id for update;
|
|
1444
|
+
if not found then
|
|
1445
|
+
raise exception 'human owner not found' using errcode = 'P0002';
|
|
1446
|
+
end if;
|
|
1447
|
+
|
|
1448
|
+
if exists (
|
|
1449
|
+
select 1
|
|
1450
|
+
from agent_board.agents a
|
|
1451
|
+
where a.owner_user_id = p_owner_user_id
|
|
1452
|
+
and jsonb_typeof(a.metadata -> 'federation') = 'object'
|
|
1453
|
+
) and not exists (
|
|
1454
|
+
select 1
|
|
1455
|
+
from agent_board.enrollment_federation_audit e
|
|
1456
|
+
join agent_board.agents a on a.id = e.agent_id
|
|
1457
|
+
where a.owner_user_id = p_owner_user_id
|
|
1458
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
1459
|
+
and nullif(btrim(e.subject), '') is not null
|
|
1460
|
+
) then
|
|
1461
|
+
raise exception 'stable federation evidence not found for human owner' using errcode = 'P0002';
|
|
1462
|
+
end if;
|
|
1463
|
+
|
|
1464
|
+
insert into agent_board.fleet_human_revocations(
|
|
1465
|
+
issuer, subject, owner_user_id, revoked_by_agent_id, revocation_reason
|
|
1466
|
+
)
|
|
1467
|
+
select distinct
|
|
1468
|
+
e.issuer,
|
|
1469
|
+
e.subject,
|
|
1470
|
+
p_owner_user_id, p_revoked_by_agent_id, p_reason
|
|
1471
|
+
from agent_board.enrollment_federation_audit e
|
|
1472
|
+
join agent_board.agents a on a.id = e.agent_id
|
|
1473
|
+
where a.owner_user_id = p_owner_user_id
|
|
1474
|
+
and nullif(btrim(e.issuer), '') is not null
|
|
1475
|
+
and nullif(btrim(e.subject), '') is not null
|
|
1476
|
+
on conflict (issuer, subject) do nothing;
|
|
1477
|
+
|
|
1478
|
+
for v_agent in
|
|
1479
|
+
select id
|
|
1480
|
+
from agent_board.agents
|
|
1481
|
+
where owner_user_id = p_owner_user_id
|
|
1482
|
+
order by id
|
|
1483
|
+
loop
|
|
1484
|
+
v_key_count := v_key_count
|
|
1485
|
+
+ agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
|
|
1486
|
+
v_agent_count := v_agent_count + 1;
|
|
1487
|
+
end loop;
|
|
1488
|
+
|
|
1489
|
+
return query select v_agent_count, v_key_count;
|
|
1490
|
+
end;
|
|
1491
|
+
$$;
|
|
1492
|
+
|
|
1493
|
+
comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
|
|
1494
|
+
'TOTAL human revocation. Locks the human owner, derives durable issuer/subject markers only from append-only enrollment audit evidence, then visits every owned agent in UUID order before the agent-before-key primitive. service_role-only.';
|
|
1495
|
+
revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
|
|
1496
|
+
grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;
|