@consiliency/agent-board-schema 1.60.0 → 1.63.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/README.md +80 -0
- package/fixtures/already-applied.json +12 -0
- package/migrations/20261011000100_agent_board_session_identities.sql +1964 -0
- package/migrations/20261012000100_agent_board_sidowner.sql +209 -0
- package/migrations/20261014000100_agent_board_grant_target_role.sql +218 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +6 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +13 -4
- package/tests/agent_board_consent_emission.test.sql +30 -11
- package/tests/agent_board_consent_gating.test.sql +16 -2
- package/tests/agent_board_delegate_role_class.test.sql +1 -1
- package/tests/agent_board_delegation_cap_ceiling.test.sql +26 -15
- package/tests/agent_board_delegation_enforcement.test.sql +1 -1
- package/tests/agent_board_enroll_resumption.test.sql +22 -4
- package/tests/agent_board_enroll_token_single_use.test.sql +18 -3
- package/tests/agent_board_federated_lock_order_class.test.sql +2 -2
- package/tests/agent_board_fk_lock_edges.test.sql +74 -14
- package/tests/agent_board_grant_target_role.test.sql +1118 -0
- package/tests/agent_board_invite_no_downgrade.test.sql +10 -8
- package/tests/agent_board_ledger_authority.test.sql +70 -12
- package/tests/agent_board_membership_current_row_class.test.sql +9 -1
- package/tests/agent_board_mint_fleet_agent_session.test.sql +2 -2
- package/tests/agent_board_mint_harden.test.sql +38 -27
- package/tests/agent_board_rolereset.test.sql +8 -2
- package/tests/agent_board_roomdeleg_lane_b.test.sql +6 -6
- package/tests/agent_board_rowid.test.sql +3 -1
- package/tests/agent_board_session_identity_approval_revocation.test.sql +334 -0
- package/tests/agent_board_session_identity_backfill.test.sql +82 -0
- package/tests/agent_board_session_identity_backfill_refusal.test.sql +14 -0
- package/tests/agent_board_session_identity_behavior_classes.test.sql +176 -0
- package/tests/agent_board_session_identity_class.test.sql +118 -0
- package/tests/agent_board_session_identity_fingerprint.test.sql +271 -0
- package/tests/agent_board_session_identity_labels_limits.test.sql +205 -0
- package/tests/agent_board_session_identity_lease.test.sql +235 -0
- package/tests/agent_board_session_identity_lifetime.test.sql +230 -0
- package/tests/agent_board_session_identity_limits.test.sql +208 -0
- package/tests/agent_board_session_identity_lineage.test.sql +64 -0
- package/tests/agent_board_session_identity_link.test.sql +201 -0
- package/tests/agent_board_session_identity_mint.test.sql +233 -0
- package/tests/agent_board_session_identity_panel_r1.test.sql +194 -0
- package/tests/agent_board_session_identity_panel_r2.test.sql +174 -0
- package/tests/agent_board_session_identity_protected_actions.test.sql +156 -0
- package/tests/agent_board_session_identity_revoke_closure.test.sql +131 -0
- package/tests/agent_board_sidowner_leases.test.sql +107 -0
- package/tests/agent_board_sidowner_owner_required.test.sql +97 -0
- package/tests/agent_board_sidowner_read_class.test.sql +171 -0
- package/tests/agent_board_sidowner_reads.test.sql +136 -0
- package/tests/agent_board_signer_lifecycle.test.sql +26 -4
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +1 -1
- package/tests/agent_board_write_enforcement_class.test.sql +74 -15
- package/tests/catalog/function_execute_privileges.json +218 -2
- package/tests/catalog/p_metadata_parameters.json +98 -2
- package/tests/isolation/expected/grant_target_role_race.out +348 -0
- package/tests/isolation/expected/session_identity_limits.out +165 -0
- package/tests/isolation/expected/session_identity_links.out +235 -0
- package/tests/isolation/expected/session_identity_reclaim.out +253 -0
- package/tests/isolation/expected/session_identity_renew_revoke.out +115 -0
- package/tests/isolation/expected/session_identity_renew_revoke_key_gate.out +138 -0
- package/tests/isolation/expected/session_identity_tombstone_onboarding.out +247 -0
- package/tests/isolation/specs/grant_target_role_race.spec +207 -0
- package/tests/isolation/specs/session_identity_limits.spec +81 -0
- package/tests/isolation/specs/session_identity_links.spec +80 -0
- package/tests/isolation/specs/session_identity_reclaim.spec +125 -0
- package/tests/isolation/specs/session_identity_renew_revoke.spec +84 -0
- package/tests/isolation/specs/session_identity_renew_revoke_key_gate.spec +94 -0
- package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +108 -0
- package/tests/seeds/20261011000100_agent_board_session_identities.sql.pre.sql +47 -0
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
-- SIDSCHEMA: durable per-session identities. Transport attestation is deliberately
|
|
2
|
+
-- not established here: SIDSERVER is the only producer allowed to bind harness
|
|
3
|
+
-- evidence to the optional session arguments below.
|
|
4
|
+
|
|
5
|
+
create table agent_board.session_identities (
|
|
6
|
+
agent_id uuid primary key references agent_board.agents(id) on delete cascade,
|
|
7
|
+
parent_agent_id uuid references agent_board.agents(id),
|
|
8
|
+
root_agent_id uuid not null references agent_board.agents(id),
|
|
9
|
+
depth smallint not null check (depth between 0 and 2),
|
|
10
|
+
identity_kind text not null default 'legacy' check (identity_kind in ('legacy', 'session')),
|
|
11
|
+
harness text check (harness is null or harness in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi')),
|
|
12
|
+
forked_from uuid references agent_board.session_identities(agent_id),
|
|
13
|
+
readmitted_from uuid references agent_board.session_identities(agent_id),
|
|
14
|
+
tombstoned_at timestamptz,
|
|
15
|
+
tombstone_reason text check (tombstone_reason is null or tombstone_reason in ('revoked', 'cascade', 'superseded_unclaimed')),
|
|
16
|
+
credential_epoch bigint not null default 0 check (credential_epoch >= 0),
|
|
17
|
+
holder_secret_hash bytea,
|
|
18
|
+
lease_alias_id uuid,
|
|
19
|
+
last_heartbeat timestamptz,
|
|
20
|
+
approved_risky_digest text,
|
|
21
|
+
current_risky_digest text,
|
|
22
|
+
recorded_fingerprint jsonb not null default '{}'::jsonb check (jsonb_typeof(recorded_fingerprint) = 'object'),
|
|
23
|
+
resolution_state text not null default 'active' check (resolution_state in ('active', 'frozen')),
|
|
24
|
+
created_at timestamptz not null default now(),
|
|
25
|
+
updated_at timestamptz not null default now(),
|
|
26
|
+
check ((identity_kind = 'session') = (harness is not null)),
|
|
27
|
+
check ((tombstoned_at is null) = (tombstone_reason is null)),
|
|
28
|
+
check (identity_kind <> 'session' or parent_agent_id is not null)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
create table agent_board.session_id_aliases (
|
|
32
|
+
id uuid primary key default gen_random_uuid(),
|
|
33
|
+
identity_agent_id uuid not null references agent_board.session_identities(agent_id) on delete cascade,
|
|
34
|
+
root_agent_id uuid not null references agent_board.agents(id),
|
|
35
|
+
harness text not null check (harness in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi')),
|
|
36
|
+
session_id text not null check (length(session_id) > 0),
|
|
37
|
+
created_at timestamptz not null default now(),
|
|
38
|
+
unique (root_agent_id, harness, session_id)
|
|
39
|
+
);
|
|
40
|
+
alter table agent_board.session_identities
|
|
41
|
+
add constraint session_identities_lease_alias_fkey foreign key (lease_alias_id)
|
|
42
|
+
references agent_board.session_id_aliases(id) deferrable initially deferred;
|
|
43
|
+
|
|
44
|
+
-- Existing principals retain their historical behavior. Delegated legacy children
|
|
45
|
+
-- are represented as depth one but never acquire a session lease implicitly.
|
|
46
|
+
do $$
|
|
47
|
+
declare v_count bigint; v_mismatch record;
|
|
48
|
+
begin
|
|
49
|
+
with recursive lineage(agent_id, parent_agent_id, root_agent_id, depth) as (
|
|
50
|
+
select id, parent_agent_id, id, 0 from agent_board.agents where parent_agent_id is null
|
|
51
|
+
union all
|
|
52
|
+
select a.id, a.parent_agent_id, l.root_agent_id, l.depth + 1
|
|
53
|
+
from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
|
|
54
|
+
where l.depth < 3
|
|
55
|
+
)
|
|
56
|
+
select count(*) into v_count from lineage where depth > 2;
|
|
57
|
+
if v_count > 0 then raise exception 'identity_depth_exceeded' using errcode = '23514'; end if;
|
|
58
|
+
with recursive lineage(agent_id, root_agent_id) as (
|
|
59
|
+
select id, id from agent_board.agents where parent_agent_id is null
|
|
60
|
+
union all
|
|
61
|
+
select a.id, l.root_agent_id from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
|
|
62
|
+
)
|
|
63
|
+
select a.id, a.root_agent_id as stored_root, l.root_agent_id as computed_root into v_mismatch
|
|
64
|
+
from lineage l join agent_board.agents a on a.id = l.agent_id
|
|
65
|
+
where coalesce(a.root_agent_id, a.id) is distinct from l.root_agent_id
|
|
66
|
+
order by a.id limit 1;
|
|
67
|
+
if found then
|
|
68
|
+
raise exception 'session identity backfill lineage mismatch: agent %, stored root %, computed root %',
|
|
69
|
+
v_mismatch.id, v_mismatch.stored_root, v_mismatch.computed_root using errcode = '23514';
|
|
70
|
+
end if;
|
|
71
|
+
with recursive lineage(agent_id, parent_agent_id, root_agent_id, depth) as (
|
|
72
|
+
select id, parent_agent_id, id, 0 from agent_board.agents where parent_agent_id is null
|
|
73
|
+
union all
|
|
74
|
+
select a.id, a.parent_agent_id, l.root_agent_id, l.depth + 1
|
|
75
|
+
from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
|
|
76
|
+
)
|
|
77
|
+
insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth)
|
|
78
|
+
select l.agent_id, l.parent_agent_id, l.root_agent_id, l.depth from lineage l;
|
|
79
|
+
if (select count(*) from agent_board.session_identities) <> (select count(*) from agent_board.agents) then
|
|
80
|
+
raise exception 'identity lineage is cyclic or incomplete' using errcode = '23514';
|
|
81
|
+
end if;
|
|
82
|
+
end;
|
|
83
|
+
$$;
|
|
84
|
+
|
|
85
|
+
-- Flush the backfill's deferred FK events before indexes, triggers and RLS DDL.
|
|
86
|
+
-- It is the only deferred constraint on the backfilled relation at this point.
|
|
87
|
+
-- Leave every unrelated constraint deferred for following statements in this
|
|
88
|
+
-- same migration-application transaction.
|
|
89
|
+
set constraints agent_board.session_identities_lease_alias_fkey immediate;
|
|
90
|
+
|
|
91
|
+
create index session_identities_root_active_idx
|
|
92
|
+
on agent_board.session_identities(root_agent_id, identity_kind, tombstoned_at);
|
|
93
|
+
create index session_identities_parent_idx on agent_board.session_identities(parent_agent_id);
|
|
94
|
+
|
|
95
|
+
create or replace function agent_board._session_identity_kind_immutable()
|
|
96
|
+
returns trigger language plpgsql security definer set search_path = '' as $$
|
|
97
|
+
begin
|
|
98
|
+
if new.identity_kind is distinct from old.identity_kind then
|
|
99
|
+
raise exception 'session identity kind is immutable' using errcode = '42501';
|
|
100
|
+
end if;
|
|
101
|
+
if old.holder_secret_hash is not null and new.holder_secret_hash is null then
|
|
102
|
+
raise exception 'a claimed session lease cannot be cleared' using errcode = '42501';
|
|
103
|
+
end if;
|
|
104
|
+
return new;
|
|
105
|
+
end;
|
|
106
|
+
$$;
|
|
107
|
+
drop trigger if exists trg_session_identity_kind_immutable on agent_board.session_identities;
|
|
108
|
+
create trigger trg_session_identity_kind_immutable
|
|
109
|
+
before update of identity_kind, holder_secret_hash on agent_board.session_identities
|
|
110
|
+
for each row execute function agent_board._session_identity_kind_immutable();
|
|
111
|
+
revoke all on function agent_board._session_identity_kind_immutable() from public, anon, authenticated;
|
|
112
|
+
grant execute on function agent_board._session_identity_kind_immutable() to service_role;
|
|
113
|
+
comment on function agent_board._session_identity_kind_immutable() is 'Keeps session identity kind immutable and prevents clearing a claimed lease to disable epoch fencing.';
|
|
114
|
+
|
|
115
|
+
create function agent_board._session_identity_lineage()
|
|
116
|
+
returns trigger language plpgsql security definer set search_path = '' as $$
|
|
117
|
+
declare v_target uuid;
|
|
118
|
+
begin
|
|
119
|
+
if tg_op = 'DELETE' then v_target := old.agent_id; else v_target := new.agent_id; end if;
|
|
120
|
+
if exists (
|
|
121
|
+
select 1 from agent_board.session_identities si
|
|
122
|
+
join agent_board.agents a on a.id = si.agent_id
|
|
123
|
+
left join agent_board.session_identities parent on parent.agent_id = si.parent_agent_id
|
|
124
|
+
where (si.agent_id = v_target or si.parent_agent_id = v_target)
|
|
125
|
+
and (si.parent_agent_id is distinct from a.parent_agent_id
|
|
126
|
+
or si.root_agent_id is distinct from coalesce(a.root_agent_id, a.id)
|
|
127
|
+
or si.depth is distinct from case when si.parent_agent_id is null then 0 else parent.depth + 1 end)
|
|
128
|
+
) then raise exception 'session identity lineage mismatch' using errcode = '23514'; end if;
|
|
129
|
+
return new;
|
|
130
|
+
end;
|
|
131
|
+
$$;
|
|
132
|
+
create constraint trigger trg_session_identity_lineage
|
|
133
|
+
after insert or update or delete on agent_board.session_identities
|
|
134
|
+
deferrable initially deferred for each row execute function agent_board._session_identity_lineage();
|
|
135
|
+
revoke all on function agent_board._session_identity_lineage() from public, anon, authenticated;
|
|
136
|
+
grant execute on function agent_board._session_identity_lineage() to service_role;
|
|
137
|
+
comment on function agent_board._session_identity_lineage() is 'Pins identity parent, root and depth to the stored agent lineage; checked at transaction end.';
|
|
138
|
+
|
|
139
|
+
-- Deferred construction lets explicit session mints supply their session row
|
|
140
|
+
-- before commit, while every legacy constructor receives a lease-free identity.
|
|
141
|
+
create function agent_board._session_identity_agent_lineage()
|
|
142
|
+
returns trigger language plpgsql security definer set search_path = '' as $$
|
|
143
|
+
declare v_root uuid; v_depth integer; v_node record;
|
|
144
|
+
begin
|
|
145
|
+
if not exists (select 1 from agent_board.agents where id = new.id) then return new; end if;
|
|
146
|
+
with recursive ancestry(agent_id, parent_agent_id, distance, path, cyclic) as (
|
|
147
|
+
select a.id, a.parent_agent_id, 0, array[a.id], false from agent_board.agents a where a.id = new.id
|
|
148
|
+
union all
|
|
149
|
+
select a.id, a.parent_agent_id, c.distance + 1, c.path || a.id, a.id = any(c.path)
|
|
150
|
+
from ancestry c join agent_board.agents a on a.id = c.parent_agent_id
|
|
151
|
+
where not c.cyclic and c.distance < 3
|
|
152
|
+
)
|
|
153
|
+
select agent_id, distance into v_root, v_depth from ancestry where parent_agent_id is null and not cyclic;
|
|
154
|
+
if v_root is null then raise exception 'session identity lineage mismatch' using errcode = '23514'; end if;
|
|
155
|
+
if v_depth > 2 then raise exception 'identity_depth_exceeded' using errcode = '23514'; end if;
|
|
156
|
+
for v_node in
|
|
157
|
+
with recursive ancestry(agent_id, distance) as (
|
|
158
|
+
select new.id, 0
|
|
159
|
+
union all
|
|
160
|
+
select a.parent_agent_id, c.distance + 1 from ancestry c join agent_board.agents a on a.id = c.agent_id
|
|
161
|
+
where a.parent_agent_id is not null and c.distance < 2
|
|
162
|
+
)
|
|
163
|
+
select a.*, v_depth - c.distance as identity_depth from ancestry c join agent_board.agents a on a.id = c.agent_id
|
|
164
|
+
order by c.distance desc
|
|
165
|
+
loop
|
|
166
|
+
if coalesce(v_node.root_agent_id, v_node.id) is distinct from v_root then
|
|
167
|
+
raise exception 'session identity lineage mismatch' using errcode = '23514';
|
|
168
|
+
end if;
|
|
169
|
+
if tg_op = 'INSERT' and v_node.id = new.id and not exists (
|
|
170
|
+
select 1 from agent_board.session_identities where agent_id = new.id
|
|
171
|
+
) then
|
|
172
|
+
insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth)
|
|
173
|
+
values (new.id, v_node.parent_agent_id, v_root, v_node.identity_depth);
|
|
174
|
+
end if;
|
|
175
|
+
if not exists (select 1 from agent_board.session_identities where agent_id = v_node.id) then
|
|
176
|
+
raise exception 'session identity lineage mismatch' using errcode = '23514';
|
|
177
|
+
end if;
|
|
178
|
+
if exists (select 1 from agent_board.session_identities si where si.agent_id = v_node.id
|
|
179
|
+
and (si.parent_agent_id is distinct from v_node.parent_agent_id
|
|
180
|
+
or si.root_agent_id is distinct from v_root or si.depth is distinct from v_node.identity_depth)) then
|
|
181
|
+
raise exception 'session identity lineage mismatch' using errcode = '23514';
|
|
182
|
+
end if;
|
|
183
|
+
end loop;
|
|
184
|
+
return new;
|
|
185
|
+
end;
|
|
186
|
+
$$;
|
|
187
|
+
create constraint trigger trg_session_identity_agent_lineage
|
|
188
|
+
after insert or update of parent_agent_id, root_agent_id on agent_board.agents
|
|
189
|
+
deferrable initially deferred for each row execute function agent_board._session_identity_agent_lineage();
|
|
190
|
+
revoke all on function agent_board._session_identity_agent_lineage() from public, anon, authenticated;
|
|
191
|
+
grant execute on function agent_board._session_identity_agent_lineage() to service_role;
|
|
192
|
+
comment on function agent_board._session_identity_agent_lineage() is 'At commit every legacy constructor gets an identity with no implicit lease or fingerprint approval. Existing explicit session rows retain their immutable kind. Derives depth and root from agents, refuses depth three and cycles, and enforces lineage equality on agent inserts and lineage updates.';
|
|
193
|
+
|
|
194
|
+
alter table agent_board.session_identities enable row level security;
|
|
195
|
+
alter table agent_board.session_id_aliases enable row level security;
|
|
196
|
+
revoke all on table agent_board.session_identities, agent_board.session_id_aliases from public, anon, authenticated;
|
|
197
|
+
grant select on agent_board.session_identities, agent_board.session_id_aliases to service_role;
|
|
198
|
+
|
|
199
|
+
-- A session credential is usable only when its epoch claim is current. Compare
|
|
200
|
+
-- text-to-text so malformed JWT input denies rather than raises.
|
|
201
|
+
create or replace function agent_board.current_agent_id()
|
|
202
|
+
returns uuid language sql stable security definer set search_path = '' as $$
|
|
203
|
+
with identity_claims as (
|
|
204
|
+
select coalesce(
|
|
205
|
+
nullif(current_setting('request.jwt.claim.identity_lease_epoch', true), ''),
|
|
206
|
+
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'identity_lease_epoch', '')
|
|
207
|
+
) as lease_epoch,
|
|
208
|
+
coalesce(
|
|
209
|
+
nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
|
|
210
|
+
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
|
|
211
|
+
) as credential_id
|
|
212
|
+
)
|
|
213
|
+
select a.id
|
|
214
|
+
from agent_board.agents a cross join identity_claims c
|
|
215
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
216
|
+
where a.auth_user_id = auth.uid()
|
|
217
|
+
and a.status = 'active'
|
|
218
|
+
and (
|
|
219
|
+
(c.credential_id is null and not a.is_delegate)
|
|
220
|
+
or (c.credential_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' and exists (
|
|
221
|
+
select 1 from agent_board.agent_credentials ac
|
|
222
|
+
where ac.id::text = lower(c.credential_id) and ac.agent_id = a.id
|
|
223
|
+
and ac.revoked_at is null and (ac.expires_at is null or ac.expires_at > now())
|
|
224
|
+
))
|
|
225
|
+
)
|
|
226
|
+
and (si.identity_kind is distinct from 'session' or c.lease_epoch = si.credential_epoch::text)
|
|
227
|
+
limit 1;
|
|
228
|
+
$$;
|
|
229
|
+
revoke all on function agent_board.current_agent_id() from public;
|
|
230
|
+
grant execute on function agent_board.current_agent_id() to authenticated, service_role;
|
|
231
|
+
comment on function agent_board.current_agent_id() is 'Resolves an active principal, preserves hosted delegates'' mandatory credential claim, and denies a session identity unless identity_lease_epoch from either JWT GUC equals its current stored epoch.';
|
|
232
|
+
|
|
233
|
+
create or replace function agent_board._session_identity_lease_live(p_identity uuid)
|
|
234
|
+
returns boolean language sql stable security definer set search_path = '' as $$
|
|
235
|
+
select exists (
|
|
236
|
+
select 1 from agent_board.session_identities si
|
|
237
|
+
where si.agent_id = p_identity and si.identity_kind = 'session'
|
|
238
|
+
and si.tombstoned_at is null and si.holder_secret_hash is not null
|
|
239
|
+
and si.last_heartbeat > now() - interval '180 seconds'
|
|
240
|
+
);
|
|
241
|
+
$$;
|
|
242
|
+
revoke all on function agent_board._session_identity_lease_live(uuid) from public, anon, authenticated;
|
|
243
|
+
grant execute on function agent_board._session_identity_lease_live(uuid) to service_role;
|
|
244
|
+
comment on function agent_board._session_identity_lease_live(uuid) is 'A non-tombstoned claimed session lease is live strictly before 180 seconds since its heartbeat.';
|
|
245
|
+
|
|
246
|
+
create or replace function agent_board.heartbeat_session_identity(p_identity_agent_id uuid)
|
|
247
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
248
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
249
|
+
begin
|
|
250
|
+
if agent_board.current_agent_id() is distinct from p_identity_agent_id then
|
|
251
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
252
|
+
end if;
|
|
253
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
254
|
+
if agent_board.current_agent_id() is distinct from p_identity_agent_id then
|
|
255
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
256
|
+
end if;
|
|
257
|
+
if v_identity.agent_id is null or not agent_board._session_identity_lease_live(p_identity_agent_id) then
|
|
258
|
+
raise exception 'identity_lease_lapsed' using errcode = '42501';
|
|
259
|
+
end if;
|
|
260
|
+
if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
|
|
261
|
+
or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
|
|
262
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
263
|
+
end if;
|
|
264
|
+
update agent_board.session_identities set last_heartbeat = now(), updated_at = now() where agent_id = p_identity_agent_id;
|
|
265
|
+
end;
|
|
266
|
+
$$;
|
|
267
|
+
revoke all on function agent_board.heartbeat_session_identity(uuid) from public, anon;
|
|
268
|
+
grant execute on function agent_board.heartbeat_session_identity(uuid) to authenticated;
|
|
269
|
+
comment on function agent_board.heartbeat_session_identity(uuid) is 'Current epoch holder refreshes a live session lease only; a lapsed lease must be reclaimed through mint_fleet_agent_session.';
|
|
270
|
+
|
|
271
|
+
create or replace function agent_board.renew_session_identity(p_identity_agent_id uuid, p_risky_digest text)
|
|
272
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
273
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
274
|
+
begin
|
|
275
|
+
if not agent_board._is_service_role() then
|
|
276
|
+
raise exception 'session renewal is service_role-only' using errcode = '42501';
|
|
277
|
+
end if;
|
|
278
|
+
perform 1 from agent_board.agents where id = (
|
|
279
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
|
|
280
|
+
) for update;
|
|
281
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
282
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
283
|
+
if agent_board.current_agent_id() is distinct from p_identity_agent_id then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
|
|
284
|
+
if v_identity.agent_id is null or not agent_board._session_identity_lease_live(p_identity_agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
|
|
285
|
+
if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
|
|
286
|
+
or v_identity.approved_risky_digest is distinct from p_risky_digest
|
|
287
|
+
or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
|
|
288
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
289
|
+
end if;
|
|
290
|
+
perform * from agent_board.renew_delegated_grant(p_identity_agent_id, interval '7 days');
|
|
291
|
+
update agent_board.session_identities set current_risky_digest = p_risky_digest, last_heartbeat = now(), updated_at = now() where agent_id = p_identity_agent_id;
|
|
292
|
+
end;
|
|
293
|
+
$$;
|
|
294
|
+
revoke all on function agent_board.renew_session_identity(uuid, text) from public, anon, authenticated;
|
|
295
|
+
grant execute on function agent_board.renew_session_identity(uuid, text) to service_role;
|
|
296
|
+
comment on function agent_board.renew_session_identity(uuid, text) is 'Service-only fresh-fingerprint renewal; SIDSERVER must bind the reading and forward the verified holder subject and epoch. Extends the same live identity grant by at most seven days, current ancestor authority and tighter operator policy; never rotates the holder or revives a tombstone.';
|
|
297
|
+
|
|
298
|
+
create function agent_board.link_session_alias(
|
|
299
|
+
p_identity_agent_id uuid,
|
|
300
|
+
p_identity_lease_epoch text,
|
|
301
|
+
p_session_id text
|
|
302
|
+
)
|
|
303
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
304
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
305
|
+
v_root uuid; v_board uuid; v_locked_boards uuid[] := '{}'; v_subject record;
|
|
306
|
+
v_membership agent_board.board_memberships%rowtype; v_allowed jsonb;
|
|
307
|
+
v_parent_role text; v_role text;
|
|
308
|
+
begin
|
|
309
|
+
if not agent_board._is_service_role() then
|
|
310
|
+
raise exception 'link_session_alias is service_role-only' using errcode = '42501';
|
|
311
|
+
end if;
|
|
312
|
+
-- Board mutexes precede the root and deterministic identity/alias row locks.
|
|
313
|
+
select root_agent_id into v_root from agent_board.session_identities where agent_id = p_identity_agent_id;
|
|
314
|
+
for v_board in
|
|
315
|
+
with recursive children(agent_id) as (
|
|
316
|
+
select p_identity_agent_id union all
|
|
317
|
+
select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
|
|
318
|
+
)
|
|
319
|
+
select distinct bm.board_id from agent_board.board_memberships bm join children c on c.agent_id = bm.agent_id order by 1
|
|
320
|
+
loop
|
|
321
|
+
perform agent_board._lock_board_authority_ceiling(v_board);
|
|
322
|
+
v_locked_boards := array_append(v_locked_boards, v_board);
|
|
323
|
+
end loop;
|
|
324
|
+
perform 1 from agent_board.agents where id = v_root for update;
|
|
325
|
+
perform 1 from agent_board.session_identities where agent_id in (
|
|
326
|
+
with recursive children(agent_id) as (
|
|
327
|
+
select p_identity_agent_id union all
|
|
328
|
+
select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
|
|
329
|
+
) select agent_id from children
|
|
330
|
+
) order by agent_id for update;
|
|
331
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
332
|
+
if not found or v_identity.identity_kind <> 'session' then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
|
|
333
|
+
if not exists (select 1 from agent_board.agents a where a.id = p_identity_agent_id
|
|
334
|
+
and a.auth_user_id = auth.uid() and a.status = 'active') then
|
|
335
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
336
|
+
end if;
|
|
337
|
+
if p_identity_lease_epoch is distinct from v_identity.credential_epoch::text then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
|
|
338
|
+
if not agent_board._session_identity_lease_live(v_identity.agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
|
|
339
|
+
if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
|
|
340
|
+
or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
|
|
341
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
342
|
+
end if;
|
|
343
|
+
perform agent_board._session_identity_ancestor_ceiling(v_identity.parent_agent_id);
|
|
344
|
+
if p_session_id is null or length(p_session_id) = 0 then raise exception 'unattributed_session_refused' using errcode = '22023'; end if;
|
|
345
|
+
if exists (
|
|
346
|
+
select 1 from agent_board.session_id_aliases a
|
|
347
|
+
where a.root_agent_id = v_identity.root_agent_id
|
|
348
|
+
and a.harness = v_identity.harness
|
|
349
|
+
and a.session_id = p_session_id
|
|
350
|
+
and a.identity_agent_id <> v_identity.agent_id
|
|
351
|
+
) then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
|
|
352
|
+
if not exists (
|
|
353
|
+
select 1 from agent_board.session_id_aliases a
|
|
354
|
+
where a.identity_agent_id = v_identity.agent_id and a.session_id = p_session_id
|
|
355
|
+
) and v_identity.harness <> 'claude' then
|
|
356
|
+
raise exception 'identity_continuity_refused' using errcode = '42501';
|
|
357
|
+
end if;
|
|
358
|
+
-- Re-read current authority, parent before descendant. No parent authority can
|
|
359
|
+
-- add to the subject's stored grant, role or currently active room set.
|
|
360
|
+
for v_subject in
|
|
361
|
+
with recursive children(agent_id) as (
|
|
362
|
+
select p_identity_agent_id union all
|
|
363
|
+
select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
|
|
364
|
+
) select si.* from agent_board.session_identities si join children c on c.agent_id = si.agent_id
|
|
365
|
+
order by si.depth, si.agent_id
|
|
366
|
+
loop
|
|
367
|
+
perform agent_board._refuse_delegate_membership_target(v_subject.agent_id, 'link_session_alias');
|
|
368
|
+
if exists (select 1 from agent_board.board_memberships bm where bm.agent_id = v_subject.agent_id
|
|
369
|
+
and not (bm.board_id = any(v_locked_boards))) then
|
|
370
|
+
raise exception 'identity authority changed during link; retry' using errcode = '40001';
|
|
371
|
+
end if;
|
|
372
|
+
if exists (select 1 from agent_board.agents a where a.id = v_subject.parent_agent_id and a.parent_agent_id is null) then
|
|
373
|
+
select dp.allowed_scopes into v_allowed from agent_board.delegation_policies dp
|
|
374
|
+
where dp.root_agent_id = v_root and dp.revoked_at is null order by dp.created_at desc, dp.id desc limit 1;
|
|
375
|
+
else
|
|
376
|
+
select g.granted_scopes into v_allowed from agent_board.delegation_grants g where g.child_agent_id = v_subject.parent_agent_id;
|
|
377
|
+
end if;
|
|
378
|
+
update agent_board.delegation_grants g
|
|
379
|
+
set granted_scopes = (select coalesce(jsonb_agg(s.value), '[]'::jsonb)
|
|
380
|
+
from jsonb_array_elements(g.granted_scopes) s where coalesce(v_allowed, '[]'::jsonb) @> jsonb_build_array(s.value))
|
|
381
|
+
where g.child_agent_id = v_subject.agent_id;
|
|
382
|
+
for v_membership in select bm.* from agent_board.board_memberships bm
|
|
383
|
+
where bm.agent_id = v_subject.agent_id and bm.membership_state in ('joined', 'subscribed')
|
|
384
|
+
order by bm.board_id, bm.id for update
|
|
385
|
+
loop
|
|
386
|
+
select bm.role into v_parent_role from agent_board.board_memberships bm
|
|
387
|
+
where bm.agent_id = v_subject.parent_agent_id and bm.board_id = v_membership.board_id
|
|
388
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
389
|
+
order by agent_board._role_rank(bm.role) desc, bm.created_at desc, bm.id desc limit 1;
|
|
390
|
+
v_role := null;
|
|
391
|
+
if v_parent_role is not null then
|
|
392
|
+
select r into v_role from unnest(array['owner', 'admin', 'member', 'observer']) r
|
|
393
|
+
where agent_board._role_rank(r) <= agent_board._role_rank(v_membership.role)
|
|
394
|
+
and agent_board._role_rank(r) <= agent_board._role_rank(v_parent_role)
|
|
395
|
+
and agent_board._grant_within_ceiling(v_subject.parent_agent_id, v_membership.board_id, r)
|
|
396
|
+
order by agent_board._role_rank(r) desc limit 1;
|
|
397
|
+
end if;
|
|
398
|
+
if v_role is null then
|
|
399
|
+
perform agent_board._upsert_board_membership_state(v_membership.board_id, v_subject.agent_id,
|
|
400
|
+
'removed', null, '{}'::jsonb, v_membership.id);
|
|
401
|
+
elsif v_role is distinct from v_membership.role then
|
|
402
|
+
update agent_board.board_memberships set role = v_role where id = v_membership.id;
|
|
403
|
+
end if;
|
|
404
|
+
end loop;
|
|
405
|
+
end loop;
|
|
406
|
+
perform 1 from agent_board.session_id_aliases a where a.root_agent_id = v_identity.root_agent_id
|
|
407
|
+
and a.harness = v_identity.harness and a.session_id = p_session_id for update;
|
|
408
|
+
insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
|
|
409
|
+
values (v_identity.agent_id, v_identity.root_agent_id, v_identity.harness, p_session_id)
|
|
410
|
+
on conflict (root_agent_id, harness, session_id) do nothing;
|
|
411
|
+
update agent_board.session_identities
|
|
412
|
+
set lease_alias_id = (select a.id from agent_board.session_id_aliases a
|
|
413
|
+
where a.identity_agent_id = v_identity.agent_id and a.session_id = p_session_id), updated_at = now()
|
|
414
|
+
where agent_id = v_identity.agent_id;
|
|
415
|
+
perform agent_board._assert_actor_not_fleet_revoked(p_identity_agent_id, 'link_session_alias');
|
|
416
|
+
end;
|
|
417
|
+
$$;
|
|
418
|
+
revoke all on function agent_board.link_session_alias(uuid, text, text) from public, anon, authenticated;
|
|
419
|
+
grant execute on function agent_board.link_session_alias(uuid, text, text) to service_role;
|
|
420
|
+
comment on function agent_board.link_session_alias(uuid, text, text) is 'Service-only continuity alias link: SIDSERVER forwards the verified holder subject, identity, epoch and measured id. Board mutexes precede root and deterministic identity/alias locks. Rechecks live lease, approved digest and ancestors; scopes, active rooms and roles only shrink, parent before descendants, through the consent sink for removals. A concurrent newly added room refuses 40001 for a safe retry. The latest binding is recorded even for an existing alias; only Claude may add a new id.';
|
|
421
|
+
|
|
422
|
+
-- Every session-form issuer walks the stored lineage, not a caller's root or
|
|
423
|
+
-- the child's self-written policy. Legacy API calls retain their original gate.
|
|
424
|
+
create function agent_board._session_identity_ancestor_ceiling(p_agent_id uuid)
|
|
425
|
+
returns timestamptz language plpgsql security definer set search_path = '' as $$
|
|
426
|
+
declare v_ancestor record; v_expires timestamptz := 'infinity';
|
|
427
|
+
begin
|
|
428
|
+
for v_ancestor in
|
|
429
|
+
with recursive lineage as (
|
|
430
|
+
select a.id, a.parent_agent_id from agent_board.agents a where a.id = p_agent_id
|
|
431
|
+
union all
|
|
432
|
+
select a.id, a.parent_agent_id from agent_board.agents a join lineage l on a.id = l.parent_agent_id
|
|
433
|
+
)
|
|
434
|
+
select a.id, a.status, si.identity_kind, si.tombstoned_at, si.resolution_state,
|
|
435
|
+
si.current_risky_digest, si.approved_risky_digest,
|
|
436
|
+
g.id as grant_id, g.revoked_at, g.grant_expires_at, dp.revoked_at as policy_revoked_at
|
|
437
|
+
from lineage l join agent_board.agents a on a.id = l.id
|
|
438
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
439
|
+
left join agent_board.delegation_grants g on g.child_agent_id = a.id
|
|
440
|
+
left join agent_board.delegation_policies dp on dp.id = g.delegation_policy_id
|
|
441
|
+
loop
|
|
442
|
+
if agent_board._actor_fleet_revoked(v_ancestor.id) then
|
|
443
|
+
raise exception 'identity_parent_inactive' using errcode = '42501';
|
|
444
|
+
end if;
|
|
445
|
+
if v_ancestor.status <> 'active' or v_ancestor.tombstoned_at is not null then
|
|
446
|
+
raise exception 'identity_parent_inactive' using errcode = '42501';
|
|
447
|
+
end if;
|
|
448
|
+
if v_ancestor.grant_id is not null and (v_ancestor.revoked_at is not null
|
|
449
|
+
or v_ancestor.policy_revoked_at is not null or v_ancestor.grant_expires_at <= now()) then
|
|
450
|
+
raise exception 'identity_parent_inactive' using errcode = '42501';
|
|
451
|
+
end if;
|
|
452
|
+
if v_ancestor.resolution_state = 'frozen' or (v_ancestor.identity_kind = 'session'
|
|
453
|
+
and (v_ancestor.approved_risky_digest is null
|
|
454
|
+
or v_ancestor.current_risky_digest is distinct from v_ancestor.approved_risky_digest)) then
|
|
455
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
456
|
+
end if;
|
|
457
|
+
v_expires := least(v_expires, v_ancestor.grant_expires_at);
|
|
458
|
+
end loop;
|
|
459
|
+
return v_expires;
|
|
460
|
+
end;
|
|
461
|
+
$$;
|
|
462
|
+
revoke all on function agent_board._session_identity_ancestor_ceiling(uuid) from public, anon, authenticated, service_role;
|
|
463
|
+
comment on function agent_board._session_identity_ancestor_ceiling(uuid) is 'Internal session-form ancestor gate: every stored ancestor must be active, unrevoked, unexpired and non-frozen; returns the tightest grant expiry.';
|
|
464
|
+
|
|
465
|
+
create function agent_board._session_identity_check_limits(p_root uuid, p_parent uuid, p_reactivation uuid default null)
|
|
466
|
+
returns void language plpgsql security definer set search_path = '' as $$
|
|
467
|
+
declare v_active bigint; v_hourly bigint; v_parent_depth smallint; v_policy_limit int;
|
|
468
|
+
begin
|
|
469
|
+
-- Caller has already locked the true root; count both depths and legacy grants.
|
|
470
|
+
select count(*) into v_active
|
|
471
|
+
from agent_board.delegation_grants g
|
|
472
|
+
join agent_board.agents a on a.id = g.child_agent_id
|
|
473
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
474
|
+
where g.root_agent_id = p_root and a.status = 'active' and g.revoked_at is null
|
|
475
|
+
and g.grant_expires_at > now() and a.id is distinct from p_reactivation
|
|
476
|
+
and (si.identity_kind is distinct from 'session' or agent_board._session_identity_lease_live(a.id));
|
|
477
|
+
select count(*) into v_hourly from agent_board.delegation_grants g
|
|
478
|
+
where g.root_agent_id = p_root and g.created_at > now() - interval '3600 seconds';
|
|
479
|
+
if v_active >= 200 then raise exception 'identity_limit_active_exceeded' using errcode = '42501'; end if;
|
|
480
|
+
select least(coalesce(dp.max_active_children, 200), coalesce(dp.operator_max_active_children, 200), 200)
|
|
481
|
+
into v_policy_limit from agent_board.delegation_policies dp
|
|
482
|
+
where dp.root_agent_id = p_root and dp.revoked_at is null
|
|
483
|
+
order by dp.created_at desc, dp.id desc limit 1;
|
|
484
|
+
if v_active >= v_policy_limit then
|
|
485
|
+
raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
|
|
486
|
+
end if;
|
|
487
|
+
if p_reactivation is null and v_hourly >= 60 then raise exception 'identity_limit_hourly_exceeded' using errcode = '42501'; end if;
|
|
488
|
+
select depth into v_parent_depth from agent_board.session_identities where agent_id = p_parent;
|
|
489
|
+
if v_parent_depth = 1 then
|
|
490
|
+
select count(*) into v_active
|
|
491
|
+
from agent_board.delegation_grants g join agent_board.agents a on a.id = g.child_agent_id
|
|
492
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
493
|
+
where g.parent_agent_id = p_parent and a.status = 'active' and g.revoked_at is null
|
|
494
|
+
and g.grant_expires_at > now() and a.id is distinct from p_reactivation
|
|
495
|
+
and (si.identity_kind is distinct from 'session' or agent_board._session_identity_lease_live(a.id));
|
|
496
|
+
select count(*) into v_hourly from agent_board.delegation_grants
|
|
497
|
+
where parent_agent_id = p_parent and created_at > now() - interval '3600 seconds';
|
|
498
|
+
if v_active >= 20 then raise exception 'identity_limit_active_exceeded' using errcode = '42501'; end if;
|
|
499
|
+
if p_reactivation is null and v_hourly >= 10 then raise exception 'identity_limit_hourly_exceeded' using errcode = '42501'; end if;
|
|
500
|
+
end if;
|
|
501
|
+
end;
|
|
502
|
+
$$;
|
|
503
|
+
revoke all on function agent_board._session_identity_check_limits(uuid, uuid, uuid) from public, anon, authenticated, service_role;
|
|
504
|
+
comment on function agent_board._session_identity_check_limits(uuid, uuid, uuid) is 'Internal root-locked descendant-wide mixed legacy/session census: 200/60 per root and 20/10 per depth-one parent; unclaimed sessions are hourly-only and tombstoned mints remain in the rolling hour.';
|
|
505
|
+
|
|
506
|
+
create or replace function agent_board.upsert_delegation_policy(
|
|
507
|
+
p_root_agent_id uuid, p_mode text default 'root_minted', p_allowed_scopes jsonb default null,
|
|
508
|
+
p_max_active_children int default null, p_child_ttl_ceiling interval default null,
|
|
509
|
+
p_require_child_signing_key boolean default null
|
|
510
|
+
)
|
|
511
|
+
returns agent_board.delegation_policies language plpgsql security definer set search_path = agent_board, public as $$
|
|
512
|
+
declare
|
|
513
|
+
v_actor uuid := agent_board._resolved_actor_agent_id(p_root_agent_id, 'p_root_agent_id');
|
|
514
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
515
|
+
v_max int; v_ttl interval := coalesce(p_child_ttl_ceiling, interval '8 hours');
|
|
516
|
+
v_operator_max int; v_operator_ttl interval;
|
|
517
|
+
v_existing agent_board.delegation_policies%rowtype;
|
|
518
|
+
v_policy agent_board.delegation_policies%rowtype;
|
|
519
|
+
begin
|
|
520
|
+
perform 1 from agent_board.agents where id = v_actor for update;
|
|
521
|
+
if not found then raise exception 'root agent not found' using errcode = 'P0002'; end if;
|
|
522
|
+
perform pg_advisory_xact_lock(hashtext('agent_board.delegation_policy:' || v_actor::text));
|
|
523
|
+
if p_mode is not null and p_mode not in ('independent_auth', 'root_minted') then
|
|
524
|
+
raise exception 'invalid delegation policy mode' using errcode = '22023';
|
|
525
|
+
end if;
|
|
526
|
+
if v_ttl <= interval '0' then raise exception 'child_ttl_ceiling must be positive' using errcode = '22023'; end if;
|
|
527
|
+
v_max := case when v_is_service then p_max_active_children else coalesce(p_max_active_children, 5) end;
|
|
528
|
+
if not v_is_service then
|
|
529
|
+
if v_max < 1 or v_max > 200 then
|
|
530
|
+
raise exception 'max_active_children must be between 1 and 200 for self-service delegation policies' using errcode = '22023';
|
|
531
|
+
end if;
|
|
532
|
+
if v_ttl > interval '24 hours' then
|
|
533
|
+
raise exception 'child_ttl_ceiling may not exceed 24 hours for self-service delegation policies' using errcode = '22023';
|
|
534
|
+
end if;
|
|
535
|
+
end if;
|
|
536
|
+
select * into v_existing from agent_board.delegation_policies dp
|
|
537
|
+
where dp.root_agent_id = v_actor and dp.revoked_at is null
|
|
538
|
+
order by dp.created_at desc, dp.id desc limit 1 for update;
|
|
539
|
+
v_operator_max := case when v_is_service then v_max else v_existing.operator_max_active_children end;
|
|
540
|
+
v_operator_ttl := case when v_is_service then v_ttl else v_existing.operator_child_ttl_ceiling end;
|
|
541
|
+
if v_existing.id is not null then
|
|
542
|
+
update agent_board.delegation_policies dp
|
|
543
|
+
set mode = coalesce(p_mode, 'root_minted'), max_depth = 2, max_active_children = v_max,
|
|
544
|
+
allowed_scopes = coalesce(p_allowed_scopes, '[]'::jsonb), child_ttl_ceiling = v_ttl,
|
|
545
|
+
require_child_signing_key = case when v_is_service then coalesce(p_require_child_signing_key, true) else true end,
|
|
546
|
+
scope_attestation = case when v_is_service then 'operator_vetted' else 'self_declared' end,
|
|
547
|
+
operator_max_active_children = v_operator_max, operator_child_ttl_ceiling = v_operator_ttl
|
|
548
|
+
where dp.id = v_existing.id returning * into v_policy;
|
|
549
|
+
else
|
|
550
|
+
insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, max_active_children, allowed_scopes,
|
|
551
|
+
child_ttl_ceiling, require_child_signing_key, scope_attestation, operator_max_active_children, operator_child_ttl_ceiling)
|
|
552
|
+
values (v_actor, coalesce(p_mode, 'root_minted'), 2, v_max, coalesce(p_allowed_scopes, '[]'::jsonb), v_ttl,
|
|
553
|
+
case when v_is_service then coalesce(p_require_child_signing_key, true) else true end,
|
|
554
|
+
case when v_is_service then 'operator_vetted' else 'self_declared' end, v_operator_max, v_operator_ttl)
|
|
555
|
+
returning * into v_policy;
|
|
556
|
+
end if;
|
|
557
|
+
return v_policy;
|
|
558
|
+
end;
|
|
559
|
+
$$;
|
|
560
|
+
revoke all on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) from public, anon;
|
|
561
|
+
grant execute on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) to authenticated, service_role;
|
|
562
|
+
comment on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) is 'Sole delegation-policy writer, serialized with mint and reactivation on the root. Self-service actor is session-derived, max children is 1..200, depth is two, keys are required and unattributed TTL stays <=24h. Operator writes establish durable ceilings; self-service preserves them.';
|
|
563
|
+
|
|
564
|
+
create or replace function agent_board.renew_delegated_grant(p_child_agent_id uuid, p_extend interval default null)
|
|
565
|
+
returns table(grant_expires_at timestamptz) language plpgsql security definer set search_path = agent_board, public as $$
|
|
566
|
+
declare
|
|
567
|
+
v_grant agent_board.delegation_grants%rowtype;
|
|
568
|
+
v_policy agent_board.delegation_policies%rowtype;
|
|
569
|
+
v_identity agent_board.session_identities%rowtype;
|
|
570
|
+
v_root uuid; v_ceiling interval; v_ancestor_expires timestamptz; v_new_expires_at timestamptz;
|
|
571
|
+
begin
|
|
572
|
+
if not agent_board._is_service_role() then
|
|
573
|
+
raise exception 'renew_delegated_grant is service_role-only' using errcode = '42501';
|
|
574
|
+
end if;
|
|
575
|
+
if p_extend <= interval '0' then raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023'; end if;
|
|
576
|
+
select g.root_agent_id into v_root from agent_board.delegation_grants g where g.child_agent_id = p_child_agent_id;
|
|
577
|
+
perform 1 from agent_board.agents where id = v_root for update;
|
|
578
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_child_agent_id for update;
|
|
579
|
+
if v_identity.identity_kind = 'session' then
|
|
580
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
581
|
+
if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
|
|
582
|
+
or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
|
|
583
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
584
|
+
end if;
|
|
585
|
+
if agent_board.current_agent_id() is distinct from p_child_agent_id then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
|
|
586
|
+
if not agent_board._session_identity_lease_live(p_child_agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
|
|
587
|
+
end if;
|
|
588
|
+
select * into v_grant from agent_board.delegation_grants g where g.child_agent_id = p_child_agent_id for update;
|
|
589
|
+
if not found then raise exception 'delegation grant not found for child' using errcode = 'P0002'; end if;
|
|
590
|
+
if v_grant.revoked_at is not null then raise exception 'grant_revoked: cannot renew a revoked delegation grant' using errcode = '42501'; end if;
|
|
591
|
+
select * into v_policy from agent_board.delegation_policies dp where dp.id = v_grant.delegation_policy_id;
|
|
592
|
+
if not found or v_policy.revoked_at is not null then
|
|
593
|
+
raise exception 'policy_revoked: the root delegation policy is revoked' using errcode = '42501';
|
|
594
|
+
end if;
|
|
595
|
+
if not agent_board._agent_principal_active(v_grant.root_agent_id) then
|
|
596
|
+
raise exception 'principal_inactive: root agent principal is not active; grant cannot outlive its root' using errcode = '42501';
|
|
597
|
+
end if;
|
|
598
|
+
v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_grant.parent_agent_id);
|
|
599
|
+
if v_grant.grant_expires_at <= now() or v_identity.identity_kind = 'session' then
|
|
600
|
+
perform agent_board._session_identity_check_limits(v_grant.root_agent_id, v_grant.parent_agent_id, p_child_agent_id);
|
|
601
|
+
end if;
|
|
602
|
+
v_ceiling := least(v_policy.child_ttl_ceiling, v_policy.operator_child_ttl_ceiling);
|
|
603
|
+
if v_identity.identity_kind = 'session' then v_ceiling := least(v_ceiling, interval '7 days');
|
|
604
|
+
elsif v_policy.scope_attestation = 'self_declared' then v_ceiling := least(v_ceiling, interval '24 hours'); end if;
|
|
605
|
+
v_new_expires_at := least(now() + least(coalesce(p_extend, v_ceiling), v_ceiling), v_ancestor_expires);
|
|
606
|
+
update agent_board.delegation_grants set grant_expires_at = v_new_expires_at, last_renewed_at = now()
|
|
607
|
+
where child_agent_id = p_child_agent_id;
|
|
608
|
+
return query select v_new_expires_at;
|
|
609
|
+
end;
|
|
610
|
+
$$;
|
|
611
|
+
revoke all on function agent_board.renew_delegated_grant(uuid, interval) from public, anon, authenticated;
|
|
612
|
+
grant execute on function agent_board.renew_delegated_grant(uuid, interval) to service_role;
|
|
613
|
+
comment on function agent_board.renew_delegated_grant(uuid, interval) is 'Service-role renewal under root then identity and grant locks. Every path honors durable operator TTL, all ancestor authority and expired-renew active recount. Session identities additionally require the verified current-epoch live holder and approved unchanged fingerprint, with a seven-day bound; legacy self-declared grants retain 24h and operator legacy grants retain their bounded latitude.';
|
|
614
|
+
|
|
615
|
+
drop function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean);
|
|
616
|
+
|
|
617
|
+
create function agent_board.mint_delegated_child(
|
|
618
|
+
p_root_agent_id uuid, p_child_slug text, p_requested_scopes jsonb,
|
|
619
|
+
p_child_public_key text default null, p_ttl interval default null, p_self_service boolean default false,
|
|
620
|
+
p_session_harness text default null, p_session_id text default null, p_minter_lease_epoch text default null,
|
|
621
|
+
p_requested_rooms jsonb default '[]'::jsonb, p_fingerprint jsonb default null, p_forked_from uuid default null
|
|
622
|
+
) returns table(child_agent_id uuid, grant_expires_at timestamptz, signing_key_id uuid)
|
|
623
|
+
language plpgsql security definer set search_path = agent_board, public as $$
|
|
624
|
+
declare
|
|
625
|
+
v_minter agent_board.session_identities%rowtype;
|
|
626
|
+
v_root agent_board.agents%rowtype;
|
|
627
|
+
v_policy agent_board.delegation_policies%rowtype;
|
|
628
|
+
v_true_root uuid;
|
|
629
|
+
v_depth smallint;
|
|
630
|
+
v_allowed jsonb;
|
|
631
|
+
v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
|
|
632
|
+
v_child_id uuid;
|
|
633
|
+
v_key_id uuid;
|
|
634
|
+
v_child_auth uuid;
|
|
635
|
+
v_slug text;
|
|
636
|
+
v_ceiling interval;
|
|
637
|
+
v_ancestor_expires timestamptz := 'infinity';
|
|
638
|
+
v_expires timestamptz;
|
|
639
|
+
v_room jsonb;
|
|
640
|
+
v_board uuid;
|
|
641
|
+
v_role text;
|
|
642
|
+
v_parent_role text;
|
|
643
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
644
|
+
v_approved text;
|
|
645
|
+
v_reserved agent_board.session_identities%rowtype;
|
|
646
|
+
v_reserved_id uuid;
|
|
647
|
+
v_reserved_alias uuid;
|
|
648
|
+
v_owner uuid;
|
|
649
|
+
begin
|
|
650
|
+
if not agent_board._is_service_role() then
|
|
651
|
+
raise exception 'mint_delegated_child is service_role-only' using errcode = '42501';
|
|
652
|
+
end if;
|
|
653
|
+
if p_ttl is not null and p_ttl <= interval '0' then
|
|
654
|
+
raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023';
|
|
655
|
+
end if;
|
|
656
|
+
v_slug := agent_board._normalize_provision_slug(p_child_slug);
|
|
657
|
+
select coalesce(a.root_agent_id, a.id) into v_true_root from agent_board.agents a where a.id = p_root_agent_id;
|
|
658
|
+
if not found then raise exception 'root agent not found' using errcode = 'P0002'; end if;
|
|
659
|
+
-- Board mutexes precede agent and membership row locks, as in grant_board_role.
|
|
660
|
+
if jsonb_typeof(coalesce(p_requested_rooms, '[]'::jsonb)) <> 'array'
|
|
661
|
+
or jsonb_typeof(v_requested) <> 'array' then
|
|
662
|
+
raise exception 'rooms and scopes must be arrays' using errcode = '22023';
|
|
663
|
+
end if;
|
|
664
|
+
for v_board in select distinct (r ->> 'board_id')::uuid
|
|
665
|
+
from jsonb_array_elements(coalesce(p_requested_rooms, '[]'::jsonb)) r order by 1 loop
|
|
666
|
+
perform agent_board._lock_board_authority_ceiling(v_board);
|
|
667
|
+
perform 1 from agent_board.boards where id = v_board for key share;
|
|
668
|
+
end loop;
|
|
669
|
+
select owner_user_id into v_owner from agent_board.agents where id = p_root_agent_id;
|
|
670
|
+
perform 1 from auth.users where id = v_owner for key share;
|
|
671
|
+
perform 1 from agent_board.agents where id = v_true_root for update;
|
|
672
|
+
select * into v_root from agent_board.agents where id = p_root_agent_id;
|
|
673
|
+
if v_root.owner_user_id is distinct from v_owner then
|
|
674
|
+
raise exception 'identity owner changed during mint; retry' using errcode = '40001';
|
|
675
|
+
end if;
|
|
676
|
+
select * into v_minter from agent_board.session_identities where agent_id = p_root_agent_id for update;
|
|
677
|
+
v_depth := coalesce(v_minter.depth, case when v_root.parent_agent_id is null then 0 else 1 end);
|
|
678
|
+
if v_minter.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
679
|
+
if exists (
|
|
680
|
+
with recursive ancestry(id, parent_agent_id) as (
|
|
681
|
+
select id, parent_agent_id from agent_board.agents where id = p_root_agent_id
|
|
682
|
+
union all
|
|
683
|
+
select a.id, a.parent_agent_id from agent_board.agents a join ancestry c on a.id = c.parent_agent_id
|
|
684
|
+
)
|
|
685
|
+
select 1 from ancestry a join agent_board.session_identities si on si.agent_id = a.id
|
|
686
|
+
where si.resolution_state = 'frozen'
|
|
687
|
+
) then raise exception 'identity_fingerprint_unapproved' using errcode = '42501'; end if;
|
|
688
|
+
if v_minter.identity_kind = 'session' and p_minter_lease_epoch is distinct from v_minter.credential_epoch::text then
|
|
689
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
690
|
+
end if;
|
|
691
|
+
if (p_session_harness is null) <> (p_session_id is null) then
|
|
692
|
+
raise exception 'unattributed_session_refused' using errcode = '22023';
|
|
693
|
+
end if;
|
|
694
|
+
if p_session_harness is null then
|
|
695
|
+
if v_root.parent_agent_id is not null or exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id) then
|
|
696
|
+
raise exception 'max_depth: only a true lineage root may mint (no transitive delegation)' using errcode = '42501';
|
|
697
|
+
end if;
|
|
698
|
+
if jsonb_array_length(coalesce(p_requested_rooms, '[]'::jsonb)) <> 0
|
|
699
|
+
or p_fingerprint is not null or p_forked_from is not null then
|
|
700
|
+
raise exception 'unattributed_session_refused' using errcode = '22023';
|
|
701
|
+
end if;
|
|
702
|
+
else
|
|
703
|
+
if p_session_harness not in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi') or length(p_session_id) = 0 then
|
|
704
|
+
raise exception 'unattributed_session_refused' using errcode = '22023';
|
|
705
|
+
end if;
|
|
706
|
+
if v_depth >= 2 then raise exception 'identity_depth_exceeded' using errcode = '42501'; end if;
|
|
707
|
+
if v_depth = 1 and v_minter.identity_kind <> 'session' then
|
|
708
|
+
raise exception 'unattributed_session_refused' using errcode = '42501';
|
|
709
|
+
end if;
|
|
710
|
+
v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(p_root_agent_id);
|
|
711
|
+
if p_forked_from is not null and not exists (
|
|
712
|
+
select 1 from agent_board.session_identities si
|
|
713
|
+
where si.agent_id = p_forked_from and si.identity_kind = 'session'
|
|
714
|
+
and si.root_agent_id = v_true_root and si.parent_agent_id = p_root_agent_id and si.tombstoned_at is null
|
|
715
|
+
) then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
716
|
+
end if;
|
|
717
|
+
select * into v_policy from agent_board.delegation_policies dp
|
|
718
|
+
where dp.root_agent_id = v_true_root and dp.revoked_at is null
|
|
719
|
+
order by dp.created_at desc, dp.id desc limit 1 for update;
|
|
720
|
+
if not found then raise exception 'no active delegation policy for root agent' using errcode = '42501'; end if;
|
|
721
|
+
if v_policy.mode <> 'root_minted' then
|
|
722
|
+
raise exception 'delegation policy mode is not root_minted (independent_auth children self-provision)' using errcode = '42501';
|
|
723
|
+
end if;
|
|
724
|
+
if not agent_board._agent_principal_active(p_root_agent_id) then
|
|
725
|
+
raise exception 'principal_inactive: root agent principal is not fully active' using errcode = '42501';
|
|
726
|
+
end if;
|
|
727
|
+
v_allowed := coalesce(v_policy.allowed_scopes, '[]'::jsonb);
|
|
728
|
+
if v_depth > 0 then
|
|
729
|
+
select g.granted_scopes into v_allowed from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id;
|
|
730
|
+
end if;
|
|
731
|
+
if not (coalesce(v_allowed, '[]'::jsonb) @> v_requested) then
|
|
732
|
+
raise exception 'scope_exceeded: requested scopes are not a subset of the root allowed_scopes' using errcode = '42501';
|
|
733
|
+
end if;
|
|
734
|
+
if p_session_harness is not null then
|
|
735
|
+
-- Match issuance/readmission: lock an existing alias before any supersede
|
|
736
|
+
-- revocation can acquire its old principal's keys or credentials.
|
|
737
|
+
perform 1 from agent_board.session_id_aliases
|
|
738
|
+
where root_agent_id = v_true_root and harness = p_session_harness and session_id = p_session_id
|
|
739
|
+
order by id for update;
|
|
740
|
+
select a.identity_agent_id, a.id into v_reserved_id, v_reserved_alias
|
|
741
|
+
from agent_board.session_id_aliases a
|
|
742
|
+
join agent_board.session_identities si on si.agent_id = a.identity_agent_id
|
|
743
|
+
where a.root_agent_id = coalesce(v_minter.root_agent_id, p_root_agent_id)
|
|
744
|
+
and a.harness = p_session_harness and a.session_id = p_session_id
|
|
745
|
+
for update of si;
|
|
746
|
+
if found then
|
|
747
|
+
select * into v_reserved from agent_board.session_identities
|
|
748
|
+
where agent_id = v_reserved_id;
|
|
749
|
+
if v_reserved.tombstoned_at is not null then
|
|
750
|
+
raise exception 'identity_tombstoned' using errcode = '42501';
|
|
751
|
+
elsif v_reserved.holder_secret_hash is not null and v_reserved.last_heartbeat > now() - interval '180 seconds' then
|
|
752
|
+
raise exception 'duplicate_live_session' using errcode = '42501';
|
|
753
|
+
elsif v_reserved.holder_secret_hash is null and v_reserved.created_at > now() - interval '180 seconds' then
|
|
754
|
+
raise exception 'identity_lease_live' using errcode = '42501';
|
|
755
|
+
elsif v_reserved.holder_secret_hash is not null then
|
|
756
|
+
raise exception 'identity_lease_lapsed' using errcode = '42501';
|
|
757
|
+
end if;
|
|
758
|
+
perform agent_board._revoke_agent_principal(v_reserved.agent_id, p_root_agent_id, 'superseded_unclaimed');
|
|
759
|
+
update agent_board.session_identities
|
|
760
|
+
set tombstoned_at = now(), tombstone_reason = 'superseded_unclaimed', updated_at = now()
|
|
761
|
+
where agent_id = v_reserved.agent_id;
|
|
762
|
+
end if;
|
|
763
|
+
end if;
|
|
764
|
+
perform agent_board._session_identity_check_limits(v_true_root, p_root_agent_id);
|
|
765
|
+
if p_fingerprint is not null then
|
|
766
|
+
if jsonb_typeof(p_fingerprint) <> 'object' or p_fingerprint ->> 'resolution' is distinct from 'resolved'
|
|
767
|
+
or nullif(p_fingerprint ->> 'risky_digest', '') is null then
|
|
768
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
769
|
+
end if;
|
|
770
|
+
if v_minter.resolution_state = 'active' and v_minter.approved_risky_digest = p_fingerprint ->> 'risky_digest'
|
|
771
|
+
and v_minter.approved_risky_digest = v_minter.current_risky_digest then
|
|
772
|
+
v_approved := v_minter.approved_risky_digest;
|
|
773
|
+
end if;
|
|
774
|
+
end if;
|
|
775
|
+
v_ceiling := least(v_policy.child_ttl_ceiling,
|
|
776
|
+
v_policy.operator_child_ttl_ceiling,
|
|
777
|
+
case when p_session_harness is null and not p_self_service then null
|
|
778
|
+
when p_session_harness is null or v_approved is null then interval '24 hours' else interval '7 days' end);
|
|
779
|
+
v_expires := least(now() + least(coalesce(p_ttl, v_ceiling), v_ceiling), v_ancestor_expires);
|
|
780
|
+
v_child_auth := agent_board._ensure_agent_auth_user(v_slug);
|
|
781
|
+
insert into agent_board.agents(agent_type, kind, handle, display_name, status,
|
|
782
|
+
auth_user_id, owner_user_id, parent_agent_id, root_agent_id, metadata)
|
|
783
|
+
values ('agent', 'agent'::agent_board.agent_kind, v_slug, agent_board._default_provision_display_name(v_slug), 'active',
|
|
784
|
+
v_child_auth, v_root.owner_user_id, p_root_agent_id, v_true_root,
|
|
785
|
+
jsonb_build_object('delegatedByRootAgentId', v_true_root, 'grantedScopes', v_requested)) returning id into v_child_id;
|
|
786
|
+
if v_policy.require_child_signing_key or p_session_harness is not null then
|
|
787
|
+
if p_child_public_key is null then
|
|
788
|
+
raise exception 'a child public key is required when the delegation policy requires a signing key' using errcode = '22023';
|
|
789
|
+
end if;
|
|
790
|
+
if p_child_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
791
|
+
raise exception 'malformed child public key: expected a 43-char base64url raw Ed25519 key' using errcode = '22023';
|
|
792
|
+
end if;
|
|
793
|
+
if p_child_public_key = repeat('A', 43) then
|
|
794
|
+
raise exception 'child public key is the all-zero small-order placeholder and is rejected' using errcode = '22023';
|
|
795
|
+
end if;
|
|
796
|
+
insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format,
|
|
797
|
+
fingerprint, status, proof_state, metadata)
|
|
798
|
+
values (gen_random_uuid(), v_child_id, 'delegated-ephemeral', 1, p_child_public_key, 'raw_ed25519_base64url',
|
|
799
|
+
agent_board._raw_key_fingerprint(p_child_public_key), 'active', 'proven',
|
|
800
|
+
jsonb_build_object('ephemeral', true, 'scopes', v_requested)) returning id into v_key_id;
|
|
801
|
+
end if;
|
|
802
|
+
insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
|
|
803
|
+
granted_scopes, grant_expires_at)
|
|
804
|
+
values (v_child_id, v_true_root, p_root_agent_id, v_policy.id, v_requested, v_expires);
|
|
805
|
+
insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth, identity_kind, harness,
|
|
806
|
+
forked_from, approved_risky_digest, current_risky_digest, recorded_fingerprint, resolution_state)
|
|
807
|
+
values (v_child_id, p_root_agent_id, v_true_root, v_depth + 1,
|
|
808
|
+
case when p_session_harness is null then 'legacy' else 'session' end, p_session_harness, p_forked_from,
|
|
809
|
+
v_approved, p_fingerprint ->> 'risky_digest',
|
|
810
|
+
case when p_fingerprint is null then '{}'::jsonb else
|
|
811
|
+
coalesce(p_fingerprint -> 'recorded', '{}'::jsonb) || jsonb_build_object('resolution', p_fingerprint ->> 'resolution') end,
|
|
812
|
+
case when p_session_harness is null or v_approved is not null then 'active' else 'frozen' end);
|
|
813
|
+
perform agent_board._refuse_delegate_membership_target(v_child_id, 'mint_delegated_child');
|
|
814
|
+
if p_session_harness is not null then
|
|
815
|
+
if v_reserved_alias is null then
|
|
816
|
+
insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
|
|
817
|
+
values (v_child_id, v_true_root, p_session_harness, p_session_id) returning id into v_reserved_alias;
|
|
818
|
+
else
|
|
819
|
+
update agent_board.session_id_aliases set identity_agent_id = v_child_id
|
|
820
|
+
where id = v_reserved_alias;
|
|
821
|
+
end if;
|
|
822
|
+
update agent_board.session_identities set lease_alias_id = v_reserved_alias where agent_id = v_child_id;
|
|
823
|
+
for v_room in select value from jsonb_array_elements(coalesce(p_requested_rooms, '[]'::jsonb)) order by value ->> 'board_id' loop
|
|
824
|
+
v_board := (v_room ->> 'board_id')::uuid;
|
|
825
|
+
v_role := v_room ->> 'role';
|
|
826
|
+
select bm.role into v_parent_role from agent_board.board_memberships bm
|
|
827
|
+
where bm.board_id = v_board and bm.agent_id = p_root_agent_id and bm.membership_state in ('joined', 'subscribed')
|
|
828
|
+
order by agent_board._role_rank(bm.role) desc limit 1;
|
|
829
|
+
if v_role not in ('owner', 'admin', 'member', 'observer') or v_role is null or v_parent_role is null
|
|
830
|
+
or agent_board._role_rank(v_role) > agent_board._role_rank(v_parent_role)
|
|
831
|
+
or not agent_board._grant_within_ceiling(p_root_agent_id, v_board, v_role) then
|
|
832
|
+
raise exception 'room_authority_exceeded' using errcode = '42501';
|
|
833
|
+
end if;
|
|
834
|
+
select * into v_membership from agent_board._upsert_board_membership_state(v_board, v_child_id, 'joined');
|
|
835
|
+
update agent_board.board_memberships set role = v_role where id = v_membership.id;
|
|
836
|
+
end loop;
|
|
837
|
+
end if;
|
|
838
|
+
perform agent_board._assert_actor_not_fleet_revoked(p_root_agent_id, 'mint_delegated_child');
|
|
839
|
+
return query select v_child_id, v_expires, v_key_id;
|
|
840
|
+
end;
|
|
841
|
+
$$;
|
|
842
|
+
revoke all on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) from public, anon, authenticated;
|
|
843
|
+
grant execute on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) to service_role;
|
|
844
|
+
comment on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) is 'Single service-role child mint. The six legacy inputs/results remain compatible. Trusted session context adds measured harness/id, verified minter epoch, explicit [{board_id,role}] rooms, {resolution,risky_digest,recorded} fingerprint and optional same-parent fork. SIDSERVER must bind every new field in child-key PoP before forwarding; public Edge shapes do not supply them. Enforces true root, depth two, granted scopes, ancestor validity, root-locked mixed census and effective lifetime; only matching approved fingerprints get seven days. Membership provenance is derived by the consent sink, never supplied by the caller.';
|
|
845
|
+
|
|
846
|
+
-- The server-facing mint keeps all historical challenge/key gates in the renamed
|
|
847
|
+
-- implementation and adds session lease claim/reclaim semantics around its result.
|
|
848
|
+
alter function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer)
|
|
849
|
+
rename to _mint_fleet_agent_session_legacy;
|
|
850
|
+
revoke all on function agent_board._mint_fleet_agent_session_legacy(uuid, text, uuid, uuid, integer) from public, anon, authenticated, service_role;
|
|
851
|
+
comment on function agent_board._mint_fleet_agent_session_legacy(uuid,text,uuid,uuid,integer) is 'Private whole-principal PoP issuer, retaining the legacy challenge/key/lineage validation and 900-second credential ceiling. No PostgREST role, including service_role, can execute it directly; the service-only mint_fleet_agent_session wrapper is its sole session-token caller and applies identity lease, fingerprint and epoch gates.';
|
|
852
|
+
create function agent_board.mint_fleet_agent_session(
|
|
853
|
+
p_challenge_id uuid, p_nonce text, p_signing_key_id uuid, p_agent_id uuid,
|
|
854
|
+
p_max_ttl_seconds integer default 900, p_holder_secret text default null, p_session_id text default null
|
|
855
|
+
) returns table(session_id text, expires_at timestamptz, identity_lease_epoch bigint, holder_secret text)
|
|
856
|
+
language plpgsql security definer set search_path = agent_board, public as $$
|
|
857
|
+
declare v_identity agent_board.session_identities%rowtype; v_session record; v_new_secret text; v_alias uuid;
|
|
858
|
+
v_ancestor_expires timestamptz; v_grant_expires timestamptz;
|
|
859
|
+
begin
|
|
860
|
+
if not agent_board._is_service_role() then raise exception 'session mint is service_role-only' using errcode = '42501'; end if;
|
|
861
|
+
-- Match the existing PoP issuer's challenge-before-principal order.
|
|
862
|
+
perform 1 from agent_board.agent_signing_key_challenges where id = p_challenge_id for update;
|
|
863
|
+
-- Serialize the census without conflicting with key-issuer FK KEY SHARE locks.
|
|
864
|
+
perform 1 from agent_board.agents where id = (
|
|
865
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_agent_id
|
|
866
|
+
) for no key update;
|
|
867
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_agent_id for update;
|
|
868
|
+
-- Readmission repoints aliases before inserting its new key. Both legacy and
|
|
869
|
+
-- session issuances use the same identity/alias-before-key acquisition order.
|
|
870
|
+
perform 1 from agent_board.session_id_aliases where identity_agent_id = p_agent_id order by id for update;
|
|
871
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
872
|
+
if exists (
|
|
873
|
+
with recursive ancestry(id, parent_agent_id) as (
|
|
874
|
+
select id, parent_agent_id from agent_board.agents where id = p_agent_id
|
|
875
|
+
union all
|
|
876
|
+
select a.id, a.parent_agent_id from agent_board.agents a join ancestry c on a.id = c.parent_agent_id
|
|
877
|
+
)
|
|
878
|
+
select 1 from ancestry a join agent_board.session_identities si on si.agent_id = a.id
|
|
879
|
+
where si.resolution_state = 'frozen'
|
|
880
|
+
) then raise exception 'identity_fingerprint_unapproved' using errcode = '42501'; end if;
|
|
881
|
+
if v_identity.agent_id is null or v_identity.identity_kind <> 'session' then
|
|
882
|
+
select * into v_session from agent_board._mint_fleet_agent_session_legacy(p_challenge_id, p_nonce, p_signing_key_id, p_agent_id, p_max_ttl_seconds);
|
|
883
|
+
return query select v_session.session_id, v_session.expires_at, null::bigint, null::text; return;
|
|
884
|
+
end if;
|
|
885
|
+
if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
|
|
886
|
+
or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
|
|
887
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
888
|
+
end if;
|
|
889
|
+
v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_identity.parent_agent_id);
|
|
890
|
+
if not agent_board._session_identity_lease_live(p_agent_id) then
|
|
891
|
+
perform agent_board._session_identity_check_limits(v_identity.root_agent_id, v_identity.parent_agent_id, p_agent_id);
|
|
892
|
+
end if;
|
|
893
|
+
if not exists (select 1 from agent_board.agent_signing_keys k where k.id = p_signing_key_id
|
|
894
|
+
and k.agent_id = p_agent_id and k.proof_state = 'proven') then
|
|
895
|
+
raise exception 'key_unproven: session key requires proof' using errcode = '42501';
|
|
896
|
+
end if;
|
|
897
|
+
select * into v_session from agent_board._mint_fleet_agent_session_legacy(p_challenge_id, p_nonce, p_signing_key_id, p_agent_id, p_max_ttl_seconds);
|
|
898
|
+
select g.grant_expires_at into v_grant_expires from agent_board.delegation_grants g where g.child_agent_id = p_agent_id;
|
|
899
|
+
v_session.expires_at := least(v_session.expires_at, v_ancestor_expires, v_grant_expires);
|
|
900
|
+
update agent_board.nonfleet_sessions s set expires_at = v_session.expires_at where s.session_id = v_session.session_id;
|
|
901
|
+
if v_identity.holder_secret_hash is not null and v_identity.last_heartbeat > now() - interval '180 seconds' then
|
|
902
|
+
if p_holder_secret is not null and extensions.digest(p_holder_secret, 'sha256') = v_identity.holder_secret_hash then
|
|
903
|
+
return query select v_session.session_id, v_session.expires_at, v_identity.credential_epoch, null::text; return;
|
|
904
|
+
end if;
|
|
905
|
+
if p_holder_secret is not null then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
|
|
906
|
+
if p_session_id is not null and exists (select 1 from agent_board.session_id_aliases a where a.id = v_identity.lease_alias_id and a.session_id = p_session_id) then
|
|
907
|
+
raise exception 'duplicate_live_session' using errcode = '42501';
|
|
908
|
+
end if;
|
|
909
|
+
raise exception 'identity_lease_live' using errcode = '42501';
|
|
910
|
+
end if;
|
|
911
|
+
select a.id into v_alias from agent_board.session_id_aliases a
|
|
912
|
+
where a.identity_agent_id = p_agent_id and a.session_id = p_session_id;
|
|
913
|
+
if v_identity.holder_secret_hash is null then
|
|
914
|
+
if v_alias is null or v_alias is distinct from v_identity.lease_alias_id then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
|
|
915
|
+
elsif v_alias is null then
|
|
916
|
+
if p_session_id is null or length(p_session_id) = 0 or v_identity.harness <> 'claude' then
|
|
917
|
+
raise exception 'identity_continuity_refused' using errcode = '42501';
|
|
918
|
+
end if;
|
|
919
|
+
if exists (select 1 from agent_board.session_id_aliases a where a.root_agent_id = v_identity.root_agent_id
|
|
920
|
+
and a.harness = v_identity.harness and a.session_id = p_session_id) then
|
|
921
|
+
raise exception 'identity_continuity_refused' using errcode = '42501';
|
|
922
|
+
end if;
|
|
923
|
+
insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
|
|
924
|
+
values (p_agent_id, v_identity.root_agent_id, v_identity.harness, p_session_id) returning id into v_alias;
|
|
925
|
+
end if;
|
|
926
|
+
v_new_secret := encode(extensions.gen_random_bytes(32), 'hex');
|
|
927
|
+
update agent_board.session_identities
|
|
928
|
+
set holder_secret_hash = extensions.digest(v_new_secret, 'sha256'), lease_alias_id = v_alias,
|
|
929
|
+
credential_epoch = case when holder_secret_hash is null then credential_epoch else credential_epoch + 1 end,
|
|
930
|
+
last_heartbeat = now(), updated_at = now()
|
|
931
|
+
where agent_id = p_agent_id
|
|
932
|
+
returning credential_epoch into identity_lease_epoch;
|
|
933
|
+
return query select v_session.session_id, v_session.expires_at, identity_lease_epoch, v_new_secret;
|
|
934
|
+
end;
|
|
935
|
+
$$;
|
|
936
|
+
revoke all on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) from public, anon, authenticated;
|
|
937
|
+
grant execute on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) to service_role;
|
|
938
|
+
comment on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) is 'Service-role PoP mint with one-live-holder session lease fencing; a newly generated holder secret is returned only on claim or reclaim.';
|
|
939
|
+
|
|
940
|
+
create or replace function agent_board._session_identity_owner(p_identity_agent_id uuid)
|
|
941
|
+
returns boolean language sql stable security definer set search_path = '' as $$
|
|
942
|
+
select exists (
|
|
943
|
+
select 1
|
|
944
|
+
from agent_board.session_identities si
|
|
945
|
+
join agent_board.agents root on root.id = si.root_agent_id
|
|
946
|
+
join agent_board.agents human on human.auth_user_id = auth.uid()
|
|
947
|
+
where si.agent_id = p_identity_agent_id
|
|
948
|
+
and root.owner_user_id = auth.uid()
|
|
949
|
+
and human.agent_type = 'human' and human.status = 'active'
|
|
950
|
+
);
|
|
951
|
+
$$;
|
|
952
|
+
revoke all on function agent_board._session_identity_owner(uuid) from public, anon, authenticated;
|
|
953
|
+
grant execute on function agent_board._session_identity_owner(uuid) to service_role;
|
|
954
|
+
comment on function agent_board._session_identity_owner(uuid) is 'Checks the authenticated active human against the identity root owner. No caller-supplied actor is accepted.';
|
|
955
|
+
|
|
956
|
+
create or replace function agent_board._session_identity_ancestor_actor(p_identity_agent_id uuid)
|
|
957
|
+
returns boolean language sql stable security definer set search_path = '' as $$
|
|
958
|
+
with recursive lineage(agent_id, parent_agent_id) as (
|
|
959
|
+
select parent.agent_id, parent.parent_agent_id
|
|
960
|
+
from agent_board.session_identities target
|
|
961
|
+
join agent_board.session_identities parent on parent.agent_id = target.parent_agent_id
|
|
962
|
+
where target.agent_id = p_identity_agent_id
|
|
963
|
+
union all
|
|
964
|
+
select parent.agent_id, parent.parent_agent_id
|
|
965
|
+
from agent_board.session_identities parent
|
|
966
|
+
join lineage child on child.parent_agent_id = parent.agent_id
|
|
967
|
+
)
|
|
968
|
+
select exists (
|
|
969
|
+
select 1 from lineage l
|
|
970
|
+
join agent_board.session_identities si on si.agent_id = l.agent_id
|
|
971
|
+
join agent_board.session_identities target on target.agent_id = p_identity_agent_id
|
|
972
|
+
where l.agent_id = agent_board.current_agent_id()
|
|
973
|
+
and si.tombstoned_at is null
|
|
974
|
+
and si.resolution_state = 'active'
|
|
975
|
+
and si.approved_risky_digest is not null
|
|
976
|
+
and si.approved_risky_digest = si.current_risky_digest
|
|
977
|
+
and si.approved_risky_digest = target.current_risky_digest
|
|
978
|
+
);
|
|
979
|
+
$$;
|
|
980
|
+
revoke all on function agent_board._session_identity_ancestor_actor(uuid) from public, anon, authenticated;
|
|
981
|
+
grant execute on function agent_board._session_identity_ancestor_actor(uuid) to service_role;
|
|
982
|
+
comment on function agent_board._session_identity_ancestor_actor(uuid) is 'Strict ancestor approval predicate: resolver-derived actor, never the target itself, must be active, non-tombstoned and non-frozen with its own approved current digest matching the target reading. Applies to legacy roots too.';
|
|
983
|
+
|
|
984
|
+
create function agent_board._session_identity_revocation_actor(p_identity_agent_id uuid)
|
|
985
|
+
returns boolean language sql stable security definer set search_path = '' as $$
|
|
986
|
+
with recursive lineage(agent_id, parent_agent_id) as (
|
|
987
|
+
select parent.agent_id, parent.parent_agent_id
|
|
988
|
+
from agent_board.session_identities target
|
|
989
|
+
join agent_board.session_identities parent on parent.agent_id = target.parent_agent_id
|
|
990
|
+
where target.agent_id = p_identity_agent_id
|
|
991
|
+
union all
|
|
992
|
+
select parent.agent_id, parent.parent_agent_id
|
|
993
|
+
from agent_board.session_identities parent join lineage child on child.parent_agent_id = parent.agent_id
|
|
994
|
+
)
|
|
995
|
+
select exists (select 1 from lineage where agent_id = agent_board.current_agent_id());
|
|
996
|
+
$$;
|
|
997
|
+
revoke all on function agent_board._session_identity_revocation_actor(uuid) from public, anon, authenticated, service_role;
|
|
998
|
+
comment on function agent_board._session_identity_revocation_actor(uuid) is 'Resolver-derived strict ancestor revocation actor. Unlike approval, any active ancestor may revoke, including a frozen one; target self and siblings are not ancestors.';
|
|
999
|
+
|
|
1000
|
+
create function agent_board.record_session_identity_state(p_identity_agent_id uuid, p_fingerprint jsonb)
|
|
1001
|
+
returns void language plpgsql security definer set search_path = '' as $$
|
|
1002
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
1003
|
+
v_risky text := nullif(p_fingerprint ->> 'risky_digest', '');
|
|
1004
|
+
v_recorded jsonb := coalesce(p_fingerprint -> 'recorded', '{}'::jsonb)
|
|
1005
|
+
|| jsonb_build_object('resolution', coalesce(p_fingerprint ->> 'resolution', 'unresolved'));
|
|
1006
|
+
begin
|
|
1007
|
+
if not agent_board._is_service_role() then
|
|
1008
|
+
raise exception 'identity reading is service_role-only' using errcode = '42501';
|
|
1009
|
+
end if;
|
|
1010
|
+
if coalesce(p_fingerprint ->> 'resolution', 'unresolved') not in ('resolved', 'unresolved') then
|
|
1011
|
+
raise exception 'invalid identity fingerprint resolution' using errcode = '22023';
|
|
1012
|
+
end if;
|
|
1013
|
+
perform 1 from agent_board.agents where id = (
|
|
1014
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
|
|
1015
|
+
) for update;
|
|
1016
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
1017
|
+
if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
|
|
1018
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
1019
|
+
if not exists (select 1 from agent_board.agents a where a.id = p_identity_agent_id and a.auth_user_id = auth.uid() and a.status = 'active')
|
|
1020
|
+
or (v_identity.identity_kind = 'session' and agent_board.current_agent_id() is distinct from p_identity_agent_id) then
|
|
1021
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
1022
|
+
end if;
|
|
1023
|
+
if jsonb_typeof(coalesce(p_fingerprint, '{}'::jsonb)) <> 'object' or jsonb_typeof(v_recorded) <> 'object' then
|
|
1024
|
+
raise exception 'invalid identity fingerprint reading' using errcode = '22023';
|
|
1025
|
+
end if;
|
|
1026
|
+
-- Return normally: throwing here would roll back the freeze. SIDSERVER must
|
|
1027
|
+
-- commit this measured reading before invoking a protected action separately.
|
|
1028
|
+
update agent_board.session_identities
|
|
1029
|
+
set current_risky_digest = v_risky, recorded_fingerprint = v_recorded,
|
|
1030
|
+
resolution_state = case
|
|
1031
|
+
when p_fingerprint ->> 'resolution' is distinct from 'resolved' or v_risky is null
|
|
1032
|
+
or v_identity.approved_risky_digest is null then 'frozen'
|
|
1033
|
+
when v_risky is distinct from v_identity.approved_risky_digest or v_identity.resolution_state = 'frozen' then 'frozen'
|
|
1034
|
+
else 'active' end,
|
|
1035
|
+
updated_at = now()
|
|
1036
|
+
where agent_id = p_identity_agent_id;
|
|
1037
|
+
end;
|
|
1038
|
+
$$;
|
|
1039
|
+
revoke all on function agent_board.record_session_identity_state(uuid,jsonb) from public, anon, authenticated;
|
|
1040
|
+
grant execute on function agent_board.record_session_identity_state(uuid,jsonb) to service_role;
|
|
1041
|
+
comment on function agent_board.record_session_identity_state(uuid,jsonb) is 'Service-only fresh measured fingerprint recording, bound to the verified subject and epoch. Normal return durably records risky/unresolved freeze without replacing approval or refreshing a lease. Recorded-only changes remain active; existing freeze needs explicit approval. SIDSERVER commits this reading before a separate protected action; no public body attestation is accepted.';
|
|
1042
|
+
|
|
1043
|
+
create function agent_board.approve_identity_state(
|
|
1044
|
+
p_identity_agent_id uuid,
|
|
1045
|
+
p_risky_digest text,
|
|
1046
|
+
p_recorded_fingerprint jsonb default '{}'::jsonb
|
|
1047
|
+
)
|
|
1048
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
1049
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
1050
|
+
begin
|
|
1051
|
+
if not agent_board._is_service_role() then
|
|
1052
|
+
raise exception 'identity approval is service_role-only' using errcode = '42501';
|
|
1053
|
+
end if;
|
|
1054
|
+
perform 1 from agent_board.agents where id = (
|
|
1055
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
|
|
1056
|
+
) for update;
|
|
1057
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
1058
|
+
if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
|
|
1059
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
1060
|
+
if p_risky_digest is null or jsonb_typeof(coalesce(p_recorded_fingerprint, '{}'::jsonb)) <> 'object'
|
|
1061
|
+
or p_recorded_fingerprint ->> 'resolution' is distinct from 'resolved'
|
|
1062
|
+
or v_identity.recorded_fingerprint ->> 'resolution' is distinct from 'resolved'
|
|
1063
|
+
or p_risky_digest is distinct from v_identity.current_risky_digest then
|
|
1064
|
+
raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
|
|
1065
|
+
end if;
|
|
1066
|
+
if v_identity.depth = 0 then
|
|
1067
|
+
if not agent_board._session_identity_owner(p_identity_agent_id) then
|
|
1068
|
+
raise exception 'identity approval requires the human owner' using errcode = '42501';
|
|
1069
|
+
end if;
|
|
1070
|
+
elsif not agent_board._session_identity_owner(p_identity_agent_id)
|
|
1071
|
+
and not agent_board._session_identity_ancestor_actor(p_identity_agent_id) then
|
|
1072
|
+
raise exception 'identity approval requires the owner or an approved ancestor' using errcode = '42501';
|
|
1073
|
+
end if;
|
|
1074
|
+
if agent_board._session_identity_owner(p_identity_agent_id) then
|
|
1075
|
+
perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'approve_identity_state');
|
|
1076
|
+
else
|
|
1077
|
+
perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'approve_identity_state');
|
|
1078
|
+
end if;
|
|
1079
|
+
update agent_board.session_identities
|
|
1080
|
+
set approved_risky_digest = p_risky_digest,
|
|
1081
|
+
current_risky_digest = p_risky_digest,
|
|
1082
|
+
recorded_fingerprint = v_identity.recorded_fingerprint || p_recorded_fingerprint,
|
|
1083
|
+
resolution_state = 'active', updated_at = now()
|
|
1084
|
+
where agent_id = p_identity_agent_id;
|
|
1085
|
+
end;
|
|
1086
|
+
$$;
|
|
1087
|
+
revoke all on function agent_board.approve_identity_state(uuid, text, jsonb) from public, anon, authenticated;
|
|
1088
|
+
grant execute on function agent_board.approve_identity_state(uuid, text, jsonb) to service_role;
|
|
1089
|
+
comment on function agent_board.approve_identity_state(uuid, text, jsonb) is 'Service-only measured-fingerprint approval preserving the resolver/auth.uid human or ancestor actor. Depth zero is human-owner-only. Strict ancestors must have their own matching approved current digest and cannot be frozen. Fresh resolution must be explicit and resolved, stored unresolved inputs and unrecorded digests refuse, and tombstones never revive. Uses root-before-identity locking.';
|
|
1090
|
+
|
|
1091
|
+
-- The shared primitive closes forks as well as lineage descendants. Legacy
|
|
1092
|
+
-- human, subtree, platform-admin and fleet-agent revocations use the same path.
|
|
1093
|
+
create or replace function agent_board._revoke_agent_principal(
|
|
1094
|
+
p_agent_id uuid,
|
|
1095
|
+
p_revoked_by_agent_id uuid,
|
|
1096
|
+
p_reason text
|
|
1097
|
+
)
|
|
1098
|
+
returns integer
|
|
1099
|
+
language plpgsql
|
|
1100
|
+
security definer
|
|
1101
|
+
set search_path = agent_board, public
|
|
1102
|
+
as $$
|
|
1103
|
+
declare
|
|
1104
|
+
v_key record;
|
|
1105
|
+
v_key_count integer := 0;
|
|
1106
|
+
v_agent_flipped boolean := false;
|
|
1107
|
+
v_ids uuid[];
|
|
1108
|
+
v_id uuid;
|
|
1109
|
+
begin
|
|
1110
|
+
perform 1 from agent_board.agents a where a.id = (
|
|
1111
|
+
select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_agent_id
|
|
1112
|
+
) for no key update;
|
|
1113
|
+
with recursive closure(agent_id) as (
|
|
1114
|
+
select p_agent_id
|
|
1115
|
+
union
|
|
1116
|
+
select a.id from agent_board.agents a
|
|
1117
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
1118
|
+
join closure c on a.parent_agent_id = c.agent_id or si.forked_from = c.agent_id
|
|
1119
|
+
where p_reason is distinct from 'superseded_unclaimed'
|
|
1120
|
+
)
|
|
1121
|
+
select array_agg(agent_id order by agent_id) into v_ids from closure;
|
|
1122
|
+
perform 1 from agent_board.session_identities si
|
|
1123
|
+
where si.agent_id = any(v_ids) order by si.agent_id for update;
|
|
1124
|
+
for v_id in select unnest(v_ids)
|
|
1125
|
+
loop
|
|
1126
|
+
v_agent_flipped := false;
|
|
1127
|
+
update agent_board.session_identities
|
|
1128
|
+
set tombstoned_at = now(),
|
|
1129
|
+
tombstone_reason = case when v_id <> p_agent_id then 'cascade'
|
|
1130
|
+
when p_reason = 'superseded_unclaimed' then 'superseded_unclaimed' else 'revoked' end,
|
|
1131
|
+
credential_epoch = credential_epoch + 1, updated_at = now()
|
|
1132
|
+
where agent_id = v_id and tombstoned_at is null;
|
|
1133
|
+
perform 1 from agent_board.agents a where a.id = v_id for update;
|
|
1134
|
+
|
|
1135
|
+
for v_key in
|
|
1136
|
+
select id
|
|
1137
|
+
from agent_board.agent_signing_keys
|
|
1138
|
+
where agent_id = v_id
|
|
1139
|
+
and status not in ('revoked', 'compromised')
|
|
1140
|
+
order by id
|
|
1141
|
+
for update
|
|
1142
|
+
loop
|
|
1143
|
+
update agent_board.agent_signing_keys
|
|
1144
|
+
set status = 'revoked',
|
|
1145
|
+
proof_state = 'revoked',
|
|
1146
|
+
revoked_at = now(),
|
|
1147
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1148
|
+
revocation_reason = p_reason,
|
|
1149
|
+
updated_at = now()
|
|
1150
|
+
where id = v_key.id;
|
|
1151
|
+
|
|
1152
|
+
insert into agent_board.fleet_revocation_events(
|
|
1153
|
+
subject_kind, subject_id, signing_key_id, agent_id,
|
|
1154
|
+
revoked_by_agent_id, revocation_reason, metadata
|
|
1155
|
+
)
|
|
1156
|
+
values (
|
|
1157
|
+
'signing_key', v_key.id, v_key.id, v_id,
|
|
1158
|
+
p_revoked_by_agent_id, p_reason,
|
|
1159
|
+
jsonb_build_object('source', 'revoke_agent_subtree')
|
|
1160
|
+
)
|
|
1161
|
+
on conflict (subject_kind, subject_id) do update
|
|
1162
|
+
set revoked_by_agent_id = excluded.revoked_by_agent_id,
|
|
1163
|
+
revocation_reason = excluded.revocation_reason,
|
|
1164
|
+
signing_key_id = excluded.signing_key_id,
|
|
1165
|
+
agent_id = excluded.agent_id,
|
|
1166
|
+
effective_at = now(),
|
|
1167
|
+
metadata = excluded.metadata;
|
|
1168
|
+
|
|
1169
|
+
v_key_count := v_key_count + 1;
|
|
1170
|
+
end loop;
|
|
1171
|
+
|
|
1172
|
+
update agent_board.agents
|
|
1173
|
+
set status = 'revoked', updated_at = now()
|
|
1174
|
+
where id = v_id and status <> 'revoked';
|
|
1175
|
+
if found then
|
|
1176
|
+
v_agent_flipped := true;
|
|
1177
|
+
end if;
|
|
1178
|
+
|
|
1179
|
+
update agent_board.delegation_grants
|
|
1180
|
+
set revoked_at = now()
|
|
1181
|
+
where child_agent_id = v_id and revoked_at is null;
|
|
1182
|
+
|
|
1183
|
+
update agent_board.agent_credentials ac
|
|
1184
|
+
set revoked_at = coalesce(ac.revoked_at, now()),
|
|
1185
|
+
updated_at = now(),
|
|
1186
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
1187
|
+
|| jsonb_build_object(
|
|
1188
|
+
'revokedByAgentId', p_revoked_by_agent_id,
|
|
1189
|
+
'revocationReason', p_reason,
|
|
1190
|
+
'source', 'revoke_agent_subtree'
|
|
1191
|
+
)
|
|
1192
|
+
where ac.agent_id = v_id and ac.revoked_at is null;
|
|
1193
|
+
|
|
1194
|
+
insert into agent_board.fleet_revocation_events(
|
|
1195
|
+
subject_kind, subject_id, agent_id,
|
|
1196
|
+
revoked_by_agent_id, revocation_reason, metadata
|
|
1197
|
+
)
|
|
1198
|
+
values (
|
|
1199
|
+
'agent', v_id, v_id,
|
|
1200
|
+
p_revoked_by_agent_id, p_reason,
|
|
1201
|
+
jsonb_build_object('source', 'revoke_agent_subtree', 'statusFlipped', v_agent_flipped)
|
|
1202
|
+
)
|
|
1203
|
+
on conflict (subject_kind, subject_id) do update
|
|
1204
|
+
set revoked_by_agent_id = excluded.revoked_by_agent_id,
|
|
1205
|
+
revocation_reason = excluded.revocation_reason,
|
|
1206
|
+
agent_id = excluded.agent_id,
|
|
1207
|
+
effective_at = now(),
|
|
1208
|
+
metadata = excluded.metadata;
|
|
1209
|
+
|
|
1210
|
+
end loop;
|
|
1211
|
+
return v_key_count;
|
|
1212
|
+
end;
|
|
1213
|
+
$$;
|
|
1214
|
+
revoke all on function agent_board._revoke_agent_principal(uuid, uuid, text) from public, anon, authenticated, service_role;
|
|
1215
|
+
grant execute on function agent_board._revoke_agent_principal(uuid, uuid, text) to service_role;
|
|
1216
|
+
comment on function agent_board._revoke_agent_principal(uuid, uuid, text) is 'Revokes a principal and transitive identity forks and lineage descendants, except superseded_unclaimed revokes only its never-claimed target. Takes root then identity locks before principal and keys. Tombstones retained alias targets, fences each epoch once, preserves permanent ledger and writes status, keys, grants and legacy credentials. Returns the number of newly revoked signing keys.';
|
|
1217
|
+
|
|
1218
|
+
create or replace function agent_board.revoke_agent_subtree(
|
|
1219
|
+
p_root_agent_id uuid,
|
|
1220
|
+
p_revoked_by_agent_id uuid,
|
|
1221
|
+
p_reason text default 'root revocation'
|
|
1222
|
+
)
|
|
1223
|
+
returns table(revoked_agent_count integer, revoked_key_count integer)
|
|
1224
|
+
language plpgsql
|
|
1225
|
+
security definer
|
|
1226
|
+
set search_path = agent_board, public
|
|
1227
|
+
as $$
|
|
1228
|
+
declare
|
|
1229
|
+
v_agent uuid;
|
|
1230
|
+
v_agent_count integer := 0;
|
|
1231
|
+
v_key_count integer := 0;
|
|
1232
|
+
begin
|
|
1233
|
+
if not agent_board._is_service_role() then
|
|
1234
|
+
raise exception 'revoke_agent_subtree is service_role-only' using errcode = '42501';
|
|
1235
|
+
end if;
|
|
1236
|
+
if trim(p_reason) = 'superseded_unclaimed' then
|
|
1237
|
+
raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
|
|
1238
|
+
end if;
|
|
1239
|
+
if not exists (select 1 from agent_board.agents where id = p_root_agent_id) then
|
|
1240
|
+
raise exception 'root agent not found' using errcode = 'P0002';
|
|
1241
|
+
end if;
|
|
1242
|
+
|
|
1243
|
+
perform 1 from agent_board.agents a where a.id = (
|
|
1244
|
+
select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_root_agent_id
|
|
1245
|
+
) for no key update;
|
|
1246
|
+
|
|
1247
|
+
for v_agent in
|
|
1248
|
+
with recursive subtree(agent_id) as (
|
|
1249
|
+
select p_root_agent_id
|
|
1250
|
+
union
|
|
1251
|
+
select a.id from agent_board.agents a
|
|
1252
|
+
left join agent_board.session_identities si on si.agent_id = a.id
|
|
1253
|
+
join subtree s on a.parent_agent_id = s.agent_id or si.forked_from = s.agent_id
|
|
1254
|
+
)
|
|
1255
|
+
select agent_id from subtree order by agent_id
|
|
1256
|
+
loop
|
|
1257
|
+
v_key_count := v_key_count
|
|
1258
|
+
+ agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
|
|
1259
|
+
v_agent_count := v_agent_count + 1;
|
|
1260
|
+
end loop;
|
|
1261
|
+
|
|
1262
|
+
return query select v_agent_count, v_key_count;
|
|
1263
|
+
end;
|
|
1264
|
+
$$;
|
|
1265
|
+
revoke all on function agent_board.revoke_agent_subtree(uuid, uuid, text) from public, anon, authenticated, service_role;
|
|
1266
|
+
grant execute on function agent_board.revoke_agent_subtree(uuid, uuid, text) to service_role;
|
|
1267
|
+
comment on function agent_board.revoke_agent_subtree(uuid, uuid, text) is 'Service-only root and descendant revocation, including transitive forks. Root-before-identity lock order and the shared principal primitive serialize renewals; returned agent and key counts cover the full closure.';
|
|
1268
|
+
|
|
1269
|
+
create function agent_board._revoke_session_identity_family(p_agent_id uuid, p_actor uuid, p_reason text)
|
|
1270
|
+
returns void language plpgsql security definer set search_path = '' as $$
|
|
1271
|
+
declare v_id uuid;
|
|
1272
|
+
begin
|
|
1273
|
+
-- A legacy ledger-only revoke keeps its legacy principal/key state. New
|
|
1274
|
+
-- session descendants still receive the full physical write-through.
|
|
1275
|
+
for v_id in
|
|
1276
|
+
with recursive family(agent_id) as (
|
|
1277
|
+
select p_agent_id
|
|
1278
|
+
union
|
|
1279
|
+
select a.id from agent_board.agents a left join agent_board.session_identities si on si.agent_id = a.id
|
|
1280
|
+
join family f on a.parent_agent_id = f.agent_id or si.forked_from = f.agent_id
|
|
1281
|
+
)
|
|
1282
|
+
select si.agent_id from family f join agent_board.session_identities si on si.agent_id = f.agent_id
|
|
1283
|
+
where si.identity_kind = 'session' order by si.agent_id
|
|
1284
|
+
loop
|
|
1285
|
+
perform agent_board._revoke_agent_principal(v_id, p_actor, p_reason);
|
|
1286
|
+
end loop;
|
|
1287
|
+
end;
|
|
1288
|
+
$$;
|
|
1289
|
+
revoke all on function agent_board._revoke_session_identity_family(uuid, uuid, text) from public, anon, authenticated;
|
|
1290
|
+
grant execute on function agent_board._revoke_session_identity_family(uuid, uuid, text) to service_role;
|
|
1291
|
+
comment on function agent_board._revoke_session_identity_family(uuid, uuid, text) is 'Closes session identities and transitive forks/descendants through the principal primitive without changing the pre-SIDSCHEMA state semantics of a legacy parent. Internal verified-actor helper for fleet and platform-admin revocation.';
|
|
1292
|
+
|
|
1293
|
+
create or replace function agent_board.revoke_fleet_subject(
|
|
1294
|
+
p_subject_kind text,
|
|
1295
|
+
p_subject_id uuid,
|
|
1296
|
+
p_revoked_by_agent_id uuid,
|
|
1297
|
+
p_revocation_reason text,
|
|
1298
|
+
p_metadata jsonb default '{}'::jsonb
|
|
1299
|
+
)
|
|
1300
|
+
returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
|
|
1301
|
+
language plpgsql
|
|
1302
|
+
security definer
|
|
1303
|
+
set search_path = agent_board, public
|
|
1304
|
+
as $$
|
|
1305
|
+
declare
|
|
1306
|
+
v_event agent_board.fleet_revocation_events%rowtype;
|
|
1307
|
+
v_actor_agent_id uuid;
|
|
1308
|
+
v_lock_machine_id uuid;
|
|
1309
|
+
v_lock_agent_id uuid;
|
|
1310
|
+
begin
|
|
1311
|
+
if trim(p_revocation_reason) = 'superseded_unclaimed' then
|
|
1312
|
+
raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
|
|
1313
|
+
end if;
|
|
1314
|
+
if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
|
|
1315
|
+
raise exception 'unsupported fleet revocation subject' using errcode = '22023';
|
|
1316
|
+
end if;
|
|
1317
|
+
|
|
1318
|
+
if p_subject_kind in ('machine', 'harness', 'repo_scope') then
|
|
1319
|
+
v_lock_machine_id := case
|
|
1320
|
+
when p_subject_kind = 'machine' then p_subject_id
|
|
1321
|
+
when p_subject_kind = 'harness' then (
|
|
1322
|
+
select fhr.machine_id
|
|
1323
|
+
from agent_board.fleet_harness_registrations fhr
|
|
1324
|
+
where fhr.id = p_subject_id)
|
|
1325
|
+
when p_subject_kind = 'repo_scope' then (
|
|
1326
|
+
select fhr.machine_id
|
|
1327
|
+
from agent_board.fleet_repo_scopes frs
|
|
1328
|
+
join agent_board.fleet_harness_registrations fhr
|
|
1329
|
+
on fhr.id = frs.harness_registration_id
|
|
1330
|
+
where frs.id = p_subject_id)
|
|
1331
|
+
end;
|
|
1332
|
+
if v_lock_machine_id is not null then
|
|
1333
|
+
perform 1
|
|
1334
|
+
from agent_board.fleet_machines fm
|
|
1335
|
+
where fm.id = v_lock_machine_id
|
|
1336
|
+
for update;
|
|
1337
|
+
end if;
|
|
1338
|
+
elsif p_subject_kind = 'agent' then
|
|
1339
|
+
for v_lock_machine_id in
|
|
1340
|
+
select distinct fma.machine_id
|
|
1341
|
+
from agent_board.fleet_machine_agents fma
|
|
1342
|
+
where fma.agent_id = p_subject_id
|
|
1343
|
+
order by fma.machine_id
|
|
1344
|
+
loop
|
|
1345
|
+
perform 1
|
|
1346
|
+
from agent_board.fleet_machines fm
|
|
1347
|
+
where fm.id = v_lock_machine_id
|
|
1348
|
+
for update;
|
|
1349
|
+
end loop;
|
|
1350
|
+
perform 1 from agent_board.agents a where a.id = (
|
|
1351
|
+
select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_subject_id
|
|
1352
|
+
) for no key update;
|
|
1353
|
+
perform 1 from agent_board.agents a where a.id = p_subject_id for update;
|
|
1354
|
+
perform 1
|
|
1355
|
+
from agent_board.fleet_machine_agents fma
|
|
1356
|
+
where fma.agent_id = p_subject_id
|
|
1357
|
+
order by fma.id
|
|
1358
|
+
for update;
|
|
1359
|
+
elsif p_subject_kind = 'signing_key' then
|
|
1360
|
+
select ask.agent_id
|
|
1361
|
+
into v_lock_agent_id
|
|
1362
|
+
from agent_board.agent_signing_keys ask
|
|
1363
|
+
where ask.id = p_subject_id;
|
|
1364
|
+
if v_lock_agent_id is not null then
|
|
1365
|
+
perform 1 from agent_board.agents a where a.id = v_lock_agent_id for update;
|
|
1366
|
+
end if;
|
|
1367
|
+
perform 1 from agent_board.agent_signing_keys ask where ask.id = p_subject_id for update;
|
|
1368
|
+
end if;
|
|
1369
|
+
|
|
1370
|
+
if p_subject_kind = 'trust_domain' then
|
|
1371
|
+
perform 1
|
|
1372
|
+
from agent_board.fleet_trust_domains ftd
|
|
1373
|
+
where ftd.id = p_subject_id
|
|
1374
|
+
for update;
|
|
1375
|
+
end if;
|
|
1376
|
+
|
|
1377
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(
|
|
1378
|
+
p_revoked_by_agent_id,
|
|
1379
|
+
'p_revoked_by_agent_id'
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
if not agent_board._is_service_role() then
|
|
1383
|
+
if p_subject_kind = 'trust_domain' and not exists (
|
|
1384
|
+
select 1 from agent_board.fleet_trust_domains ftd
|
|
1385
|
+
where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
|
|
1386
|
+
) then
|
|
1387
|
+
raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
|
|
1388
|
+
elsif p_subject_kind = 'machine' and not exists (
|
|
1389
|
+
select 1 from agent_board.fleet_machines fm
|
|
1390
|
+
where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1391
|
+
) then
|
|
1392
|
+
raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
|
|
1393
|
+
elsif p_subject_kind = 'harness' and not exists (
|
|
1394
|
+
select 1
|
|
1395
|
+
from agent_board.fleet_harness_registrations fhr
|
|
1396
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
1397
|
+
where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1398
|
+
) then
|
|
1399
|
+
raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
|
|
1400
|
+
elsif p_subject_kind = 'repo_scope' and not exists (
|
|
1401
|
+
select 1
|
|
1402
|
+
from agent_board.fleet_repo_scopes frs
|
|
1403
|
+
join agent_board.fleet_harness_registrations fhr
|
|
1404
|
+
on fhr.id = frs.harness_registration_id
|
|
1405
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
1406
|
+
where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
1407
|
+
) then
|
|
1408
|
+
raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
|
|
1409
|
+
elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
|
|
1410
|
+
raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
|
|
1411
|
+
elsif p_subject_kind = 'signing_key' and not exists (
|
|
1412
|
+
select 1 from agent_board.agent_signing_keys ask
|
|
1413
|
+
where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
|
|
1414
|
+
) then
|
|
1415
|
+
raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
|
|
1416
|
+
elsif p_subject_kind = 'credential' and not exists (
|
|
1417
|
+
select 1 from agent_board.agent_credentials ac
|
|
1418
|
+
where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
|
|
1419
|
+
) then
|
|
1420
|
+
raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
|
|
1421
|
+
end if;
|
|
1422
|
+
end if;
|
|
1423
|
+
|
|
1424
|
+
select *
|
|
1425
|
+
into v_event
|
|
1426
|
+
from agent_board.fleet_revocation_events fre
|
|
1427
|
+
where fre.subject_kind = p_subject_kind
|
|
1428
|
+
and fre.subject_id = p_subject_id
|
|
1429
|
+
limit 1;
|
|
1430
|
+
|
|
1431
|
+
if v_event.id is null then
|
|
1432
|
+
insert into agent_board.fleet_revocation_events(
|
|
1433
|
+
subject_kind,
|
|
1434
|
+
subject_id,
|
|
1435
|
+
trust_domain_id,
|
|
1436
|
+
machine_id,
|
|
1437
|
+
harness_registration_id,
|
|
1438
|
+
repo_scope_id,
|
|
1439
|
+
agent_id,
|
|
1440
|
+
signing_key_id,
|
|
1441
|
+
revoked_by_agent_id,
|
|
1442
|
+
revocation_reason,
|
|
1443
|
+
metadata
|
|
1444
|
+
)
|
|
1445
|
+
values (
|
|
1446
|
+
p_subject_kind,
|
|
1447
|
+
p_subject_id,
|
|
1448
|
+
case when p_subject_kind = 'trust_domain' then p_subject_id end,
|
|
1449
|
+
case when p_subject_kind = 'machine' then p_subject_id end,
|
|
1450
|
+
case when p_subject_kind = 'harness' then p_subject_id end,
|
|
1451
|
+
case when p_subject_kind = 'repo_scope' then p_subject_id end,
|
|
1452
|
+
case when p_subject_kind = 'agent' then p_subject_id end,
|
|
1453
|
+
case when p_subject_kind = 'signing_key' then p_subject_id end,
|
|
1454
|
+
p_revoked_by_agent_id,
|
|
1455
|
+
trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1456
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
1457
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
1458
|
+
)
|
|
1459
|
+
returning * into v_event;
|
|
1460
|
+
else
|
|
1461
|
+
update agent_board.fleet_revocation_events fre
|
|
1462
|
+
set effective_at = now(),
|
|
1463
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1464
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1465
|
+
metadata = coalesce(p_metadata, '{}'::jsonb)
|
|
1466
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
1467
|
+
where fre.id = v_event.id
|
|
1468
|
+
returning * into v_event;
|
|
1469
|
+
end if;
|
|
1470
|
+
|
|
1471
|
+
if p_subject_kind = 'trust_domain' then
|
|
1472
|
+
update agent_board.fleet_trust_domains
|
|
1473
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1474
|
+
where id = p_subject_id;
|
|
1475
|
+
elsif p_subject_kind = 'machine' then
|
|
1476
|
+
update agent_board.fleet_machines
|
|
1477
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1478
|
+
where id = p_subject_id;
|
|
1479
|
+
elsif p_subject_kind = 'harness' then
|
|
1480
|
+
update agent_board.fleet_harness_registrations
|
|
1481
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1482
|
+
where id = p_subject_id;
|
|
1483
|
+
elsif p_subject_kind = 'repo_scope' then
|
|
1484
|
+
update agent_board.fleet_repo_scopes
|
|
1485
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1486
|
+
where id = p_subject_id;
|
|
1487
|
+
elsif p_subject_kind = 'agent' then
|
|
1488
|
+
update agent_board.fleet_machine_agents
|
|
1489
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
1490
|
+
where agent_id = p_subject_id;
|
|
1491
|
+
elsif p_subject_kind = 'signing_key' then
|
|
1492
|
+
update agent_board.agent_signing_keys
|
|
1493
|
+
set status = case when status = 'compromised' then status else 'revoked' end,
|
|
1494
|
+
proof_state = 'revoked',
|
|
1495
|
+
revoked_at = coalesce(revoked_at, v_event.effective_at),
|
|
1496
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
1497
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
1498
|
+
updated_at = now()
|
|
1499
|
+
where id = p_subject_id;
|
|
1500
|
+
end if;
|
|
1501
|
+
|
|
1502
|
+
if p_subject_kind = 'agent' then
|
|
1503
|
+
perform agent_board._revoke_session_identity_family(p_subject_id, v_actor_agent_id, p_revocation_reason);
|
|
1504
|
+
end if;
|
|
1505
|
+
return query select v_event.id, v_event.subject_kind, v_event.effective_at;
|
|
1506
|
+
end;
|
|
1507
|
+
$$;
|
|
1508
|
+
revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon, authenticated, service_role;
|
|
1509
|
+
grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
1510
|
+
comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is 'Effective fleet revocation preserving existing actor ownership and machine-before-agent locking. Agent subjects additionally take their lineage root before the agent and close identity aliases, forks, descendants, epochs and all principal credential state through the shared primitive.';
|
|
1511
|
+
|
|
1512
|
+
create or replace function agent_board.revoke_agent(
|
|
1513
|
+
p_agent_id uuid,
|
|
1514
|
+
p_reason text default null
|
|
1515
|
+
)
|
|
1516
|
+
returns table(
|
|
1517
|
+
agent_id uuid,
|
|
1518
|
+
status text,
|
|
1519
|
+
revoked_credential_count integer,
|
|
1520
|
+
revoked_at timestamptz
|
|
1521
|
+
)
|
|
1522
|
+
language plpgsql
|
|
1523
|
+
security definer
|
|
1524
|
+
set search_path = agent_board, public
|
|
1525
|
+
as $$
|
|
1526
|
+
declare
|
|
1527
|
+
v_admin_user_id uuid;
|
|
1528
|
+
v_agent agent_board.agents%rowtype;
|
|
1529
|
+
v_revoked_at timestamptz := now();
|
|
1530
|
+
v_revoked_count integer := 0;
|
|
1531
|
+
v_actor uuid;
|
|
1532
|
+
begin
|
|
1533
|
+
v_admin_user_id := agent_board._require_platform_admin_user_id();
|
|
1534
|
+
v_actor := agent_board.current_agent_id();
|
|
1535
|
+
if trim(p_reason) = 'superseded_unclaimed' then
|
|
1536
|
+
raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
|
|
1537
|
+
end if;
|
|
1538
|
+
if v_actor is not null then
|
|
1539
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'revoke_agent');
|
|
1540
|
+
end if;
|
|
1541
|
+
|
|
1542
|
+
perform 1 from agent_board.agents a where a.id = (
|
|
1543
|
+
select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_agent_id
|
|
1544
|
+
) for no key update;
|
|
1545
|
+
|
|
1546
|
+
-- Legacy service scripts and Portal admins need no board actor. Only a
|
|
1547
|
+
-- session family writes the permanent ledger, whose attribution is required.
|
|
1548
|
+
if v_actor is null and exists (
|
|
1549
|
+
with recursive family(agent_id) as (
|
|
1550
|
+
select p_agent_id
|
|
1551
|
+
union
|
|
1552
|
+
select a.id from agent_board.agents a left join agent_board.session_identities si on si.agent_id = a.id
|
|
1553
|
+
join family f on a.parent_agent_id = f.agent_id or si.forked_from = f.agent_id
|
|
1554
|
+
)
|
|
1555
|
+
select 1 from family f join agent_board.session_identities si on si.agent_id = f.agent_id
|
|
1556
|
+
where si.identity_kind = 'session'
|
|
1557
|
+
) then raise exception 'revocation requires a resolved actor' using errcode = '42501'; end if;
|
|
1558
|
+
|
|
1559
|
+
select count(*)::integer into v_revoked_count from agent_board.agent_credentials ac
|
|
1560
|
+
where ac.agent_id = p_agent_id and ac.revoked_at is null;
|
|
1561
|
+
perform agent_board._revoke_session_identity_family(p_agent_id,
|
|
1562
|
+
v_actor, coalesce(p_reason, 'operator revocation'));
|
|
1563
|
+
update agent_board.agents a
|
|
1564
|
+
set status = case when exists (select 1 from agent_board.session_identities si
|
|
1565
|
+
where si.agent_id = p_agent_id and si.identity_kind = 'session') then 'revoked' else 'suspended' end,
|
|
1566
|
+
updated_at = v_revoked_at,
|
|
1567
|
+
metadata = coalesce(a.metadata, '{}'::jsonb)
|
|
1568
|
+
|| jsonb_build_object(
|
|
1569
|
+
'revokedByUserId', v_admin_user_id,
|
|
1570
|
+
'revokedAt', v_revoked_at,
|
|
1571
|
+
'revocationReason', nullif(trim(p_reason), '')
|
|
1572
|
+
)
|
|
1573
|
+
where a.id = p_agent_id
|
|
1574
|
+
returning * into v_agent;
|
|
1575
|
+
|
|
1576
|
+
if v_agent.id is null then
|
|
1577
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
1578
|
+
end if;
|
|
1579
|
+
|
|
1580
|
+
update agent_board.agent_credentials ac
|
|
1581
|
+
set revoked_at = coalesce(ac.revoked_at, v_revoked_at),
|
|
1582
|
+
updated_at = v_revoked_at,
|
|
1583
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
1584
|
+
|| jsonb_build_object(
|
|
1585
|
+
'revokedByUserId', v_admin_user_id,
|
|
1586
|
+
'revokedAt', v_revoked_at,
|
|
1587
|
+
'revocationReason', nullif(trim(p_reason), '')
|
|
1588
|
+
)
|
|
1589
|
+
where ac.agent_id = p_agent_id
|
|
1590
|
+
and ac.revoked_at is null;
|
|
1591
|
+
|
|
1592
|
+
return query
|
|
1593
|
+
select v_agent.id, v_agent.status, v_revoked_count, v_revoked_at;
|
|
1594
|
+
end;
|
|
1595
|
+
$$;
|
|
1596
|
+
revoke all on function agent_board.revoke_agent(uuid, text) from public, anon, authenticated, service_role;
|
|
1597
|
+
grant execute on function agent_board.revoke_agent(uuid, text) to authenticated, service_role;
|
|
1598
|
+
comment on function agent_board.revoke_agent(uuid, text) is 'Platform-admin agent revocation preserves its admin gate and returned credential count while closing the full identity fork and descendant closure under root-before-identity locks; returns the final revoked principal status.';
|
|
1599
|
+
|
|
1600
|
+
create function agent_board.revoke_session_identity(p_identity_agent_id uuid)
|
|
1601
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
1602
|
+
declare v_target agent_board.session_identities%rowtype; v_id uuid; v_actor uuid := agent_board.current_agent_id();
|
|
1603
|
+
begin
|
|
1604
|
+
if not agent_board._session_identity_owner(p_identity_agent_id)
|
|
1605
|
+
and not agent_board._session_identity_revocation_actor(p_identity_agent_id) then
|
|
1606
|
+
raise exception 'identity revocation requires the owner or an ancestor' using errcode = '42501';
|
|
1607
|
+
end if;
|
|
1608
|
+
perform 1 from agent_board.agents where id = (
|
|
1609
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
|
|
1610
|
+
) for update;
|
|
1611
|
+
select * into v_target from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
1612
|
+
if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
|
|
1613
|
+
if not agent_board._session_identity_owner(p_identity_agent_id)
|
|
1614
|
+
and not agent_board._session_identity_revocation_actor(p_identity_agent_id) then
|
|
1615
|
+
raise exception 'identity revocation requires the owner or an ancestor' using errcode = '42501';
|
|
1616
|
+
end if;
|
|
1617
|
+
if agent_board._session_identity_owner(p_identity_agent_id) then
|
|
1618
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'revoke_session_identity');
|
|
1619
|
+
end if;
|
|
1620
|
+
perform agent_board._revoke_agent_principal(p_identity_agent_id,
|
|
1621
|
+
coalesce(v_actor, v_target.root_agent_id), 'session_identity_revoked');
|
|
1622
|
+
end;
|
|
1623
|
+
$$;
|
|
1624
|
+
revoke all on function agent_board.revoke_session_identity(uuid) from public, anon;
|
|
1625
|
+
grant execute on function agent_board.revoke_session_identity(uuid) to authenticated;
|
|
1626
|
+
comment on function agent_board.revoke_session_identity(uuid) is 'Owner or ancestor revocation of an identity, its descendants and its transitive forks. Each affected principal is revoked through _revoke_agent_principal and has its lease epoch fenced.';
|
|
1627
|
+
|
|
1628
|
+
create function agent_board.readmit_session_identity(
|
|
1629
|
+
p_identity_agent_id uuid, p_child_slug text, p_child_public_key text,
|
|
1630
|
+
p_requested_scopes jsonb default '[]'::jsonb,
|
|
1631
|
+
p_requested_rooms jsonb default '[]'::jsonb, p_ttl interval default null
|
|
1632
|
+
)
|
|
1633
|
+
returns table(child_agent_id uuid, grant_expires_at timestamptz, signing_key_id uuid)
|
|
1634
|
+
language plpgsql security definer set search_path = agent_board, public as $$
|
|
1635
|
+
declare
|
|
1636
|
+
v_old agent_board.session_identities%rowtype;
|
|
1637
|
+
v_parent agent_board.agents%rowtype;
|
|
1638
|
+
v_original_grant agent_board.delegation_grants%rowtype;
|
|
1639
|
+
v_policy agent_board.delegation_policies%rowtype;
|
|
1640
|
+
v_root uuid;
|
|
1641
|
+
v_owner uuid;
|
|
1642
|
+
v_parent_scopes jsonb;
|
|
1643
|
+
v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
|
|
1644
|
+
v_rooms jsonb := coalesce(p_requested_rooms, '[]'::jsonb);
|
|
1645
|
+
v_alias uuid;
|
|
1646
|
+
v_new_id uuid := gen_random_uuid();
|
|
1647
|
+
v_new_auth uuid;
|
|
1648
|
+
v_key_id uuid;
|
|
1649
|
+
v_slug text;
|
|
1650
|
+
v_expires timestamptz;
|
|
1651
|
+
v_ceiling interval;
|
|
1652
|
+
v_ancestor_expires timestamptz;
|
|
1653
|
+
v_room jsonb;
|
|
1654
|
+
v_board uuid;
|
|
1655
|
+
v_role text;
|
|
1656
|
+
v_old_role text;
|
|
1657
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
1658
|
+
begin
|
|
1659
|
+
-- Owner identity comes from this authenticated session, never from an argument.
|
|
1660
|
+
if not agent_board._session_identity_owner(p_identity_agent_id) then
|
|
1661
|
+
raise exception 'identity readmission requires the human owner' using errcode = '42501';
|
|
1662
|
+
end if;
|
|
1663
|
+
if jsonb_typeof(v_requested) <> 'array' or jsonb_typeof(v_rooms) <> 'array'
|
|
1664
|
+
or p_ttl <= interval '0' then raise exception 'invalid readmission authority request' using errcode = '22023'; end if;
|
|
1665
|
+
-- All board locks precede the root/accounting lock and identity rows.
|
|
1666
|
+
for v_board in select distinct (r ->> 'board_id')::uuid from jsonb_array_elements(v_rooms) r order by 1 loop
|
|
1667
|
+
perform agent_board._lock_board_authority_ceiling(v_board);
|
|
1668
|
+
perform 1 from agent_board.boards where id = v_board for key share;
|
|
1669
|
+
end loop;
|
|
1670
|
+
select root_agent_id into v_root from agent_board.session_identities where agent_id = p_identity_agent_id;
|
|
1671
|
+
select owner_user_id into v_owner from agent_board.agents where id = v_root;
|
|
1672
|
+
perform 1 from auth.users where id = v_owner for key share;
|
|
1673
|
+
perform 1 from agent_board.agents where id = v_root for update;
|
|
1674
|
+
select * into v_old from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
1675
|
+
if v_old.tombstoned_at is null or v_old.tombstone_reason = 'superseded_unclaimed'
|
|
1676
|
+
or v_old.forked_from is null then
|
|
1677
|
+
raise exception 'identity readmission requires a revoked fork' using errcode = '42501';
|
|
1678
|
+
end if;
|
|
1679
|
+
if exists (select 1 from agent_board.session_identities where readmitted_from = p_identity_agent_id) then
|
|
1680
|
+
raise exception 'identity_already_readmitted' using errcode = '42501';
|
|
1681
|
+
end if;
|
|
1682
|
+
select * into v_parent from agent_board.agents where id = v_old.parent_agent_id;
|
|
1683
|
+
if v_parent.id is null or v_parent.status <> 'active' or exists (
|
|
1684
|
+
select 1 from agent_board.session_identities where agent_id = v_parent.id and tombstoned_at is not null
|
|
1685
|
+
) then raise exception 'identity_parent_inactive' using errcode = '42501'; end if;
|
|
1686
|
+
v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_parent.id);
|
|
1687
|
+
select * into v_original_grant from agent_board.delegation_grants g where g.child_agent_id = p_identity_agent_id;
|
|
1688
|
+
select * into v_policy from agent_board.delegation_policies dp
|
|
1689
|
+
where dp.root_agent_id = v_root and dp.revoked_at is null order by dp.created_at desc, dp.id desc limit 1 for update;
|
|
1690
|
+
if not found or v_policy.mode <> 'root_minted' then
|
|
1691
|
+
raise exception 'identity_parent_inactive' using errcode = '42501';
|
|
1692
|
+
end if;
|
|
1693
|
+
if v_parent.parent_agent_id is null then v_parent_scopes := v_policy.allowed_scopes;
|
|
1694
|
+
else select g.granted_scopes into v_parent_scopes from agent_board.delegation_grants g where g.child_agent_id = v_parent.id;
|
|
1695
|
+
end if;
|
|
1696
|
+
if not (coalesce(v_parent_scopes, '[]'::jsonb) @> v_requested)
|
|
1697
|
+
or not (coalesce(v_original_grant.granted_scopes, '[]'::jsonb) @> v_requested) then
|
|
1698
|
+
raise exception 'scope_exceeded: readmission cannot widen original or parent scopes' using errcode = '42501';
|
|
1699
|
+
end if;
|
|
1700
|
+
perform agent_board._session_identity_check_limits(v_root, v_parent.id);
|
|
1701
|
+
-- The most recently bound alias is reserved; no caller supplies a session id.
|
|
1702
|
+
select a.id into v_alias from agent_board.session_id_aliases a
|
|
1703
|
+
where a.identity_agent_id = p_identity_agent_id and a.id = v_old.lease_alias_id for update;
|
|
1704
|
+
if v_alias is null then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
|
|
1705
|
+
v_slug := agent_board._normalize_provision_slug(p_child_slug);
|
|
1706
|
+
if exists (select 1 from agent_board.agents where handle = v_slug) then
|
|
1707
|
+
raise exception 'readmission requires a new unique agent handle' using errcode = '23505';
|
|
1708
|
+
end if;
|
|
1709
|
+
if p_child_public_key is null or p_child_public_key = repeat('A', 43) then
|
|
1710
|
+
raise exception 'readmission requires a fresh child public key' using errcode = '22023';
|
|
1711
|
+
end if;
|
|
1712
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.agent_id = p_identity_agent_id and k.public_key = p_child_public_key) then
|
|
1713
|
+
raise exception 'readmission cannot reuse the revoked key' using errcode = '42501';
|
|
1714
|
+
end if;
|
|
1715
|
+
v_new_auth := agent_board._ensure_agent_auth_user(v_slug);
|
|
1716
|
+
v_ceiling := least(v_policy.child_ttl_ceiling, v_policy.operator_child_ttl_ceiling, interval '24 hours');
|
|
1717
|
+
v_expires := least(now() + least(coalesce(p_ttl, v_ceiling), v_ceiling), v_ancestor_expires);
|
|
1718
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id,
|
|
1719
|
+
parent_agent_id, root_agent_id, metadata)
|
|
1720
|
+
values (v_new_id, 'agent', 'agent'::agent_board.agent_kind, v_slug, agent_board._default_provision_display_name(v_slug),
|
|
1721
|
+
'active', v_new_auth, v_parent.owner_user_id, v_parent.id, v_root, jsonb_build_object('readmittedFrom', p_identity_agent_id));
|
|
1722
|
+
insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format,
|
|
1723
|
+
fingerprint, status, proof_state, metadata)
|
|
1724
|
+
values (v_new_id, 'readmitted-ephemeral', 1, p_child_public_key, 'raw_ed25519_base64url',
|
|
1725
|
+
agent_board._raw_key_fingerprint(p_child_public_key), 'pending_proof', 'unproven', jsonb_build_object('readmittedFrom', p_identity_agent_id))
|
|
1726
|
+
returning id into v_key_id;
|
|
1727
|
+
insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
|
|
1728
|
+
granted_scopes, grant_expires_at)
|
|
1729
|
+
values (v_new_id, v_root, v_parent.id, v_policy.id, v_requested, v_expires);
|
|
1730
|
+
insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth, identity_kind, harness,
|
|
1731
|
+
forked_from, readmitted_from, current_risky_digest, recorded_fingerprint, resolution_state)
|
|
1732
|
+
values (v_new_id, v_parent.id, v_root, v_old.depth, 'session', v_old.harness, v_old.forked_from,
|
|
1733
|
+
p_identity_agent_id, v_old.current_risky_digest, v_old.recorded_fingerprint, 'frozen');
|
|
1734
|
+
update agent_board.session_id_aliases set identity_agent_id = v_new_id where identity_agent_id = p_identity_agent_id;
|
|
1735
|
+
update agent_board.session_identities set lease_alias_id = v_alias where agent_id = v_new_id;
|
|
1736
|
+
perform agent_board._refuse_delegate_membership_target(v_new_id, 'readmit_session_identity');
|
|
1737
|
+
for v_room in select value from jsonb_array_elements(v_rooms) order by value ->> 'board_id' loop
|
|
1738
|
+
v_board := (v_room ->> 'board_id')::uuid;
|
|
1739
|
+
v_role := v_room ->> 'role';
|
|
1740
|
+
select bm.role into v_old_role from agent_board.board_memberships bm
|
|
1741
|
+
where bm.board_id = v_board and bm.agent_id = p_identity_agent_id
|
|
1742
|
+
order by case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
1743
|
+
bm.created_at desc, bm.id desc limit 1;
|
|
1744
|
+
if v_role not in ('owner', 'admin', 'member', 'observer') or v_role is null or v_old_role is null
|
|
1745
|
+
or agent_board._role_rank(v_role) > agent_board._role_rank(v_old_role)
|
|
1746
|
+
or not agent_board._grant_within_ceiling(v_parent.id, v_board, v_role) then
|
|
1747
|
+
raise exception 'room_authority_exceeded' using errcode = '42501';
|
|
1748
|
+
end if;
|
|
1749
|
+
select * into v_membership from agent_board._upsert_board_membership_state(v_board, v_new_id, 'joined');
|
|
1750
|
+
update agent_board.board_memberships set role = v_role where id = v_membership.id;
|
|
1751
|
+
end loop;
|
|
1752
|
+
perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'readmit_session_identity');
|
|
1753
|
+
return query select v_new_id, v_expires, v_key_id;
|
|
1754
|
+
end;
|
|
1755
|
+
$$;
|
|
1756
|
+
revoke all on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) from public, anon, service_role;
|
|
1757
|
+
grant execute on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) to authenticated;
|
|
1758
|
+
comment on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) is 'Human root owner readmits a selected revoked fork by minting a fresh principal, unclaimed frozen identity and pending fresh key. The old principal, key and permanent ledger remain revoked. Parent/original scopes and roles only shrink, all ancestors and mint quotas are checked under the root lock, all aliases repoint and the latest is reserved. Approve the resolved fingerprint and prove the pending key via record_signing_key_proof_result before first claim.';
|
|
1759
|
+
|
|
1760
|
+
create function agent_board.rename_agent(p_agent_id uuid, p_display_name text)
|
|
1761
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
1762
|
+
begin
|
|
1763
|
+
if p_display_name is null or length(trim(p_display_name)) = 0 then
|
|
1764
|
+
raise exception 'display name is required' using errcode = '22023';
|
|
1765
|
+
end if;
|
|
1766
|
+
if not exists (
|
|
1767
|
+
select 1 from agent_board.agents target join agent_board.agents human
|
|
1768
|
+
on human.auth_user_id = auth.uid()
|
|
1769
|
+
where target.id = p_agent_id and target.owner_user_id = auth.uid()
|
|
1770
|
+
and human.agent_type = 'human' and human.status = 'active'
|
|
1771
|
+
) then
|
|
1772
|
+
raise exception 'agent rename requires the human owner' using errcode = '42501';
|
|
1773
|
+
end if;
|
|
1774
|
+
perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'rename_agent');
|
|
1775
|
+
update agent_board.agents set display_name = p_display_name, updated_at = now() where id = p_agent_id;
|
|
1776
|
+
end;
|
|
1777
|
+
$$;
|
|
1778
|
+
revoke all on function agent_board.rename_agent(uuid, text) from public, anon;
|
|
1779
|
+
grant execute on function agent_board.rename_agent(uuid, text) to authenticated;
|
|
1780
|
+
comment on function agent_board.rename_agent(uuid, text) is 'Human-owner-only display-name update. Agent handles remain immutable.';
|
|
1781
|
+
|
|
1782
|
+
create function agent_board.set_board_member_label(p_board_id uuid, p_agent_id uuid, p_label text)
|
|
1783
|
+
returns void language plpgsql security definer set search_path = agent_board, public as $$
|
|
1784
|
+
declare v_actor uuid := agent_board.current_agent_id();
|
|
1785
|
+
begin
|
|
1786
|
+
if v_actor is null or not exists (
|
|
1787
|
+
select 1 from agent_board.board_memberships bm
|
|
1788
|
+
where bm.board_id = p_board_id and bm.agent_id = v_actor and bm.role in ('owner', 'admin')
|
|
1789
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1790
|
+
) then
|
|
1791
|
+
raise exception 'board label requires a board owner or admin' using errcode = '42501';
|
|
1792
|
+
end if;
|
|
1793
|
+
if length(p_label) > 200 then raise exception 'board label exceeds 200 characters' using errcode = '22023'; end if;
|
|
1794
|
+
perform agent_board._refuse_delegate_membership_target(p_agent_id, 'set_board_member_label');
|
|
1795
|
+
update agent_board.board_memberships
|
|
1796
|
+
set metadata = metadata || jsonb_build_object('label', p_label), updated_at = now()
|
|
1797
|
+
where board_id = p_board_id and agent_id = p_agent_id;
|
|
1798
|
+
if not found then raise exception 'membership not found on this board' using errcode = 'P0002'; end if;
|
|
1799
|
+
end;
|
|
1800
|
+
$$;
|
|
1801
|
+
revoke all on function agent_board.set_board_member_label(uuid, uuid, text) from public, anon;
|
|
1802
|
+
grant execute on function agent_board.set_board_member_label(uuid, uuid, text) to authenticated;
|
|
1803
|
+
comment on function agent_board.set_board_member_label(uuid, uuid, text) is 'Board-admin-only local membership label. It changes membership metadata on the named board and never the agent handle or global display name.';
|
|
1804
|
+
|
|
1805
|
+
-- Catalog-derived by agent_board_atomic_fleet_onboarding.test.sql. Adding the
|
|
1806
|
+
-- three SIDSCHEMA readers changes the helper's machine-checked registry too.
|
|
1807
|
+
create or replace function agent_board.tombstone_delegate_instance(
|
|
1808
|
+
p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
|
|
1809
|
+
returns table(instance_id text, tombstoned_at timestamptz, first boolean)
|
|
1810
|
+
language plpgsql
|
|
1811
|
+
security definer
|
|
1812
|
+
set search_path = ''
|
|
1813
|
+
as $fn$
|
|
1814
|
+
#variable_conflict use_column
|
|
1815
|
+
declare
|
|
1816
|
+
v_owner uuid;
|
|
1817
|
+
v_owner_slug text;
|
|
1818
|
+
v_inserted boolean;
|
|
1819
|
+
v_row agent_board.delegate_instances%rowtype;
|
|
1820
|
+
v_board uuid;
|
|
1821
|
+
begin
|
|
1822
|
+
if not agent_board._is_service_role() then
|
|
1823
|
+
raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
|
|
1824
|
+
end if;
|
|
1825
|
+
if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
1826
|
+
or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
|
|
1827
|
+
or p_reason is null
|
|
1828
|
+
or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
|
|
1829
|
+
raise exception 'revocation_malformed' using errcode = '22023';
|
|
1830
|
+
end if;
|
|
1831
|
+
|
|
1832
|
+
-- Board advisory mutexes precede every owner/instance and identity-family row lock,
|
|
1833
|
+
-- as in onboarding and add_delegate_to_board. Lock all existing boards in UUID order:
|
|
1834
|
+
-- an active-membership snapshot misses a concurrent placement onto another board,
|
|
1835
|
+
-- which takes that board mutex and the owner's agents row before the instance row.
|
|
1836
|
+
-- Taking a board mutex after the instance row would invert that established order.
|
|
1837
|
+
for v_board in select b.id from agent_board.boards b order by b.id loop
|
|
1838
|
+
perform agent_board._lock_board_authority_ceiling(v_board);
|
|
1839
|
+
end loop;
|
|
1840
|
+
|
|
1841
|
+
-- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
|
|
1842
|
+
-- same position: before the instance row), so a revocation and an exchange of a new owner's
|
|
1843
|
+
-- instance never provision two auth users for one owner.
|
|
1844
|
+
v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
|
|
1845
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
|
|
1846
|
+
v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
|
|
1847
|
+
|
|
1848
|
+
-- HOSTED #591: NO auth.users ROW LOCK here, deliberately, unlike the exchange. This function
|
|
1849
|
+
-- reaches boards (update_board_membership below writes board_membership_events, whose foreign
|
|
1850
|
+
-- key takes FOR KEY SHARE on the board), and every route that locks both a board and an owner
|
|
1851
|
+
-- row must take the board first (agent_board_federated_lock_order_class EC-3):
|
|
1852
|
+
-- enroll_federated_human holds its default board FOR UPDATE and then wants the owner's row FOR
|
|
1853
|
+
-- UPDATE, so an owner-row lock taken here first closes a cycle with it. The deadlock against
|
|
1854
|
+
-- revoke_human_subtree (#591 item 8) is closed by the agents-row order below instead
|
|
1855
|
+
-- (hosted_delegate_tombstone_revoke_race P1, P2). What the owner-row lock would have added is
|
|
1856
|
+
-- stated in AB15: see revoke_human_subtree (section 4).
|
|
1857
|
+
|
|
1858
|
+
-- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
|
|
1859
|
+
-- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
|
|
1860
|
+
-- HOSTED #591: with the owner's verified (iss, sub) (section 1).
|
|
1861
|
+
insert into agent_board.delegate_instances as di(
|
|
1862
|
+
instance_id, owner_user_id, owner_issuer, owner_subject, tombstoned_at, tombstone_reason)
|
|
1863
|
+
values (p_instance_id, v_owner, p_issuer, p_owner_sub, now(), p_reason)
|
|
1864
|
+
on conflict (instance_id) do nothing
|
|
1865
|
+
returning true into v_inserted;
|
|
1866
|
+
|
|
1867
|
+
if v_inserted then
|
|
1868
|
+
return query select p_instance_id, now(), true;
|
|
1869
|
+
return;
|
|
1870
|
+
end if;
|
|
1871
|
+
|
|
1872
|
+
select * into v_row
|
|
1873
|
+
from agent_board.delegate_instances di
|
|
1874
|
+
where di.instance_id = p_instance_id
|
|
1875
|
+
for update;
|
|
1876
|
+
|
|
1877
|
+
if v_row.owner_user_id is distinct from v_owner then
|
|
1878
|
+
raise exception 'owner_mismatch' using errcode = '42501';
|
|
1879
|
+
end if;
|
|
1880
|
+
|
|
1881
|
+
-- Idempotent: the key is the instance, and a tombstone is permanent.
|
|
1882
|
+
if v_row.tombstoned_at is not null then
|
|
1883
|
+
-- HOSTED #591: a legacy (pre-1.59.0) tombstoned row has no owner pair, and nothing else ever
|
|
1884
|
+
-- writes it again: the exchange refuses it (instance_revoked) before its UPDATE. So a repeated
|
|
1885
|
+
-- revocation fills the pair here, and nothing else about the row changes. Sound for the same
|
|
1886
|
+
-- reason as the fill below: owner_mismatch above proved this row's uid is the one
|
|
1887
|
+
-- (p_issuer, p_owner_sub) derives. A recorded pair is never touched (the trigger refuses).
|
|
1888
|
+
if v_row.owner_issuer is null then
|
|
1889
|
+
update agent_board.delegate_instances di
|
|
1890
|
+
set owner_issuer = p_issuer,
|
|
1891
|
+
owner_subject = p_owner_sub
|
|
1892
|
+
where di.instance_id = p_instance_id;
|
|
1893
|
+
end if;
|
|
1894
|
+
return query select v_row.instance_id, v_row.tombstoned_at, false;
|
|
1895
|
+
return;
|
|
1896
|
+
end if;
|
|
1897
|
+
|
|
1898
|
+
update agent_board.delegate_instances di
|
|
1899
|
+
set tombstoned_at = now(),
|
|
1900
|
+
tombstone_reason = p_reason,
|
|
1901
|
+
-- HOSTED #591: fills a legacy row's null pair; sound because owner_mismatch above
|
|
1902
|
+
-- proved this row's uid is the one (p_issuer, p_owner_sub) derives.
|
|
1903
|
+
owner_issuer = coalesce(di.owner_issuer, p_issuer),
|
|
1904
|
+
owner_subject = coalesce(di.owner_subject, p_owner_sub),
|
|
1905
|
+
updated_at = now()
|
|
1906
|
+
where di.instance_id = p_instance_id;
|
|
1907
|
+
|
|
1908
|
+
if v_row.agent_id is not null then
|
|
1909
|
+
for v_board in
|
|
1910
|
+
select bm.board_id from agent_board.board_memberships bm
|
|
1911
|
+
where bm.agent_id = v_row.agent_id and bm.membership_state in ('joined', 'subscribed')
|
|
1912
|
+
order by bm.board_id
|
|
1913
|
+
loop
|
|
1914
|
+
perform 1 from agent_board.boards b where b.id = v_board for key share of b;
|
|
1915
|
+
end loop;
|
|
1916
|
+
perform agent_board._revoke_session_identity_family(v_row.agent_id, v_row.agent_id, 'instance_tombstoned');
|
|
1917
|
+
-- HOSTED #591 (item 8): THE DELEGATE'S agents ROW FIRST, before any credential. Every other
|
|
1918
|
+
-- writer of a delegate's agents row and its credentials takes the agents row first
|
|
1919
|
+
-- (_revoke_agent_principal, for revoke_agent_subtree and revoke_human_subtree: FOR UPDATE,
|
|
1920
|
+
-- then credentials; revoke_agent: its UPDATE, then credentials). This UPDATE used to come
|
|
1921
|
+
-- last, after the credentials and the memberships, so either subtree revoker interleaved
|
|
1922
|
+
-- with a tombstone of the same delegate deadlocked (hosted_delegate_tombstone_revoke_race
|
|
1923
|
+
-- P1, P2). It is the same statement and the same FOR NO KEY UPDATE lock the 1.58.0 function
|
|
1924
|
+
-- took, moved ahead; it adds no lock. FOR NO KEY UPDATE does not block the FOR KEY SHARE of a
|
|
1925
|
+
-- foreign-key check, so add_delegate_to_board's sink insert is not newly blocked (the two
|
|
1926
|
+
-- already serialise on the instance row above). An agent already revoked and committed is
|
|
1927
|
+
-- not matched and not locked; its revoker has finished, so there is nothing to order against.
|
|
1928
|
+
update agent_board.agents a
|
|
1929
|
+
set status = 'revoked', updated_at = now()
|
|
1930
|
+
where a.id = v_row.agent_id
|
|
1931
|
+
and a.status <> 'revoked';
|
|
1932
|
+
|
|
1933
|
+
update agent_board.agent_credentials ac
|
|
1934
|
+
set revoked_at = coalesce(ac.revoked_at, now()),
|
|
1935
|
+
updated_at = now(),
|
|
1936
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
1937
|
+
|| jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
|
|
1938
|
+
where ac.agent_id = v_row.agent_id
|
|
1939
|
+
and ac.revoked_at is null;
|
|
1940
|
+
|
|
1941
|
+
-- Every active membership ends through update_board_membership(..., 'left') under
|
|
1942
|
+
-- service_role, so each ends with an append-only operator_removal event.
|
|
1943
|
+
for v_board in
|
|
1944
|
+
select bm.board_id
|
|
1945
|
+
from agent_board.board_memberships bm
|
|
1946
|
+
where bm.agent_id = v_row.agent_id
|
|
1947
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1948
|
+
order by bm.board_id
|
|
1949
|
+
loop
|
|
1950
|
+
perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
|
|
1951
|
+
end loop;
|
|
1952
|
+
end if;
|
|
1953
|
+
|
|
1954
|
+
return query select p_instance_id, now(), true;
|
|
1955
|
+
end;
|
|
1956
|
+
$fn$;
|
|
1957
|
+
|
|
1958
|
+
revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
|
|
1959
|
+
grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
|
|
1960
|
+
|
|
1961
|
+
comment on function agent_board.tombstone_delegate_instance(text,text,text,text) is 'Service-only verified Portal instance tombstone, preserving owner/instance gates, permanent instance state, legacy principal credential and membership behavior. Closes any session descendants and transitive forks through the shared identity-family primitive before principal credentials; never revives an identity.';
|
|
1962
|
+
|
|
1963
|
+
comment on function agent_board._lock_board_authority_ceiling(uuid) is
|
|
1964
|
+
'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row, held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] READERS[serialized: add_delegate_to_board, grant_board_role, link_session_alias, mint_delegated_child, onboard_federated_fleet_agent, readmit_session_identity][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] The SIDSCHEMA readers take the mutexes for every requested or existing descendant room before root, identity and membership locks. The two UNSERIALIZED readers still evaluate a missing ceiling as default-owner without serializing its absence; that known race (message-board#277) is not changed here. Both lists are machine-compared against the catalog; a new reader/writer or changed serialization state fails by name. Internal invoker helper, not an authorization gate; caller privileges determine table access.';
|