@consiliency/agent-board-schema 1.2.0 → 1.4.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 (64) hide show
  1. package/bin/agent-board-schema.js +0 -0
  2. package/fixtures/already-applied.json +108 -0
  3. package/migrations/20260714000200_agent_board_workload_providers_rls.sql +35 -0
  4. package/migrations/20260717000100_agent_board_board_role.sql +180 -0
  5. package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
  6. package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
  7. package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
  8. package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
  9. package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
  10. package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
  11. package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
  12. package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
  13. package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
  14. package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
  15. package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
  16. package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
  17. package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
  18. package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
  19. package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
  20. package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
  21. package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
  22. package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
  23. package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
  24. package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
  25. package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
  26. package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
  27. package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
  28. package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
  29. package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
  30. package/package.json +9 -9
  31. package/schema-manifest.json +247 -4
  32. package/src/tooling.test.mjs +42 -1
  33. package/tests/agent_board_agent_lineage.test.sql +73 -0
  34. package/tests/agent_board_agents_status_revoked.test.sql +60 -0
  35. package/tests/agent_board_auth_rpcs.test.sql +17 -16
  36. package/tests/agent_board_board_role.test.sql +129 -0
  37. package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
  38. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
  39. package/tests/agent_board_delegation_enforcement.test.sql +175 -0
  40. package/tests/agent_board_delegation_policies.test.sql +119 -0
  41. package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
  42. package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
  43. package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
  44. package/tests/agent_board_enrollment_audit_content.test.sql +134 -0
  45. package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
  46. package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
  47. package/tests/agent_board_fleet_registry.test.sql +2 -2
  48. package/tests/agent_board_identity_providers.test.sql +104 -0
  49. package/tests/agent_board_inbox_rpcs.test.sql +6 -6
  50. package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
  51. package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
  52. package/tests/agent_board_portal_offline_invariant.test.sql +130 -0
  53. package/tests/agent_board_resigning_refresh.test.sql +73 -0
  54. package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
  55. package/tests/agent_board_schema_version.test.sql +2 -2
  56. package/tests/agent_board_schema_version_rpc.test.sql +1 -1
  57. package/tests/agent_board_scoped_send_regrant.test.sql +138 -0
  58. package/tests/agent_board_send_authz_config.test.sql +84 -0
  59. package/tests/agent_board_send_authz_matrix.test.sql +159 -0
  60. package/tests/agent_board_send_role_gate.test.sql +177 -0
  61. package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
  62. package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
  63. package/tests/agent_board_thread_acl.test.sql +129 -0
  64. package/tests/agent_board_workload_providers_rls.test.sql +40 -0
