@consiliency/agent-board-schema 1.12.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +124 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +282 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MINTHARDEN (#138) — child-key fingerprint integrity + EF-mediated authenticated
|
|
3
|
+
-- minting (completes ROOMDELEG-2 / #59).
|
|
4
|
+
-- =============================================================================
|
|
5
|
+
-- Four defects, one class, closed together (CLAUDE.md rule #1 — enumerate from the
|
|
6
|
+
-- catalog, not a list):
|
|
7
|
+
--
|
|
8
|
+
-- 1. RAW-BYTE FINGERPRINT. mint_delegated_child derived a signing-key fingerprint
|
|
9
|
+
-- over the base64url STRING (`digest(convert_to(key,'UTF8'))`, 20260718001000:203)
|
|
10
|
+
-- while enroll_federated_identity was migrated to the RAW 32 bytes (20260719001200).
|
|
11
|
+
-- Same key, two fingerprints => agent_signing_keys.fingerprint UNIQUE did NOT dedup
|
|
12
|
+
-- mint vs enroll, defeating the squat backstop. This migration extracts ONE
|
|
13
|
+
-- derivation — `_raw_key_fingerprint(text)` — and routes BOTH writers through it.
|
|
14
|
+
-- A catalog-derived pgTAP sweep (pg_get_functiondef) pins that no fingerprint-
|
|
15
|
+
-- INSERTing function bypasses it, so a new divergent writer names itself.
|
|
16
|
+
--
|
|
17
|
+
-- 2. TABLE-BOUNDARY TRIGGER. The function sweep only catches FUNCTION writers; direct
|
|
18
|
+
-- or dynamic DML bypasses it. A BEFORE INSERT OR UPDATE trigger on
|
|
19
|
+
-- agent_signing_keys recomputes the fingerprint from public_key for the raw format
|
|
20
|
+
-- and RAISES on mismatch — the invariant is enforced at the table boundary.
|
|
21
|
+
--
|
|
22
|
+
-- 3. NON-POSITIVE TTL FLOOR. mint accepted p_ttl <= 0, minting an immediately-expired
|
|
23
|
+
-- grant that escapes the max_active_children count while still provisioning an
|
|
24
|
+
-- auth user + agent + key — a fan-out bypass once the authenticated path opens.
|
|
25
|
+
--
|
|
26
|
+
-- 4. EF-MEDIATED AUTHENTICATED MINTING (topology A, maintainer-frozen 2026-07-31).
|
|
27
|
+
-- There is NO in-DB Ed25519 verifier, so mint STAYS service_role-only; direct
|
|
28
|
+
-- authenticated EXECUTE remains REVOKED. The authenticated product surface is the
|
|
29
|
+
-- Edge Function supabase/functions/mint-child, which verifies the root's session
|
|
30
|
+
-- JWT + the child-key proof-of-possession signature (domain-separated) and calls
|
|
31
|
+
-- this RPC with p_self_service=true and the session-bound root identity. On that
|
|
32
|
+
-- path the RPC does NOT trust the stored policy caps (a root can self-widen its
|
|
33
|
+
-- operator_vetted row, #142): it RE-CLAMPS max_active_children to
|
|
34
|
+
-- least(coalesce(stored,25),25) and the TTL ceiling to <= 24h, and applies a
|
|
35
|
+
-- per-root provisioning RATE cap (the concurrent max_active_children cap does not
|
|
36
|
+
-- stop mint->expire->mint cumulative fan-out).
|
|
37
|
+
--
|
|
38
|
+
-- F2 (non-blocking, documented): policy revocation is NOT a sticky kill-switch under
|
|
39
|
+
-- self-service — a root can re-upsert a fresh unrevoked policy row. Agent deactivation
|
|
40
|
+
-- (_agent_principal_active) is the durable lockdown lever; revoked_at on a policy is not.
|
|
41
|
+
--
|
|
42
|
+
-- Additive, forward-only. schema_version stays 8 (helper + trigger + one table + two
|
|
43
|
+
-- function recreations; no data-shape change here — the backfill ships in 20260822000200).
|
|
44
|
+
-- The mint signature widens 5 -> 6 args (trailing p_self_service boolean default false),
|
|
45
|
+
-- so the 5-arg function is DROPped and recreated; existing 4-/5-positional callers still
|
|
46
|
+
-- resolve (the new arg defaults false => the pre-existing operator behavior is unchanged).
|
|
47
|
+
-- =============================================================================
|
|
48
|
+
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
-- 1. _raw_key_fingerprint — the ONE canonical raw-byte fingerprint derivation.
|
|
51
|
+
-- Decodes base64url -> raw 32 bytes, REJECTS a non-canonical (aliased) encoding,
|
|
52
|
+
-- and returns 'sha256:' over the RAW bytes. Byte-identical to enroll's derivation
|
|
53
|
+
-- (20260724000200:120-123) and the client child_keygen keyRef.
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
create or replace function agent_board._raw_key_fingerprint(p_public_key text)
|
|
56
|
+
returns text
|
|
57
|
+
language plpgsql
|
|
58
|
+
immutable
|
|
59
|
+
set search_path = agent_board, public
|
|
60
|
+
as $$
|
|
61
|
+
declare
|
|
62
|
+
v_raw bytea;
|
|
63
|
+
v_reencoded text;
|
|
64
|
+
begin
|
|
65
|
+
if p_public_key is null or p_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
66
|
+
raise exception 'malformed public key: expected a 43-char base64url raw Ed25519 key'
|
|
67
|
+
using errcode = '22023';
|
|
68
|
+
end if;
|
|
69
|
+
-- 43 base64url chars encode 32 bytes with a single '=' pad for the standard alphabet.
|
|
70
|
+
v_raw := decode(translate(p_public_key, '-_', '+/') || '=', 'base64');
|
|
71
|
+
if octet_length(v_raw) <> 32 then
|
|
72
|
+
raise exception 'public key must decode to exactly 32 raw Ed25519 bytes'
|
|
73
|
+
using errcode = '22023';
|
|
74
|
+
end if;
|
|
75
|
+
-- A 32-byte key has 2 don't-care low bits, so up to 4 distinct base64url strings
|
|
76
|
+
-- decode to the same key. Require the canonical re-encode to equal the input so ONE
|
|
77
|
+
-- keypair maps to exactly ONE fingerprint (dedup + squat backstop across all writers).
|
|
78
|
+
v_reencoded := rtrim(translate(encode(v_raw, 'base64'), '+/', '-_'), '=');
|
|
79
|
+
if v_reencoded is distinct from p_public_key then
|
|
80
|
+
raise exception 'public key is not canonical base64url (aliased encoding rejected)'
|
|
81
|
+
using errcode = '22023';
|
|
82
|
+
end if;
|
|
83
|
+
return 'sha256:' || rtrim(translate(encode(extensions.digest(v_raw, 'sha256'), 'base64'), '+/', '-_'), '=');
|
|
84
|
+
end;
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
comment on function agent_board._raw_key_fingerprint(text) is
|
|
88
|
+
'The single canonical raw-byte signing-key fingerprint derivation: decode base64url -> '
|
|
89
|
+
'raw 32 bytes, reject a non-canonical (aliased) encoding, return sha256:base64url over '
|
|
90
|
+
'the RAW bytes. Every agent_signing_keys.fingerprint writer (mint_delegated_child, '
|
|
91
|
+
'enroll_federated_identity) MUST derive through this — a catalog sweep pins it.';
|
|
92
|
+
|
|
93
|
+
revoke all on function agent_board._raw_key_fingerprint(text) from public, anon, authenticated;
|
|
94
|
+
grant execute on function agent_board._raw_key_fingerprint(text) to service_role;
|
|
95
|
+
|
|
96
|
+
-- ---------------------------------------------------------------------------
|
|
97
|
+
-- 2. Table-boundary integrity trigger. Recompute the fingerprint from public_key for
|
|
98
|
+
-- the raw format and raise on mismatch, catching direct/dynamic DML the function
|
|
99
|
+
-- sweep cannot see. Skips unrelated UPDATEs (neither key nor fingerprint changed) so
|
|
100
|
+
-- a legacy row predating the backfill stays operable until 20260822000200 corrects it.
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
create or replace function agent_board._enforce_raw_key_fingerprint()
|
|
103
|
+
returns trigger
|
|
104
|
+
language plpgsql
|
|
105
|
+
security definer
|
|
106
|
+
set search_path = agent_board, public
|
|
107
|
+
as $$
|
|
108
|
+
declare
|
|
109
|
+
v_expected text;
|
|
110
|
+
begin
|
|
111
|
+
if new.public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
112
|
+
return new;
|
|
113
|
+
end if;
|
|
114
|
+
-- Skip only a genuinely unrelated UPDATE: neither the key, the fingerprint, NOR the
|
|
115
|
+
-- format changed. Validating every transition INTO the raw format (format changed) closes
|
|
116
|
+
-- the toggle bypass — flip a raw row to a non-raw format with a bogus fingerprint (non-raw
|
|
117
|
+
-- rows skip validation), then flip back keeping key+fingerprint, and without the format
|
|
118
|
+
-- clause the short-circuit would smuggle the mismatch through.
|
|
119
|
+
if tg_op = 'UPDATE'
|
|
120
|
+
and new.public_key is not distinct from old.public_key
|
|
121
|
+
and new.fingerprint is not distinct from old.fingerprint
|
|
122
|
+
and new.public_key_format is not distinct from old.public_key_format then
|
|
123
|
+
return new;
|
|
124
|
+
end if;
|
|
125
|
+
v_expected := agent_board._raw_key_fingerprint(new.public_key);
|
|
126
|
+
if new.fingerprint is distinct from v_expected then
|
|
127
|
+
raise exception 'agent_signing_keys.fingerprint (%) does not match the canonical raw-byte derivation of public_key (expected %)',
|
|
128
|
+
new.fingerprint, v_expected
|
|
129
|
+
using errcode = '23514';
|
|
130
|
+
end if;
|
|
131
|
+
return new;
|
|
132
|
+
end;
|
|
133
|
+
$$;
|
|
134
|
+
|
|
135
|
+
comment on function agent_board._enforce_raw_key_fingerprint() is
|
|
136
|
+
'BEFORE INSERT OR UPDATE trigger on agent_signing_keys: for raw_ed25519_base64url keys, '
|
|
137
|
+
'asserts fingerprint = _raw_key_fingerprint(public_key), catching direct/dynamic DML the '
|
|
138
|
+
'pg_get_functiondef sweep misses. Skips unrelated UPDATEs so pre-backfill legacy rows stay operable.';
|
|
139
|
+
|
|
140
|
+
revoke all on function agent_board._enforce_raw_key_fingerprint() from public, anon, authenticated;
|
|
141
|
+
|
|
142
|
+
drop trigger if exists trg_agent_signing_keys_raw_fingerprint on agent_board.agent_signing_keys;
|
|
143
|
+
create trigger trg_agent_signing_keys_raw_fingerprint
|
|
144
|
+
before insert or update on agent_board.agent_signing_keys
|
|
145
|
+
for each row
|
|
146
|
+
execute function agent_board._enforce_raw_key_fingerprint();
|
|
147
|
+
|
|
148
|
+
-- ---------------------------------------------------------------------------
|
|
149
|
+
-- 3. Per-root provisioning RATE cap (authenticated mint path). Mirrors
|
|
150
|
+
-- check_and_bump_enroll_rate (20260719000300): records+allows while under the cap in
|
|
151
|
+
-- the rolling window, returns false (recording nothing) at/over it. The concurrent
|
|
152
|
+
-- max_active_children cap bounds LIVE children; this bounds cumulative
|
|
153
|
+
-- mint->expire->mint fan-out of agents/auth.users rows.
|
|
154
|
+
-- ---------------------------------------------------------------------------
|
|
155
|
+
create table if not exists agent_board.child_mint_rate_events (
|
|
156
|
+
id uuid primary key default gen_random_uuid(),
|
|
157
|
+
root_agent_id uuid not null references agent_board.agents(id) on delete cascade,
|
|
158
|
+
created_at timestamptz not null default now()
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
comment on table agent_board.child_mint_rate_events is
|
|
162
|
+
'Per-root rolling-window provisioning rate-cap events for the authenticated (EF-mediated) '
|
|
163
|
+
'mint_delegated_child path. Bounds cumulative mint->expire->mint fan-out the concurrent '
|
|
164
|
+
'max_active_children cap cannot.';
|
|
165
|
+
|
|
166
|
+
create index if not exists idx_agent_board_child_mint_rate_root_created
|
|
167
|
+
on agent_board.child_mint_rate_events(root_agent_id, created_at desc);
|
|
168
|
+
|
|
169
|
+
grant select, insert on agent_board.child_mint_rate_events to service_role;
|
|
170
|
+
|
|
171
|
+
create or replace function agent_board._check_and_bump_child_mint_rate(
|
|
172
|
+
p_root_agent_id uuid,
|
|
173
|
+
p_limit integer,
|
|
174
|
+
p_window interval
|
|
175
|
+
)
|
|
176
|
+
returns boolean
|
|
177
|
+
language plpgsql
|
|
178
|
+
security definer
|
|
179
|
+
set search_path = agent_board, public
|
|
180
|
+
as $$
|
|
181
|
+
declare
|
|
182
|
+
v_count integer;
|
|
183
|
+
begin
|
|
184
|
+
if p_root_agent_id is null then
|
|
185
|
+
raise exception 'root agent id is required' using errcode = '22023';
|
|
186
|
+
end if;
|
|
187
|
+
if p_limit is null or p_limit < 0 then
|
|
188
|
+
raise exception 'child mint rate limit must be non-negative' using errcode = '22023';
|
|
189
|
+
end if;
|
|
190
|
+
|
|
191
|
+
select count(*)
|
|
192
|
+
into v_count
|
|
193
|
+
from agent_board.child_mint_rate_events e
|
|
194
|
+
where e.root_agent_id = p_root_agent_id
|
|
195
|
+
and e.created_at > now() - coalesce(p_window, interval '1 hour');
|
|
196
|
+
|
|
197
|
+
if v_count >= p_limit then
|
|
198
|
+
return false;
|
|
199
|
+
end if;
|
|
200
|
+
|
|
201
|
+
insert into agent_board.child_mint_rate_events(root_agent_id) values (p_root_agent_id);
|
|
202
|
+
return true;
|
|
203
|
+
end;
|
|
204
|
+
$$;
|
|
205
|
+
|
|
206
|
+
comment on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) is
|
|
207
|
+
'Rolling per-root provisioning rate cap for the authenticated mint path: records+allows '
|
|
208
|
+
'while under p_limit in p_window, rejects (without recording) at/over the cap. service_role-only.';
|
|
209
|
+
|
|
210
|
+
revoke all on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) from public, anon, authenticated;
|
|
211
|
+
grant execute on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) to service_role;
|
|
212
|
+
|
|
213
|
+
-- ---------------------------------------------------------------------------
|
|
214
|
+
-- 4. mint_delegated_child — recreated. Signature widens to add a trailing
|
|
215
|
+
-- p_self_service boolean default false (the EF-forwarded authenticated-path flag).
|
|
216
|
+
-- STAYS service_role-only (topology A); direct authenticated EXECUTE remains revoked.
|
|
217
|
+
-- Verbatim from 20260718001000 except: raw-byte fingerprint via _raw_key_fingerprint,
|
|
218
|
+
-- non-positive-TTL floor, and the self-service F1 re-clamp + per-root rate cap.
|
|
219
|
+
-- ---------------------------------------------------------------------------
|
|
220
|
+
drop function if exists agent_board.mint_delegated_child(uuid, text, jsonb, text, interval);
|
|
221
|
+
|
|
222
|
+
create or replace function agent_board.mint_delegated_child(
|
|
223
|
+
p_root_agent_id uuid,
|
|
224
|
+
p_child_slug text,
|
|
225
|
+
p_requested_scopes jsonb,
|
|
226
|
+
p_child_public_key text default null,
|
|
227
|
+
p_ttl interval default null,
|
|
228
|
+
p_self_service boolean default false
|
|
229
|
+
)
|
|
230
|
+
returns table(
|
|
231
|
+
child_agent_id uuid,
|
|
232
|
+
grant_expires_at timestamptz,
|
|
233
|
+
signing_key_id uuid
|
|
234
|
+
)
|
|
235
|
+
language plpgsql
|
|
236
|
+
security definer
|
|
237
|
+
set search_path = agent_board, public
|
|
238
|
+
as $$
|
|
239
|
+
declare
|
|
240
|
+
v_policy agent_board.delegation_policies%rowtype;
|
|
241
|
+
v_root agent_board.agents%rowtype;
|
|
242
|
+
v_slug text;
|
|
243
|
+
v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
|
|
244
|
+
v_allowed jsonb;
|
|
245
|
+
v_active_children int;
|
|
246
|
+
v_child_auth_user_id uuid;
|
|
247
|
+
v_child_id uuid;
|
|
248
|
+
v_key_id uuid := null;
|
|
249
|
+
v_grant_expires_at timestamptz;
|
|
250
|
+
v_effective_max int;
|
|
251
|
+
v_ceiling interval;
|
|
252
|
+
-- Per-root provisioning rate cap for the authenticated (self-service) path.
|
|
253
|
+
v_rate_limit constant int := 25;
|
|
254
|
+
v_rate_window constant interval := interval '1 hour';
|
|
255
|
+
begin
|
|
256
|
+
if not agent_board._is_service_role() then
|
|
257
|
+
raise exception 'mint_delegated_child is service_role-only' using errcode = '42501';
|
|
258
|
+
end if;
|
|
259
|
+
|
|
260
|
+
-- Non-positive TTL floor: an immediately-expired grant escapes the
|
|
261
|
+
-- max_active_children count while still provisioning an auth user/agent/key.
|
|
262
|
+
if p_ttl is not null and p_ttl <= interval '0' then
|
|
263
|
+
raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023';
|
|
264
|
+
end if;
|
|
265
|
+
|
|
266
|
+
v_slug := agent_board._normalize_provision_slug(p_child_slug);
|
|
267
|
+
|
|
268
|
+
-- Root must exist.
|
|
269
|
+
select * into v_root from agent_board.agents where id = p_root_agent_id;
|
|
270
|
+
if not found then
|
|
271
|
+
raise exception 'root agent not found' using errcode = 'P0002';
|
|
272
|
+
end if;
|
|
273
|
+
|
|
274
|
+
-- Root policy must exist, be unrevoked, and be root_minted. #11: take a row lock
|
|
275
|
+
-- on the policy up front so a concurrent mint against the same root serializes
|
|
276
|
+
-- here — the max_active_children count->insert below cannot then be raced past
|
|
277
|
+
-- the ceiling (TOCTOU close).
|
|
278
|
+
select * into v_policy
|
|
279
|
+
from agent_board.delegation_policies dp
|
|
280
|
+
where dp.root_agent_id = p_root_agent_id
|
|
281
|
+
and dp.revoked_at is null
|
|
282
|
+
order by dp.created_at desc
|
|
283
|
+
limit 1
|
|
284
|
+
for update;
|
|
285
|
+
if not found then
|
|
286
|
+
raise exception 'no active delegation policy for root agent' using errcode = '42501';
|
|
287
|
+
end if;
|
|
288
|
+
if v_policy.mode <> 'root_minted' then
|
|
289
|
+
raise exception 'delegation policy mode is not root_minted (independent_auth children self-provision)' using errcode = '42501';
|
|
290
|
+
end if;
|
|
291
|
+
|
|
292
|
+
-- Root principal must be active (self + lineage).
|
|
293
|
+
if not agent_board._agent_principal_active(p_root_agent_id) then
|
|
294
|
+
raise exception 'principal_inactive: root agent principal is not fully active' using errcode = '42501';
|
|
295
|
+
end if;
|
|
296
|
+
|
|
297
|
+
-- max_depth = 1 / no transitive minting: p_root_agent_id must be a TRUE lineage
|
|
298
|
+
-- root (unparented) and must not itself be a delegated child.
|
|
299
|
+
if v_root.parent_agent_id is not null then
|
|
300
|
+
raise exception 'max_depth: only a true lineage root may mint (no transitive delegation)' using errcode = '42501';
|
|
301
|
+
end if;
|
|
302
|
+
if exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id) then
|
|
303
|
+
raise exception 'max_depth: a delegated child may not mint further children' using errcode = '42501';
|
|
304
|
+
end if;
|
|
305
|
+
|
|
306
|
+
-- Scope subset: requested ⊆ allowed (JSON array containment). A NULL allowed_scopes
|
|
307
|
+
-- means the root carries no delegable scope, so only an empty request is a subset.
|
|
308
|
+
v_allowed := coalesce(v_policy.allowed_scopes, '[]'::jsonb);
|
|
309
|
+
if not (v_allowed @> v_requested) then
|
|
310
|
+
raise exception 'scope_exceeded: requested scopes are not a subset of the root allowed_scopes' using errcode = '42501';
|
|
311
|
+
end if;
|
|
312
|
+
|
|
313
|
+
-- max_active_children: the root's live (non-revoked, unexpired) grant count must
|
|
314
|
+
-- remain within the ceiling after this mint.
|
|
315
|
+
if p_self_service then
|
|
316
|
+
-- F1: never trust the stored caps on the authenticated (EF-forwarded) path. A root
|
|
317
|
+
-- can self-widen its own operator_vetted policy row (#142), so re-clamp at mint —
|
|
318
|
+
-- NULL is treated as the 25-child ceiling, and any stored value above 25 is clamped
|
|
319
|
+
-- down. The authenticated path can NEVER mint uncapped.
|
|
320
|
+
v_effective_max := least(coalesce(v_policy.max_active_children, 25), 25);
|
|
321
|
+
select count(*) into v_active_children
|
|
322
|
+
from agent_board.delegation_grants g
|
|
323
|
+
where g.root_agent_id = p_root_agent_id
|
|
324
|
+
and g.revoked_at is null
|
|
325
|
+
and g.grant_expires_at > now();
|
|
326
|
+
if v_active_children >= v_effective_max then
|
|
327
|
+
raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
|
|
328
|
+
end if;
|
|
329
|
+
elsif v_policy.max_active_children is not null then
|
|
330
|
+
-- Operator path: trusted caps as supplied (NULL = unbounded, operator's choice).
|
|
331
|
+
select count(*) into v_active_children
|
|
332
|
+
from agent_board.delegation_grants g
|
|
333
|
+
where g.root_agent_id = p_root_agent_id
|
|
334
|
+
and g.revoked_at is null
|
|
335
|
+
and g.grant_expires_at > now();
|
|
336
|
+
if v_active_children >= v_policy.max_active_children then
|
|
337
|
+
raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
|
|
338
|
+
end if;
|
|
339
|
+
end if;
|
|
340
|
+
|
|
341
|
+
-- Per-root provisioning RATE cap (authenticated path only): bounds cumulative
|
|
342
|
+
-- mint->expire->mint fan-out. Enforced after the gates so only would-be-successful
|
|
343
|
+
-- mints consume budget, before any provisioning.
|
|
344
|
+
if p_self_service then
|
|
345
|
+
if not agent_board._check_and_bump_child_mint_rate(p_root_agent_id, v_rate_limit, v_rate_window) then
|
|
346
|
+
raise exception 'mint_rate_exceeded: root has reached its provisioning rate cap for the current window' using errcode = '42501';
|
|
347
|
+
end if;
|
|
348
|
+
end if;
|
|
349
|
+
|
|
350
|
+
-- Provision the child: parent = root = p_root_agent_id, same owner_user_id, own
|
|
351
|
+
-- auth binding (distinct auth_user_id for the active-unique index).
|
|
352
|
+
v_child_auth_user_id := agent_board._ensure_agent_auth_user(v_slug);
|
|
353
|
+
|
|
354
|
+
insert into agent_board.agents(
|
|
355
|
+
agent_type, kind, handle, display_name, status,
|
|
356
|
+
auth_user_id, owner_user_id, parent_agent_id, root_agent_id, metadata
|
|
357
|
+
)
|
|
358
|
+
values (
|
|
359
|
+
'agent', 'agent'::agent_board.agent_kind, v_slug,
|
|
360
|
+
agent_board._default_provision_display_name(v_slug), 'active',
|
|
361
|
+
v_child_auth_user_id, v_root.owner_user_id, p_root_agent_id, p_root_agent_id,
|
|
362
|
+
jsonb_build_object('delegatedByRootAgentId', p_root_agent_id, 'grantedScopes', v_requested)
|
|
363
|
+
)
|
|
364
|
+
returning id into v_child_id;
|
|
365
|
+
|
|
366
|
+
-- Scoped REVOCABLE signing key when the policy requires it. #5: the child's
|
|
367
|
+
-- public key MUST be supplied by the caller (generated client-side; the private
|
|
368
|
+
-- key never touches the DB). We NEVER seed a placeholder/zero key and NEVER
|
|
369
|
+
-- generate a key in-DB. status='active' + proof_state='proven' so the child can
|
|
370
|
+
-- immediately re-sign with the private key it already holds. The fingerprint is
|
|
371
|
+
-- derived over the RAW 32 bytes via _raw_key_fingerprint (single-sourced with enroll;
|
|
372
|
+
-- rejects a non-canonical alias) so the same key hashes identically across writers.
|
|
373
|
+
if v_policy.require_child_signing_key then
|
|
374
|
+
if p_child_public_key is null then
|
|
375
|
+
raise exception 'a child public key is required when the delegation policy requires a signing key' using errcode = '22023';
|
|
376
|
+
end if;
|
|
377
|
+
-- Raw Ed25519 public keys are 32 bytes => 43 base64url chars (no padding).
|
|
378
|
+
if p_child_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
379
|
+
raise exception 'malformed child public key: expected a 43-char base64url raw Ed25519 key' using errcode = '22023';
|
|
380
|
+
end if;
|
|
381
|
+
-- Reject the all-''A'' key: 43 base64url ''A''s decode to 32 zero bytes (a
|
|
382
|
+
-- small-order point), which is exactly the retired placeholder.
|
|
383
|
+
if p_child_public_key = repeat('A', 43) then
|
|
384
|
+
raise exception 'child public key is the all-zero small-order placeholder and is rejected' using errcode = '22023';
|
|
385
|
+
end if;
|
|
386
|
+
|
|
387
|
+
insert into agent_board.agent_signing_keys(
|
|
388
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
389
|
+
status, proof_state, metadata
|
|
390
|
+
)
|
|
391
|
+
values (
|
|
392
|
+
gen_random_uuid(), v_child_id, 'delegated-ephemeral', 1, p_child_public_key,
|
|
393
|
+
'raw_ed25519_base64url',
|
|
394
|
+
agent_board._raw_key_fingerprint(p_child_public_key),
|
|
395
|
+
'active', 'proven',
|
|
396
|
+
jsonb_build_object('ephemeral', true, 'scopes', v_requested)
|
|
397
|
+
)
|
|
398
|
+
returning id into v_key_id;
|
|
399
|
+
end if;
|
|
400
|
+
|
|
401
|
+
-- Grant row: ceiling-bounded hours-scale working clock. On the authenticated path the
|
|
402
|
+
-- TTL ceiling is itself re-clamped to <= 24h (F1) — the stored ceiling is not trusted.
|
|
403
|
+
if p_self_service then
|
|
404
|
+
v_ceiling := least(coalesce(v_policy.child_ttl_ceiling, interval '24 hours'), interval '24 hours');
|
|
405
|
+
else
|
|
406
|
+
v_ceiling := v_policy.child_ttl_ceiling;
|
|
407
|
+
end if;
|
|
408
|
+
v_grant_expires_at := now() + least(coalesce(p_ttl, v_ceiling), v_ceiling);
|
|
409
|
+
|
|
410
|
+
insert into agent_board.delegation_grants(
|
|
411
|
+
child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
|
|
412
|
+
granted_scopes, grant_expires_at
|
|
413
|
+
)
|
|
414
|
+
values (
|
|
415
|
+
v_child_id, p_root_agent_id, p_root_agent_id, v_policy.id,
|
|
416
|
+
v_requested, v_grant_expires_at
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
return query select v_child_id, v_grant_expires_at, v_key_id;
|
|
420
|
+
end;
|
|
421
|
+
$$;
|
|
422
|
+
|
|
423
|
+
comment on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) is
|
|
424
|
+
'Mints a bounded root_minted delegated child (scope ⊆ root allowed_scopes, max_depth=1, '
|
|
425
|
+
'policy-row-locked max_active_children, positive ceiling-bounded grant TTL) enrolling the '
|
|
426
|
+
'CALLER-SUPPLIED Ed25519 public key with a RAW-byte fingerprint (_raw_key_fingerprint; '
|
|
427
|
+
'private key stays client-side). service_role-only. p_self_service marks the EF-forwarded '
|
|
428
|
+
'authenticated path: it re-clamps max_active_children to least(coalesce(stored,25),25) and '
|
|
429
|
+
'the TTL ceiling to <=24h (stored caps are NOT trusted — a root can self-widen its policy, '
|
|
430
|
+
'#142) and applies a per-root provisioning rate cap. Direct authenticated EXECUTE stays revoked.';
|
|
431
|
+
|
|
432
|
+
revoke all on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) from public, anon, authenticated;
|
|
433
|
+
grant execute on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) to service_role;
|
|
434
|
+
|
|
435
|
+
-- ---------------------------------------------------------------------------
|
|
436
|
+
-- 5. enroll_federated_identity — recreated to derive its fingerprint through the SAME
|
|
437
|
+
-- _raw_key_fingerprint helper (single source of truth for the catalog sweep). The
|
|
438
|
+
-- 14-arg (ROOMLINK) function from 20260724000200 is reproduced VERBATIM except the
|
|
439
|
+
-- fingerprint COMPUTATION (its inline raw-byte block) is replaced by the helper call;
|
|
440
|
+
-- the released 20260724000200 bytes are NOT edited. Signature is unchanged, so no
|
|
441
|
+
-- caller and no existence assertion moves.
|
|
442
|
+
-- ---------------------------------------------------------------------------
|
|
443
|
+
drop function if exists agent_board.enroll_federated_identity(
|
|
444
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text);
|
|
445
|
+
|
|
446
|
+
create or replace function agent_board.enroll_federated_identity(
|
|
447
|
+
p_issuer text,
|
|
448
|
+
p_audience text,
|
|
449
|
+
p_subject text,
|
|
450
|
+
p_human_identity_claim jsonb,
|
|
451
|
+
p_slug text,
|
|
452
|
+
p_agent_public_key text,
|
|
453
|
+
p_agent_public_key_format text,
|
|
454
|
+
p_agent_public_key_fingerprint text,
|
|
455
|
+
p_pop_verified boolean,
|
|
456
|
+
p_actor_sub text default null,
|
|
457
|
+
p_agent_kind text default 'agent',
|
|
458
|
+
p_requested_scopes text[] default '{}',
|
|
459
|
+
p_requested_board_scopes text[] default '{}',
|
|
460
|
+
p_invite_code text default null
|
|
461
|
+
)
|
|
462
|
+
returns table(
|
|
463
|
+
agent_id uuid,
|
|
464
|
+
auth_user_id uuid,
|
|
465
|
+
provider_id text,
|
|
466
|
+
signing_key_id uuid,
|
|
467
|
+
granted_scopes text[],
|
|
468
|
+
key_id text,
|
|
469
|
+
key_version int,
|
|
470
|
+
agent_handle text,
|
|
471
|
+
invite_redeemed boolean
|
|
472
|
+
)
|
|
473
|
+
language plpgsql
|
|
474
|
+
security definer
|
|
475
|
+
set search_path = agent_board, public
|
|
476
|
+
as $$
|
|
477
|
+
declare
|
|
478
|
+
v_slug text;
|
|
479
|
+
v_kind text;
|
|
480
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
481
|
+
v_owner_user_id uuid;
|
|
482
|
+
v_auth_user_id uuid;
|
|
483
|
+
v_agent_handle text;
|
|
484
|
+
v_agent_id uuid;
|
|
485
|
+
v_signing_key_id uuid;
|
|
486
|
+
v_fingerprint text;
|
|
487
|
+
v_key_hex text;
|
|
488
|
+
v_raw_key bytea;
|
|
489
|
+
v_reencoded text;
|
|
490
|
+
v_key_id text := 'portal-enroll';
|
|
491
|
+
v_key_version int := 1;
|
|
492
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
493
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
494
|
+
v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
|
|
495
|
+
v_invite_redeemed boolean := false;
|
|
496
|
+
begin
|
|
497
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
498
|
+
if not agent_board._is_service_role() then
|
|
499
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
500
|
+
end if;
|
|
501
|
+
|
|
502
|
+
-- A verified subject + human identity claim are always present from the EF.
|
|
503
|
+
if p_subject is null then
|
|
504
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
505
|
+
end if;
|
|
506
|
+
if p_human_identity_claim is null then
|
|
507
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
508
|
+
end if;
|
|
509
|
+
|
|
510
|
+
-- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
|
|
511
|
+
-- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
|
|
512
|
+
if p_pop_verified is not true then
|
|
513
|
+
raise exception 'proof-of-possession was not verified; enrollment refused'
|
|
514
|
+
using errcode = '42501';
|
|
515
|
+
end if;
|
|
516
|
+
|
|
517
|
+
-- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
|
|
518
|
+
-- base64url chars).
|
|
519
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
520
|
+
raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
|
|
521
|
+
end if;
|
|
522
|
+
if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
523
|
+
raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
|
|
524
|
+
using errcode = '22023';
|
|
525
|
+
end if;
|
|
526
|
+
|
|
527
|
+
-- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
|
|
528
|
+
v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
|
|
529
|
+
if octet_length(v_raw_key) <> 32 then
|
|
530
|
+
raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
|
|
531
|
+
using errcode = '22023';
|
|
532
|
+
end if;
|
|
533
|
+
v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
|
|
534
|
+
if v_reencoded is distinct from p_agent_public_key then
|
|
535
|
+
raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
|
|
536
|
+
using errcode = '22023';
|
|
537
|
+
end if;
|
|
538
|
+
|
|
539
|
+
-- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
|
|
540
|
+
if v_raw_key = decode(repeat('00', 32), 'hex') then
|
|
541
|
+
raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
|
|
542
|
+
using errcode = '22023';
|
|
543
|
+
end if;
|
|
544
|
+
|
|
545
|
+
-- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
|
|
546
|
+
-- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
|
|
547
|
+
v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
|
|
548
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
549
|
+
raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
|
|
550
|
+
using errcode = '22023';
|
|
551
|
+
end if;
|
|
552
|
+
|
|
553
|
+
-- (3) act.sub is audit-only, but when present it must be a real value.
|
|
554
|
+
if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
|
|
555
|
+
raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
|
|
556
|
+
end if;
|
|
557
|
+
|
|
558
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
559
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
560
|
+
|
|
561
|
+
-- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
|
|
562
|
+
select *
|
|
563
|
+
into v_provider
|
|
564
|
+
from agent_board.identity_providers
|
|
565
|
+
where issuer = p_issuer
|
|
566
|
+
and audience = p_audience
|
|
567
|
+
limit 1;
|
|
568
|
+
if v_provider.id is null then
|
|
569
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
570
|
+
end if;
|
|
571
|
+
if not v_provider.supported then
|
|
572
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
573
|
+
end if;
|
|
574
|
+
if not v_provider.policy_approved then
|
|
575
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
576
|
+
end if;
|
|
577
|
+
|
|
578
|
+
-- (5) scope-ceiling + kind gate. 'system' is never federatable.
|
|
579
|
+
if v_kind = 'system' then
|
|
580
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
581
|
+
end if;
|
|
582
|
+
if v_kind not in ('human', 'agent') then
|
|
583
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
584
|
+
end if;
|
|
585
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
586
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
587
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
588
|
+
end if;
|
|
589
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
590
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
591
|
+
end if;
|
|
592
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
593
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
594
|
+
end if;
|
|
595
|
+
|
|
596
|
+
-- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
|
|
597
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
|
|
598
|
+
raise exception 'this signing key is already enrolled (duplicate fingerprint)'
|
|
599
|
+
using errcode = '23505';
|
|
600
|
+
end if;
|
|
601
|
+
|
|
602
|
+
-- (7) HUMAN OWNER — established idempotently per (issuer, sub).
|
|
603
|
+
v_owner_user_id := agent_board._ensure_agent_auth_user(
|
|
604
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
|
|
605
|
+
|
|
606
|
+
-- (8) AGENT handle — unique per enrolled key.
|
|
607
|
+
v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
|
|
608
|
+
v_agent_handle := v_slug;
|
|
609
|
+
if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
|
|
610
|
+
v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
|
|
611
|
+
end if;
|
|
612
|
+
|
|
613
|
+
-- (9) the agent's OWN board principal (session-usable via its enrolled key).
|
|
614
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
|
|
615
|
+
|
|
616
|
+
-- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
|
|
617
|
+
insert into agent_board.agents(
|
|
618
|
+
agent_type,
|
|
619
|
+
kind,
|
|
620
|
+
handle,
|
|
621
|
+
display_name,
|
|
622
|
+
status,
|
|
623
|
+
auth_user_id,
|
|
624
|
+
owner_user_id,
|
|
625
|
+
metadata
|
|
626
|
+
)
|
|
627
|
+
values (
|
|
628
|
+
v_kind,
|
|
629
|
+
v_kind::agent_board.agent_kind,
|
|
630
|
+
v_agent_handle,
|
|
631
|
+
agent_board._default_provision_display_name(v_agent_handle),
|
|
632
|
+
'active',
|
|
633
|
+
v_auth_user_id,
|
|
634
|
+
v_owner_user_id,
|
|
635
|
+
jsonb_build_object(
|
|
636
|
+
'federation', jsonb_build_object(
|
|
637
|
+
'issuer', p_issuer,
|
|
638
|
+
'audience', p_audience,
|
|
639
|
+
'subject', p_subject,
|
|
640
|
+
'actorSub', p_actor_sub,
|
|
641
|
+
'providerId', v_provider.id,
|
|
642
|
+
'signingKeyFingerprint', v_fingerprint,
|
|
643
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
644
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
645
|
+
'popVerified', true,
|
|
646
|
+
'enrolledAt', now()
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
returning id into v_agent_id;
|
|
651
|
+
|
|
652
|
+
-- (11) enroll the public key as active + proven (satisfies active_requires_proof).
|
|
653
|
+
insert into agent_board.agent_signing_keys(
|
|
654
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
655
|
+
status, proof_state, metadata
|
|
656
|
+
)
|
|
657
|
+
values (
|
|
658
|
+
gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
|
|
659
|
+
p_agent_public_key_format, v_fingerprint,
|
|
660
|
+
'active', 'proven',
|
|
661
|
+
jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
|
|
662
|
+
)
|
|
663
|
+
returning id into v_signing_key_id;
|
|
664
|
+
|
|
665
|
+
-- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
|
|
666
|
+
insert into agent_board.enrollment_federation_audit(
|
|
667
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
668
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
669
|
+
)
|
|
670
|
+
values (
|
|
671
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
672
|
+
v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
-- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
|
|
676
|
+
-- membership for the freshly-created agent. Wrapped in a subtransaction so a
|
|
677
|
+
-- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
|
|
678
|
+
-- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
|
|
679
|
+
if v_invite_code is not null then
|
|
680
|
+
begin
|
|
681
|
+
perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
|
|
682
|
+
v_invite_redeemed := true;
|
|
683
|
+
exception when others then
|
|
684
|
+
v_invite_redeemed := false;
|
|
685
|
+
end;
|
|
686
|
+
end if;
|
|
687
|
+
|
|
688
|
+
-- Server-authoritative identity: the client mints /session with the RETURNED
|
|
689
|
+
-- key_id/key_version/agent_handle (its private key stays in the keystore).
|
|
690
|
+
return query
|
|
691
|
+
select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
|
|
692
|
+
v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
|
|
693
|
+
end;
|
|
694
|
+
$$;
|
|
695
|
+
|
|
696
|
+
comment on function agent_board.enroll_federated_identity(
|
|
697
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
698
|
+
'PoP-at-enrollment federated identity (ROOMLINK-extended). Same server-authoritative '
|
|
699
|
+
'identity establishment as 20260724000200, with the fingerprint derived through the shared '
|
|
700
|
+
'_raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned). Optional '
|
|
701
|
+
'trailing p_invite_code soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION '
|
|
702
|
+
'subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false). '
|
|
703
|
+
'service_role-only; never returns a Portal token.';
|
|
704
|
+
|
|
705
|
+
revoke all on function agent_board.enroll_federated_identity(
|
|
706
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
707
|
+
from public, anon, authenticated;
|
|
708
|
+
grant execute on function agent_board.enroll_federated_identity(
|
|
709
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
710
|
+
to service_role;
|