@consiliency/agent-board-schema 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +4 -0
- package/migrations/20260728000100_agent_board_enroll_federated_human.sql +313 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +6 -2
- package/tests/agent_board_enrollment_audit_content.test.sql +5 -2
- package/tests/agent_board_fedsession.test.sql +314 -0
|
@@ -312,6 +312,10 @@
|
|
|
312
312
|
{
|
|
313
313
|
"id": "20260727000100_agent_board_roomuiapi",
|
|
314
314
|
"sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "20260728000100_agent_board_enroll_federated_human",
|
|
318
|
+
"sha256": "5f300449c29ec05c2cd3c2cd7eca622ca41e8b32b8856c5bc0695b5750a196e7"
|
|
315
319
|
}
|
|
316
320
|
]
|
|
317
321
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
-- FEDSESSION (Option A): service_role no-PoP federated-HUMAN provisioning substrate.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT: enroll_federated_human() is the DB half of the /portal-session Edge Function
|
|
4
|
+
-- minter. A browser human who holds a live Portal OIDC session hits /portal-session; the
|
|
5
|
+
-- EF RS256-verifies the Portal token (aud/exp/sig against the issuer JWKS), then calls
|
|
6
|
+
-- THIS RPC (service_role) to establish a kind='human' agent keyed on (issuer, sub), and
|
|
7
|
+
-- finally self-signs a bounded (<=15 min) board-native access token (the same HS256
|
|
8
|
+
-- mechanism as /session — SESSION_SIGNING_SECRET == the project PostgREST JWT secret).
|
|
9
|
+
--
|
|
10
|
+
-- Option A = ACCESS-ONLY. There is NO GoTrue session and therefore NO refresh_token and
|
|
11
|
+
-- NO revocable auth.sessions row. The browser re-mints from /portal-session on expiry
|
|
12
|
+
-- (it holds a live Portal session throughout). This RPC never returns a token — the EF
|
|
13
|
+
-- mints it — and never touches a Portal token itself.
|
|
14
|
+
--
|
|
15
|
+
-- WHY a new RPC (cannot reuse the existing federation paths):
|
|
16
|
+
-- * enroll_federated_identity (20260719001200) HARD-REQUIRES p_pop_verified=true + a
|
|
17
|
+
-- raw Ed25519 key. A human holds no signing key, so that path cannot apply.
|
|
18
|
+
-- * self_enroll_human (20260726000100) binds the REAL auth.uid() (authenticated-only).
|
|
19
|
+
-- The Portal human is not authenticated to THIS project, so there is no auth.uid().
|
|
20
|
+
-- => a service_role, no-PoP, human-only RPC that mints a SYNTHETIC per-identity uid.
|
|
21
|
+
--
|
|
22
|
+
-- IDENTITY MODEL: the human is its OWN principal — auth_user_id = owner_user_id = a
|
|
23
|
+
-- SYNTHETIC uid derived idempotently from (issuer, sub). This mirrors self_enroll_human's
|
|
24
|
+
-- self-ownership, NOT enroll_federated_identity (where the human is only owner_user_id of
|
|
25
|
+
-- a separate key-identified agent). The synthetic-uid handle uses the SAME byte-exact
|
|
26
|
+
-- derivation as enroll_federated_identity:198-199, so a fed-human here and a federated
|
|
27
|
+
-- IDENTITY enrollment for the SAME (issuer, sub) converge on ONE shared owner uid (the
|
|
28
|
+
-- IDLINK substrate). L-4b (one agent per real auth user) is not violated: this uid is
|
|
29
|
+
-- synthetic, and enroll_federated_identity never puts an agent ON that owner uid.
|
|
30
|
+
--
|
|
31
|
+
-- KIND ENFORCEMENT IS STRUCTURAL: there is NO p_agent_kind argument — this RPC ONLY ever
|
|
32
|
+
-- inserts kind='human'/agent_type='human'. No agent_signing_keys row is created (humans
|
|
33
|
+
-- hold no key). Authority stays membership/role-gated: merely holding a session grants no
|
|
34
|
+
-- board access; current_agent_id() resolves auth.uid() -> the active human agent (with NO
|
|
35
|
+
-- agent_credential_id claim, the null-credential branch), and RLS then gates by
|
|
36
|
+
-- membership/role exactly as for any other principal.
|
|
37
|
+
--
|
|
38
|
+
-- F-13 REVOCATION LEVER (Option A's substitute for a revocable session row): suspend or
|
|
39
|
+
-- revoke the fed-human agent (agents.status <> 'active'). current_agent_id() then resolves
|
|
40
|
+
-- to NULL, so every RLS-gated read/write denies — any outstanding <=15-min token is inert
|
|
41
|
+
-- at the AUTHORITY layer within one token lifetime. The non-active guard below ALSO blocks
|
|
42
|
+
-- re-mint (no self-reactivation), so a suspended human cannot re-provision a fresh session.
|
|
43
|
+
--
|
|
44
|
+
-- F-10 TRUST BOUNDARY (named): Portal `sub`-issuance is the takeover boundary. A
|
|
45
|
+
-- compromised or mis-issued Portal `sub` for a human = full takeover of that human on the
|
|
46
|
+
-- board, INCLUDING ownership of any agent subtree the human owns (this fed-human is the
|
|
47
|
+
-- owner_user_id of it). The board delegates that decision to the Portal by design.
|
|
48
|
+
--
|
|
49
|
+
-- F-14 FLAG: because the fed-human is owner_user_id of its subtree, a FUTURE "act-as-owned"
|
|
50
|
+
-- RPC would turn this bounded session into act-as-fleet. No such RPC exists today; if one
|
|
51
|
+
-- is ever added it MUST NOT be reachable from a fedsession-minted token.
|
|
52
|
+
--
|
|
53
|
+
-- PII (F-12): the verified email is written ONLY to auth.users.raw_user_meta_data
|
|
54
|
+
-- (service-role-only), tagged provenance='portal-claim-unverified' (the board has NOT
|
|
55
|
+
-- independently verified possession — an IDLINK link-time flow MUST re-verify before
|
|
56
|
+
-- trusting it). agents.metadata.federation stores ONLY the boolean email_verified, NEVER
|
|
57
|
+
-- the address: the agents SELECT RLS is not row-scoped, so a stored email would be a
|
|
58
|
+
-- fleet-wide harvestable directory (the LOCALAUTH lesson, 20260726000100:15-22).
|
|
59
|
+
--
|
|
60
|
+
-- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
|
|
61
|
+
|
|
62
|
+
-- F-6: tag each federation-audit row with its provisioning mode. Additive nullable column
|
|
63
|
+
-- (enroll_federated_identity's insert leaves it null; the audit-content pgTAP uses
|
|
64
|
+
-- has_column, not a strict column-set pin, so this is safe). enroll_federated_human sets
|
|
65
|
+
-- mode='fedsession' for accountability — Option A has no revocable session row, so the
|
|
66
|
+
-- audit trail is the durable per-provision record.
|
|
67
|
+
alter table agent_board.enrollment_federation_audit
|
|
68
|
+
add column if not exists mode text;
|
|
69
|
+
|
|
70
|
+
create or replace function agent_board.enroll_federated_human(
|
|
71
|
+
p_issuer text,
|
|
72
|
+
p_audience text,
|
|
73
|
+
p_subject text,
|
|
74
|
+
p_human_identity_claim jsonb,
|
|
75
|
+
p_display_name text default null
|
|
76
|
+
)
|
|
77
|
+
returns table(
|
|
78
|
+
agent_id uuid,
|
|
79
|
+
auth_user_id uuid,
|
|
80
|
+
handle text,
|
|
81
|
+
kind text,
|
|
82
|
+
status text,
|
|
83
|
+
provider_id text,
|
|
84
|
+
created_at timestamptz
|
|
85
|
+
)
|
|
86
|
+
language plpgsql
|
|
87
|
+
security definer
|
|
88
|
+
set search_path = agent_board, public
|
|
89
|
+
as $$
|
|
90
|
+
declare
|
|
91
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
92
|
+
v_handle text;
|
|
93
|
+
v_uid uuid;
|
|
94
|
+
v_agent agent_board.agents%rowtype;
|
|
95
|
+
v_existing agent_board.agents%rowtype;
|
|
96
|
+
v_email text := nullif(trim(coalesce(p_human_identity_claim->>'email', '')), '');
|
|
97
|
+
v_email_verified boolean;
|
|
98
|
+
begin
|
|
99
|
+
-- Derive the verified-email boolean from the claim (never trust it as board-proven
|
|
100
|
+
-- possession; it only records what the Portal asserted). A non-boolean/absent value
|
|
101
|
+
-- collapses to false.
|
|
102
|
+
begin
|
|
103
|
+
v_email_verified := coalesce((p_human_identity_claim->>'email_verified')::boolean, false);
|
|
104
|
+
exception when others then
|
|
105
|
+
v_email_verified := false;
|
|
106
|
+
end;
|
|
107
|
+
|
|
108
|
+
-- (1) service_role-only (defense in depth on top of the execute-grant revocation).
|
|
109
|
+
if not agent_board._is_service_role() then
|
|
110
|
+
raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
|
|
111
|
+
end if;
|
|
112
|
+
|
|
113
|
+
-- (2) A verified subject + human identity claim are always present from the EF.
|
|
114
|
+
if p_subject is null then
|
|
115
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
116
|
+
end if;
|
|
117
|
+
if p_human_identity_claim is null then
|
|
118
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
119
|
+
end if;
|
|
120
|
+
|
|
121
|
+
-- (3) F-11: the synthetic-uid preimage is issuer||'|'||subject. Guard the separator so a
|
|
122
|
+
-- subject cannot inject a '|' to collide two distinct (issuer, subject) pairs onto one
|
|
123
|
+
-- owner uid. ALSO reject an email-shaped subject ('@'): the subject is stored VERBATIM in
|
|
124
|
+
-- agents.metadata.federation.subject, and the agents SELECT RLS is not row-scoped, so an
|
|
125
|
+
-- email `sub` would be a fleet-harvestable PII leak — the exact hazard the email is kept
|
|
126
|
+
-- out of agents.metadata to avoid. The operator's ENROLL_SUBJECT_CLAIM must resolve to an
|
|
127
|
+
-- opaque id; this is the fail-closed backstop, and the pgTAP PII guard asserts BOTH the
|
|
128
|
+
-- '@'-rejection and metadata carrying no '@'.
|
|
129
|
+
if position('|' in p_subject) > 0 then
|
|
130
|
+
raise exception 'verified subject must not contain a pipe separator character'
|
|
131
|
+
using errcode = '22023';
|
|
132
|
+
end if;
|
|
133
|
+
if position('@' in p_subject) > 0 then
|
|
134
|
+
raise exception 'verified subject must be an opaque id, not an email/@-bearing value'
|
|
135
|
+
using errcode = '22023';
|
|
136
|
+
end if;
|
|
137
|
+
|
|
138
|
+
-- (4) provider gate on (issuer, audience): must exist + supported + policy_approved.
|
|
139
|
+
-- Defense in depth atop the EF's signature verify (mirrors 20260719001200:154-181).
|
|
140
|
+
select *
|
|
141
|
+
into v_provider
|
|
142
|
+
from agent_board.identity_providers
|
|
143
|
+
where issuer = p_issuer
|
|
144
|
+
and audience = p_audience
|
|
145
|
+
limit 1;
|
|
146
|
+
if v_provider.id is null then
|
|
147
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
148
|
+
end if;
|
|
149
|
+
if not v_provider.supported then
|
|
150
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
151
|
+
end if;
|
|
152
|
+
if not v_provider.policy_approved then
|
|
153
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
154
|
+
end if;
|
|
155
|
+
|
|
156
|
+
-- (5) HUMAN-ONLY ceiling. allowed_agent_kinds is permissive on empty/null (the ceiling
|
|
157
|
+
-- is "unset"); reject ONLY when it is non-empty AND does not include 'human'.
|
|
158
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
159
|
+
and not ('human' = any (v_provider.allowed_agent_kinds)) then
|
|
160
|
+
raise exception 'provider ceiling does not permit the human kind' using errcode = '42501';
|
|
161
|
+
end if;
|
|
162
|
+
|
|
163
|
+
-- (6) SYNTHETIC uid — byte-identical derivation to enroll_federated_identity:198-199
|
|
164
|
+
-- (extensions.digest, since the pinned search_path lacks extensions). Idempotent per
|
|
165
|
+
-- (issuer, sub) so re-provisioning converges to ONE owner auth.users row = the shared
|
|
166
|
+
-- IDLINK uid. Pass issuer/subject VERBATIM (no trim/normalize) to stay byte-identical.
|
|
167
|
+
v_handle := 'fed-human-' || substr(
|
|
168
|
+
encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
|
|
169
|
+
1, 32);
|
|
170
|
+
v_uid := agent_board._ensure_agent_auth_user(v_handle);
|
|
171
|
+
|
|
172
|
+
-- (7) Idempotent + no-self-reactivation, keyed on auth_user_id = the synthetic uid (ANY
|
|
173
|
+
-- status). An ACTIVE row -> return it (idempotent). A NON-active row -> fail closed
|
|
174
|
+
-- (42501): a suspended/revoked human must NOT re-mint a session (mirrors
|
|
175
|
+
-- 20260726000100:78-81 and reinforces the F-13 revocation lever). F-5: assert the row is
|
|
176
|
+
-- human before returning it as a human session (a non-human agent on this owner uid must
|
|
177
|
+
-- never yield a "human" provision).
|
|
178
|
+
select *
|
|
179
|
+
into v_existing
|
|
180
|
+
from agent_board.agents a
|
|
181
|
+
where a.auth_user_id = v_uid
|
|
182
|
+
order by (a.status = 'active') desc, a.created_at asc
|
|
183
|
+
limit 1;
|
|
184
|
+
if found then
|
|
185
|
+
if v_existing.status = 'active' then
|
|
186
|
+
if v_existing.kind <> 'human' then
|
|
187
|
+
raise exception 'existing agent on this identity is not human; refusing human session'
|
|
188
|
+
using errcode = '42501';
|
|
189
|
+
end if;
|
|
190
|
+
-- Refresh the verified-email snapshot on re-provision (MERGE, see step 9).
|
|
191
|
+
if v_email is not null then
|
|
192
|
+
update auth.users u
|
|
193
|
+
set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
|
|
194
|
+
|| jsonb_build_object(
|
|
195
|
+
'fedsession_email', v_email,
|
|
196
|
+
'fedsession_email_verified', v_email_verified,
|
|
197
|
+
'fedsession_email_provenance', 'portal-claim-unverified')
|
|
198
|
+
where u.id = v_uid;
|
|
199
|
+
end if;
|
|
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, mode)
|
|
203
|
+
values (
|
|
204
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
205
|
+
v_existing.id, v_uid, '{}', '{}', 'fedsession');
|
|
206
|
+
return query
|
|
207
|
+
select v_existing.id, v_existing.auth_user_id, v_existing.handle,
|
|
208
|
+
v_existing.kind::text, v_existing.status, v_provider.id, v_existing.created_at;
|
|
209
|
+
return;
|
|
210
|
+
else
|
|
211
|
+
raise exception 'a non-active identity already exists for this human; contact an operator'
|
|
212
|
+
using errcode = '42501';
|
|
213
|
+
end if;
|
|
214
|
+
end if;
|
|
215
|
+
|
|
216
|
+
-- (8) Create the kind='human' agent (self-owned: auth_user_id = owner_user_id = uid).
|
|
217
|
+
-- No agent_signing_keys row (humans hold no key). metadata.federation carries ONLY the
|
|
218
|
+
-- boolean email_verified — never the address (PII / fleet-readable RLS).
|
|
219
|
+
begin
|
|
220
|
+
insert into agent_board.agents(
|
|
221
|
+
agent_type,
|
|
222
|
+
kind,
|
|
223
|
+
handle,
|
|
224
|
+
display_name,
|
|
225
|
+
status,
|
|
226
|
+
auth_user_id,
|
|
227
|
+
owner_user_id,
|
|
228
|
+
metadata
|
|
229
|
+
)
|
|
230
|
+
values (
|
|
231
|
+
'human',
|
|
232
|
+
'human'::agent_board.agent_kind,
|
|
233
|
+
v_handle,
|
|
234
|
+
coalesce(nullif(trim(p_display_name), ''), 'Human'),
|
|
235
|
+
'active',
|
|
236
|
+
v_uid,
|
|
237
|
+
v_uid,
|
|
238
|
+
jsonb_build_object(
|
|
239
|
+
'federation', jsonb_build_object(
|
|
240
|
+
'mode', 'fedsession',
|
|
241
|
+
'issuer', p_issuer,
|
|
242
|
+
'audience', p_audience,
|
|
243
|
+
'subject', p_subject,
|
|
244
|
+
'providerId', v_provider.id,
|
|
245
|
+
'email_verified', v_email_verified,
|
|
246
|
+
'enrolledAt', now()
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
returning * into v_agent;
|
|
251
|
+
exception when unique_violation then
|
|
252
|
+
-- Concurrent first-provision race on the synthetic uid. F-5: re-select the ACTIVE row
|
|
253
|
+
-- and ASSERT it is human (fail closed otherwise) — never return a non-human agent as a
|
|
254
|
+
-- human session.
|
|
255
|
+
select *
|
|
256
|
+
into v_existing
|
|
257
|
+
from agent_board.agents a
|
|
258
|
+
where a.auth_user_id = v_uid
|
|
259
|
+
and a.status = 'active'
|
|
260
|
+
and a.kind = 'human'
|
|
261
|
+
limit 1;
|
|
262
|
+
if found then
|
|
263
|
+
v_agent := v_existing;
|
|
264
|
+
else
|
|
265
|
+
raise exception 'could not provision federated human (race or non-human on owner uid), please retry'
|
|
266
|
+
using errcode = '40001';
|
|
267
|
+
end if;
|
|
268
|
+
end;
|
|
269
|
+
|
|
270
|
+
-- (9) F-12/F-4: store the verified email ONLY in auth.users.raw_user_meta_data
|
|
271
|
+
-- (service-role-only), MERGED so _ensure_agent_auth_user's {slug, issuer} is preserved,
|
|
272
|
+
-- tagged provenance='portal-claim-unverified'. The definer owner (postgres) owns auth.*.
|
|
273
|
+
if v_email is not null then
|
|
274
|
+
update auth.users u
|
|
275
|
+
set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
|
|
276
|
+
|| jsonb_build_object(
|
|
277
|
+
'fedsession_email', v_email,
|
|
278
|
+
'fedsession_email_verified', v_email_verified,
|
|
279
|
+
'fedsession_email_provenance', 'portal-claim-unverified')
|
|
280
|
+
where u.id = v_uid;
|
|
281
|
+
end if;
|
|
282
|
+
|
|
283
|
+
-- (10) F-6: append the audit row (mode='fedsession'). No requested/granted scopes on the
|
|
284
|
+
-- human path (authority is membership/role, not enrollment scopes) -> empty arrays.
|
|
285
|
+
insert into agent_board.enrollment_federation_audit(
|
|
286
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
287
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
|
|
288
|
+
values (
|
|
289
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
290
|
+
v_agent.id, v_uid, '{}', '{}', 'fedsession');
|
|
291
|
+
|
|
292
|
+
return query
|
|
293
|
+
select v_agent.id, v_agent.auth_user_id, v_agent.handle,
|
|
294
|
+
v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
|
|
295
|
+
end;
|
|
296
|
+
$$;
|
|
297
|
+
|
|
298
|
+
comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
|
|
299
|
+
'FEDSESSION (Option A): service_role no-PoP federated-HUMAN provisioning. Re-gates the '
|
|
300
|
+
'(issuer,audience) provider allowlist/policy + the human-only ceiling, establishes a '
|
|
301
|
+
'kind=human self-owned agent (auth_user_id = owner_user_id = a SYNTHETIC uid derived '
|
|
302
|
+
'byte-identically to enroll_federated_identity per (issuer,sub) = the IDLINK substrate), '
|
|
303
|
+
'stores the verified email ONLY in auth.users.raw_user_meta_data (provenance '
|
|
304
|
+
'portal-claim-unverified) with only the boolean email_verified in agents.metadata, and '
|
|
305
|
+
'writes a mode=fedsession audit row. No signing key. Idempotent for an active identity; '
|
|
306
|
+
'a non-active identity fails closed (42501, no self-reactivation) — the F-13 revocation '
|
|
307
|
+
'lever (suspend the agent -> current_agent_id null -> RLS denies + re-mint blocked). '
|
|
308
|
+
'service_role-only; never returns or touches a Portal token.';
|
|
309
|
+
|
|
310
|
+
revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
|
|
311
|
+
from public, anon, authenticated;
|
|
312
|
+
grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
|
|
313
|
+
to service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.10.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 79,
|
|
50
|
+
"aggregateSha256": "6e82ca1943a3848b3f7c871e97cdf82387194ac361e965111785e236a0e850fd",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -750,6 +750,15 @@
|
|
|
750
750
|
"sourceRef": "message-board",
|
|
751
751
|
"bytes": 5217,
|
|
752
752
|
"sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
"order": 78,
|
|
756
|
+
"id": "20260728000100_agent_board_enroll_federated_human",
|
|
757
|
+
"filename": "20260728000100_agent_board_enroll_federated_human.sql",
|
|
758
|
+
"origin": "message-board-forward",
|
|
759
|
+
"sourceRef": "message-board",
|
|
760
|
+
"bytes": 15404,
|
|
761
|
+
"sha256": "5f300449c29ec05c2cd3c2cd7eca622ca41e8b32b8856c5bc0695b5750a196e7"
|
|
753
762
|
}
|
|
754
763
|
],
|
|
755
764
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
|
|
|
37
37
|
expect(manifest.schemaVersion).toBe(8);
|
|
38
38
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
39
39
|
});
|
|
40
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI forward migrations (migrationCount = prior
|
|
40
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION forward migrations (migrationCount = prior 78 + 1 = 79)', () => {
|
|
41
41
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
42
|
+
expect(manifest.migrationCount).toBe(79);
|
|
43
43
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
44
44
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
45
45
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -91,6 +91,10 @@ describe('manifest integrity', () => {
|
|
|
91
91
|
// list_board_pending_memberships + SECURITY INVOKER resolve_agent_handle).
|
|
92
92
|
// schema stays 8.
|
|
93
93
|
'20260727000100_agent_board_roomuiapi.sql',
|
|
94
|
+
// FEDSESSION: service_role no-PoP federated-HUMAN provisioning (enroll_federated_human)
|
|
95
|
+
// backing the /portal-session Option A self-signed access-token minter. Additive
|
|
96
|
+
// (adds a nullable mode column to enrollment_federation_audit); schema stays 8.
|
|
97
|
+
'20260728000100_agent_board_enroll_federated_human.sql',
|
|
94
98
|
]) {
|
|
95
99
|
expect(names.has(f)).toBe(true);
|
|
96
100
|
}
|
|
@@ -76,10 +76,13 @@ select is(
|
|
|
76
76
|
-- ("X is an aggregate function"); restrict to callable functions +
|
|
77
77
|
-- procedures (the RPC surface this invariant is about).
|
|
78
78
|
and p.prokind in ('f', 'p')
|
|
79
|
-
|
|
79
|
+
-- The federation enroll RPCs WRITE the audit (enroll_federated_human added by
|
|
80
|
+
-- FEDSESSION, F-6); the invariant is that nothing on the send/session read path
|
|
81
|
+
-- touches it (asserted specifically in the next case).
|
|
82
|
+
and p.proname not in ('enroll_federated_identity', 'enroll_federated_human')
|
|
80
83
|
) d
|
|
81
84
|
where lower(d.def) like '%enrollment_federation_audit%'),
|
|
82
|
-
0, 'no agent_board RPC other than
|
|
85
|
+
0, 'no agent_board RPC other than the federation enroll RPCs references enrollment_federation_audit');
|
|
83
86
|
select is(
|
|
84
87
|
(select count(*)::int from (
|
|
85
88
|
select pg_get_functiondef(p.oid) as def
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
-- FEDSESSION pgTAP: enroll_federated_human() — service_role no-PoP federated-HUMAN
|
|
2
|
+
-- provisioning (Option A minter substrate).
|
|
3
|
+
--
|
|
4
|
+
-- Proves: the 5-arg signature + service_role-only privs; one provision mints a kind=human
|
|
5
|
+
-- SELF-owned agent (auth_user_id = owner_user_id = a synthetic uid) with a real auth.users
|
|
6
|
+
-- row + NO signing key + a mode='fedsession' audit row; the verified email lives ONLY in
|
|
7
|
+
-- auth.users.raw_user_meta_data (provenance-tagged, MERGED preserving slug) with just the
|
|
8
|
+
-- boolean email_verified in agents.metadata (no '@' anywhere in agents.metadata);
|
|
9
|
+
-- idempotency + L-4b (one agent per synthetic uid); current_agent_id() resolves the
|
|
10
|
+
-- no-credential session to the agent; the IDLINK shared-uid invariant against
|
|
11
|
+
-- enroll_federated_identity; and the provider/kind/subject/non-service rejection matrix.
|
|
12
|
+
-- Runs as postgres (service_role-equivalent), mirroring
|
|
13
|
+
-- agent_board_enroll_federated_identity.test.sql + agent_board_localauth.test.sql.
|
|
14
|
+
|
|
15
|
+
create extension if not exists pgtap with schema extensions;
|
|
16
|
+
set search_path = extensions, public, agent_board;
|
|
17
|
+
|
|
18
|
+
begin;
|
|
19
|
+
|
|
20
|
+
select plan(36);
|
|
21
|
+
|
|
22
|
+
-- ---------------------------------------------------------------------------
|
|
23
|
+
-- Phase 0 (superuser): function metadata + privileges
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
-- 1
|
|
26
|
+
select has_function(
|
|
27
|
+
'agent_board', 'enroll_federated_human',
|
|
28
|
+
array['text','text','text','jsonb','text'],
|
|
29
|
+
'enroll_federated_human(text,text,text,jsonb,text) exists');
|
|
30
|
+
-- 2
|
|
31
|
+
select function_privs_are(
|
|
32
|
+
'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
|
|
33
|
+
'service_role', array['EXECUTE'], 'service_role may EXECUTE enroll_federated_human');
|
|
34
|
+
-- 3
|
|
35
|
+
select function_privs_are(
|
|
36
|
+
'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
|
|
37
|
+
'authenticated', array[]::text[], 'authenticated may NOT EXECUTE enroll_federated_human');
|
|
38
|
+
-- 4
|
|
39
|
+
select function_privs_are(
|
|
40
|
+
'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
|
|
41
|
+
'anon', array[]::text[], 'anon may NOT EXECUTE enroll_federated_human');
|
|
42
|
+
|
|
43
|
+
-- ---------------------------------------------------------------------------
|
|
44
|
+
-- Fixtures + provision identity A (email_verified) and B (unverified)
|
|
45
|
+
-- ---------------------------------------------------------------------------
|
|
46
|
+
create temp table _f (
|
|
47
|
+
s text, iss text, aud text,
|
|
48
|
+
sub_a text, email_a text, sub_b text
|
|
49
|
+
) on commit drop;
|
|
50
|
+
grant all on table _f to authenticated, service_role;
|
|
51
|
+
|
|
52
|
+
create temp table _r (
|
|
53
|
+
label text, agent_id uuid, auth_user_id uuid, owner_user_id uuid,
|
|
54
|
+
handle text, kind text, status text, created_at timestamptz
|
|
55
|
+
) on commit drop;
|
|
56
|
+
grant all on table _r to authenticated, service_role;
|
|
57
|
+
|
|
58
|
+
do $$
|
|
59
|
+
declare
|
|
60
|
+
s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
61
|
+
v_iss text; v_agent uuid; v_auth uuid; v_owner uuid; v_handle text;
|
|
62
|
+
v_kind text; v_status text; v_created timestamptz;
|
|
63
|
+
begin
|
|
64
|
+
v_iss := 'https://fed-hs-' || s || '.test';
|
|
65
|
+
insert into _f values (
|
|
66
|
+
s, v_iss, 'message-board',
|
|
67
|
+
'portal-user-' || s, 'alice-' || s || '@example.test', 'portal-user2-' || s);
|
|
68
|
+
|
|
69
|
+
-- Approved provider that permits BOTH agent + human (agent needed for the IDLINK case).
|
|
70
|
+
insert into agent_board.identity_providers
|
|
71
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
|
|
72
|
+
values
|
|
73
|
+
(v_iss, 'message-board', true, true, '{board:send}', '{board:x}', '{agent,human}');
|
|
74
|
+
|
|
75
|
+
-- Provision A (verified email present in the claim).
|
|
76
|
+
select e.agent_id, e.auth_user_id, e.handle, e.kind, e.status, e.created_at
|
|
77
|
+
into v_agent, v_auth, v_handle, v_kind, v_status, v_created
|
|
78
|
+
from agent_board.enroll_federated_human(
|
|
79
|
+
v_iss, 'message-board', 'portal-user-' || s,
|
|
80
|
+
jsonb_build_object('sub', 'portal-user-' || s, 'email', 'alice-' || s || '@example.test',
|
|
81
|
+
'email_verified', true, 'name', 'Alice ' || s),
|
|
82
|
+
'Alice ' || s) e;
|
|
83
|
+
select a.owner_user_id into v_owner from agent_board.agents a where a.id = v_agent;
|
|
84
|
+
insert into _r values ('A', v_agent, v_auth, v_owner, v_handle, v_kind, v_status, v_created);
|
|
85
|
+
|
|
86
|
+
-- Provision B (DIFFERENT identity, email_verified=false).
|
|
87
|
+
select e.agent_id, e.auth_user_id, e.handle, e.kind, e.status, e.created_at
|
|
88
|
+
into v_agent, v_auth, v_handle, v_kind, v_status, v_created
|
|
89
|
+
from agent_board.enroll_federated_human(
|
|
90
|
+
v_iss, 'message-board', 'portal-user2-' || s,
|
|
91
|
+
jsonb_build_object('sub', 'portal-user2-' || s, 'email', 'bob-' || s || '@example.test',
|
|
92
|
+
'email_verified', false),
|
|
93
|
+
null) e;
|
|
94
|
+
select a.owner_user_id into v_owner from agent_board.agents a where a.id = v_agent;
|
|
95
|
+
insert into _r values ('B', v_agent, v_auth, v_owner, v_handle, v_kind, v_status, v_created);
|
|
96
|
+
end
|
|
97
|
+
$$;
|
|
98
|
+
|
|
99
|
+
-- One provision mints a kind=human, self-owned, active agent -----------------------------
|
|
100
|
+
-- 5
|
|
101
|
+
select is((select count(*)::int from _r where label = 'A'), 1, 'provision A returned exactly one row');
|
|
102
|
+
-- 6
|
|
103
|
+
select is((select kind from _r where label = 'A'), 'human', 'agent A kind = human');
|
|
104
|
+
-- 7
|
|
105
|
+
select is(
|
|
106
|
+
(select a.agent_type from agent_board.agents a where a.id = (select agent_id from _r where label='A')),
|
|
107
|
+
'human', 'agent A agent_type = human');
|
|
108
|
+
-- 8
|
|
109
|
+
select is((select status from _r where label = 'A'), 'active', 'agent A status = active');
|
|
110
|
+
-- 9
|
|
111
|
+
select is(
|
|
112
|
+
(select auth_user_id from _r where label='A'),
|
|
113
|
+
(select owner_user_id from _r where label='A'),
|
|
114
|
+
'agent A is self-owned (auth_user_id = owner_user_id = the synthetic uid)');
|
|
115
|
+
-- 10
|
|
116
|
+
select is(
|
|
117
|
+
(select count(*)::int from auth.users u where u.id = (select auth_user_id from _r where label='A')),
|
|
118
|
+
1, 'the synthetic auth.users row for agent A exists');
|
|
119
|
+
-- 11
|
|
120
|
+
select is(
|
|
121
|
+
(select handle from _r where label='A'),
|
|
122
|
+
'fed-human-' || substr(
|
|
123
|
+
encode(extensions.digest(convert_to((select iss from _f) || '|' || (select sub_a from _f), 'UTF8'), 'sha256'), 'hex'),
|
|
124
|
+
1, 32),
|
|
125
|
+
'handle = fed-human-<sha256(issuer|subject)[:32]> (byte-identical to enroll_federated_identity)');
|
|
126
|
+
|
|
127
|
+
-- metadata carries the federation snapshot, boolean-only email, no PII --------------------
|
|
128
|
+
-- 12
|
|
129
|
+
select is(
|
|
130
|
+
(select a.metadata->'federation'->>'issuer' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
|
|
131
|
+
(select iss from _f), 'metadata.federation.issuer round-trips');
|
|
132
|
+
-- 13
|
|
133
|
+
select is(
|
|
134
|
+
(select a.metadata->'federation'->>'subject' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
|
|
135
|
+
(select sub_a from _f), 'metadata.federation.subject round-trips');
|
|
136
|
+
-- 14
|
|
137
|
+
select is(
|
|
138
|
+
(select a.metadata->'federation'->>'mode' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
|
|
139
|
+
'fedsession', 'metadata.federation.mode = fedsession');
|
|
140
|
+
-- 15
|
|
141
|
+
select is(
|
|
142
|
+
(select a.metadata->'federation'->>'email_verified' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
|
|
143
|
+
'true', 'verified identity -> metadata.federation.email_verified = true');
|
|
144
|
+
-- 16
|
|
145
|
+
select ok(
|
|
146
|
+
(select a.metadata::text not like '%@%' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
|
|
147
|
+
'agent A metadata carries NO plaintext email/@ (PII stays out of fleet-readable metadata)');
|
|
148
|
+
|
|
149
|
+
-- verified email + slug live (merged) in auth.users.raw_user_meta_data --------------------
|
|
150
|
+
-- 17
|
|
151
|
+
select is(
|
|
152
|
+
(select u.raw_user_meta_data->>'fedsession_email' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
|
|
153
|
+
(select email_a from _f), 'verified email is stored in auth.users.raw_user_meta_data');
|
|
154
|
+
-- 18
|
|
155
|
+
select is(
|
|
156
|
+
(select u.raw_user_meta_data->>'fedsession_email_provenance' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
|
|
157
|
+
'portal-claim-unverified', 'stored email is tagged provenance=portal-claim-unverified (F-12)');
|
|
158
|
+
-- 19 (F-4 merge preserved _ensure_agent_auth_user's slug)
|
|
159
|
+
select is(
|
|
160
|
+
(select u.raw_user_meta_data->>'slug' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
|
|
161
|
+
(select handle from _r where label='A'),
|
|
162
|
+
'raw_user_meta_data merge preserved the _ensure_agent_auth_user slug (no overwrite)');
|
|
163
|
+
|
|
164
|
+
-- NO signing key; audit row written ------------------------------------------------------
|
|
165
|
+
-- 20
|
|
166
|
+
select is(
|
|
167
|
+
(select count(*)::int from agent_board.agent_signing_keys k where k.agent_id=(select agent_id from _r where label='A')),
|
|
168
|
+
0, 'agent A holds NO agent_signing_keys row (humans hold no key)');
|
|
169
|
+
-- 21
|
|
170
|
+
select is(
|
|
171
|
+
(select count(*)::int from agent_board.enrollment_federation_audit e
|
|
172
|
+
where e.agent_id=(select agent_id from _r where label='A') and e.mode='fedsession'),
|
|
173
|
+
1, 'provision A wrote exactly one mode=fedsession audit row');
|
|
174
|
+
|
|
175
|
+
-- Identity B: unverified boolean + PII guard ---------------------------------------------
|
|
176
|
+
-- 22
|
|
177
|
+
select is(
|
|
178
|
+
(select a.metadata->'federation'->>'email_verified' from agent_board.agents a where a.id=(select agent_id from _r where label='B')),
|
|
179
|
+
'false', 'unverified identity -> metadata.federation.email_verified = false');
|
|
180
|
+
-- 23
|
|
181
|
+
select ok(
|
|
182
|
+
(select a.metadata::text not like '%@%' from agent_board.agents a where a.id=(select agent_id from _r where label='B')),
|
|
183
|
+
'agent B metadata carries NO plaintext email/@');
|
|
184
|
+
|
|
185
|
+
-- Idempotency + L-4b ---------------------------------------------------------------------
|
|
186
|
+
-- 24
|
|
187
|
+
select is(
|
|
188
|
+
(select e.agent_id from agent_board.enroll_federated_human(
|
|
189
|
+
(select iss from _f), 'message-board', (select sub_a from _f),
|
|
190
|
+
jsonb_build_object('sub', (select sub_a from _f), 'email_verified', true), 'Alice again') e),
|
|
191
|
+
(select agent_id from _r where label='A'),
|
|
192
|
+
're-provision of the same (issuer,sub) is idempotent (same agent_id)');
|
|
193
|
+
-- 25
|
|
194
|
+
select is(
|
|
195
|
+
(select count(*)::int from agent_board.agents a where a.auth_user_id=(select auth_user_id from _r where label='A')),
|
|
196
|
+
1, 'still exactly one agent on the synthetic uid (L-4b)');
|
|
197
|
+
-- 26
|
|
198
|
+
select is(
|
|
199
|
+
(select count(*)::int from agent_board.agents a
|
|
200
|
+
where a.owner_user_id=(select auth_user_id from _r where label='A') and a.kind <> 'human'),
|
|
201
|
+
0, 'no non-human agent was ever created on this owner uid (kind is structural)');
|
|
202
|
+
|
|
203
|
+
-- IDLINK: enroll_federated_identity for the SAME (issuer,sub) shares the owner uid --------
|
|
204
|
+
create temp table _idlink (owner uuid) on commit drop;
|
|
205
|
+
do $$
|
|
206
|
+
declare
|
|
207
|
+
f record;
|
|
208
|
+
v_pub text; v_fp text; v_agent uuid;
|
|
209
|
+
begin
|
|
210
|
+
select * into f from _f;
|
|
211
|
+
-- A deterministic canonical 32-byte raw key + its raw-byte fingerprint (same construction
|
|
212
|
+
-- as the enroll_federated_identity B1 harness).
|
|
213
|
+
v_pub := rtrim(translate(encode(extensions.digest(convert_to('idlink-'||f.s,'UTF8'),'sha256'),'base64'),'+/','-_'),'=');
|
|
214
|
+
v_fp := 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate(v_pub,'-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'=');
|
|
215
|
+
select e.agent_id into v_agent
|
|
216
|
+
from agent_board.enroll_federated_identity(
|
|
217
|
+
f.iss, 'message-board', f.sub_a, jsonb_build_object('sub', f.sub_a),
|
|
218
|
+
'idlink-'||f.s, v_pub, 'raw_ed25519_base64url', v_fp,
|
|
219
|
+
true, null, 'agent', '{board:send}', '{}') e;
|
|
220
|
+
insert into _idlink select a.owner_user_id from agent_board.agents a where a.id = v_agent;
|
|
221
|
+
end
|
|
222
|
+
$$;
|
|
223
|
+
-- 27
|
|
224
|
+
select is(
|
|
225
|
+
(select owner from _idlink),
|
|
226
|
+
(select auth_user_id from _r where label='A'),
|
|
227
|
+
'IDLINK: enroll_federated_identity for the same (issuer,sub) shares the fed-human owner uid');
|
|
228
|
+
|
|
229
|
+
-- current_agent_id() resolves the no-credential session to the fed-human agent -----------
|
|
230
|
+
set local role authenticated;
|
|
231
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
232
|
+
select set_config('request.jwt.claim.sub', (select auth_user_id::text from _r where label='A'), true);
|
|
233
|
+
-- 28
|
|
234
|
+
select is(
|
|
235
|
+
agent_board.current_agent_id(),
|
|
236
|
+
(select agent_id from _r where label='A'),
|
|
237
|
+
'current_agent_id() (no agent_credential_id claim) resolves to the fed-human agent');
|
|
238
|
+
-- 29 (non-service caller: authenticated lacks EXECUTE -> 42501)
|
|
239
|
+
select throws_ok(
|
|
240
|
+
$$ select agent_board.enroll_federated_human('https://x.test','message-board','portal-x','{}'::jsonb,null) $$,
|
|
241
|
+
'42501', null, 'a non-service (authenticated) caller is rejected with 42501');
|
|
242
|
+
reset role;
|
|
243
|
+
-- Clear BOTH request.jwt GUCs: reset role alone leaves request.jwt.claim.role set (it
|
|
244
|
+
-- persists as is_local), so auth.role() would stay 'authenticated' and _is_service_role()
|
|
245
|
+
-- would wrongly reject the following superuser calls (they must run as service_role-equiv).
|
|
246
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
247
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
248
|
+
|
|
249
|
+
-- Provider / kind / subject rejection matrix (superuser) ---------------------------------
|
|
250
|
+
-- 30 unknown (issuer, audience)
|
|
251
|
+
select throws_ok(
|
|
252
|
+
(select format($q$select agent_board.enroll_federated_human('https://nope-%s.test','message-board',%L,%L::jsonb,null)$q$,
|
|
253
|
+
s, sub_a, jsonb_build_object('sub', sub_a)::text) from _f),
|
|
254
|
+
'42501', null, 'unknown (issuer,audience) is rejected');
|
|
255
|
+
-- 31 null verified subject
|
|
256
|
+
select throws_ok(
|
|
257
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',NULL,%L::jsonb,null)$q$,
|
|
258
|
+
iss, jsonb_build_object('x', 1)::text) from _f),
|
|
259
|
+
'22023', null, 'a null verified subject is rejected');
|
|
260
|
+
-- 32 subject contains a '|' (F-11 preimage-injection guard)
|
|
261
|
+
select throws_ok(
|
|
262
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
|
|
263
|
+
iss, 'portal|evil', jsonb_build_object('sub','portal|evil')::text) from _f),
|
|
264
|
+
'22023', null, 'a subject containing a pipe separator is rejected (F-11)');
|
|
265
|
+
-- 32b subject is email-shaped ('@') (F-11 PII backstop; subject is stored fleet-readable)
|
|
266
|
+
select throws_ok(
|
|
267
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
|
|
268
|
+
iss, 'evil@example.test', jsonb_build_object('sub','evil@example.test')::text) from _f),
|
|
269
|
+
'22023', null, 'an email-shaped (@) subject is rejected before it reaches fleet-readable metadata (F-11)');
|
|
270
|
+
|
|
271
|
+
-- provider present but NOT policy_approved -> 42501
|
|
272
|
+
do $$
|
|
273
|
+
declare s2 text := replace(substr(gen_random_uuid()::text,1,8),'-','');
|
|
274
|
+
begin
|
|
275
|
+
insert into agent_board.identity_providers
|
|
276
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
|
|
277
|
+
values ('https://fed-unapproved-'||s2||'.test','message-board', true, false, '{}','{}','{human}');
|
|
278
|
+
insert into _f(s,iss,aud,sub_a,email_a,sub_b)
|
|
279
|
+
values ('U'||s2, 'https://fed-unapproved-'||s2||'.test','message-board','portal-u-'||s2,null,null);
|
|
280
|
+
end $$;
|
|
281
|
+
-- 33
|
|
282
|
+
select throws_ok(
|
|
283
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
|
|
284
|
+
iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where s like 'U%'),
|
|
285
|
+
'42501', null, 'an un-approved (policy_approved=false) provider is rejected');
|
|
286
|
+
|
|
287
|
+
-- provider approved but allowed_agent_kinds EXCLUDES human (non-empty {agent}) -> 42501
|
|
288
|
+
do $$
|
|
289
|
+
declare s3 text := replace(substr(gen_random_uuid()::text,1,8),'-','');
|
|
290
|
+
begin
|
|
291
|
+
insert into agent_board.identity_providers
|
|
292
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
|
|
293
|
+
values ('https://fed-nohuman-'||s3||'.test','message-board', true, true, '{}','{}','{agent}');
|
|
294
|
+
insert into _f(s,iss,aud,sub_a,email_a,sub_b)
|
|
295
|
+
values ('N'||s3, 'https://fed-nohuman-'||s3||'.test','message-board','portal-n-'||s3,null,null);
|
|
296
|
+
end $$;
|
|
297
|
+
-- 34
|
|
298
|
+
select throws_ok(
|
|
299
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
|
|
300
|
+
iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where s like 'N%'),
|
|
301
|
+
'42501', null, 'a non-empty allowed_agent_kinds that excludes human is rejected');
|
|
302
|
+
|
|
303
|
+
-- Non-active identity cannot re-provision (no self-reactivation; F-13 lever) --------------
|
|
304
|
+
update agent_board.agents set status='suspended', updated_at=now()
|
|
305
|
+
where id = (select agent_id from _r where label='A');
|
|
306
|
+
-- 35
|
|
307
|
+
select throws_ok(
|
|
308
|
+
(select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
|
|
309
|
+
iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where sub_a like 'portal-user-%'),
|
|
310
|
+
'42501', null, 'a suspended (non-active) fed-human cannot re-provision a session (42501)');
|
|
311
|
+
|
|
312
|
+
select finish();
|
|
313
|
+
|
|
314
|
+
rollback;
|