@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.
Files changed (66) hide show
  1. package/bin/agent-board-schema.js +0 -0
  2. package/fixtures/already-applied.json +112 -0
  3. package/migrations/20260717000100_agent_board_board_role.sql +180 -0
  4. package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
  5. package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
  6. package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
  7. package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
  8. package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
  9. package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
  10. package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
  11. package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
  12. package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
  13. package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
  14. package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
  15. package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
  16. package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
  17. package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
  18. package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
  19. package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
  20. package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
  21. package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
  22. package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
  23. package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
  24. package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
  25. package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
  26. package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
  27. package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
  28. package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
  29. package/migrations/20260723000100_agent_board_roomown_owner_seed.sql +167 -0
  30. package/migrations/20260723000200_agent_board_roomown_grant_role_live.sql +72 -0
  31. package/package.json +9 -9
  32. package/schema-manifest.json +256 -4
  33. package/src/tooling.test.mjs +46 -1
  34. package/tests/agent_board_agent_lineage.test.sql +73 -0
  35. package/tests/agent_board_agents_status_revoked.test.sql +60 -0
  36. package/tests/agent_board_auth_rpcs.test.sql +17 -16
  37. package/tests/agent_board_board_role.test.sql +129 -0
  38. package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
  39. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
  40. package/tests/agent_board_delegation_enforcement.test.sql +175 -0
  41. package/tests/agent_board_delegation_policies.test.sql +120 -0
  42. package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
  43. package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
  44. package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
  45. package/tests/agent_board_enrollment_audit_content.test.sql +139 -0
  46. package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
  47. package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
  48. package/tests/agent_board_fleet_registry.test.sql +2 -2
  49. package/tests/agent_board_identity_providers.test.sql +104 -0
  50. package/tests/agent_board_inbox_rpcs.test.sql +10 -10
  51. package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
  52. package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
  53. package/tests/agent_board_portal_offline_invariant.test.sql +134 -0
  54. package/tests/agent_board_resigning_refresh.test.sql +73 -0
  55. package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
  56. package/tests/agent_board_roomown.test.sql +233 -0
  57. package/tests/agent_board_schema_version.test.sql +2 -2
  58. package/tests/agent_board_schema_version_rpc.test.sql +1 -1
  59. package/tests/agent_board_scoped_send_regrant.test.sql +142 -0
  60. package/tests/agent_board_send_authz_config.test.sql +84 -0
  61. package/tests/agent_board_send_authz_matrix.test.sql +162 -0
  62. package/tests/agent_board_send_role_gate.test.sql +181 -0
  63. package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
  64. package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
  65. package/tests/agent_board_thread_acl.test.sql +129 -0
  66. package/tests/agent_board_v2_invariants.test.sql +5 -5