@@ -0,0 +1,188 @@
1
+ -- MBDELEG SL-1 pgTAP (IF-0-MBDELEG-3): DOWN-walking refresh-checked subtree cascade.
2
+ --
3
+ -- Proves the two linchpins and the survival contract:
4
+ -- (a) REFRESH-BOUNDARY KILL: after a live child fleet_agent_session exists,
5
+ -- revoke_agent_subtree(root) flips ONLY key+status+grant (NOT board membership),
6
+ -- and a fresh mint_fleet_agent_session for that child RAISES within one <=15-min
7
+ -- TTL (the re-mint fails; not old-token expiry).
8
+ -- (b) INERT-LEDGER: a bare fleet_revocation_events row inserted directly (no key/status
9
+ -- flip) does NOT stop mint_fleet_agent_session — the refresh path reads key/status,
10
+ -- not the audit ledger.
11
+ -- + revoke_agent_subtree kills the root + grant-derived descendants (keys + status +
12
+ -- grants + audit rows), while a lineage-independent independent_auth principal (NULL
13
+ -- parent, own active key+status, same owner) SURVIVES and still mints; and
14
+ -- revoke_human_subtree(owner) is TOTAL — it also kills the independent principal.
15
+ --
16
+ -- Runs as postgres (service_role-equivalent).
17
+ create extension if not exists pgtap with schema extensions;
18
+ set search_path = extensions, public, agent_board;
19
+
20
+ begin;
21
+
22
+ select plan(19);
23
+
24
+ create temp table _rev_fx (
25
+ owner_id uuid, admin uuid, r uuid, r_key uuid,
26
+ c uuid, c_key uuid, i uuid, i_key uuid, z uuid, z_key uuid, bd uuid,
27
+ ch_c1 uuid, ch_c2 uuid, ch_i1 uuid, ch_i2 uuid, ch_i3 uuid, ch_z uuid
28
+ ) on commit drop;
29
+
30
+ do $$
31
+ declare
32
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
33
+ u_owner uuid; u_admin uuid; u_r uuid; u_i uuid; u_z uuid;
34
+ v_admin uuid := gen_random_uuid(); v_r uuid := gen_random_uuid();
35
+ v_i uuid := gen_random_uuid(); v_z uuid := gen_random_uuid();
36
+ v_bd uuid := gen_random_uuid();
37
+ v_rkey uuid := gen_random_uuid(); v_ikey uuid := gen_random_uuid(); v_zkey uuid := gen_random_uuid();
38
+ v_c uuid; v_ckey uuid;
39
+ ch_c1 uuid := gen_random_uuid(); ch_c2 uuid := gen_random_uuid();
40
+ ch_i1 uuid := gen_random_uuid(); ch_i2 uuid := gen_random_uuid(); ch_i3 uuid := gen_random_uuid();
41
+ ch_z uuid := gen_random_uuid();
42
+ begin
43
+ u_owner := public.ensure_github_user('rev-own-'||s, 'rev-own-'||s||'@example.test', 'rev-own-'||s, 'OWN');
44
+ u_admin := public.ensure_github_user('rev-adm-'||s, 'rev-adm-'||s||'@example.test', 'rev-adm-'||s, 'ADM');
45
+ u_r := public.ensure_github_user('rev-r-'||s, 'rev-r-'||s||'@example.test', 'rev-r-'||s, 'R');
46
+ u_i := public.ensure_github_user('rev-i-'||s, 'rev-i-'||s||'@example.test', 'rev-i-'||s, 'I');
47
+ u_z := public.ensure_github_user('rev-z-'||s, 'rev-z-'||s||'@example.test', 'rev-z-'||s, 'Z');
48
+
49
+ -- Admin (revoker; distinct owner so it is not swept by revoke_human_subtree(owner)).
50
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id)
51
+ values (v_admin, 'agent', 'rev-adm-'||s, 'ADM', 'active', u_admin);
52
+
53
+ -- Root R (owner = the human). Its own active key.
54
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
55
+ values (v_r, 'agent', 'rev-r-'||s, 'R', 'active', u_r, u_owner);
56
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_active_children, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
57
+ values (v_r, 'root_minted', 5, jsonb_build_array('scope:a'), interval '8 hours', true);
58
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
59
+ values (v_rkey, v_r, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_rkey::text,'-',''),43,'A'), 'active', 'proven');
60
+
61
+ -- Delegated child C under R (gets an ephemeral revocable key + a grant).
62
+ select child_agent_id, signing_key_id into v_c, v_ckey
63
+ from agent_board.mint_delegated_child(v_r, 'rev-c-'||s, jsonb_build_array('scope:a'),
64
+ rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '='));
65
+
66
+ -- Give C a joined board membership; the cascade must NOT touch it.
67
+ insert into agent_board.boards(id, slug, name, created_by_agent_id)
68
+ values (v_bd, 'rev-bd-'||s, 'Rev Board', v_admin);
69
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
70
+ values (v_bd, v_c, 'joined', 'member');
71
+
72
+ -- Give C a live legacy agent_credentials row; the cascade must revoke it (#3).
73
+ insert into agent_board.agent_credentials(agent_id, refresh_token_hash, hash_version, credential_label, expires_at)
74
+ values (v_c, extensions.digest('rev-c-cred-'||s, 'sha256'), 1, 'legacy', now() + interval '1 day');
75
+
76
+ -- Independent principal I: lineage-independent (NULL parent/root), own active key,
77
+ -- same human owner. Survives a root revoke; killed only by self or human revoke.
78
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
79
+ values (v_i, 'agent', 'rev-i-'||s, 'I', 'active', u_i, u_owner);
80
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
81
+ values (v_ikey, v_i, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_ikey::text,'-',''),43,'A'), 'active', 'proven');
82
+
83
+ -- Z: independent bystander (distinct owner) used only for the inert-ledger proof.
84
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
85
+ values (v_z, 'agent', 'rev-z-'||s, 'Z', 'active', u_z, u_z);
86
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
87
+ values (v_zkey, v_z, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_zkey::text,'-',''),43,'A'), 'active', 'proven');
88
+
89
+ -- Challenges (pending, unexpired), bound to (key, agent).
90
+ insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at)
91
+ values
92
+ (ch_c1, v_ckey, v_c, ch_c1::text, 'agent-signing-key-proof.v1:'||ch_c1||':n', 'pending', now() + interval '10 minutes'),
93
+ (ch_c2, v_ckey, v_c, ch_c2::text, 'agent-signing-key-proof.v1:'||ch_c2||':n', 'pending', now() + interval '10 minutes'),
94
+ (ch_i1, v_ikey, v_i, ch_i1::text, 'agent-signing-key-proof.v1:'||ch_i1||':n', 'pending', now() + interval '10 minutes'),
95
+ (ch_i2, v_ikey, v_i, ch_i2::text, 'agent-signing-key-proof.v1:'||ch_i2||':n', 'pending', now() + interval '10 minutes'),
96
+ (ch_i3, v_ikey, v_i, ch_i3::text, 'agent-signing-key-proof.v1:'||ch_i3||':n', 'pending', now() + interval '10 minutes'),
97
+ (ch_z, v_zkey, v_z, ch_z::text, 'agent-signing-key-proof.v1:'||ch_z||':n', 'pending', now() + interval '10 minutes');
98
+
99
+ insert into _rev_fx values (
100
+ u_owner, v_admin, v_r, v_rkey, v_c, v_ckey, v_i, v_ikey, v_z, v_zkey, v_bd,
101
+ ch_c1, ch_c2, ch_i1, ch_i2, ch_i3, ch_z
102
+ );
103
+ end
104
+ $$;
105
+
106
+ -- Function existence.
107
+ select has_function('agent_board', 'revoke_agent_subtree', array['uuid','uuid','text'], 'revoke_agent_subtree exists');
108
+ select has_function('agent_board', 'revoke_human_subtree', array['uuid','uuid','text'], 'revoke_human_subtree exists');
109
+
110
+ -- Phase 1: live sessions mint pre-revoke.
111
+ select lives_ok(
112
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_c1, c_key, c) from _rev_fx),
113
+ 'child C mints a live fleet_agent_session while its lineage is active');
114
+ select lives_ok(
115
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i1, i_key, i) from _rev_fx),
116
+ 'independent principal I mints a live session pre-revoke');
117
+
118
+ -- INERT-LEDGER: a bare audit row alone does NOT stop Z's mint.
119
+ insert into agent_board.fleet_revocation_events(subject_kind, subject_id, agent_id, revoked_by_agent_id, revocation_reason)
120
+ select 'agent', z, z, admin, 'bare-ledger-inert-test' from _rev_fx;
121
+ select lives_ok(
122
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_z, z_key, z) from _rev_fx),
123
+ 'inert-ledger: a bare fleet_revocation_events row does NOT stop mint (refresh reads key/status, not the ledger)');
124
+
125
+ -- Phase 2: DOWN-walking cascade from the root R.
126
+ create temp table _rev_counts (revoked_agent_count int, revoked_key_count int) on commit drop;
127
+ insert into _rev_counts
128
+ select * from agent_board.revoke_agent_subtree((select r from _rev_fx), (select admin from _rev_fx), 'root revocation');
129
+
130
+ select is((select revoked_agent_count from _rev_counts), 2, 'revoke_agent_subtree revoked 2 agents (root R + grant-derived child C)');
131
+ select is((select revoked_key_count from _rev_counts), 2, 'revoke_agent_subtree revoked 2 signing keys (R primary + C ephemeral)');
132
+
133
+ select is(
134
+ (select k.status from agent_board.agent_signing_keys k where k.id = (select c_key from _rev_fx)),
135
+ 'revoked', 'child C signing key flipped to revoked');
136
+ select is(
137
+ (select a.status from agent_board.agents a where a.id = (select c from _rev_fx)),
138
+ 'revoked', 'child C agents.status flipped to revoked');
139
+ select ok(
140
+ (select g.revoked_at is not null from agent_board.delegation_grants g where g.child_agent_id = (select c from _rev_fx)),
141
+ 'child C delegation grant marked revoked');
142
+
143
+ -- #3 fix: the cascade also revokes the descendant's legacy agent_credentials.
144
+ select is(
145
+ (select count(*)::int from agent_board.agent_credentials ac
146
+ where ac.agent_id = (select c from _rev_fx) and ac.revoked_at is null),
147
+ 0, 'cascade revoked child C''s agent_credentials (no live legacy credential remains)');
148
+
149
+ -- NOT board membership: C's joined membership is untouched by the cascade.
150
+ select is(
151
+ (select bm.membership_state from agent_board.board_memberships bm
152
+ where bm.agent_id = (select c from _rev_fx) and bm.board_id = (select bd from _rev_fx)),
153
+ 'joined', 'cascade did NOT touch board membership (still joined)');
154
+
155
+ -- Audit rows appended for C (agent + signing_key).
156
+ select ok(
157
+ (select count(*) filter (where subject_kind = 'agent') >= 1
158
+ and count(*) filter (where subject_kind = 'signing_key') >= 1
159
+ from agent_board.fleet_revocation_events e where e.agent_id = (select c from _rev_fx)),
160
+ 'cascade appended audit fleet_revocation_events rows for C (agent + signing_key)');
161
+
162
+ -- REFRESH-BOUNDARY KILL [linchpin]: a fresh re-mint for C now RAISES (not old-token expiry).
163
+ select throws_ok(
164
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_c2, c_key, c) from _rev_fx),
165
+ '42501', null, 'refresh-boundary kill: a fresh re-mint for C AFTER revoke_agent_subtree raises');
166
+
167
+ -- independent_auth SURVIVAL: I is untouched by revoke_agent_subtree(root) and still mints.
168
+ select lives_ok(
169
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i2, i_key, i) from _rev_fx),
170
+ 'independent principal I survives revoke_agent_subtree(root) and still mints');
171
+ select is(
172
+ (select k.status from agent_board.agent_signing_keys k where k.id = (select i_key from _rev_fx)),
173
+ 'active', 'independent principal I signing key remains active after root revoke');
174
+
175
+ -- Phase 3: TOTAL human revocation kills I as well.
176
+ select lives_ok(
177
+ (select format($q$select agent_board.revoke_human_subtree(%L::uuid, %L::uuid, 'human revocation')$q$, owner_id, admin) from _rev_fx),
178
+ 'revoke_human_subtree runs for the human owner');
179
+ select throws_ok(
180
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i3, i_key, i) from _rev_fx),
181
+ '42501', null, 'after revoke_human_subtree, independent principal I can no longer mint (total human revocation)');
182
+ select is(
183
+ (select a.status from agent_board.agents a where a.id = (select i from _rev_fx)),
184
+ 'revoked', 'revoke_human_subtree flipped the independent principal I to revoked');
185
+
186
+ select finish();
187
+
188
+ rollback;
@@ -10,8 +10,8 @@ SELECT is(
10
10
 
11
11
  SELECT is(
12
12
  (SELECT version FROM agent_board.schema_version),
13
- 7,
14
- 'schema_version.version is 7'
13
+ 8,
14
+ 'schema_version.version is 8'
15
15
  );
16
16
 
17
17
  SELECT throws_ok(
@@ -31,7 +31,7 @@ set local role authenticated;
31
31
 
32
32
  select is(
33
33
  (select version from agent_board.get_schema_version()),
34
- 7,
34
+ 8,
35
35
  'authenticated agents receive the metadata-only schema version'
36
36
  );
37
37
 
@@ -0,0 +1,138 @@
1
+ -- MBSESSION SL-1 pgTAP: the 8 core send RPCs are re-granted to authenticated, the
2
+ -- authenticated path derives the actor from current_agent_id() (rejecting a mismatched
3
+ -- p_author_agent_id), real membership is evaluated with _is_service_role() false, a
4
+ -- membership-revoked-mid-session send fails, and no send RPC references Portal
5
+ -- issuer/JWKS (a write succeeds with no Portal dependency present).
6
+ --
7
+ -- Runs as postgres; request.jwt.claim.role toggles _is_service_role()
8
+ -- ('authenticated' => false) and request.jwt.claim.sub picks the acting agent.
9
+ create extension if not exists pgtap with schema extensions;
10
+ set search_path = extensions, public, agent_board;
11
+
12
+ begin;
13
+
14
+ select plan(15);
15
+
16
+ create temp table _regrant_fixture (
17
+ board uuid,
18
+ member_agent uuid,
19
+ other_agent uuid,
20
+ recipient_agent uuid,
21
+ member_user uuid,
22
+ thread_member uuid,
23
+ thread_dm uuid
24
+ ) on commit drop;
25
+
26
+ do $$
27
+ declare
28
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
29
+ v_member_user uuid;
30
+ v_other_user uuid;
31
+ v_member uuid := gen_random_uuid();
32
+ v_other uuid := gen_random_uuid();
33
+ v_recipient uuid := gen_random_uuid();
34
+ v_board uuid;
35
+ v_thread_member uuid := gen_random_uuid();
36
+ v_thread_dm uuid := gen_random_uuid();
37
+ begin
38
+ v_member_user := public.ensure_github_user('rg-member-' || v_suffix, 'rg-member-' || v_suffix || '@example.test', 'rg-member-' || v_suffix, 'RG Member');
39
+ v_other_user := public.ensure_github_user('rg-other-' || v_suffix, 'rg-other-' || v_suffix || '@example.test', 'rg-other-' || v_suffix, 'RG Other');
40
+
41
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
42
+ values
43
+ (v_member, 'agent', 'rg-member-' || v_suffix, 'RG Member', 'active', v_member_user, v_member_user),
44
+ (v_other, 'agent', 'rg-other-' || v_suffix, 'RG Other', 'active', v_other_user, v_other_user),
45
+ (v_recipient, 'agent', 'rg-recipient-' || v_suffix, 'RG Recipient', 'active', null, null);
46
+
47
+ select board_id into v_board
48
+ from agent_board.create_board(v_member, 'rg-board-' || v_suffix, 'RG Board', 'regrant test', '{}'::jsonb, 'private');
49
+
50
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
51
+ values (v_board, v_member, 'joined', v_member, 'member');
52
+
53
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
54
+ values
55
+ (v_thread_member, 'coordination', v_member, 'RG member thread'),
56
+ (v_thread_dm, 'coordination', v_member, 'RG dm thread');
57
+
58
+ insert into _regrant_fixture values
59
+ (v_board, v_member, v_other, v_recipient, v_member_user, v_thread_member, v_thread_dm);
60
+ end
61
+ $$;
62
+
63
+ -- 1. The 8 core send-RPC overloads are re-granted execute to authenticated. --------
64
+ select function_privs_are(
65
+ 'agent_board', 'send_direct_message', array['uuid','uuid','uuid','text','text','text','text','text','jsonb'],
66
+ 'authenticated', array['EXECUTE'], 'send_direct_message(uuid recipient) re-granted to authenticated');
67
+ select function_privs_are(
68
+ 'agent_board', 'send_direct_message', array['uuid','uuid','jsonb','text','text','text','text','text','text','jsonb'],
69
+ 'authenticated', array['EXECUTE'], 'send_direct_message(jsonb descriptor) re-granted to authenticated');
70
+ select function_privs_are(
71
+ 'agent_board', 'send_multicast_message', array['uuid','uuid','uuid[]','text','text','text','text','text','jsonb'],
72
+ 'authenticated', array['EXECUTE'], 'send_multicast_message(uuid[] recipients) re-granted to authenticated');
73
+ select function_privs_are(
74
+ 'agent_board', 'send_multicast_message', array['uuid','uuid','jsonb','text','text','text','text','text','text','jsonb'],
75
+ 'authenticated', array['EXECUTE'], 'send_multicast_message(jsonb descriptors) re-granted to authenticated');
76
+ select function_privs_are(
77
+ 'agent_board', 'post_board_message', array['uuid','uuid','uuid','text','text','text','text','text','jsonb'],
78
+ 'authenticated', array['EXECUTE'], 'post_board_message(uuid board) re-granted to authenticated');
79
+ select function_privs_are(
80
+ 'agent_board', 'post_board_message', array['uuid','uuid','text','text','text','text','text','text','text','jsonb'],
81
+ 'authenticated', array['EXECUTE'], 'post_board_message(text board_slug) re-granted to authenticated');
82
+ select function_privs_are(
83
+ 'agent_board', 'create_tasking_delivery', array['uuid','uuid','text','text','uuid[]','uuid','text','text','text','jsonb'],
84
+ 'authenticated', array['EXECUTE'], 'create_tasking_delivery(uuid[] recipients) re-granted to authenticated');
85
+ select function_privs_are(
86
+ 'agent_board', 'create_tasking_delivery', array['uuid','uuid','text','text','jsonb','text','text','text','text','text','jsonb'],
87
+ 'authenticated', array['EXECUTE'], 'create_tasking_delivery(jsonb descriptors) re-granted to authenticated');
88
+
89
+ -- 2. Authenticated path: _is_service_role() is false, real membership evaluated. ----
90
+ select set_config('request.jwt.claim.role', 'authenticated', true);
91
+ select set_config('request.jwt.claim.sub', (select member_user from _regrant_fixture)::text, true);
92
+ select is(agent_board._is_service_role(), false, 'authenticated path: _is_service_role() is false');
93
+
94
+ select lives_ok(
95
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
96
+ thread_member, member_agent, board) from _regrant_fixture),
97
+ 'a non-observer board member send succeeds with real membership evaluated (Portal-offline write, no Portal dependency present)');
98
+
99
+ -- 3. A caller-supplied actor that mismatches current_agent_id() is rejected. --------
100
+ select throws_ok(
101
+ (select format($q$select agent_board.send_direct_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
102
+ thread_dm, other_agent, recipient_agent) from _regrant_fixture),
103
+ '42501', null,
104
+ 'send with p_author_agent_id <> current_agent_id() is rejected by _resolved_actor_agent_id');
105
+
106
+ -- 4. A board-less direct send by the member also succeeds (no Portal dependency). ---
107
+ select lives_ok(
108
+ (select format($q$select agent_board.send_direct_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
109
+ thread_dm, member_agent, recipient_agent) from _regrant_fixture),
110
+ 'a board-less direct send succeeds on the authenticated path (Portal-offline behavioral)');
111
+
112
+ -- 5. Membership revoked mid-session: the same member board send now fails. ----------
113
+ update agent_board.board_memberships
114
+ set membership_state = 'left', left_at = now()
115
+ where board_id = (select board from _regrant_fixture)
116
+ and agent_id = (select member_agent from _regrant_fixture);
117
+ select throws_ok(
118
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
119
+ thread_member, member_agent, board) from _regrant_fixture),
120
+ '42501', null,
121
+ 'a membership-revoked-mid-session board send is rejected by the role gate');
122
+
123
+ select set_config('request.jwt.claim.role', '', true);
124
+ select set_config('request.jwt.claim.sub', '', true);
125
+
126
+ -- 6. Portal-offline static: no send RPC body references a Portal issuer / JWKS. -----
127
+ select ok(
128
+ position('portal' in lower(pg_get_functiondef(
129
+ 'agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb)'::regprocedure))) = 0,
130
+ 'post_board_message references no Portal issuer/URL');
131
+ select ok(
132
+ position('jwks' in lower(pg_get_functiondef(
133
+ 'agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb)'::regprocedure))) = 0,
134
+ 'post_board_message references no JWKS');
135
+
136
+ select finish();
137
+
138
+ rollback;
@@ -0,0 +1,84 @@
1
+ -- MBSENDAUTHZ SL-0 pgTAP: shared send-authz enforcement config + telemetry.
2
+ --
3
+ -- Runs as the postgres db role. request.jwt.claim.role toggles _is_service_role():
4
+ -- unset/'' => service_role true (default here); 'authenticated' => false.
5
+ create extension if not exists pgtap with schema extensions;
6
+ set search_path = extensions, public, agent_board;
7
+
8
+ begin;
9
+
10
+ select plan(20);
11
+
12
+ -- Structure: fleet_send_enforcement_flags ------------------------------------
13
+ select has_table('agent_board', 'fleet_send_enforcement_flags', 'fleet_send_enforcement_flags table exists');
14
+ select has_column('agent_board', 'fleet_send_enforcement_flags', 'scope_key', 'flags has scope_key');
15
+ select col_is_pk('agent_board', 'fleet_send_enforcement_flags', 'scope_key', 'scope_key is the primary key');
16
+ select has_column('agent_board', 'fleet_send_enforcement_flags', 'mode', 'flags has mode');
17
+ select col_has_check('agent_board', 'fleet_send_enforcement_flags', 'mode', 'mode is check-constrained');
18
+ select has_column('agent_board', 'fleet_send_enforcement_flags', 'updated_at', 'flags has updated_at');
19
+
20
+ -- Default mode is fail_open_preserved (absent explicit mode).
21
+ insert into agent_board.fleet_send_enforcement_flags(scope_key) values ('sl0-default-scope');
22
+ select is(
23
+ (select mode from agent_board.fleet_send_enforcement_flags where scope_key = 'sl0-default-scope'),
24
+ 'fail_open_preserved', 'flag mode defaults to fail_open_preserved');
25
+
26
+ -- Invalid mode is rejected by the CHECK.
27
+ select throws_ok(
28
+ $$insert into agent_board.fleet_send_enforcement_flags(scope_key, mode) values ('sl0-bad', 'block')$$,
29
+ '23514', null, 'flags mode CHECK rejects an unknown mode');
30
+
31
+ -- _send_fleet_scope_mode: default on no row, resolved value on a row -----------
32
+ select is(
33
+ agent_board._send_fleet_scope_mode('sl0-no-such-scope'),
34
+ 'fail_open_preserved', '_send_fleet_scope_mode defaults to fail_open_preserved on no row');
35
+ insert into agent_board.fleet_send_enforcement_flags(scope_key, mode) values ('sl0-observe-scope', 'observe');
36
+ select is(
37
+ agent_board._send_fleet_scope_mode('sl0-observe-scope'),
38
+ 'observe', '_send_fleet_scope_mode returns the stored mode when a row exists');
39
+
40
+ -- Structure: nonfleet_sender_allowlist ---------------------------------------
41
+ select has_table('agent_board', 'nonfleet_sender_allowlist', 'nonfleet_sender_allowlist table exists');
42
+ select col_is_pk('agent_board', 'nonfleet_sender_allowlist', 'agent_id', 'allowlist agent_id is the primary key');
43
+
44
+ -- Structure: send_authz_telemetry + recorder ---------------------------------
45
+ select has_table('agent_board', 'send_authz_telemetry', 'send_authz_telemetry table exists');
46
+ select col_has_check('agent_board', 'send_authz_telemetry', 'kind', 'telemetry kind is check-constrained');
47
+ select col_has_check('agent_board', 'send_authz_telemetry', 'origin', 'telemetry origin is check-constrained');
48
+
49
+ -- Recorder inserts a row and normalizes an out-of-vocab origin to 'unknown'.
50
+ do $$
51
+ declare v_id uuid;
52
+ begin
53
+ v_id := agent_board._record_send_authz_telemetry(
54
+ 'service_role_bypass', 'garbage-origin', null, null, 'send_direct_message', 'nonfleet', null, '{}'::jsonb);
55
+ end $$;
56
+ select is(
57
+ (select origin from agent_board.send_authz_telemetry where operation = 'send_direct_message' order by recorded_at desc limit 1),
58
+ 'unknown', 'recorder normalizes an out-of-vocab origin to unknown');
59
+ select is(
60
+ (select kind from agent_board.send_authz_telemetry where operation = 'send_direct_message' order by recorded_at desc limit 1),
61
+ 'service_role_bypass', 'recorder persists kind');
62
+
63
+ -- Recorder is service_role-only: under an authenticated caller it raises 42501.
64
+ select set_config('request.jwt.claim.role', 'authenticated', true);
65
+ select throws_ok(
66
+ $$select agent_board._record_send_authz_telemetry('service_role_bypass','ef',null,null,'send_direct_message','nonfleet',null,'{}'::jsonb)$$,
67
+ '42501', null, 'recorder rejects a non-service-role caller');
68
+ select set_config('request.jwt.claim.role', '', true);
69
+
70
+ -- Recorder execute privilege is revoked from authenticated.
71
+ select function_privs_are(
72
+ 'agent_board', '_record_send_authz_telemetry',
73
+ array['text', 'text', 'uuid', 'uuid', 'text', 'text', 'text', 'jsonb'],
74
+ 'authenticated', array[]::text[],
75
+ 'recorder execute is revoked from authenticated');
76
+
77
+ -- _resolve_send_fleet_scope: a non-fleet actor maps to 'nonfleet' -------------
78
+ select is(
79
+ agent_board._resolve_send_fleet_scope(gen_random_uuid()),
80
+ 'nonfleet', '_resolve_send_fleet_scope maps a non-fleet (no active machine) actor to nonfleet');
81
+
82
+ select finish();
83
+
84
+ rollback;
@@ -0,0 +1,159 @@
1
+ -- MBSENDAUTHZ SL-3 pgTAP: cross-cutting authorization matrix. Proves, in one
2
+ -- suite, the board-role send gate (SL-1), the non-fleet default-deny + tight
3
+ -- allowlist + per-scope flag toggle incl. the boardless path (SL-2/SL-0), and the
4
+ -- grantee-bounded ceiling consumption in grant_board_role (SL-3 + MBAUTHZ helper).
5
+ --
6
+ -- Runs as the postgres db role; request.jwt.claim.role/.sub simulate callers.
7
+ create extension if not exists pgtap with schema extensions;
8
+ set search_path = extensions, public, agent_board;
9
+
10
+ begin;
11
+
12
+ select plan(11);
13
+
14
+ create temp table _mtx_fixture (
15
+ sb_board uuid,
16
+ sb_member uuid, sb_observer uuid,
17
+ sb_member_user uuid, sb_observer_user uuid,
18
+ thread_member uuid, thread_observer uuid,
19
+ nf_sender uuid, nf_allow uuid,
20
+ b1 uuid, g_owner uuid, g_admin uuid, g_grantee uuid,
21
+ b2 uuid, g2_owner uuid, g2_grantee uuid
22
+ ) on commit drop;
23
+
24
+ do $$
25
+ declare
26
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
27
+ v_sb_member_user uuid;
28
+ v_sb_observer_user uuid;
29
+ v_sb_member uuid := gen_random_uuid();
30
+ v_sb_observer uuid := gen_random_uuid();
31
+ v_sb_board uuid;
32
+ v_thread_member uuid := gen_random_uuid();
33
+ v_thread_observer uuid := gen_random_uuid();
34
+ v_nf_sender uuid := gen_random_uuid();
35
+ v_nf_allow uuid := gen_random_uuid();
36
+ v_b1 uuid; v_g_owner uuid := gen_random_uuid(); v_g_admin uuid := gen_random_uuid(); v_g_grantee uuid := gen_random_uuid();
37
+ v_b2 uuid; v_g2_owner uuid := gen_random_uuid(); v_g2_grantee uuid := gen_random_uuid();
38
+ begin
39
+ v_sb_member_user := public.ensure_github_user('mtx-member-' || v_suffix, 'mtx-member-' || v_suffix || '@example.test', 'mtx-member-' || v_suffix, 'MTX Member');
40
+ v_sb_observer_user := public.ensure_github_user('mtx-observer-' || v_suffix, 'mtx-observer-' || v_suffix || '@example.test', 'mtx-observer-' || v_suffix, 'MTX Observer');
41
+
42
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
43
+ values
44
+ (v_sb_member, 'agent', 'mtx-member-' || v_suffix, 'MTX Member', 'active', v_sb_member_user, v_sb_member_user),
45
+ (v_sb_observer, 'agent', 'mtx-observer-' || v_suffix, 'MTX Observer', 'active', v_sb_observer_user, v_sb_observer_user);
46
+ insert into agent_board.agents(id, agent_type, handle, display_name)
47
+ values
48
+ (v_nf_sender, 'agent', 'mtx-nf-' || v_suffix, 'MTX NonFleet'),
49
+ (v_nf_allow, 'agent', 'mtx-nfa-' || v_suffix, 'MTX NonFleet Allow'),
50
+ (v_g_owner, 'agent', 'mtx-gowner-' || v_suffix, 'MTX Grant Owner'),
51
+ (v_g_admin, 'agent', 'mtx-gadmin-' || v_suffix, 'MTX Grant Admin'),
52
+ (v_g_grantee, 'agent', 'mtx-ggrantee-' || v_suffix, 'MTX Grant Grantee'),
53
+ (v_g2_owner, 'agent', 'mtx-g2owner-' || v_suffix, 'MTX Grant2 Owner'),
54
+ (v_g2_grantee, 'agent', 'mtx-g2grantee-' || v_suffix, 'MTX Grant2 Grantee');
55
+
56
+ -- Send board with member/observer roles + accessible threads.
57
+ select board_id into v_sb_board from agent_board.create_board(v_sb_member, 'mtx-sb-' || v_suffix, 'MTX Send Board', 'matrix', '{}'::jsonb, 'private');
58
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
59
+ values
60
+ (v_sb_board, v_sb_member, 'joined', v_sb_member, 'member'),
61
+ (v_sb_board, v_sb_observer, 'joined', v_sb_member, 'observer');
62
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
63
+ values
64
+ (v_thread_member, 'coordination', v_sb_member, 'MTX member thread'),
65
+ (v_thread_observer, 'coordination', v_sb_observer, 'MTX observer thread');
66
+
67
+ -- Grant board B1: no ceiling row (ceiling defaults to owner).
68
+ select board_id into v_b1 from agent_board.create_board(v_g_owner, 'mtx-b1-' || v_suffix, 'MTX Grant B1', 'ceiling', '{}'::jsonb, 'private');
69
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
70
+ values
71
+ (v_b1, v_g_owner, 'joined', v_g_owner, 'owner'),
72
+ (v_b1, v_g_admin, 'joined', v_g_owner, 'admin'),
73
+ (v_b1, v_g_grantee, 'joined', v_g_owner, 'observer');
74
+
75
+ -- Grant board B2: ceiling lowered to member.
76
+ select board_id into v_b2 from agent_board.create_board(v_g2_owner, 'mtx-b2-' || v_suffix, 'MTX Grant B2', 'ceiling', '{}'::jsonb, 'private');
77
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
78
+ values
79
+ (v_b2, v_g2_owner, 'joined', v_g2_owner, 'owner'),
80
+ (v_b2, v_g2_grantee, 'joined', v_g2_owner, 'observer');
81
+ insert into agent_board.board_authority_ceilings(board_id, max_grantable_role) values (v_b2, 'member');
82
+
83
+ insert into _mtx_fixture values (
84
+ v_sb_board, v_sb_member, v_sb_observer, v_sb_member_user, v_sb_observer_user,
85
+ v_thread_member, v_thread_observer, v_nf_sender, v_nf_allow,
86
+ v_b1, v_g_owner, v_g_admin, v_g_grantee, v_b2, v_g2_owner, v_g2_grantee);
87
+ end
88
+ $$;
89
+
90
+ -- A. Board-role send gate (authenticated path) -------------------------------
91
+ select set_config('request.jwt.claim.role', 'authenticated', true);
92
+ select set_config('request.jwt.claim.sub', (select sb_observer_user from _mtx_fixture)::text, true);
93
+ select throws_ok(
94
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 's', 'b')$q$,
95
+ thread_observer, sb_observer, sb_board) from _mtx_fixture),
96
+ '42501', 'board send requires a non-observer board role',
97
+ 'matrix: observer authenticated actor denied on a board send');
98
+ select set_config('request.jwt.claim.sub', (select sb_member_user from _mtx_fixture)::text, true);
99
+ select lives_ok(
100
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 's', 'b')$q$,
101
+ thread_member, sb_member, sb_board) from _mtx_fixture),
102
+ 'matrix: member authenticated actor allowed on a board send');
103
+
104
+ -- B/C. Non-fleet default-deny + tight allowlist + per-scope flag toggle -------
105
+ select set_config('request.jwt.claim.role', '', true);
106
+ select set_config('request.jwt.claim.sub', '', true);
107
+ insert into agent_board.fleet_send_enforcement_flags(scope_key, mode) values ('nonfleet', 'deny');
108
+ insert into agent_board.nonfleet_sender_allowlist(agent_id, reason) values ((select nf_allow from _mtx_fixture), 'matrix enumerated flow');
109
+
110
+ select is(
111
+ (select allowed from agent_board.evaluate_signed_write_fleet_policy('send_direct_message', '{}'::jsonb,
112
+ jsonb_build_object('agent_id', (select nf_sender from _mtx_fixture)::text))),
113
+ false, 'matrix: deny mode blocks an unenrolled non-fleet sender');
114
+ select is(
115
+ (select allowed from agent_board.evaluate_signed_write_fleet_policy('send_direct_message', '{}'::jsonb,
116
+ jsonb_build_object('agent_id', (select nf_allow from _mtx_fixture)::text))),
117
+ true, 'matrix: tight allowlist admits only the enumerated non-fleet sender');
118
+ -- Boardless send_direct_message path is gated by the scope flag (scope before board).
119
+ select is(
120
+ (select denial_class from agent_board.evaluate_signed_write_fleet_policy('send_direct_message', '{}'::jsonb,
121
+ jsonb_build_object('agent_id', (select nf_sender from _mtx_fixture)::text))),
122
+ 'nonfleet_sender_denied', 'matrix: flag gates the boardless send_direct_message path under deny');
123
+ -- Toggle the scope off: the same sender now passes (fail_open_preserved).
124
+ update agent_board.fleet_send_enforcement_flags set mode = 'fail_open_preserved' where scope_key = 'nonfleet';
125
+ select is(
126
+ (select allowed from agent_board.evaluate_signed_write_fleet_policy('send_direct_message', '{}'::jsonb,
127
+ jsonb_build_object('agent_id', (select nf_sender from _mtx_fixture)::text))),
128
+ true, 'matrix: flag toggle to fail_open_preserved re-admits the same non-fleet sender');
129
+
130
+ -- D. Grantee-bounded ceiling consumption via grant_board_role -----------------
131
+ -- B1 has no ceiling row (ceiling defaults to owner).
132
+ select lives_ok(
133
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'member', %L::uuid)$q$,
134
+ b1, g_grantee, g_admin) from _mtx_fixture),
135
+ 'matrix: within-ceiling grant (admin grants member) is admitted');
136
+ select throws_ok(
137
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'owner', %L::uuid)$q$,
138
+ b1, g_grantee, g_admin) from _mtx_fixture),
139
+ '42501', 'grant exceeds grantor role or board authority ceiling',
140
+ 'matrix: a grant exceeding the grantor role is rejected (grantee-bounded)');
141
+ select lives_ok(
142
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin', %L::uuid)$q$,
143
+ b1, g_grantee, g_owner) from _mtx_fixture),
144
+ 'matrix: no-ceiling-row board treats ceiling as owner (owner grants admin admitted)');
145
+ -- B2 ceiling is member: owner may not grant admin (over-ceiling).
146
+ select throws_ok(
147
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin', %L::uuid)$q$,
148
+ b2, g2_grantee, g2_owner) from _mtx_fixture),
149
+ '42501', 'grant exceeds grantor role or board authority ceiling',
150
+ 'matrix: an over-ceiling grant is rejected even for an owner grantor');
151
+
152
+ -- E. Dormancy: grant_board_role is revoked from authenticated -----------------
153
+ select function_privs_are(
154
+ 'agent_board', 'grant_board_role', array['uuid', 'uuid', 'text', 'uuid'],
155
+ 'authenticated', array[]::text[], 'matrix: grant_board_role execute is revoked from authenticated');
156
+
157
+ select finish();
158
+
159
+ rollback;