@consiliency/agent-board-schema 1.5.0 → 1.7.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/20260724000100_agent_board_roomlink_invite_tokens.sql +490 -0
- package/migrations/20260724000200_agent_board_roomlink_enroll_binding.sql +285 -0
- package/migrations/20260725000100_agent_board_roomdeleg_role_gate.sql +232 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +8 -2
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_identity_providers.test.sql +3 -3
- package/tests/agent_board_roomdeleg.test.sql +167 -0
- package/tests/agent_board_roomlink.test.sql +364 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
-- ROOMDELEG Lane A pgTAP: owner-gated set_board_role_gate + R-DELEG-1 creator-identity
|
|
2
|
+
-- gate-bypass fix on invite_board_member / resolve_board_request.
|
|
3
|
+
--
|
|
4
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
|
|
5
|
+
-- (''/service_role => true, 'authenticated' => false) and request.jwt.claim.sub picks
|
|
6
|
+
-- the acting agent. Fixture style mirrors agent_board_roomown.test.sql.
|
|
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 _roomdeleg_fixture (
|
|
15
|
+
board uuid,
|
|
16
|
+
alice uuid, -- creator, seeded owner
|
|
17
|
+
bob uuid, -- role 'member'
|
|
18
|
+
target uuid, -- invite target (existing agent, no membership needed)
|
|
19
|
+
requester uuid, -- has a pending 'requested' membership (resolve target)
|
|
20
|
+
nomember uuid, -- existing agent, no membership on the board
|
|
21
|
+
suspended uuid, -- suspended agent WITH a valid auth user
|
|
22
|
+
alice_user uuid,
|
|
23
|
+
bob_user uuid,
|
|
24
|
+
suspended_user uuid
|
|
25
|
+
) on commit drop;
|
|
26
|
+
|
|
27
|
+
do $$
|
|
28
|
+
declare
|
|
29
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
30
|
+
v_alice_user uuid;
|
|
31
|
+
v_bob_user uuid;
|
|
32
|
+
v_suspended_user uuid;
|
|
33
|
+
v_alice uuid := gen_random_uuid();
|
|
34
|
+
v_bob uuid := gen_random_uuid();
|
|
35
|
+
v_target uuid := gen_random_uuid();
|
|
36
|
+
v_requester uuid := gen_random_uuid();
|
|
37
|
+
v_nomember uuid := gen_random_uuid();
|
|
38
|
+
v_suspended uuid := gen_random_uuid();
|
|
39
|
+
v_board uuid;
|
|
40
|
+
v_slug text := 'roomdeleg-board-' || v_suffix;
|
|
41
|
+
begin
|
|
42
|
+
v_alice_user := public.ensure_github_user('rd-alice-' || v_suffix, 'rd-alice-' || v_suffix || '@example.test', 'rd-alice-' || v_suffix, 'RD Alice');
|
|
43
|
+
v_bob_user := public.ensure_github_user('rd-bob-' || v_suffix, 'rd-bob-' || v_suffix || '@example.test', 'rd-bob-' || v_suffix, 'RD Bob');
|
|
44
|
+
v_suspended_user := public.ensure_github_user('rd-susp-' || v_suffix, 'rd-susp-' || v_suffix || '@example.test', 'rd-susp-' || v_suffix, 'RD Suspended');
|
|
45
|
+
|
|
46
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
47
|
+
values
|
|
48
|
+
(v_alice, 'agent', 'rd-alice-' || v_suffix, 'RD Alice', 'active', v_alice_user, v_alice_user),
|
|
49
|
+
(v_bob, 'agent', 'rd-bob-' || v_suffix, 'RD Bob', 'active', v_bob_user, v_bob_user),
|
|
50
|
+
-- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
|
|
51
|
+
-- so it resolves to null while auth.uid() still maps.
|
|
52
|
+
(v_suspended, 'agent', 'rd-susp-' || v_suffix, 'RD Suspended', 'suspended', v_suspended_user, v_suspended_user),
|
|
53
|
+
(v_target, 'agent', 'rd-target-' || v_suffix, 'RD Target', 'active', null, null),
|
|
54
|
+
(v_requester, 'agent', 'rd-requester-' || v_suffix, 'RD Requester', 'active', null, null),
|
|
55
|
+
(v_nomember, 'agent', 'rd-nomember-' || v_suffix, 'RD NoMember', 'active', null, null);
|
|
56
|
+
|
|
57
|
+
-- Alice creates board (service_role null-actor here -> supplied arg honored):
|
|
58
|
+
-- create_board seeds Alice's joined owner membership (ROOMOWN).
|
|
59
|
+
select board_id into v_board
|
|
60
|
+
from agent_board.create_board(v_alice, v_slug, 'ROOMDELEG Board', 'roomdeleg test', '{}'::jsonb, 'private');
|
|
61
|
+
|
|
62
|
+
-- Bob is a genuine NON-creator member (role member). Requester has a pending
|
|
63
|
+
-- 'requested' membership so resolve_board_request reaches the gate check.
|
|
64
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
65
|
+
values
|
|
66
|
+
(v_board, v_bob, 'joined', v_alice, 'member'),
|
|
67
|
+
(v_board, v_requester, 'requested', v_alice, 'member');
|
|
68
|
+
|
|
69
|
+
insert into _roomdeleg_fixture values
|
|
70
|
+
(v_board, v_alice, v_bob, v_target, v_requester, v_nomember, v_suspended,
|
|
71
|
+
v_alice_user, v_bob_user, v_suspended_user);
|
|
72
|
+
end
|
|
73
|
+
$$;
|
|
74
|
+
|
|
75
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
76
|
+
|
|
77
|
+
-- === Part A: set_board_role_gate authz (gate starts false) ========================
|
|
78
|
+
-- 1. non-owner member (Bob) cannot flip the gate -> 42501 (gate unchanged) ----------
|
|
79
|
+
select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
|
|
80
|
+
select throws_ok(
|
|
81
|
+
(select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
|
|
82
|
+
'42501', 'only a board owner may set the role gate',
|
|
83
|
+
'set_board_role_gate: a non-owner member is rejected');
|
|
84
|
+
|
|
85
|
+
-- 2. non-member cannot flip the gate -> 42501 --------------------------------------
|
|
86
|
+
select set_config('request.jwt.claim.sub', gen_random_uuid()::text, true);
|
|
87
|
+
select throws_ok(
|
|
88
|
+
(select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
|
|
89
|
+
'42501', 'only a board owner may set the role gate',
|
|
90
|
+
'set_board_role_gate: a non-member (unmapped sub) is rejected');
|
|
91
|
+
|
|
92
|
+
-- 3. suspended principal (current_agent_id() null) cannot flip the gate -> 42501 ----
|
|
93
|
+
select set_config('request.jwt.claim.sub', (select suspended_user from _roomdeleg_fixture)::text, true);
|
|
94
|
+
select throws_ok(
|
|
95
|
+
(select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
|
|
96
|
+
'42501', 'only a board owner may set the role gate',
|
|
97
|
+
'set_board_role_gate: a suspended principal (null actor) is rejected');
|
|
98
|
+
|
|
99
|
+
-- 4. owner (Alice) flips false->true (pinned to a single assertion) -----------------
|
|
100
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomdeleg_fixture)::text, true);
|
|
101
|
+
select is(
|
|
102
|
+
(select role_gate_enforced from agent_board.set_board_role_gate((select board from _roomdeleg_fixture), true)),
|
|
103
|
+
true, 'set_board_role_gate: the owner flips role_gate_enforced false->true');
|
|
104
|
+
|
|
105
|
+
-- === Part B: the flipped gate restricts invite/resolve (gate now ON) ==============
|
|
106
|
+
-- 5. gate ON -> non-owner/admin member (Bob) invite rejected -> 42501 ---------------
|
|
107
|
+
select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
|
|
108
|
+
select throws_ok(
|
|
109
|
+
(select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
|
|
110
|
+
'42501', 'board invite requires owner or admin role',
|
|
111
|
+
'gate ON: a role-member actor is denied invite');
|
|
112
|
+
|
|
113
|
+
-- 6. gate ON -> non-owner/admin member (Bob) resolve rejected -> 42501 --------------
|
|
114
|
+
select throws_ok(
|
|
115
|
+
(select format($q$select agent_board.resolve_board_request(%L::uuid, %L::uuid, 'joined', %L::uuid, '{}'::jsonb)$q$, board, requester, alice) from _roomdeleg_fixture),
|
|
116
|
+
'42501', 'board request resolution requires owner or admin role',
|
|
117
|
+
'gate ON: a role-member actor is denied resolve');
|
|
118
|
+
|
|
119
|
+
-- === Part C: R-DELEG-1 creator-identity exemption dropped under an enforced gate ===
|
|
120
|
+
-- 7. creator-who-is-still-owner (Alice) + gate ON -> invite still succeeds ----------
|
|
121
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomdeleg_fixture)::text, true);
|
|
122
|
+
select lives_ok(
|
|
123
|
+
(select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
|
|
124
|
+
'gate ON: the creator who still holds owner membership may invite');
|
|
125
|
+
|
|
126
|
+
-- Demote the creator to member (bare statement, not an assertion). created_by is
|
|
127
|
+
-- immutable, so this models a creator who has lost owner authority.
|
|
128
|
+
update agent_board.board_memberships bm
|
|
129
|
+
set role = 'member'
|
|
130
|
+
from _roomdeleg_fixture f
|
|
131
|
+
where bm.board_id = f.board and bm.agent_id = f.alice;
|
|
132
|
+
|
|
133
|
+
-- 8. demoted creator + gate ON -> invite rejected -> 42501 (R-DELEG-1) --------------
|
|
134
|
+
select throws_ok(
|
|
135
|
+
(select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
|
|
136
|
+
'42501', 'board invite requires owner or admin role',
|
|
137
|
+
'gate ON: a DEMOTED creator no longer bypasses invite (creator-identity gate closed)');
|
|
138
|
+
|
|
139
|
+
-- 9. demoted creator + gate ON -> resolve rejected -> 42501 (R-DELEG-1) -------------
|
|
140
|
+
select throws_ok(
|
|
141
|
+
(select format($q$select agent_board.resolve_board_request(%L::uuid, %L::uuid, 'joined', %L::uuid, '{}'::jsonb)$q$, board, requester, alice) from _roomdeleg_fixture),
|
|
142
|
+
'42501', 'board request resolution requires owner or admin role',
|
|
143
|
+
'gate ON: a DEMOTED creator no longer bypasses resolve (creator-identity gate closed)');
|
|
144
|
+
|
|
145
|
+
-- === Part D: gate OFF regression (creator/any active member bypass unchanged) ======
|
|
146
|
+
-- Flip the gate back OFF via a direct update (demoted Alice can no longer use the RPC).
|
|
147
|
+
update agent_board.boards b
|
|
148
|
+
set role_gate_enforced = false
|
|
149
|
+
where b.id = (select board from _roomdeleg_fixture);
|
|
150
|
+
|
|
151
|
+
-- 10. gate OFF + demoted creator -> invite still bypasses (creator exemption intact) -
|
|
152
|
+
select lives_ok(
|
|
153
|
+
(select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
|
|
154
|
+
'gate OFF: the demoted creator still bypasses (gate-OFF behavior unchanged)');
|
|
155
|
+
|
|
156
|
+
-- 11. gate OFF + any active member (Bob) -> invite succeeds -------------------------
|
|
157
|
+
select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
|
|
158
|
+
select lives_ok(
|
|
159
|
+
(select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
|
|
160
|
+
'gate OFF: any active member may invite (any-active-member behavior preserved)');
|
|
161
|
+
|
|
162
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
163
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
164
|
+
|
|
165
|
+
select finish();
|
|
166
|
+
|
|
167
|
+
rollback;
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
-- ROOMLINK pgTAP: shareable board invite links (create/redeem/revoke) + the
|
|
2
|
+
-- enroll-on-redeem binding. Exercises the panel's correctness invariants:
|
|
3
|
+
-- * grantor-bounded mint; owner-preset forbidden; admin-preset requires a use cap
|
|
4
|
+
-- and a <=7d TTL (R-LINK-3),
|
|
5
|
+
-- * create actor-guard (_resolved_actor_agent_id),
|
|
6
|
+
-- * atomic rank-max redemption that never downgrades a higher role (R-LINK-1),
|
|
7
|
+
-- * idempotent replay of a maxed link (M1) + exhaustion + expiry + revoked
|
|
8
|
+
-- (uniform 42501),
|
|
9
|
+
-- * suspended-creator refusal (R-LINK-2) + creator-ceiling re-bind,
|
|
10
|
+
-- * kicked-agent ('removed') refusal (R-LINK-7),
|
|
11
|
+
-- * redeem actor fail-closed on null (suspended principal),
|
|
12
|
+
-- * _redeem_invite_for_agent is NOT authenticated-callable,
|
|
13
|
+
-- * enroll_federated_identity(...,p_invite_code) soft-honors a code
|
|
14
|
+
-- (invite_redeemed=true) and soft-fails a bad one (enroll still succeeds).
|
|
15
|
+
--
|
|
16
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
|
|
17
|
+
-- (''/service_role => true, 'authenticated' => false) and request.jwt.claim.sub picks
|
|
18
|
+
-- the acting agent. Fixture style mirrors agent_board_roomown.test.sql. The two
|
|
19
|
+
-- Ed25519 public keys + fingerprints are reused from agent_board_enroll_federated_identity.
|
|
20
|
+
create extension if not exists pgtap with schema extensions;
|
|
21
|
+
set search_path = extensions, public, agent_board;
|
|
22
|
+
|
|
23
|
+
begin;
|
|
24
|
+
|
|
25
|
+
select plan(34);
|
|
26
|
+
|
|
27
|
+
create temp table _lf (
|
|
28
|
+
board uuid,
|
|
29
|
+
alice uuid, bob uuid, carol uuid, carol2 uuid, carol3 uuid,
|
|
30
|
+
dave uuid, erin uuid, frank uuid, gus uuid, grimm uuid, harry uuid, mallory uuid,
|
|
31
|
+
alice_user uuid, bob_user uuid, carol_user uuid, mallory_user uuid,
|
|
32
|
+
code_member text, code_maxed text, code_public text, code_mallory text,
|
|
33
|
+
code_harry text, code_revoke text, code_revoke_authz text, code_enroll text,
|
|
34
|
+
code_expired text,
|
|
35
|
+
prov_iss text, prov_sub text, prov_slug text,
|
|
36
|
+
pub1 text, fp1 text, pub2 text, fp2 text
|
|
37
|
+
) on commit drop;
|
|
38
|
+
|
|
39
|
+
create temp table _er (label text, agent_id uuid, invite_redeemed boolean) on commit drop;
|
|
40
|
+
|
|
41
|
+
do $$
|
|
42
|
+
declare
|
|
43
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
44
|
+
v_alice_user uuid; v_bob_user uuid; v_carol_user uuid; v_mallory_user uuid;
|
|
45
|
+
v_alice uuid := gen_random_uuid();
|
|
46
|
+
v_bob uuid := gen_random_uuid();
|
|
47
|
+
v_carol uuid := gen_random_uuid();
|
|
48
|
+
v_carol2 uuid := gen_random_uuid();
|
|
49
|
+
v_carol3 uuid := gen_random_uuid();
|
|
50
|
+
v_dave uuid := gen_random_uuid();
|
|
51
|
+
v_erin uuid := gen_random_uuid();
|
|
52
|
+
v_frank uuid := gen_random_uuid();
|
|
53
|
+
v_gus uuid := gen_random_uuid();
|
|
54
|
+
v_grimm uuid := gen_random_uuid();
|
|
55
|
+
v_harry uuid := gen_random_uuid();
|
|
56
|
+
v_mallory uuid := gen_random_uuid();
|
|
57
|
+
v_board uuid;
|
|
58
|
+
v_code_member text; v_code_maxed text; v_code_public text; v_code_mallory text;
|
|
59
|
+
v_code_harry text; v_code_revoke text; v_code_revoke_authz text; v_code_enroll text;
|
|
60
|
+
v_code_expired text := 'expired-code-' || v_suffix;
|
|
61
|
+
v_prov_iss text := 'https://roomlink-fed-' || v_suffix || '.test';
|
|
62
|
+
begin
|
|
63
|
+
v_alice_user := public.ensure_github_user('rl-alice-' || v_suffix, 'rl-alice-' || v_suffix || '@example.test', 'rl-alice-' || v_suffix, 'RL Alice');
|
|
64
|
+
v_bob_user := public.ensure_github_user('rl-bob-' || v_suffix, 'rl-bob-' || v_suffix || '@example.test', 'rl-bob-' || v_suffix, 'RL Bob');
|
|
65
|
+
v_carol_user := public.ensure_github_user('rl-carol-' || v_suffix, 'rl-carol-' || v_suffix || '@example.test', 'rl-carol-' || v_suffix, 'RL Carol');
|
|
66
|
+
v_mallory_user := public.ensure_github_user('rl-mallory-' || v_suffix, 'rl-mallory-' || v_suffix || '@example.test', 'rl-mallory-' || v_suffix, 'RL Mallory');
|
|
67
|
+
|
|
68
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
69
|
+
values
|
|
70
|
+
(v_alice, 'agent', 'rl-alice-' || v_suffix, 'RL Alice', 'active', v_alice_user, v_alice_user),
|
|
71
|
+
(v_bob, 'agent', 'rl-bob-' || v_suffix, 'RL Bob', 'active', v_bob_user, v_bob_user),
|
|
72
|
+
(v_carol, 'agent', 'rl-carol-' || v_suffix, 'RL Carol', 'active', v_carol_user, v_carol_user),
|
|
73
|
+
-- mallory is SUSPENDED but still holds an admin membership + a valid auth user:
|
|
74
|
+
-- current_agent_id() filters status='active' (fail-closed redeem), and R-LINK-2
|
|
75
|
+
-- refuses redeeming her outstanding link.
|
|
76
|
+
(v_mallory, 'agent', 'rl-mallory-' || v_suffix, 'RL Mallory', 'suspended', v_mallory_user, v_mallory_user),
|
|
77
|
+
(v_carol2, 'agent', 'rl-carol2-' || v_suffix, 'RL Carol2', 'active', null, null),
|
|
78
|
+
(v_carol3, 'agent', 'rl-carol3-' || v_suffix, 'RL Carol3', 'active', null, null),
|
|
79
|
+
(v_dave, 'agent', 'rl-dave-' || v_suffix, 'RL Dave', 'active', null, null),
|
|
80
|
+
(v_erin, 'agent', 'rl-erin-' || v_suffix, 'RL Erin', 'active', null, null),
|
|
81
|
+
(v_frank, 'agent', 'rl-frank-' || v_suffix, 'RL Frank', 'active', null, null),
|
|
82
|
+
(v_gus, 'agent', 'rl-gus-' || v_suffix, 'RL Gus', 'active', null, null),
|
|
83
|
+
(v_grimm, 'agent', 'rl-grimm-' || v_suffix, 'RL Grimm', 'active', null, null),
|
|
84
|
+
(v_harry, 'agent', 'rl-harry-' || v_suffix, 'RL Harry', 'active', null, null);
|
|
85
|
+
|
|
86
|
+
-- Alice creates board B (service_role null-actor -> supplied arg honored): create_board
|
|
87
|
+
-- seeds Alice's joined owner membership.
|
|
88
|
+
select board_id into v_board
|
|
89
|
+
from agent_board.create_board(v_alice, 'roomlink-board-' || v_suffix, 'ROOMLINK Board', 'roomlink test', '{}'::jsonb, 'private');
|
|
90
|
+
|
|
91
|
+
-- Pre-existing memberships used by the redemption cases.
|
|
92
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
93
|
+
values
|
|
94
|
+
(v_board, v_dave, 'joined', v_alice, 'admin'), -- no-downgrade target
|
|
95
|
+
(v_board, v_grimm, 'joined', v_alice, 'observer'), -- too-low to mint member links
|
|
96
|
+
(v_board, v_harry, 'joined', v_alice, 'admin'), -- mints an admin link, later demoted
|
|
97
|
+
(v_board, v_mallory, 'joined', v_alice, 'admin'); -- mints a link, agent suspended
|
|
98
|
+
-- Erin was kicked (removed): the active partial index does not cover this row, so a
|
|
99
|
+
-- redeem must explicitly refuse rather than seat a fresh joined row.
|
|
100
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
|
|
101
|
+
values (v_board, v_erin, 'removed', v_alice, 'member', now());
|
|
102
|
+
|
|
103
|
+
-- Mint links (service_role context: default role '', session_user postgres).
|
|
104
|
+
select code into v_code_member
|
|
105
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
106
|
+
select code into v_code_maxed
|
|
107
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', 1, null, v_alice, '{}'::jsonb);
|
|
108
|
+
select code into v_code_public
|
|
109
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
110
|
+
select code into v_code_revoke
|
|
111
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
112
|
+
select code into v_code_revoke_authz
|
|
113
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
114
|
+
select code into v_code_enroll
|
|
115
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
116
|
+
-- Mallory (admin membership) mints a member link; her agent is suspended.
|
|
117
|
+
select code into v_code_mallory
|
|
118
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_mallory, '{}'::jsonb);
|
|
119
|
+
-- Harry (admin) mints an ADMIN link (use cap required); harry is then demoted.
|
|
120
|
+
select code into v_code_harry
|
|
121
|
+
from agent_board.create_invite_link(v_board, 'admin', 'joined', 2, null, v_harry, '{}'::jsonb);
|
|
122
|
+
update agent_board.board_memberships bm set role = 'member'
|
|
123
|
+
where bm.board_id = v_board and bm.agent_id = v_harry;
|
|
124
|
+
|
|
125
|
+
-- An already-expired token (direct insert; create_invite_link rejects a past TTL).
|
|
126
|
+
insert into agent_board.board_invite_tokens(code, board_id, preset_role, preset_membership_state, expires_at, created_by_agent_id)
|
|
127
|
+
values (v_code_expired, v_board, 'member', 'joined', now() - interval '1 day', v_alice);
|
|
128
|
+
|
|
129
|
+
-- Approved identity provider for the enroll-binding cases.
|
|
130
|
+
insert into agent_board.identity_providers
|
|
131
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
|
|
132
|
+
values (v_prov_iss, 'message-board', true, true, '{}', '{}', '{agent,human}');
|
|
133
|
+
|
|
134
|
+
insert into _lf values (
|
|
135
|
+
v_board, v_alice, v_bob, v_carol, v_carol2, v_carol3, v_dave, v_erin, v_frank,
|
|
136
|
+
v_gus, v_grimm, v_harry, v_mallory,
|
|
137
|
+
v_alice_user, v_bob_user, v_carol_user, v_mallory_user,
|
|
138
|
+
v_code_member, v_code_maxed, v_code_public, v_code_mallory, v_code_harry,
|
|
139
|
+
v_code_revoke, v_code_revoke_authz, v_code_enroll, v_code_expired,
|
|
140
|
+
v_prov_iss, 'portal|' || v_suffix, 'rl-enrollee-' || v_suffix,
|
|
141
|
+
'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0',
|
|
142
|
+
'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
|
|
143
|
+
'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk',
|
|
144
|
+
'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA'
|
|
145
|
+
);
|
|
146
|
+
end
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
-- === A. create validation ============================================== (7)
|
|
150
|
+
-- 1
|
|
151
|
+
select throws_ok(
|
|
152
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'owner', 'joined', 1, null, %L::uuid)$q$, board, alice) from _lf),
|
|
153
|
+
'22023', 'owner presets are not invite-linkable',
|
|
154
|
+
'create_invite_link forbids the owner preset (R-LINK-3)');
|
|
155
|
+
-- 2
|
|
156
|
+
select throws_ok(
|
|
157
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'admin', 'joined', null, null, %L::uuid)$q$, board, alice) from _lf),
|
|
158
|
+
'22023', 'admin invite links require a bounded p_max_uses',
|
|
159
|
+
'admin preset requires a use cap (R-LINK-3)');
|
|
160
|
+
-- 3
|
|
161
|
+
select throws_ok(
|
|
162
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'admin', 'joined', 5, now() + interval '10 days', %L::uuid)$q$, board, alice) from _lf),
|
|
163
|
+
'22023', 'admin invite links require a TTL of at most 7 days',
|
|
164
|
+
'admin preset requires a <=7d TTL (R-LINK-3)');
|
|
165
|
+
-- 4
|
|
166
|
+
select throws_ok(
|
|
167
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'member', 'joined', null, null, %L::uuid)$q$, board, grimm) from _lf),
|
|
168
|
+
'42501', 'preset role exceeds creator role or board authority ceiling',
|
|
169
|
+
'create is grantor-bounded: an observer cannot mint a member link');
|
|
170
|
+
-- 5
|
|
171
|
+
select is((select length(code_member) from _lf), 43, 'the minted code is a 43-char base64url (256-bit) string');
|
|
172
|
+
-- 6
|
|
173
|
+
select is(
|
|
174
|
+
(select preset_role from agent_board.board_invite_tokens t join _lf f on t.code = f.code_member),
|
|
175
|
+
'member', 'the minted member link stores preset_role=member');
|
|
176
|
+
-- 7
|
|
177
|
+
select isnt((select code_member from _lf), (select code_maxed from _lf), 'distinct mints produce distinct codes (uniqueness)');
|
|
178
|
+
|
|
179
|
+
-- === B. create actor-guard ============================================= (1)
|
|
180
|
+
-- 8
|
|
181
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
182
|
+
select set_config('request.jwt.claim.sub', (select bob_user from _lf)::text, true);
|
|
183
|
+
select throws_ok(
|
|
184
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'member', 'joined', null, null, %L::uuid)$q$, board, alice) from _lf),
|
|
185
|
+
'42501', 'p_created_by_agent_id must match current_agent_id()',
|
|
186
|
+
'actor-guard: authenticated Bob cannot mint a link as Alice');
|
|
187
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
188
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
189
|
+
|
|
190
|
+
-- === C. redeem basics ================================================== (3)
|
|
191
|
+
-- 9
|
|
192
|
+
select is(
|
|
193
|
+
(select role from agent_board._redeem_invite_for_agent((select code_member from _lf), (select carol from _lf))),
|
|
194
|
+
'member', 'redeem seats Carol with the member role');
|
|
195
|
+
-- 10
|
|
196
|
+
select is(
|
|
197
|
+
(select count(*)::int from agent_board.board_memberships bm join _lf f on bm.board_id = f.board and bm.agent_id = f.carol
|
|
198
|
+
where bm.membership_state in ('joined','subscribed')),
|
|
199
|
+
1, 'Carol holds exactly one active membership after redeem');
|
|
200
|
+
-- 11
|
|
201
|
+
select is(
|
|
202
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_member),
|
|
203
|
+
1, 'the member link uses_count incremented to 1');
|
|
204
|
+
|
|
205
|
+
-- === D. idempotency + exhaustion (maxed link) ========================== (5)
|
|
206
|
+
-- 12
|
|
207
|
+
select is(
|
|
208
|
+
(select role from agent_board._redeem_invite_for_agent((select code_maxed from _lf), (select frank from _lf))),
|
|
209
|
+
'member', 'first redeem of the maxed link seats Frank');
|
|
210
|
+
-- 13
|
|
211
|
+
select is(
|
|
212
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_maxed),
|
|
213
|
+
1, 'the maxed link uses_count is 1 after Frank');
|
|
214
|
+
-- 14
|
|
215
|
+
select lives_ok(
|
|
216
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_maxed, frank) from _lf),
|
|
217
|
+
'replay by the same agent on a maxed link is an idempotent no-op (M1), not 42501');
|
|
218
|
+
-- 15
|
|
219
|
+
select is(
|
|
220
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_maxed),
|
|
221
|
+
1, 'the idempotent replay did NOT inflate uses_count');
|
|
222
|
+
-- 16
|
|
223
|
+
select throws_ok(
|
|
224
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_maxed, gus) from _lf),
|
|
225
|
+
'42501', 'invite is not usable',
|
|
226
|
+
'a DIFFERENT agent redeeming the exhausted link is refused (uniform 42501)');
|
|
227
|
+
|
|
228
|
+
-- === E. no-downgrade (rank-max) ======================================== (2)
|
|
229
|
+
-- 17
|
|
230
|
+
select is(
|
|
231
|
+
(select role from agent_board._redeem_invite_for_agent((select code_member from _lf), (select dave from _lf))),
|
|
232
|
+
'admin', 'redeeming a member link never downgrades Dave''s existing admin role (R-LINK-1)');
|
|
233
|
+
-- 18
|
|
234
|
+
select is(
|
|
235
|
+
(select role from agent_board.board_memberships bm join _lf f on bm.board_id = f.board and bm.agent_id = f.dave
|
|
236
|
+
where bm.membership_state in ('joined','subscribed')),
|
|
237
|
+
'admin', 'Dave''s stored membership role remains admin');
|
|
238
|
+
|
|
239
|
+
-- === F. kicked-agent refusal (R-LINK-7) ================================ (1)
|
|
240
|
+
-- 19
|
|
241
|
+
select throws_ok(
|
|
242
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_member, erin) from _lf),
|
|
243
|
+
'42501', 'invite is not usable',
|
|
244
|
+
'a removed (kicked) agent cannot re-enter via an invite link (R-LINK-7)');
|
|
245
|
+
|
|
246
|
+
-- === G. suspended-creator refusal (R-LINK-2) =========================== (1)
|
|
247
|
+
-- 20
|
|
248
|
+
select throws_ok(
|
|
249
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_mallory, carol2) from _lf),
|
|
250
|
+
'42501', 'invite is not usable',
|
|
251
|
+
'a suspended creator''s outstanding link stops minting (R-LINK-2)');
|
|
252
|
+
|
|
253
|
+
-- === H. creator-ceiling re-bind ======================================== (1)
|
|
254
|
+
-- 21
|
|
255
|
+
select throws_ok(
|
|
256
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_harry, carol3) from _lf),
|
|
257
|
+
'42501', 'invite is not usable',
|
|
258
|
+
'a demoted creator''s admin link auto-degrades at redeem (re-bound ceiling)');
|
|
259
|
+
|
|
260
|
+
-- === I. expired ======================================================== (1)
|
|
261
|
+
-- 22
|
|
262
|
+
select throws_ok(
|
|
263
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_expired, gus) from _lf),
|
|
264
|
+
'42501', 'invite is not usable',
|
|
265
|
+
'an expired link is refused (uniform 42501)');
|
|
266
|
+
|
|
267
|
+
-- === J. revoke RPC ===================================================== (3)
|
|
268
|
+
-- 23
|
|
269
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
270
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _lf)::text, true);
|
|
271
|
+
select lives_ok(
|
|
272
|
+
(select format($q$select agent_board.revoke_invite_link(%L)$q$, code_revoke) from _lf),
|
|
273
|
+
'the creator (Alice) revokes her own link');
|
|
274
|
+
-- 24
|
|
275
|
+
select ok(
|
|
276
|
+
(select revoked_at is not null from agent_board.board_invite_tokens t join _lf f on t.code = f.code_revoke),
|
|
277
|
+
'the revoked link carries a revoked_at timestamp');
|
|
278
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
279
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
280
|
+
-- 25
|
|
281
|
+
select throws_ok(
|
|
282
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_revoke, carol2) from _lf),
|
|
283
|
+
'42501', 'invite is not usable',
|
|
284
|
+
'a revoked link is refused at redeem (uniform 42501)');
|
|
285
|
+
|
|
286
|
+
-- === K. revoke authz =================================================== (1)
|
|
287
|
+
-- 26
|
|
288
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
289
|
+
select set_config('request.jwt.claim.sub', (select carol_user from _lf)::text, true);
|
|
290
|
+
select throws_ok(
|
|
291
|
+
(select format($q$select agent_board.revoke_invite_link(%L)$q$, code_revoke_authz) from _lf),
|
|
292
|
+
'42501', 'not authorized to revoke this invite',
|
|
293
|
+
'a plain member (Carol) cannot revoke someone else''s link');
|
|
294
|
+
|
|
295
|
+
-- === L. public redeem happy path ======================================= (1)
|
|
296
|
+
-- 27
|
|
297
|
+
select is(
|
|
298
|
+
(select role from agent_board.redeem_invite_link((select code_public from _lf))),
|
|
299
|
+
'member', 'the public redeem_invite_link resolves the actor via current_agent_id() and seats Carol');
|
|
300
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
301
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
302
|
+
|
|
303
|
+
-- === M. redeem actor fail-closed ======================================= (1)
|
|
304
|
+
-- 28
|
|
305
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
306
|
+
select set_config('request.jwt.claim.sub', (select mallory_user from _lf)::text, true);
|
|
307
|
+
select throws_ok(
|
|
308
|
+
(select format($q$select agent_board.redeem_invite_link(%L)$q$, code_public) from _lf),
|
|
309
|
+
'42501', 'current agent principal not resolved',
|
|
310
|
+
'a suspended principal (current_agent_id() null) cannot redeem-as-nobody');
|
|
311
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
312
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
313
|
+
|
|
314
|
+
-- === N. internal core is not authenticated-callable ==================== (1)
|
|
315
|
+
-- 29
|
|
316
|
+
select function_privs_are(
|
|
317
|
+
'agent_board', '_redeem_invite_for_agent', array['text', 'uuid'],
|
|
318
|
+
'authenticated', array[]::text[],
|
|
319
|
+
'_redeem_invite_for_agent execute is revoked from authenticated (service_role-only core)');
|
|
320
|
+
|
|
321
|
+
-- === O. enroll-on-redeem binding ======================================= (5)
|
|
322
|
+
do $$
|
|
323
|
+
declare v_aid uuid; v_red boolean;
|
|
324
|
+
begin
|
|
325
|
+
-- Valid code: the enrollee lands in the board and invite_redeemed=true.
|
|
326
|
+
select e.agent_id, e.invite_redeemed into v_aid, v_red
|
|
327
|
+
from _lf f, agent_board.enroll_federated_identity(
|
|
328
|
+
f.prov_iss, 'message-board', f.prov_sub,
|
|
329
|
+
jsonb_build_object('sub', f.prov_sub), f.prov_slug,
|
|
330
|
+
f.pub1, 'raw_ed25519_base64url', f.fp1, true, null, 'agent', '{}', '{}', f.code_enroll) e;
|
|
331
|
+
insert into _er values ('X', v_aid, v_red);
|
|
332
|
+
|
|
333
|
+
-- Bad code: enrollment still succeeds, invite_redeemed=false, no membership.
|
|
334
|
+
select e.agent_id, e.invite_redeemed into v_aid, v_red
|
|
335
|
+
from _lf f, agent_board.enroll_federated_identity(
|
|
336
|
+
f.prov_iss, 'message-board', f.prov_sub || '-2',
|
|
337
|
+
jsonb_build_object('sub', f.prov_sub || '-2'), f.prov_slug || '-2',
|
|
338
|
+
f.pub2, 'raw_ed25519_base64url', f.fp2, true, null, 'agent', '{}', '{}', 'bogus-code-nonexistent') e;
|
|
339
|
+
insert into _er values ('Y', v_aid, v_red);
|
|
340
|
+
end
|
|
341
|
+
$$;
|
|
342
|
+
-- 30
|
|
343
|
+
select is((select invite_redeemed from _er where label = 'X'), true, 'enroll with a valid invite_code reports invite_redeemed=true');
|
|
344
|
+
-- 31
|
|
345
|
+
select is(
|
|
346
|
+
(select count(*)::int from agent_board.board_memberships bm, _lf f, _er r
|
|
347
|
+
where r.label = 'X' and bm.agent_id = r.agent_id and bm.board_id = f.board
|
|
348
|
+
and bm.membership_state in ('joined','subscribed')),
|
|
349
|
+
1, 'the enrollee was seated on the board by the redeemed code');
|
|
350
|
+
-- 32
|
|
351
|
+
select is(
|
|
352
|
+
(select status from agent_board.agents a join _er r on a.id = r.agent_id where r.label = 'Y'),
|
|
353
|
+
'active', 'enroll with a BAD invite_code still creates an active agent (soft-fail)');
|
|
354
|
+
-- 33
|
|
355
|
+
select is((select invite_redeemed from _er where label = 'Y'), false, 'a bad invite_code reports invite_redeemed=false');
|
|
356
|
+
-- 34
|
|
357
|
+
select is(
|
|
358
|
+
(select count(*)::int from agent_board.board_memberships bm, _lf f, _er r
|
|
359
|
+
where r.label = 'Y' and bm.agent_id = r.agent_id and bm.board_id = f.board),
|
|
360
|
+
0, 'the soft-failed enrollee gained no membership');
|
|
361
|
+
|
|
362
|
+
select finish();
|
|
363
|
+
|
|
364
|
+
rollback;
|