@@ -0,0 +1,120 @@
1
+ -- MBAUTHZ SL-4 (Lane D) pgTAP: delegation_policies, board_authority_ceilings,
2
+ -- and the _grant_within_ceiling helper.
3
+ create extension if not exists pgtap with schema extensions;
4
+ set search_path = extensions, public, agent_board;
5
+
6
+ begin;
7
+
8
+ select plan(16);
9
+
10
+ create temp table _deleg_fixture (
11
+ board uuid,
12
+ owner_agent uuid,
13
+ member_agent uuid,
14
+ policy uuid,
15
+ owner_user uuid,
16
+ member_user uuid
17
+ ) on commit drop;
18
+
19
+ do $$
20
+ declare
21
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
22
+ v_owner_user uuid;
23
+ v_member_user uuid;
24
+ v_owner uuid := gen_random_uuid();
25
+ v_member uuid := gen_random_uuid();
26
+ v_board uuid;
27
+ v_policy uuid := gen_random_uuid();
28
+ begin
29
+ v_owner_user := public.ensure_github_user('dg-owner-' || v_suffix, 'dg-owner-' || v_suffix || '@example.test', 'dg-owner-' || v_suffix, 'DG Owner');
30
+ v_member_user := public.ensure_github_user('dg-member-' || v_suffix, 'dg-member-' || v_suffix || '@example.test', 'dg-member-' || v_suffix, 'DG Member');
31
+
32
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
33
+ values
34
+ (v_owner, 'agent', 'dg-owner-' || v_suffix, 'DG Owner', 'active', v_owner_user, v_owner_user),
35
+ (v_member, 'agent', 'dg-member-' || v_suffix, 'DG Member', 'active', v_member_user, v_member_user);
36
+
37
+ select board_id into v_board
38
+ from agent_board.create_board(v_owner, 'dg-board-' || v_suffix, 'DG Board', 'delegation test', '{}'::jsonb, 'private');
39
+
40
+ -- The owner's owner membership is now seeded by create_board (ROOMOWN); only the
41
+ -- member membership needs an explicit insert.
42
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
43
+ values
44
+ (v_board, v_member, 'joined', v_owner, 'member');
45
+
46
+ -- Policy with only root_agent_id set -> defaults apply (child_ttl_ceiling 8h).
47
+ insert into agent_board.delegation_policies(id, root_agent_id) values (v_policy, v_owner);
48
+
49
+ insert into _deleg_fixture values (v_board, v_owner, v_member, v_policy, v_owner_user, v_member_user);
50
+ end
51
+ $$;
52
+ grant select on _deleg_fixture to authenticated;
53
+
54
+ -- delegation_policies shape -------------------------------------------------
55
+ select has_table('agent_board', 'delegation_policies', 'delegation_policies table exists');
56
+ select has_column('agent_board', 'delegation_policies', 'mode', 'delegation_policies has mode');
57
+ select has_column('agent_board', 'delegation_policies', 'child_ttl_ceiling', 'delegation_policies has child_ttl_ceiling');
58
+ select is(
59
+ (select child_ttl_ceiling > interval '15 minutes' from agent_board.delegation_policies dp join _deleg_fixture f on dp.id = f.policy),
60
+ true, 'child_ttl_ceiling default is hours-scale (> 15 minutes, not the 900s session clock)');
61
+ select is(
62
+ (select child_ttl_ceiling = interval '8 hours' from agent_board.delegation_policies dp join _deleg_fixture f on dp.id = f.policy),
63
+ true, 'child_ttl_ceiling default is 8 hours');
64
+
65
+ -- board_authority_ceilings shape --------------------------------------------
66
+ select has_table('agent_board', 'board_authority_ceilings', 'board_authority_ceilings table exists');
67
+ select col_is_pk('agent_board', 'board_authority_ceilings', 'board_id', 'board_id is the primary key');
68
+ select fk_ok('agent_board', 'board_authority_ceilings', 'board_id', 'agent_board', 'boards', 'id',
69
+ 'board_id references boards(id)');
70
+
71
+ -- _grant_within_ceiling: no ceiling row (defaults to owner) -----------------
72
+ select is(
73
+ (select agent_board._grant_within_ceiling(owner_agent, board, 'admin') from _deleg_fixture),
74
+ true, 'no ceiling: owner may grant admin (within grantor role)');
75
+ select is(
76
+ (select agent_board._grant_within_ceiling(owner_agent, board, 'owner') from _deleg_fixture),
77
+ true, 'no ceiling row is treated as owner: owner may grant owner (unchanged)');
78
+
79
+ -- Exceeding grantor role is rejected regardless of ceiling ------------------
80
+ select is(
81
+ (select agent_board._grant_within_ceiling(member_agent, board, 'admin') from _deleg_fixture),
82
+ false, 'grant exceeding grantor role is rejected (member cannot grant admin)');
83
+
84
+ -- Over-ceiling grant is rejected --------------------------------------------
85
+ insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
86
+ select board, 'member' from _deleg_fixture;
87
+ select is(
88
+ (select agent_board._grant_within_ceiling(owner_agent, board, 'admin') from _deleg_fixture),
89
+ false, 'over-ceiling grant is rejected (owner cannot grant admin when ceiling is member)');
90
+
91
+ -- #13 owner-settable ceiling: set_board_authority_ceiling is owner-gated. ---------
92
+ select has_function('agent_board', 'set_board_authority_ceiling', array['uuid','text'],
93
+ 'set_board_authority_ceiling exists');
94
+
95
+ set local role authenticated;
96
+ select set_config('request.jwt.claim.role', 'authenticated', true);
97
+
98
+ -- A non-owner (member) is denied.
99
+ select set_config('request.jwt.claim.sub', (select member_user from _deleg_fixture)::text, true);
100
+ select throws_ok(
101
+ (select format($q$select agent_board.set_board_authority_ceiling(%L::uuid, 'admin')$q$, board) from _deleg_fixture),
102
+ '42501', null, 'a non-owner is denied set_board_authority_ceiling');
103
+
104
+ -- The board owner may set it (changing the pre-existing 'member' ceiling to 'admin').
105
+ select set_config('request.jwt.claim.sub', (select owner_user from _deleg_fixture)::text, true);
106
+ select lives_ok(
107
+ (select format($q$select agent_board.set_board_authority_ceiling(%L::uuid, 'admin')$q$, board) from _deleg_fixture),
108
+ 'the board owner can set the authority ceiling');
109
+
110
+ reset role;
111
+
112
+ -- Verify the stored ceiling as the table owner (the SELECT RLS policy helper
113
+ -- _current_agent_has_board_access is not executable by the authenticated role).
114
+ select is(
115
+ (select max_grantable_role from agent_board.board_authority_ceilings c where c.board_id = (select board from _deleg_fixture)),
116
+ 'admin', 'the owner-set ceiling was upserted to admin');
117
+
118
+ select finish();
119
+
120
+ rollback;
@@ -0,0 +1,217 @@
1
+ -- MBPORTALGATE SL-2 pgTAP: the reworked enroll_federated_identity principal model.
2
+ --
3
+ -- Proves: the 13-arg signature; one enroll MINTS a human owner + a SEPARATE
4
+ -- key-identified agent (own auth_user_id) + an active+proven signing key + one audit
5
+ -- row; ONE human (issuer, sub) enrolling a 2nd distinct key creates a SECOND agent
6
+ -- (first unmutated) sharing ONE owner_user_id but with a DISTINCT auth_user_id
7
+ -- (independently session-mintable); revoking one agent leaves neither the human owner
8
+ -- nor a sibling revoked; and the provider/scope/kind rejection matrix still raises.
9
+ -- The PoP-binding suite (fingerprint recompute/tamper/dedup, PoP gate, actor_sub) lives
10
+ -- in agent_board_enroll_pop_binding.test.sql. Runs as postgres (service_role-equiv).
11
+ create extension if not exists pgtap with schema extensions;
12
+ set search_path = extensions, public, agent_board;
13
+
14
+ begin;
15
+
16
+ select plan(28);
17
+
18
+ -- New 13-arg signature exists (the old 8-arg was dropped by 20260719001100). --------
19
+ select has_function(
20
+ 'agent_board', 'enroll_federated_identity',
21
+ array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]'],
22
+ 'enroll_federated_identity exists with the reworked 13-arg PoP signature');
23
+ select hasnt_function(
24
+ 'agent_board', 'enroll_federated_identity',
25
+ array['text','text','text','jsonb','text','text','text[]','text[]'],
26
+ 'the prior 8-arg enroll_federated_identity was dropped');
27
+
28
+ -- Fixtures: an APPROVED provider + two real Ed25519 keys with round-trip fingerprints.
29
+ create temp table _p (
30
+ iss text, aud text, sub text, slug text, claim jsonb,
31
+ pub1 text, fp1 text, pub2 text, fp2 text
32
+ ) on commit drop;
33
+ create temp table _r (
34
+ label text, agent_id uuid, auth_user_id uuid, provider_id text,
35
+ signing_key_id uuid, granted text[], owner_user_id uuid
36
+ ) on commit drop;
37
+
38
+ do $$
39
+ declare
40
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
41
+ v_agent_id uuid; v_auth uuid; v_prov text; v_key uuid; v_granted text[];
42
+ begin
43
+ insert into agent_board.identity_providers
44
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
45
+ values
46
+ ('https://fed-ok-'||s||'.test', 'message-board', true, true, '{board:send}', '{board:x}', '{agent,human}');
47
+
48
+ insert into _p values (
49
+ 'https://fed-ok-'||s||'.test', 'message-board', 'portal|'||s, 'fed-agent-'||s,
50
+ jsonb_build_object('sub', 'portal|'||s, 'act', jsonb_build_object('sub', 'act|'||s), 'name', 'Fed '||s),
51
+ 'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0',
52
+ 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
53
+ 'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk',
54
+ 'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA'
55
+ );
56
+
57
+ -- Enroll A (key 1).
58
+ select e.agent_id, e.auth_user_id, e.provider_id, e.signing_key_id, e.granted_scopes
59
+ into v_agent_id, v_auth, v_prov, v_key, v_granted
60
+ from _p p, agent_board.enroll_federated_identity(
61
+ p.iss, p.aud, p.sub, p.claim, p.slug, p.pub1, 'raw_ed25519_base64url', p.fp1,
62
+ true, 'act|'||s, 'agent', '{board:send}', '{board:x}') e;
63
+ insert into _r
64
+ select 'A', v_agent_id, v_auth, v_prov, v_key, v_granted, a.owner_user_id
65
+ from agent_board.agents a where a.id = v_agent_id;
66
+
67
+ -- Enroll B: SAME human (issuer, sub) + SAME slug, DIFFERENT key -> a 2nd agent.
68
+ select e.agent_id, e.auth_user_id, e.provider_id, e.signing_key_id, e.granted_scopes
69
+ into v_agent_id, v_auth, v_prov, v_key, v_granted
70
+ from _p p, agent_board.enroll_federated_identity(
71
+ p.iss, p.aud, p.sub, p.claim, p.slug, p.pub2, 'raw_ed25519_base64url', p.fp2,
72
+ true, 'act|'||s, 'agent', '{board:send}', '{board:x}') e;
73
+ insert into _r
74
+ select 'B', v_agent_id, v_auth, v_prov, v_key, v_granted, a.owner_user_id
75
+ from agent_board.agents a where a.id = v_agent_id;
76
+ end
77
+ $$;
78
+
79
+ -- One enroll mints a distinct agent + human owner + own principal --------------------
80
+ select is((select count(*)::int from _r where label='A'), 1, 'enroll A returned exactly one row');
81
+ select is(
82
+ (select a.status from agent_board.agents a join _r r on r.agent_id=a.id where r.label='A'),
83
+ 'active', 'agent A is active');
84
+ select isnt(
85
+ (select owner_user_id from _r where label='A'),
86
+ (select auth_user_id from _r where label='A'),
87
+ 'agent A owner_user_id (human) is DISTINCT from its own auth_user_id (agent principal)');
88
+ select is(
89
+ (select count(*)::int from auth.users u where u.id = (select owner_user_id from _r where label='A')),
90
+ 1, 'the human owner auth.users row exists');
91
+ select is(
92
+ (select count(*)::int from auth.users u where u.id = (select auth_user_id from _r where label='A')),
93
+ 1, 'the agent principal auth.users row exists (session-usable via its key)');
94
+
95
+ -- The enrolled key is active + proven and round-trips the fingerprint ---------------
96
+ select is(
97
+ (select k.status||'/'||k.proof_state from agent_board.agent_signing_keys k
98
+ where k.id = (select signing_key_id from _r where label='A')),
99
+ 'active/proven', 'agent A signing key is active + proven (satisfies active_requires_proof)');
100
+ select is(
101
+ (select k.fingerprint from agent_board.agent_signing_keys k
102
+ where k.id = (select signing_key_id from _r where label='A')),
103
+ 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
104
+ 'the DB-recomputed fingerprint round-trips the independently-computed client fingerprint');
105
+ select is(
106
+ (select k.agent_id from agent_board.agent_signing_keys k
107
+ where k.id = (select signing_key_id from _r where label='A')),
108
+ (select agent_id from _r where label='A'),
109
+ 'the returned signing_key_id belongs to the returned agent');
110
+ select is(
111
+ (select count(*)::int from agent_board.enrollment_federation_audit e where e.agent_id = (select agent_id from _r where label='A')),
112
+ 1, 'enroll A wrote exactly one enrollment_federation_audit row');
113
+
114
+ -- One human -> N agents: the 2nd enroll creates a 2nd agent, first unmutated ---------
115
+ select isnt(
116
+ (select agent_id from _r where label='B'),
117
+ (select agent_id from _r where label='A'),
118
+ 'enroll B created a SECOND, distinct agent (never keyed on sub)');
119
+ select is(
120
+ (select owner_user_id from _r where label='B'),
121
+ (select owner_user_id from _r where label='A'),
122
+ 'both agents of the same human share ONE owner_user_id (idempotent human owner)');
123
+ select isnt(
124
+ (select auth_user_id from _r where label='B'),
125
+ (select auth_user_id from _r where label='A'),
126
+ 'each agent has a DISTINCT auth_user_id (each independently session-mintable)');
127
+ select is(
128
+ (select count(distinct a.id)::int from agent_board.agents a
129
+ where a.owner_user_id = (select owner_user_id from _r where label='A')),
130
+ 2, 'the human owner now has exactly two agents');
131
+ select is(
132
+ (select a.status from agent_board.agents a where a.id = (select agent_id from _r where label='A')),
133
+ 'active', 'the FIRST agent is unmutated (still active) after the 2nd enroll');
134
+ select is(
135
+ (select k.fingerprint from agent_board.agent_signing_keys k where k.agent_id = (select agent_id from _r where label='A')),
136
+ 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
137
+ 'the first agent still holds ONLY its original key (not mutated to the 2nd key)');
138
+ select isnt(
139
+ (select a.handle from agent_board.agents a where a.id = (select agent_id from _r where label='B')),
140
+ (select a.handle from agent_board.agents a where a.id = (select agent_id from _r where label='A')),
141
+ 'the 2nd agent under the same slug got a distinct (disambiguated) handle');
142
+
143
+ -- Revoke isolation: revoking agent A leaves neither the human nor sibling B revoked --
144
+ do $$
145
+ begin
146
+ update agent_board.agents set status='revoked', updated_at=now()
147
+ where id = (select agent_id from _r where label='A');
148
+ update agent_board.agent_signing_keys set status='revoked', revoked_at=now()
149
+ where agent_id = (select agent_id from _r where label='A');
150
+ end
151
+ $$;
152
+ select is(
153
+ (select a.status from agent_board.agents a where a.id = (select agent_id from _r where label='B')),
154
+ 'active', 'revoking agent A leaves sibling agent B active (independent principals)');
155
+ select is(
156
+ (select k.status from agent_board.agent_signing_keys k where k.agent_id = (select agent_id from _r where label='B')),
157
+ 'active', 'sibling B''s signing key stays active (B remains session-mintable)');
158
+ select is(
159
+ (select count(*)::int from auth.users u where u.id = (select owner_user_id from _r where label='A')),
160
+ 1, 'revoking agent A does NOT revoke the shared human owner');
161
+
162
+ -- Provider / scope / kind rejection matrix still raises ------------------------------
163
+ select throws_ok(
164
+ (select format($q$select agent_board.enroll_federated_identity('https://nope.test',%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
165
+ aud, sub, claim::text, 'x-'||slug, pub1, fp1) from _p),
166
+ '42501', null, 'unknown (issuer,audience) is rejected');
167
+ select throws_ok(
168
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,NULL,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
169
+ iss, aud, claim::text, 'y-'||slug, pub1, fp1) from _p),
170
+ '22023', null, 'a null verified subject is rejected');
171
+ -- requested scope outside the provider allowed_scopes ceiling
172
+ select throws_ok(
173
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:admin}','{}')$q$,
174
+ iss, aud, sub, claim::text, 'z-'||slug, pub1, fp1) from _p),
175
+ '42501', null, 'a requested scope outside allowed_scopes is rejected');
176
+ -- the 'system' kind is never federatable
177
+ select throws_ok(
178
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'system','{board:send}','{}')$q$,
179
+ iss, aud, sub, claim::text, 'w-'||slug, pub1, fp1) from _p),
180
+ '42501', null, 'the system kind cannot be federated');
181
+
182
+ -- B1: the RPC RETURNS the server-assigned key_id/key_version/agent_handle so the client
183
+ -- mints /session against the AUTHORITATIVE identity (the missing binding that otherwise
184
+ -- left every enrolled agent unable to authenticate). /session resolves by exactly
185
+ -- (agents.handle, key_id, key_version), so all three must be the server's values.
186
+ create temp table _b1 (agent uuid, key_id text, key_version int, handle text) on commit drop;
187
+ do $$
188
+ declare
189
+ s text := replace(substr(gen_random_uuid()::text,1,8),'-','');
190
+ v_pub text := rtrim(translate(encode(extensions.digest(convert_to('b1-'||s,'UTF8'),'sha256'),'base64'),'+/','-_'),'=');
191
+ v_fp text := 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate(v_pub,'-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'=');
192
+ v_kid text; v_kver int; v_handle text; v_agent uuid;
193
+ begin
194
+ insert into agent_board.identity_providers
195
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
196
+ values ('https://fed-b1-'||s||'.test','message-board',true,true,'{board:send}','{}','{agent}');
197
+ select e.agent_id, e.key_id, e.key_version, e.agent_handle
198
+ into v_agent, v_kid, v_kver, v_handle
199
+ from agent_board.enroll_federated_identity(
200
+ 'https://fed-b1-'||s||'.test','message-board','portal|'||s,
201
+ jsonb_build_object('sub','portal|'||s), 'b1-'||s, v_pub,'raw_ed25519_base64url', v_fp,
202
+ true, null, 'agent', '{board:send}', '{}') e;
203
+ insert into _b1 values (v_agent, v_kid, v_kver, v_handle);
204
+ end $$;
205
+ select is(
206
+ (select key_id from _b1),
207
+ (select k.key_id from agent_board.agent_signing_keys k where k.agent_id = (select agent from _b1)),
208
+ 'the returned key_id equals the enrolled signing key''s key_id (session-resolvable)');
209
+ select is((select key_version from _b1), 1, 'the returned key_version is the enrolled key_version (1)');
210
+ select is(
211
+ (select handle from _b1),
212
+ (select a.handle from agent_board.agents a where a.id = (select agent from _b1)),
213
+ 'the returned agent_handle equals agents.handle (the /session resolver key)');
214
+
215
+ select finish();
216
+
217
+ rollback;
@@ -0,0 +1,131 @@
1
+ -- MBPORTALGATE SL-2 pgTAP: the PoP-binding suite for enroll_federated_identity.
2
+ --
3
+ -- Proves the proof-of-possession + fingerprint contract the /enroll EF depends on:
4
+ -- * the DB-recomputed sha256:base64url fingerprint is BYTE-IDENTICAL to the value an
5
+ -- independent client (WebCrypto) computes over the same base64url key STRING
6
+ -- (client<->DB round-trip);
7
+ -- * p_pop_verified must be true (false / null -> raise, no writes);
8
+ -- * the RPC recomputes-and-asserts the fingerprint (a tampered fingerprint -> raise);
9
+ -- * a duplicate fingerprint (a key already enrolled) -> raise, dedup on the UNIQUE index;
10
+ -- * malformed / placeholder / wrong-format keys -> raise;
11
+ -- * p_actor_sub is audit-only: present-non-empty succeeds + is captured; empty -> raise.
12
+ -- Runs as postgres (service_role-equivalent for _is_service_role()).
13
+ create extension if not exists pgtap with schema extensions;
14
+ set search_path = extensions, public, agent_board;
15
+
16
+ begin;
17
+
18
+ select plan(18);
19
+
20
+ -- Client<->DB fingerprint round-trip (B2 raw-byte scheme): the DB derivation over the
21
+ -- DECODED 32 key bytes must equal the constant an independent tool (Node crypto,
22
+ -- matching WebCrypto + the client child_keygen keyRef) computed over the same raw bytes.
23
+ select is(
24
+ 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate('eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0','-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'='),
25
+ 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
26
+ 'the DB raw-byte fingerprint derivation round-trips the independently-computed client value (key 1)');
27
+ select is(
28
+ 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate('yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk','-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'='),
29
+ 'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA',
30
+ 'the DB raw-byte fingerprint derivation round-trips the independently-computed client value (key 2)');
31
+
32
+ create temp table _p (
33
+ iss text, aud text, sub text, claim jsonb,
34
+ pub1 text, fp1 text, pub2 text, fp2 text
35
+ ) on commit drop;
36
+ do $$
37
+ declare s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
38
+ begin
39
+ insert into agent_board.identity_providers
40
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
41
+ values ('https://fed-pop-'||s||'.test', 'message-board', true, true, '{board:send}', '{}', '{agent,human}');
42
+ insert into _p values (
43
+ 'https://fed-pop-'||s||'.test', 'message-board', 'portal|'||s,
44
+ jsonb_build_object('sub','portal|'||s,'act',jsonb_build_object('sub','act|'||s)),
45
+ 'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0', 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
46
+ 'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk', 'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA');
47
+ end
48
+ $$;
49
+
50
+ -- Happy path with a present, non-empty actor_sub -> succeeds and is audited (key 1).
51
+ select lives_ok(
52
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,%L,'agent','{board:send}','{}')$q$,
53
+ iss, aud, sub, claim::text, 'pop-ok', pub1, fp1, 'act|present') from _p),
54
+ 'enroll with a present non-empty actor_sub and verified PoP succeeds');
55
+ select is(
56
+ (select e.human_identity_claim->'act'->>'sub' from agent_board.enrollment_federation_audit e
57
+ join agent_board.agents a on a.id=e.agent_id where a.handle='pop-ok'),
58
+ (select claim->'act'->>'sub' from _p),
59
+ 'the act.sub is captured (audit-only) in the enrollment claim snapshot');
60
+
61
+ -- Duplicate fingerprint: re-enrolling key 1 raises (dedup on the UNIQUE index).
62
+ select throws_ok(
63
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
64
+ iss, aud, sub, claim::text, 'pop-dup', pub1, fp1) from _p),
65
+ '23505', null, 'a duplicate fingerprint (key already enrolled) is rejected');
66
+ select is(
67
+ (select count(*)::int from agent_board.agent_signing_keys k where k.fingerprint = (select fp1 from _p)),
68
+ 1, 'the duplicate attempt did not enroll a second key with the same fingerprint');
69
+
70
+ -- B2: a NON-CANONICAL base64url encoding of an already-enrolled key (same 32 raw bytes,
71
+ -- 2 don't-care low bits flipped) is rejected as non-canonical (22023) BEFORE the dedup
72
+ -- check — so one private key can NEVER back multiple agents (the 4-strings-one-key
73
+ -- aliasing attack that broke revocation isolation).
74
+ select throws_ok(
75
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
76
+ iss, aud, sub, claim::text, 'pop-alias1', 'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f1', fp1) from _p),
77
+ '22023', null, 'a non-canonical base64url alias of an enrolled key is rejected (aliasing attack blocked)');
78
+ select throws_ok(
79
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
80
+ iss, aud, sub, claim::text, 'pop-alias2', 'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f2', fp1) from _p),
81
+ '22023', null, 'a second non-canonical alias of the same key is also rejected');
82
+ select is(
83
+ (select count(*)::int from agent_board.agent_signing_keys k where k.fingerprint = (select fp1 from _p)),
84
+ 1, 'the aliasing attempts enrolled NO extra key/agent (one private key -> exactly one agent)');
85
+
86
+ -- PoP gate: p_pop_verified false / null -> raise, and NO agent is written.
87
+ select throws_ok(
88
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,false,null,'agent','{board:send}','{}')$q$,
89
+ iss, aud, sub, claim::text, 'pop-false', pub2, fp2) from _p),
90
+ '42501', null, 'p_pop_verified=false is refused');
91
+ select is(
92
+ (select count(*)::int from agent_board.agents a where a.handle = 'pop-false'),
93
+ 0, 'a refused (pop=false) enrollment wrote no agent (no partial write)');
94
+ select throws_ok(
95
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,NULL,null,'agent','{board:send}','{}')$q$,
96
+ iss, aud, sub, claim::text, 'pop-null', pub2, fp2) from _p),
97
+ '42501', null, 'p_pop_verified=null is refused');
98
+
99
+ -- Fingerprint tamper: supplied fingerprint != in-DB recompute -> raise, no write.
100
+ select throws_ok(
101
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
102
+ iss, aud, sub, claim::text, 'pop-tamper', pub2, 'sha256:'||repeat('B',43)) from _p),
103
+ '22023', null, 'a tampered fingerprint (!= in-DB derive of the key) is rejected');
104
+ select is(
105
+ (select count(*)::int from agent_board.agents a where a.handle = 'pop-tamper'),
106
+ 0, 'a tampered-fingerprint enrollment wrote no agent');
107
+
108
+ -- Malformed / placeholder / wrong-format keys -> raise.
109
+ select throws_ok(
110
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,'not-a-valid-43char-key','raw_ed25519_base64url','sha256:'||%L,true,null,'agent','{board:send}','{}')$q$,
111
+ iss, aud, sub, claim::text, 'pop-malformed', repeat('C',43)) from _p),
112
+ '22023', null, 'a malformed (non-43-base64url) public key is rejected');
113
+ select throws_ok(
114
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
115
+ iss, aud, sub, claim::text, 'pop-zero', repeat('A',43),
116
+ 'sha256:'||rtrim(translate(encode(extensions.digest(convert_to(repeat('A',43),'UTF8'),'sha256'),'base64'),'+/','-_'),'=')) from _p),
117
+ '22023', null, 'the all-zero small-order placeholder key is rejected');
118
+ select throws_ok(
119
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'spki_pem',%L,true,null,'agent','{board:send}','{}')$q$,
120
+ iss, aud, sub, claim::text, 'pop-fmt', pub2, fp2) from _p),
121
+ '22023', null, 'a non-raw_ed25519_base64url public_key_format is rejected');
122
+
123
+ -- actor_sub present-but-empty -> raise (present means a real delegated actor).
124
+ select throws_ok(
125
+ (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,'','agent','{board:send}','{}')$q$,
126
+ iss, aud, sub, claim::text, 'pop-emptyact', pub2, fp2) from _p),
127
+ '22023', null, 'a present-but-empty actor_sub is rejected');
128
+
129
+ select finish();
130
+
131
+ rollback;
@@ -0,0 +1,53 @@
1
+ -- MBHARDEN SL-1 pgTAP: check_and_bump_enroll_rate allows <=limit calls per source
2
+ -- per rolling window and rejects past the limit; sources are independent; the
3
+ -- reject path records nothing; and execute is revoked from anon/authenticated.
4
+ create extension if not exists pgtap with schema extensions;
5
+ set search_path = extensions, public, agent_board;
6
+
7
+ begin;
8
+
9
+ select plan(10);
10
+
11
+ select has_function(
12
+ 'agent_board', 'check_and_bump_enroll_rate', array['text', 'integer', 'interval'],
13
+ 'check_and_bump_enroll_rate(text, integer, interval) exists');
14
+ select has_table(
15
+ 'agent_board', 'enroll_rate_events', 'enroll_rate_events counter table exists');
16
+
17
+ -- Source A: limit 3 in a 1-minute window. First three allowed, fourth rejected.
18
+ select is(
19
+ agent_board.check_and_bump_enroll_rate('src-a', 3, interval '1 minute'),
20
+ true, '1st call for src-a is allowed');
21
+ select is(
22
+ agent_board.check_and_bump_enroll_rate('src-a', 3, interval '1 minute'),
23
+ true, '2nd call for src-a is allowed');
24
+ select is(
25
+ agent_board.check_and_bump_enroll_rate('src-a', 3, interval '1 minute'),
26
+ true, '3rd call for src-a is allowed (at the limit)');
27
+ select is(
28
+ agent_board.check_and_bump_enroll_rate('src-a', 3, interval '1 minute'),
29
+ false, '4th call for src-a is rejected (over the cap)');
30
+
31
+ -- A different source is bounded independently.
32
+ select is(
33
+ agent_board.check_and_bump_enroll_rate('src-b', 3, interval '1 minute'),
34
+ true, 'a different source is counted independently');
35
+
36
+ -- The reject path records nothing: src-a still has exactly its 3 allowed events.
37
+ select is(
38
+ (select count(*)::int from agent_board.enroll_rate_events where source = 'src-a'),
39
+ 3, 'the over-cap reject recorded no event (only the 3 allowed remain)');
40
+
41
+ -- Privilege: execute revoked from anon and authenticated.
42
+ select function_privs_are(
43
+ 'agent_board', 'check_and_bump_enroll_rate', array['text', 'integer', 'interval'],
44
+ 'anon', array[]::text[],
45
+ 'check_and_bump_enroll_rate execute is revoked from anon');
46
+ select function_privs_are(
47
+ 'agent_board', 'check_and_bump_enroll_rate', array['text', 'integer', 'interval'],
48
+ 'authenticated', array[]::text[],
49
+ 'check_and_bump_enroll_rate execute is revoked from authenticated');
50
+
51
+ select finish();
52
+
53
+ rollback;