@consiliency/agent-board-schema 1.3.0 → 1.5.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/bin/agent-board-schema.js +0 -0
- package/fixtures/already-applied.json +112 -0
- package/migrations/20260717000100_agent_board_board_role.sql +180 -0
- package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
- package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
- package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
- package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
- package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
- package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
- package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
- package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
- package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
- package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
- package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
- package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
- package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
- package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
- package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
- package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
- package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
- package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
- package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
- package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
- package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
- package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
- package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
- package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
- package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
- package/migrations/20260723000100_agent_board_roomown_owner_seed.sql +167 -0
- package/migrations/20260723000200_agent_board_roomown_grant_role_live.sql +72 -0
- package/package.json +9 -9
- package/schema-manifest.json +256 -4
- package/src/tooling.test.mjs +46 -1
- package/tests/agent_board_agent_lineage.test.sql +73 -0
- package/tests/agent_board_agents_status_revoked.test.sql +60 -0
- package/tests/agent_board_auth_rpcs.test.sql +17 -16
- package/tests/agent_board_board_role.test.sql +129 -0
- package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
- package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
- package/tests/agent_board_delegation_enforcement.test.sql +175 -0
- package/tests/agent_board_delegation_policies.test.sql +120 -0
- package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
- package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
- package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
- package/tests/agent_board_enrollment_audit_content.test.sql +139 -0
- package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
- package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
- package/tests/agent_board_fleet_registry.test.sql +2 -2
- package/tests/agent_board_identity_providers.test.sql +104 -0
- package/tests/agent_board_inbox_rpcs.test.sql +10 -10
- package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
- package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +134 -0
- package/tests/agent_board_resigning_refresh.test.sql +73 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
- package/tests/agent_board_roomown.test.sql +233 -0
- package/tests/agent_board_schema_version.test.sql +2 -2
- package/tests/agent_board_schema_version_rpc.test.sql +1 -1
- package/tests/agent_board_scoped_send_regrant.test.sql +142 -0
- package/tests/agent_board_send_authz_config.test.sql +84 -0
- package/tests/agent_board_send_authz_matrix.test.sql +162 -0
- package/tests/agent_board_send_role_gate.test.sql +181 -0
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
- package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
- package/tests/agent_board_thread_acl.test.sql +129 -0
- package/tests/agent_board_v2_invariants.test.sql +5 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- MBFED SL-1 (Lane A): Portal OIDC identity-provider allowlist (enrollment-only).
|
|
2
|
+
--
|
|
3
|
+
-- Mirrors agent_board.workload_providers (20260710000401 + its RLS corrective
|
|
4
|
+
-- 20260714000200) verbatim in shape and posture: a GLOBAL allowlist keyed by
|
|
5
|
+
-- unique(issuer, audience), with policy_approved defaulting false and scope-ceiling
|
|
6
|
+
-- arrays, RLS enabled with a deny-anon + authenticated-read-true policy pair, and
|
|
7
|
+
-- grants select->authenticated + select,insert,update->service_role.
|
|
8
|
+
--
|
|
9
|
+
-- Federation ceilings (allowed_scopes / allowed_board_scopes / allowed_agent_kinds)
|
|
10
|
+
-- bound what a federated enrollment may request; the 'system' kind is never
|
|
11
|
+
-- federatable (enforced in the enroll RPC, 20260718001500). subject_claim names the
|
|
12
|
+
-- verified OIDC claim the /enroll EF reads as the subject (default 'sub').
|
|
13
|
+
--
|
|
14
|
+
-- Seed policy (load-bearing): ship ONLY a clearly-marked TEST/mock issuer, seeded
|
|
15
|
+
-- INERT (policy_approved = false). NO provider ships policy_approved = true; flipping
|
|
16
|
+
-- the real Portal issuer to supported + policy_approved and wiring its real JWKS URL
|
|
17
|
+
-- is the OPERATIONAL step captured in plans/MBP27-FED-READINESS.md, not a migration.
|
|
18
|
+
-- Additive forward-only migration; writes no agent_board.schema_version (stays 8).
|
|
19
|
+
|
|
20
|
+
create table if not exists agent_board.identity_providers (
|
|
21
|
+
id text primary key default gen_random_uuid()::text,
|
|
22
|
+
issuer text not null,
|
|
23
|
+
audience text not null,
|
|
24
|
+
supported boolean not null default true,
|
|
25
|
+
policy_approved boolean not null default false,
|
|
26
|
+
allowed_scopes text[] not null default '{}',
|
|
27
|
+
allowed_board_scopes text[] not null default '{}',
|
|
28
|
+
allowed_agent_kinds text[] not null default '{}',
|
|
29
|
+
subject_claim text not null default 'sub',
|
|
30
|
+
created_at timestamptz not null default now(),
|
|
31
|
+
unique (issuer, audience)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- Idempotent column re-assertion (mirrors the workload_providers pattern so a
|
|
35
|
+
-- pre-existing table converges to the frozen shape).
|
|
36
|
+
alter table agent_board.identity_providers
|
|
37
|
+
add column if not exists supported boolean not null default true,
|
|
38
|
+
add column if not exists policy_approved boolean not null default false,
|
|
39
|
+
add column if not exists allowed_scopes text[] not null default '{}',
|
|
40
|
+
add column if not exists allowed_board_scopes text[] not null default '{}',
|
|
41
|
+
add column if not exists allowed_agent_kinds text[] not null default '{}',
|
|
42
|
+
add column if not exists subject_claim text not null default 'sub';
|
|
43
|
+
|
|
44
|
+
comment on table agent_board.identity_providers is
|
|
45
|
+
'Enrollment-only allowlist of federated OIDC identity providers (mirrors workload_providers). policy_approved defaults false; no real Portal issuer ships approved. Read only at enrollment by enroll_federated_identity; never on the send/session write path.';
|
|
46
|
+
|
|
47
|
+
-- Seed: a clearly-marked TEST/mock issuer only, INERT (policy_approved = false).
|
|
48
|
+
-- The real Portal issuer is intentionally NOT seeded here (omitted); wiring it is
|
|
49
|
+
-- operational. Ceilings are illustrative bounds for the mock; enrollment via this
|
|
50
|
+
-- row still requires a deliberate policy_approved flip (operational).
|
|
51
|
+
insert into agent_board.identity_providers
|
|
52
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds, subject_claim)
|
|
53
|
+
values
|
|
54
|
+
('https://mock-portal.test', 'message-board', true, false,
|
|
55
|
+
'{board:send,board:read}', '{}', '{agent,human}', 'sub')
|
|
56
|
+
on conflict (issuer, audience) do nothing;
|
|
57
|
+
|
|
58
|
+
alter table agent_board.identity_providers enable row level security;
|
|
59
|
+
|
|
60
|
+
drop policy if exists identity_providers_deny_anon on agent_board.identity_providers;
|
|
61
|
+
create policy identity_providers_deny_anon on agent_board.identity_providers
|
|
62
|
+
as permissive for select to anon using (false);
|
|
63
|
+
|
|
64
|
+
drop policy if exists identity_providers_authenticated_read on agent_board.identity_providers;
|
|
65
|
+
create policy identity_providers_authenticated_read on agent_board.identity_providers
|
|
66
|
+
as permissive for select to authenticated using (true);
|
|
67
|
+
|
|
68
|
+
revoke all on agent_board.identity_providers from public, anon, authenticated;
|
|
69
|
+
grant select on agent_board.identity_providers to authenticated;
|
|
70
|
+
grant select, insert, update on agent_board.identity_providers to service_role;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- MBFED SL-1 (Lane A): enrollment federation audit (static claim snapshot).
|
|
2
|
+
--
|
|
3
|
+
-- Records the federated human identity claim as a verbatim, enrollment-time
|
|
4
|
+
-- snapshot. This table is written ONLY by enroll_federated_identity (20260718001500)
|
|
5
|
+
-- and is NEVER read by any send/session write-path RPC. By design there is NO Portal
|
|
6
|
+
-- session / MFA / availability / liveness column: nothing on the write path depends
|
|
7
|
+
-- on the Portal being reachable after enrollment. The audit is an append record of
|
|
8
|
+
-- what was verified at enrollment, not a live trust signal.
|
|
9
|
+
--
|
|
10
|
+
-- RLS + revoke-all-from-public/anon + select->authenticated + select,insert->service_role.
|
|
11
|
+
-- Additive forward-only migration; writes no agent_board.schema_version (stays 8).
|
|
12
|
+
|
|
13
|
+
create table if not exists agent_board.enrollment_federation_audit (
|
|
14
|
+
id text primary key default gen_random_uuid()::text,
|
|
15
|
+
issuer text,
|
|
16
|
+
audience text,
|
|
17
|
+
subject text,
|
|
18
|
+
human_identity_claim jsonb not null,
|
|
19
|
+
provider_id text references agent_board.identity_providers(id),
|
|
20
|
+
agent_id uuid references agent_board.agents(id),
|
|
21
|
+
auth_user_id uuid,
|
|
22
|
+
requested_scopes text[],
|
|
23
|
+
granted_scopes text[],
|
|
24
|
+
enrolled_at timestamptz not null default now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
comment on table agent_board.enrollment_federation_audit is
|
|
28
|
+
'Append-only snapshot of the verified federated human identity claim at enrollment. Written only by enroll_federated_identity; read by no write-path RPC. No Portal session/MFA/liveness column exists by design.';
|
|
29
|
+
|
|
30
|
+
create index if not exists idx_agent_board_enrollment_federation_audit_agent_id
|
|
31
|
+
on agent_board.enrollment_federation_audit(agent_id)
|
|
32
|
+
where agent_id is not null;
|
|
33
|
+
|
|
34
|
+
alter table agent_board.enrollment_federation_audit enable row level security;
|
|
35
|
+
|
|
36
|
+
drop policy if exists enrollment_federation_audit_deny_anon on agent_board.enrollment_federation_audit;
|
|
37
|
+
create policy enrollment_federation_audit_deny_anon on agent_board.enrollment_federation_audit
|
|
38
|
+
as permissive for select to anon using (false);
|
|
39
|
+
|
|
40
|
+
-- Scope the authenticated read to the OWNING principal (mirrors delegation_grants):
|
|
41
|
+
-- the row carries human OIDC PII, and agent session tokens carry role 'authenticated',
|
|
42
|
+
-- so `using (true)` would let any agent read every enrollee's identity. An agent may
|
|
43
|
+
-- read only its own enrollment audit row.
|
|
44
|
+
drop policy if exists enrollment_federation_audit_authenticated_read on agent_board.enrollment_federation_audit;
|
|
45
|
+
create policy enrollment_federation_audit_authenticated_read on agent_board.enrollment_federation_audit
|
|
46
|
+
as permissive for select to authenticated using (agent_id = agent_board.current_agent_id());
|
|
47
|
+
|
|
48
|
+
revoke all on agent_board.enrollment_federation_audit from public, anon, authenticated;
|
|
49
|
+
grant select on agent_board.enrollment_federation_audit to authenticated;
|
|
50
|
+
grant select, insert on agent_board.enrollment_federation_audit to service_role;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
-- MBFED SL-1 (Lane A): enroll_federated_identity — the fat enrollment RPC.
|
|
2
|
+
--
|
|
3
|
+
-- The /enroll Edge Function (supabase/functions/enroll) verifies the Portal OIDC
|
|
4
|
+
-- RS256 signature and extracts the VERIFIED subject + human identity claim, then
|
|
5
|
+
-- calls this RPC under a service_role client. ALL trust decisions live here (defense
|
|
6
|
+
-- in depth — the DB re-gates even though the EF already verified):
|
|
7
|
+
-- (i) look up identity_providers by (issuer, audience);
|
|
8
|
+
-- (ii) reject unless the row exists AND supported AND policy_approved;
|
|
9
|
+
-- (iii) reject unless kind <> 'system', kind in allowed_agent_kinds (when non-empty),
|
|
10
|
+
-- requested_scopes ⊆ allowed_scopes, requested_board_scopes ⊆ allowed_board_scopes;
|
|
11
|
+
-- (iv) establish auth.users via agent_board._ensure_agent_auth_user(slug) — NOT
|
|
12
|
+
-- provision_agent (whose _require_platform_admin_user_id gate the federated
|
|
13
|
+
-- flow does not satisfy) — and upsert the agent the same way provision_agent does;
|
|
14
|
+
-- (v) insert one enrollment_federation_audit row (static claim snapshot).
|
|
15
|
+
--
|
|
16
|
+
-- Establishes auth.users AT ENROLLMENT ONLY and returns a board-native agent — never a
|
|
17
|
+
-- Portal token. No Portal issuer/JWKS/URL is referenced here. service_role-only.
|
|
18
|
+
-- Additive forward-only migration; writes no agent_board.schema_version (stays 8).
|
|
19
|
+
|
|
20
|
+
create or replace function agent_board.enroll_federated_identity(
|
|
21
|
+
p_issuer text,
|
|
22
|
+
p_audience text,
|
|
23
|
+
p_subject text,
|
|
24
|
+
p_human_identity_claim jsonb,
|
|
25
|
+
p_slug text,
|
|
26
|
+
p_agent_kind text default 'agent',
|
|
27
|
+
p_requested_scopes text[] default '{}',
|
|
28
|
+
p_requested_board_scopes text[] default '{}'
|
|
29
|
+
)
|
|
30
|
+
returns table(
|
|
31
|
+
agent_id uuid,
|
|
32
|
+
auth_user_id uuid,
|
|
33
|
+
provider_id text,
|
|
34
|
+
granted_scopes text[]
|
|
35
|
+
)
|
|
36
|
+
language plpgsql
|
|
37
|
+
security definer
|
|
38
|
+
set search_path = agent_board, public
|
|
39
|
+
as $$
|
|
40
|
+
declare
|
|
41
|
+
v_slug text;
|
|
42
|
+
v_kind text;
|
|
43
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
44
|
+
v_auth_user_id uuid;
|
|
45
|
+
v_agent agent_board.agents%rowtype;
|
|
46
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
47
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
48
|
+
begin
|
|
49
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
50
|
+
if not agent_board._is_service_role() then
|
|
51
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
52
|
+
end if;
|
|
53
|
+
|
|
54
|
+
-- The EF always passes a cryptographically VERIFIED subject; a null subject here
|
|
55
|
+
-- would let the (issuer, subject) lookup below degenerate into a null-match and is
|
|
56
|
+
-- never legitimate. Reject it explicitly (defense in depth).
|
|
57
|
+
if p_subject is null then
|
|
58
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
59
|
+
end if;
|
|
60
|
+
|
|
61
|
+
if p_human_identity_claim is null then
|
|
62
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
63
|
+
end if;
|
|
64
|
+
|
|
65
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
66
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
67
|
+
|
|
68
|
+
-- (i) allowlist lookup by (issuer, audience).
|
|
69
|
+
select *
|
|
70
|
+
into v_provider
|
|
71
|
+
from agent_board.identity_providers
|
|
72
|
+
where issuer = p_issuer
|
|
73
|
+
and audience = p_audience
|
|
74
|
+
limit 1;
|
|
75
|
+
|
|
76
|
+
-- (ii) provider must exist, be supported, and be policy-approved.
|
|
77
|
+
if v_provider.id is null then
|
|
78
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
79
|
+
end if;
|
|
80
|
+
if not v_provider.supported then
|
|
81
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
82
|
+
end if;
|
|
83
|
+
if not v_provider.policy_approved then
|
|
84
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
85
|
+
end if;
|
|
86
|
+
|
|
87
|
+
-- (iii) scope-ceiling + kind gate. 'system' is never federatable.
|
|
88
|
+
if v_kind = 'system' then
|
|
89
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
90
|
+
end if;
|
|
91
|
+
if v_kind not in ('human', 'agent') then
|
|
92
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
93
|
+
end if;
|
|
94
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
95
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
96
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
97
|
+
end if;
|
|
98
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
99
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
100
|
+
end if;
|
|
101
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
102
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
103
|
+
end if;
|
|
104
|
+
|
|
105
|
+
-- (iv) establish auth.users (idempotent) and upsert the agent keyed on the
|
|
106
|
+
-- cryptographically VERIFIED federation identity (issuer + p_subject), NOT the
|
|
107
|
+
-- client-supplied slug. This closes the confused-deputy / revoked-resurrection
|
|
108
|
+
-- hole: an approved-provider token can only ever land on the agent that ALREADY
|
|
109
|
+
-- belongs to that verified subject, and it can never re-activate a revoked handle
|
|
110
|
+
-- nor clobber an unrelated handle.
|
|
111
|
+
select *
|
|
112
|
+
into v_agent
|
|
113
|
+
from agent_board.agents a
|
|
114
|
+
where a.metadata -> 'federation' ->> 'issuer' = p_issuer
|
|
115
|
+
and a.metadata -> 'federation' ->> 'subject' = p_subject
|
|
116
|
+
limit 1
|
|
117
|
+
for update;
|
|
118
|
+
|
|
119
|
+
if v_agent.id is not null then
|
|
120
|
+
-- An agent already belongs to this verified (issuer, subject).
|
|
121
|
+
-- REFUSE reactivation of ANY non-active principal: enrollment must never reverse a
|
|
122
|
+
-- revocation cascade NOR reactivate an operator-suspended handle. The legacy
|
|
123
|
+
-- operator RPC revoke_agent (20260512000000) sets status='suspended', so guarding
|
|
124
|
+
-- only 'revoked' would let a suspended federated agent be silently reactivated via
|
|
125
|
+
-- /enroll. Refuse anything that is not already 'active' (revoked, suspended,
|
|
126
|
+
-- inactive, …); reactivation is an operator-only action.
|
|
127
|
+
if v_agent.status <> 'active' then
|
|
128
|
+
raise exception 'federated identity is % and cannot be reactivated by enrollment', v_agent.status
|
|
129
|
+
using errcode = '42501';
|
|
130
|
+
end if;
|
|
131
|
+
|
|
132
|
+
v_auth_user_id := coalesce(v_agent.auth_user_id, agent_board._ensure_agent_auth_user(v_agent.handle));
|
|
133
|
+
|
|
134
|
+
update agent_board.agents a
|
|
135
|
+
set agent_type = v_kind,
|
|
136
|
+
kind = v_kind::agent_board.agent_kind,
|
|
137
|
+
status = 'active',
|
|
138
|
+
auth_user_id = v_auth_user_id,
|
|
139
|
+
metadata = coalesce(a.metadata, '{}'::jsonb)
|
|
140
|
+
|| jsonb_build_object(
|
|
141
|
+
'federation', jsonb_build_object(
|
|
142
|
+
'issuer', p_issuer,
|
|
143
|
+
'audience', p_audience,
|
|
144
|
+
'subject', p_subject,
|
|
145
|
+
'providerId', v_provider.id,
|
|
146
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
147
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
148
|
+
'enrolledAt', now()
|
|
149
|
+
)
|
|
150
|
+
),
|
|
151
|
+
updated_at = now()
|
|
152
|
+
where a.id = v_agent.id
|
|
153
|
+
returning * into v_agent;
|
|
154
|
+
else
|
|
155
|
+
-- No agent is bound to this verified identity yet. Because the lookup above
|
|
156
|
+
-- already excluded the matching (issuer, subject), ANY agent that already holds
|
|
157
|
+
-- the requested slug is a DIFFERENT identity — refuse to clobber it (handle
|
|
158
|
+
-- takeover / confused-deputy guard).
|
|
159
|
+
if exists (select 1 from agent_board.agents a2 where a2.handle = v_slug) then
|
|
160
|
+
raise exception 'slug % is already bound to a different identity and cannot be clobbered by enrollment', v_slug
|
|
161
|
+
using errcode = '42501';
|
|
162
|
+
end if;
|
|
163
|
+
|
|
164
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_slug);
|
|
165
|
+
|
|
166
|
+
insert into agent_board.agents(
|
|
167
|
+
agent_type,
|
|
168
|
+
kind,
|
|
169
|
+
handle,
|
|
170
|
+
display_name,
|
|
171
|
+
status,
|
|
172
|
+
auth_user_id,
|
|
173
|
+
owner_user_id,
|
|
174
|
+
metadata
|
|
175
|
+
)
|
|
176
|
+
values (
|
|
177
|
+
v_kind,
|
|
178
|
+
v_kind::agent_board.agent_kind,
|
|
179
|
+
v_slug,
|
|
180
|
+
agent_board._default_provision_display_name(v_slug),
|
|
181
|
+
'active',
|
|
182
|
+
v_auth_user_id,
|
|
183
|
+
null,
|
|
184
|
+
jsonb_build_object(
|
|
185
|
+
'federation', jsonb_build_object(
|
|
186
|
+
'issuer', p_issuer,
|
|
187
|
+
'audience', p_audience,
|
|
188
|
+
'subject', p_subject,
|
|
189
|
+
'providerId', v_provider.id,
|
|
190
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
191
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
192
|
+
'enrolledAt', now()
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
returning * into v_agent;
|
|
197
|
+
end if;
|
|
198
|
+
|
|
199
|
+
-- (v) one static claim-snapshot audit row (no Portal liveness signal).
|
|
200
|
+
insert into agent_board.enrollment_federation_audit(
|
|
201
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
202
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
203
|
+
)
|
|
204
|
+
values (
|
|
205
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
206
|
+
v_agent.id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return query
|
|
210
|
+
select v_agent.id, v_auth_user_id, v_provider.id, v_requested_scopes;
|
|
211
|
+
end;
|
|
212
|
+
$$;
|
|
213
|
+
|
|
214
|
+
comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text[], text[]) is
|
|
215
|
+
'Enrollment-only federated identity establishment: allowlist + policy_approved + scope-ceiling gate, auth.users via _ensure_agent_auth_user, agent upsert, and one enrollment_federation_audit row. service_role-only; returns a board-native agent, never a Portal token.';
|
|
216
|
+
|
|
217
|
+
revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text[], text[])
|
|
218
|
+
from public, anon, authenticated;
|
|
219
|
+
grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text[], text[])
|
|
220
|
+
to service_role;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
-- MBHARDEN SL-1 (IF-0-MBHARDEN-1a): per-key pending-challenge cap on
|
|
2
|
+
-- issue_signing_key_challenge.
|
|
3
|
+
--
|
|
4
|
+
-- Forward-only `create or replace`. Reproduces the 20260628000001 body verbatim
|
|
5
|
+
-- and adds ONE new pre-insert guard: a hard cap of N=16 concurrently-pending
|
|
6
|
+
-- (proof_state='pending', consumed_at is null, expires_at > now()) challenges per
|
|
7
|
+
-- signing_key_id. The existing `for update` lock on the agent_signing_keys row
|
|
8
|
+
-- serializes concurrent issuers for the same key, so the count is race-free. No
|
|
9
|
+
-- existing success path changes: <=16 pending challenges still issue exactly as
|
|
10
|
+
-- before; only the 17th concurrent pending challenge for one key is rejected.
|
|
11
|
+
-- `create or replace` preserves the grants from 20260628000001; they are re-issued
|
|
12
|
+
-- below for an explicit, self-contained frozen (authenticated, service_role) surface.
|
|
13
|
+
create or replace function agent_board.issue_signing_key_challenge(
|
|
14
|
+
p_agent_id uuid,
|
|
15
|
+
p_key_id text,
|
|
16
|
+
p_expires_in interval default interval '5 minutes'
|
|
17
|
+
)
|
|
18
|
+
returns table (
|
|
19
|
+
challenge_id uuid,
|
|
20
|
+
agent_id uuid,
|
|
21
|
+
key_id text,
|
|
22
|
+
key_version integer,
|
|
23
|
+
fingerprint text,
|
|
24
|
+
challenge text,
|
|
25
|
+
expires_at timestamptz
|
|
26
|
+
)
|
|
27
|
+
language plpgsql
|
|
28
|
+
security definer
|
|
29
|
+
set search_path = agent_board, public
|
|
30
|
+
as $$
|
|
31
|
+
declare
|
|
32
|
+
v_actor uuid;
|
|
33
|
+
v_key agent_board.agent_signing_keys%rowtype;
|
|
34
|
+
v_challenge_id uuid := gen_random_uuid();
|
|
35
|
+
v_nonce text := encode(extensions.gen_random_bytes(24), 'base64');
|
|
36
|
+
v_expires_at timestamptz;
|
|
37
|
+
v_pending integer;
|
|
38
|
+
-- Frozen pending-challenge cap per signing key (IF-0-MBHARDEN-1a). N=16.
|
|
39
|
+
c_pending_cap constant integer := 16;
|
|
40
|
+
begin
|
|
41
|
+
if p_expires_in is null or p_expires_in <= interval '0 seconds' or p_expires_in > interval '15 minutes' then
|
|
42
|
+
raise exception 'challenge expiry must be between 0 and 15 minutes' using errcode = '22023';
|
|
43
|
+
end if;
|
|
44
|
+
|
|
45
|
+
v_actor := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
46
|
+
|
|
47
|
+
select *
|
|
48
|
+
into v_key
|
|
49
|
+
from agent_board.agent_signing_keys ask
|
|
50
|
+
where ask.agent_id = v_actor
|
|
51
|
+
and ask.key_id = p_key_id
|
|
52
|
+
for update;
|
|
53
|
+
|
|
54
|
+
if not found then
|
|
55
|
+
raise exception 'signing key not found' using errcode = 'P0002';
|
|
56
|
+
end if;
|
|
57
|
+
if v_key.status in ('revoked', 'compromised') then
|
|
58
|
+
raise exception 'key_revoked: signing key is revoked' using errcode = '22023';
|
|
59
|
+
end if;
|
|
60
|
+
if v_key.expires_at is not null and v_key.expires_at <= now() then
|
|
61
|
+
update agent_board.agent_signing_keys
|
|
62
|
+
set status = 'expired',
|
|
63
|
+
proof_state = 'expired',
|
|
64
|
+
updated_at = now()
|
|
65
|
+
where id = v_key.id;
|
|
66
|
+
raise exception 'key_expired: signing key is expired' using errcode = '22023';
|
|
67
|
+
end if;
|
|
68
|
+
|
|
69
|
+
-- Pending-challenge cap: reject once N=16 unexpired, unconsumed, still-pending
|
|
70
|
+
-- challenges already exist for this key. Serialized by the `for update` above.
|
|
71
|
+
select count(*)
|
|
72
|
+
into v_pending
|
|
73
|
+
from agent_board.agent_signing_key_challenges c
|
|
74
|
+
where c.signing_key_id = v_key.id
|
|
75
|
+
and c.proof_state = 'pending'
|
|
76
|
+
and c.consumed_at is null
|
|
77
|
+
and c.expires_at > now();
|
|
78
|
+
|
|
79
|
+
if v_pending >= c_pending_cap then
|
|
80
|
+
raise exception 'challenge_pending_cap: too many pending signing-key challenges for this key (max %)', c_pending_cap
|
|
81
|
+
using errcode = '22023';
|
|
82
|
+
end if;
|
|
83
|
+
|
|
84
|
+
v_expires_at := now() + p_expires_in;
|
|
85
|
+
|
|
86
|
+
insert into agent_board.agent_signing_key_challenges(
|
|
87
|
+
id,
|
|
88
|
+
signing_key_id,
|
|
89
|
+
agent_id,
|
|
90
|
+
challenge_nonce,
|
|
91
|
+
challenge_bytes,
|
|
92
|
+
expires_at,
|
|
93
|
+
metadata
|
|
94
|
+
)
|
|
95
|
+
values (
|
|
96
|
+
v_challenge_id,
|
|
97
|
+
v_key.id,
|
|
98
|
+
v_actor,
|
|
99
|
+
v_nonce,
|
|
100
|
+
'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
|
|
101
|
+
v_expires_at,
|
|
102
|
+
jsonb_build_object('keyId', v_key.key_id, 'keyVersion', v_key.key_version)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
update agent_board.agent_signing_keys
|
|
106
|
+
set proof_state = 'challenge_issued',
|
|
107
|
+
updated_at = now()
|
|
108
|
+
where id = v_key.id
|
|
109
|
+
and proof_state <> 'proven';
|
|
110
|
+
|
|
111
|
+
return query
|
|
112
|
+
select
|
|
113
|
+
v_challenge_id,
|
|
114
|
+
v_actor,
|
|
115
|
+
v_key.key_id,
|
|
116
|
+
v_key.key_version,
|
|
117
|
+
v_key.fingerprint,
|
|
118
|
+
'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
|
|
119
|
+
v_expires_at;
|
|
120
|
+
end;
|
|
121
|
+
$$;
|
|
122
|
+
|
|
123
|
+
revoke all on function agent_board.issue_signing_key_challenge(uuid, text, interval) from public, anon;
|
|
124
|
+
grant execute on function agent_board.issue_signing_key_challenge(uuid, text, interval) to authenticated, service_role;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- MBHARDEN SL-1 (IF-0-MBHARDEN-1b): expired signing-key-challenge reaper.
|
|
2
|
+
--
|
|
3
|
+
-- Additive, forward-only. Mirrors reap_expired_nonfleet_sessions
|
|
4
|
+
-- (20260718000700): security definer, service_role-only, deletes-and-counts.
|
|
5
|
+
-- Deletes agent_signing_key_challenges rows whose expires_at is older than
|
|
6
|
+
-- p_older_than (default 7 days) and returns the affected count. Unexpired rows
|
|
7
|
+
-- (expires_at within the grace) are never touched. Scheduling (pg_cron / external
|
|
8
|
+
-- cadence) is a deferred owner; the callable function is in-scope.
|
|
9
|
+
create or replace function agent_board.reap_expired_signing_key_challenges(
|
|
10
|
+
p_older_than interval default interval '7 days'
|
|
11
|
+
)
|
|
12
|
+
returns bigint
|
|
13
|
+
language plpgsql
|
|
14
|
+
security definer
|
|
15
|
+
set search_path = agent_board, public
|
|
16
|
+
as $$
|
|
17
|
+
declare
|
|
18
|
+
v_count bigint;
|
|
19
|
+
begin
|
|
20
|
+
with deleted as (
|
|
21
|
+
delete from agent_board.agent_signing_key_challenges c
|
|
22
|
+
where c.expires_at < now() - coalesce(p_older_than, interval '7 days')
|
|
23
|
+
returning 1
|
|
24
|
+
)
|
|
25
|
+
select count(*) into v_count from deleted;
|
|
26
|
+
return v_count;
|
|
27
|
+
end;
|
|
28
|
+
$$;
|
|
29
|
+
|
|
30
|
+
comment on function agent_board.reap_expired_signing_key_challenges(interval) is
|
|
31
|
+
'Deletes agent_signing_key_challenges rows expired older than p_older_than (default 7 days); returns the affected count. service_role-only.';
|
|
32
|
+
|
|
33
|
+
revoke all on function agent_board.reap_expired_signing_key_challenges(interval) from public, anon, authenticated;
|
|
34
|
+
grant execute on function agent_board.reap_expired_signing_key_challenges(interval) to service_role;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- MBHARDEN SL-1 (IF-0-MBHARDEN-1d): standalone /enroll per-source rate cap.
|
|
2
|
+
--
|
|
3
|
+
-- Additive, forward-only. /enroll is verify_jwt=false and creates NO
|
|
4
|
+
-- pre-verification rows, so there is no pending set to bound (unlike the challenge
|
|
5
|
+
-- path). DoS/amplification is instead bounded by a standalone per-source rolling
|
|
6
|
+
-- rate cap: a counter table + a check_and_bump function.
|
|
7
|
+
--
|
|
8
|
+
-- MECHANISM ONLY (Execution Notes): this migration delivers a proven, callable
|
|
9
|
+
-- cap. Its request-path call-site lives in supabase/functions/enroll/index.ts,
|
|
10
|
+
-- which MBHARDEN must NOT touch (writer partition). Wiring the cap into the
|
|
11
|
+
-- /enroll request path is delivered by MBPORTALGATE (which owns index.ts) or is an
|
|
12
|
+
-- operational step. check_and_bump_enroll_rate is therefore intentionally
|
|
13
|
+
-- not-yet-called by request code — it is NOT dead code.
|
|
14
|
+
create table if not exists agent_board.enroll_rate_events (
|
|
15
|
+
id uuid primary key default gen_random_uuid(),
|
|
16
|
+
source text not null,
|
|
17
|
+
created_at timestamptz not null default now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
comment on table agent_board.enroll_rate_events is
|
|
21
|
+
'Per-source rolling-window rate-cap events for the verify_jwt=false /enroll surface. Mechanism only; request-path wiring is deferred to the index.ts owner.';
|
|
22
|
+
|
|
23
|
+
create index if not exists idx_agent_board_enroll_rate_events_source_created
|
|
24
|
+
on agent_board.enroll_rate_events(source, created_at desc);
|
|
25
|
+
|
|
26
|
+
-- Returns true and records an event when the source is under p_limit within the
|
|
27
|
+
-- rolling p_window; returns false (and records NOTHING) once the source is at/over
|
|
28
|
+
-- the cap. Rejecting without recording keeps an attacker from growing the table
|
|
29
|
+
-- past the cap and fails closed. Approximate under high concurrency (no per-source
|
|
30
|
+
-- lock) — acceptable for a coarse DoS cap.
|
|
31
|
+
create or replace function agent_board.check_and_bump_enroll_rate(
|
|
32
|
+
p_source text,
|
|
33
|
+
p_limit integer,
|
|
34
|
+
p_window interval
|
|
35
|
+
)
|
|
36
|
+
returns boolean
|
|
37
|
+
language plpgsql
|
|
38
|
+
security definer
|
|
39
|
+
set search_path = agent_board, public
|
|
40
|
+
as $$
|
|
41
|
+
declare
|
|
42
|
+
v_count integer;
|
|
43
|
+
begin
|
|
44
|
+
if p_source is null or length(p_source) = 0 then
|
|
45
|
+
raise exception 'enroll rate source must be non-empty' using errcode = '22023';
|
|
46
|
+
end if;
|
|
47
|
+
if p_limit is null or p_limit < 0 then
|
|
48
|
+
raise exception 'enroll rate limit must be non-negative' using errcode = '22023';
|
|
49
|
+
end if;
|
|
50
|
+
|
|
51
|
+
select count(*)
|
|
52
|
+
into v_count
|
|
53
|
+
from agent_board.enroll_rate_events e
|
|
54
|
+
where e.source = p_source
|
|
55
|
+
and e.created_at > now() - coalesce(p_window, interval '1 minute');
|
|
56
|
+
|
|
57
|
+
if v_count >= p_limit then
|
|
58
|
+
return false;
|
|
59
|
+
end if;
|
|
60
|
+
|
|
61
|
+
insert into agent_board.enroll_rate_events(source) values (p_source);
|
|
62
|
+
return true;
|
|
63
|
+
end;
|
|
64
|
+
$$;
|
|
65
|
+
|
|
66
|
+
comment on function agent_board.check_and_bump_enroll_rate(text, integer, interval) is
|
|
67
|
+
'Rolling per-source rate cap for /enroll: records+allows while under p_limit in p_window, rejects (without recording) at/over the cap. service_role-only. Mechanism only; wiring deferred.';
|
|
68
|
+
|
|
69
|
+
revoke all on function agent_board.check_and_bump_enroll_rate(text, integer, interval) from public, anon, authenticated;
|
|
70
|
+
grant execute on function agent_board.check_and_bump_enroll_rate(text, integer, interval) to service_role;
|
|
71
|
+
|
|
72
|
+
grant select, insert on agent_board.enroll_rate_events to service_role;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- MBPORTALGATE SL-1 (IF-0-MBPORTALGATE-1/2/6): freeze the real Portal token contract
|
|
2
|
+
-- as config on the identity_providers allowlist.
|
|
3
|
+
--
|
|
4
|
+
-- Two forward-only, additive changes:
|
|
5
|
+
-- (1) add an `actor_claim text` column (default 'act.sub') — names the verified OIDC
|
|
6
|
+
-- claim the /enroll EF reads as the delegated actor (audit-only; never a trust
|
|
7
|
+
-- decision). Mirrors subject_claim (added 20260718001300). Backfills the
|
|
8
|
+
-- pre-existing mock row harmlessly (it is inert).
|
|
9
|
+
-- (2) seed the REAL Portal identity_providers row INERT:
|
|
10
|
+
-- issuer = https://portal.consiliency.io
|
|
11
|
+
-- audience = https://message-board.consiliency.io (RFC 8707 resource; the
|
|
12
|
+
-- frozen resource-audience constant — byte-identical across the EF
|
|
13
|
+
-- ENROLL_AUDIENCE env, this seed row, and the client `resource=`)
|
|
14
|
+
-- subject_claim = 'sub', actor_claim = 'act.sub'
|
|
15
|
+
-- supported = true, policy_approved = FALSE (INERT until the operational flip)
|
|
16
|
+
--
|
|
17
|
+
-- No provider ships policy_approved=true. Flipping the real Portal issuer to
|
|
18
|
+
-- policy_approved and wiring its real JWKS URL is the OPERATIONAL step (E1/E2 external
|
|
19
|
+
-- Portal gates; see plans/MBPORTAL-GATE-DECISION.md). The mock issuer row
|
|
20
|
+
-- (https://mock-portal.test) is left untouched.
|
|
21
|
+
--
|
|
22
|
+
-- Additive forward-only migration; writes no agent_board.schema_version (stays 8).
|
|
23
|
+
|
|
24
|
+
-- (1) actor_claim column — forward-only, default 'act.sub'.
|
|
25
|
+
alter table agent_board.identity_providers
|
|
26
|
+
add column if not exists actor_claim text not null default 'act.sub';
|
|
27
|
+
|
|
28
|
+
comment on column agent_board.identity_providers.actor_claim is
|
|
29
|
+
'Verified OIDC claim naming the delegated actor (default act.sub). Audit-only: read by the /enroll EF into the enrollment audit; never a trust decision.';
|
|
30
|
+
|
|
31
|
+
-- (2) seed the real Portal row, INERT (policy_approved = false). Idempotent.
|
|
32
|
+
insert into agent_board.identity_providers
|
|
33
|
+
(issuer, audience, supported, policy_approved,
|
|
34
|
+
allowed_scopes, allowed_board_scopes, allowed_agent_kinds, subject_claim, actor_claim)
|
|
35
|
+
values
|
|
36
|
+
('https://portal.consiliency.io', 'https://message-board.consiliency.io', true, false,
|
|
37
|
+
'{board:send,board:read}', '{}', '{agent,human}', 'sub', 'act.sub')
|
|
38
|
+
on conflict (issuer, audience) do nothing;
|