@consiliency/agent-board-schema 1.7.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 +12 -0
- package/migrations/20260726000100_agent_board_localauth_self_enroll.sql +172 -0
- package/migrations/20260727000100_agent_board_roomuiapi.sql +122 -0
- package/migrations/20260728000100_agent_board_enroll_federated_human.sql +313 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +13 -2
- package/tests/agent_board_enrollment_audit_content.test.sql +5 -2
- package/tests/agent_board_fedsession.test.sql +314 -0
- package/tests/agent_board_localauth.test.sql +171 -0
- package/tests/agent_board_roomuiapi.test.sql +225 -0
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,
|
|
@@ -732,6 +732,33 @@
|
|
|
732
732
|
"sourceRef": "message-board",
|
|
733
733
|
"bytes": 9521,
|
|
734
734
|
"sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
"order": 76,
|
|
738
|
+
"id": "20260726000100_agent_board_localauth_self_enroll",
|
|
739
|
+
"filename": "20260726000100_agent_board_localauth_self_enroll.sql",
|
|
740
|
+
"origin": "message-board-forward",
|
|
741
|
+
"sourceRef": "message-board",
|
|
742
|
+
"bytes": 7323,
|
|
743
|
+
"sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
"order": 77,
|
|
747
|
+
"id": "20260727000100_agent_board_roomuiapi",
|
|
748
|
+
"filename": "20260727000100_agent_board_roomuiapi.sql",
|
|
749
|
+
"origin": "message-board-forward",
|
|
750
|
+
"sourceRef": "message-board",
|
|
751
|
+
"bytes": 5217,
|
|
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"
|
|
735
762
|
}
|
|
736
763
|
],
|
|
737
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 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,
|
|
@@ -84,6 +84,17 @@ describe('manifest integrity', () => {
|
|
|
84
84
|
// ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
|
|
85
85
|
// fix on invite_board_member/resolve_board_request. schema stays 8.
|
|
86
86
|
'20260725000100_agent_board_roomdeleg_role_gate.sql',
|
|
87
|
+
// LOCALAUTH: Portal-free standalone human self-enroll (self_enroll_human RPC +
|
|
88
|
+
// one-agent-per-real-auth-user backstop). schema stays 8.
|
|
89
|
+
'20260726000100_agent_board_localauth_self_enroll.sql',
|
|
90
|
+
// ROOMUIAPI: two read RPCs for the room console/CLI (owner/admin-gated
|
|
91
|
+
// list_board_pending_memberships + SECURITY INVOKER resolve_agent_handle).
|
|
92
|
+
// schema stays 8.
|
|
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',
|
|
87
98
|
]) {
|
|
88
99
|
expect(names.has(f)).toBe(true);
|
|
89
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;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
-- LOCALAUTH pgTAP: self_enroll_human() — Portal-free human self-enrollment.
|
|
2
|
+
--
|
|
3
|
+
-- Harness mirrors agent_board_authbound_boundary.test.sql: seed auth.users via
|
|
4
|
+
-- public.ensure_github_user, fake an authenticated caller with
|
|
5
|
+
-- set local role authenticated + set_config('request.jwt.claim.sub', <uid>),
|
|
6
|
+
-- so auth.uid() = sub. reset role -> superuser (auth.uid() null / service_role).
|
|
7
|
+
--
|
|
8
|
+
-- CRITICAL harness notes:
|
|
9
|
+
-- * L-7: `reset role` does NOT null auth.uid(); request.jwt.claim.sub is set
|
|
10
|
+
-- is_local=true and PERSISTS. Cases 12/13 explicitly clear the sub GUC before
|
|
11
|
+
-- asserting 42501, else the RPC finds the prior uid's agent (vacuous pass).
|
|
12
|
+
-- * L-8: ensure_github_user sets email_confirmed_at = now() unconditionally, so
|
|
13
|
+
-- the unverified case nulls it first.
|
|
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(17);
|
|
21
|
+
|
|
22
|
+
-- ---------------------------------------------------------------------------
|
|
23
|
+
-- Phase 0 (superuser): function metadata + seed
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
-- 1
|
|
27
|
+
select has_function('agent_board', 'self_enroll_human', array['text'], 'self_enroll_human(text) exists');
|
|
28
|
+
-- 2
|
|
29
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'authenticated', array['EXECUTE'],
|
|
30
|
+
'authenticated may EXECUTE self_enroll_human');
|
|
31
|
+
-- 3
|
|
32
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'anon', array[]::text[],
|
|
33
|
+
'anon may NOT EXECUTE self_enroll_human');
|
|
34
|
+
|
|
35
|
+
create temp table _lfix (
|
|
36
|
+
user_a uuid,
|
|
37
|
+
user_b uuid,
|
|
38
|
+
user_c uuid,
|
|
39
|
+
agent_a uuid,
|
|
40
|
+
agent_a2 uuid
|
|
41
|
+
) on commit drop;
|
|
42
|
+
grant all on table _lfix to authenticated, service_role;
|
|
43
|
+
|
|
44
|
+
do $$
|
|
45
|
+
declare
|
|
46
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
47
|
+
v_a uuid;
|
|
48
|
+
v_b uuid;
|
|
49
|
+
v_c uuid;
|
|
50
|
+
begin
|
|
51
|
+
-- verified user (email_confirmed_at defaults to now())
|
|
52
|
+
v_a := public.ensure_github_user(
|
|
53
|
+
'localauth-a-' || v_suffix,
|
|
54
|
+
'localauth-a-' || v_suffix || '@example.test',
|
|
55
|
+
'localauth-a-' || v_suffix,
|
|
56
|
+
'LOCALAUTH A ' || v_suffix);
|
|
57
|
+
|
|
58
|
+
-- unverified user: L-8 — null out the unconditionally-set email_confirmed_at
|
|
59
|
+
v_b := public.ensure_github_user(
|
|
60
|
+
'localauth-b-' || v_suffix,
|
|
61
|
+
'localauth-b-' || v_suffix || '@example.test',
|
|
62
|
+
'localauth-b-' || v_suffix,
|
|
63
|
+
'LOCALAUTH B ' || v_suffix);
|
|
64
|
+
update auth.users set email_confirmed_at = null where id = v_b;
|
|
65
|
+
|
|
66
|
+
-- user with a pre-existing SUSPENDED human agent (no self-reactivation case)
|
|
67
|
+
v_c := public.ensure_github_user(
|
|
68
|
+
'localauth-c-' || v_suffix,
|
|
69
|
+
'localauth-c-' || v_suffix || '@example.test',
|
|
70
|
+
'localauth-c-' || v_suffix,
|
|
71
|
+
'LOCALAUTH C ' || v_suffix);
|
|
72
|
+
insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
73
|
+
values ('human', 'human', 'localauth-suspended-' || v_suffix, 'Suspended Human', 'suspended', v_c, v_c);
|
|
74
|
+
|
|
75
|
+
insert into _lfix(user_a, user_b, user_c) values (v_a, v_b, v_c);
|
|
76
|
+
end
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
-- ---------------------------------------------------------------------------
|
|
80
|
+
-- Phase 1 (authenticated as user_a): enroll + auth-context assertions
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
set local role authenticated;
|
|
83
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
84
|
+
select set_config('request.jwt.claim.sub', (select user_a::text from _lfix), true);
|
|
85
|
+
|
|
86
|
+
do $$
|
|
87
|
+
declare
|
|
88
|
+
v_first uuid;
|
|
89
|
+
v_second uuid;
|
|
90
|
+
begin
|
|
91
|
+
select s.agent_id into v_first from agent_board.self_enroll_human('Alice') s;
|
|
92
|
+
-- idempotent second call for the same uid returns the SAME agent
|
|
93
|
+
select s.agent_id into v_second from agent_board.self_enroll_human('Alice Again') s;
|
|
94
|
+
update _lfix set agent_a = v_first, agent_a2 = v_second;
|
|
95
|
+
end
|
|
96
|
+
$$;
|
|
97
|
+
|
|
98
|
+
-- 4 (part 1 of case 8): current_agent_id() resolves to the freshly-enrolled agent
|
|
99
|
+
select is(agent_board.current_agent_id(), (select agent_a from _lfix),
|
|
100
|
+
'current_agent_id() (no agent_credential_id claim) resolves to the self-enrolled agent');
|
|
101
|
+
-- 5 (case 9): idempotent — second enroll returned the same agent_id
|
|
102
|
+
select is((select agent_a2 from _lfix), (select agent_a from _lfix),
|
|
103
|
+
'second self_enroll_human for the same uid is idempotent (same agent_id)');
|
|
104
|
+
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
-- Phase 2 (authenticated as user_b): enroll unverified user
|
|
107
|
+
-- ---------------------------------------------------------------------------
|
|
108
|
+
select set_config('request.jwt.claim.sub', (select user_b::text from _lfix), true);
|
|
109
|
+
select agent_board.self_enroll_human(null);
|
|
110
|
+
|
|
111
|
+
-- ---------------------------------------------------------------------------
|
|
112
|
+
-- Phase 3 (superuser): row-shape assertions without RLS in the way
|
|
113
|
+
-- ---------------------------------------------------------------------------
|
|
114
|
+
reset role;
|
|
115
|
+
|
|
116
|
+
-- 6/7/8 (case 4): the enrolled row is a human bound to the uid
|
|
117
|
+
select is((select kind::text from agent_board.agents where id = (select agent_a from _lfix)),
|
|
118
|
+
'human', 'self-enrolled agent kind = human');
|
|
119
|
+
select is((select agent_type from agent_board.agents where id = (select agent_a from _lfix)),
|
|
120
|
+
'human', 'self-enrolled agent agent_type = human');
|
|
121
|
+
select is((select auth_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
122
|
+
(select user_a from _lfix), 'self-enrolled agent auth_user_id = auth.uid()');
|
|
123
|
+
-- 9 (case 5): self-owned
|
|
124
|
+
select is((select owner_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
125
|
+
(select user_a from _lfix), 'self-enrolled agent owner_user_id = auth.uid() (self-owned)');
|
|
126
|
+
-- 10 (case 6): active
|
|
127
|
+
select is((select status from agent_board.agents where id = (select agent_a from _lfix)),
|
|
128
|
+
'active', 'self-enrolled agent status = active');
|
|
129
|
+
-- 11 (case 7): verified user -> email_verified BOOLEAN true (never a plaintext email)
|
|
130
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where id = (select agent_a from _lfix)),
|
|
131
|
+
'true', 'verified user -> metadata.localauth.email_verified = true');
|
|
132
|
+
-- 12 (case 10): exactly one active row for the uid
|
|
133
|
+
select is((select count(*) from agent_board.agents where auth_user_id = (select user_a from _lfix) and status = 'active'),
|
|
134
|
+
1::bigint, 'exactly one active agent per uid');
|
|
135
|
+
-- 13 (case 11): unverified user -> email_verified BOOLEAN false
|
|
136
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where auth_user_id = (select user_b from _lfix)),
|
|
137
|
+
'false', 'unverified user -> metadata.localauth.email_verified = false');
|
|
138
|
+
-- 14 (case 15 / L-4b): the DB backstop unique index exists
|
|
139
|
+
select ok(exists(
|
|
140
|
+
select 1 from pg_indexes
|
|
141
|
+
where schemaname = 'agent_board'
|
|
142
|
+
and indexname = 'idx_agent_board_agents_auth_user_id_unique'),
|
|
143
|
+
'L-4b one-agent-per-real-auth-user backstop index exists');
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------------
|
|
146
|
+
-- Phase 4: fail-closed cases
|
|
147
|
+
-- ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
-- 15 (case 12): authenticated role but NO subject (L-7: clear the sub GUC) -> 42501
|
|
150
|
+
set local role authenticated;
|
|
151
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
152
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
153
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
154
|
+
null, 'unauthenticated (no auth.uid()) is rejected with 42501');
|
|
155
|
+
|
|
156
|
+
-- 16 (case 13): service-role / superuser (reset role) + cleared sub -> 42501
|
|
157
|
+
reset role;
|
|
158
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
159
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
160
|
+
null, 'service-role / superuser (auth.uid() null) is rejected with 42501');
|
|
161
|
+
|
|
162
|
+
-- 17 (case 14): a pre-existing non-active identity cannot self-reactivate -> 42501
|
|
163
|
+
set local role authenticated;
|
|
164
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
165
|
+
select set_config('request.jwt.claim.sub', (select user_c::text from _lfix), true);
|
|
166
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
167
|
+
null, 'a suspended (non-active) identity cannot self-reactivate (42501)');
|
|
168
|
+
|
|
169
|
+
select finish();
|
|
170
|
+
|
|
171
|
+
rollback;
|