@consiliency/agent-board-schema 1.5.0 → 1.9.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 +20 -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/migrations/20260726000100_agent_board_localauth_self_enroll.sql +172 -0
- package/migrations/20260727000100_agent_board_roomuiapi.sql +122 -0
- package/package.json +1 -1
- package/schema-manifest.json +48 -3
- package/src/tooling.test.mjs +15 -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_localauth.test.sql +171 -0
- package/tests/agent_board_roomdeleg.test.sql +167 -0
- package/tests/agent_board_roomlink.test.sql +364 -0
- package/tests/agent_board_roomuiapi.test.sql +225 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
-- ROOMUIAPI: two read RPCs the room console/CLI need. Additive, no schema_version
|
|
2
|
+
-- bump (stays 8). Adds:
|
|
3
|
+
-- * agent_board.list_board_pending_memberships(p_board_id) — owner/admin-gated
|
|
4
|
+
-- reader that returns the board's pending (requested + invited) memberships,
|
|
5
|
+
-- latest-row-per-agent. board_memberships SELECT RLS is self-scoped, so a board
|
|
6
|
+
-- owner/admin cannot see EITHER pending state today; this reader is the sole
|
|
7
|
+
-- window. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (mirrors
|
|
8
|
+
-- set_board_role_gate 20260725000100:46-55; NOT the write-RPC `if v_actor is not
|
|
9
|
+
-- null` bypass). Deliberately STRICTER than resolve_board_request: enumerating
|
|
10
|
+
-- requesters is disclosure, so no creator-identity exemption and no gate-off
|
|
11
|
+
-- relaxation. service_role (current_agent_id() = null) hits 42501 here; operators
|
|
12
|
+
-- read the table directly, bypassing RLS.
|
|
13
|
+
-- * agent_board.resolve_agent_handle(p_handle) — SECURITY INVOKER handle->identity
|
|
14
|
+
-- resolver. Runs under caller RLS, so by construction it cannot exceed
|
|
15
|
+
-- agent_board_authenticated_select_agents (20260512000003:49-54, which already
|
|
16
|
+
-- lets any authenticated active agent read every handle) — zero new exposure. The
|
|
17
|
+
-- status='active' filter is a UI affordance (invite accepts any status), not a
|
|
18
|
+
-- security boundary. Returns 0-or-1 row.
|
|
19
|
+
|
|
20
|
+
-- 1. list_board_pending_memberships (owner/admin-gated pending reader) --------------
|
|
21
|
+
create or replace function agent_board.list_board_pending_memberships(
|
|
22
|
+
p_board_id uuid
|
|
23
|
+
)
|
|
24
|
+
returns table(
|
|
25
|
+
membership_id uuid,
|
|
26
|
+
board_id uuid,
|
|
27
|
+
agent_id uuid,
|
|
28
|
+
handle text,
|
|
29
|
+
display_name text,
|
|
30
|
+
agent_type text,
|
|
31
|
+
membership_state text,
|
|
32
|
+
role text,
|
|
33
|
+
created_by_agent_id uuid,
|
|
34
|
+
requested_at timestamptz,
|
|
35
|
+
updated_at timestamptz
|
|
36
|
+
)
|
|
37
|
+
language plpgsql
|
|
38
|
+
stable
|
|
39
|
+
security definer
|
|
40
|
+
set search_path = agent_board, public
|
|
41
|
+
as $$
|
|
42
|
+
declare
|
|
43
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
44
|
+
begin
|
|
45
|
+
-- FLAT owner/admin gate, ALWAYS. Fail closed on a null actor (suspended/unmapped
|
|
46
|
+
-- principal, or service_role) — mirrors set_board_role_gate, NOT the write-RPC
|
|
47
|
+
-- `if v_actor is not null` bypass. Authz precedes disclosure.
|
|
48
|
+
if v_actor is null
|
|
49
|
+
or not exists (
|
|
50
|
+
select 1
|
|
51
|
+
from agent_board.board_memberships bm
|
|
52
|
+
where bm.board_id = p_board_id
|
|
53
|
+
and bm.agent_id = v_actor
|
|
54
|
+
and bm.role in ('owner', 'admin')
|
|
55
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
56
|
+
) then
|
|
57
|
+
raise exception 'only a board owner or admin may list pending memberships' using errcode = '42501';
|
|
58
|
+
end if;
|
|
59
|
+
|
|
60
|
+
-- Latest row per agent (in-place upserts append; the newest created_at is current
|
|
61
|
+
-- state), scoped to this board, filtered to the pending set. The dedup subquery is
|
|
62
|
+
-- ALSO scoped to p_board_id so no other board's rows can leak in.
|
|
63
|
+
return query
|
|
64
|
+
select
|
|
65
|
+
latest.id,
|
|
66
|
+
latest.board_id,
|
|
67
|
+
latest.agent_id,
|
|
68
|
+
a.handle,
|
|
69
|
+
a.display_name,
|
|
70
|
+
a.agent_type,
|
|
71
|
+
latest.membership_state,
|
|
72
|
+
latest.role,
|
|
73
|
+
latest.created_by_agent_id,
|
|
74
|
+
latest.created_at as requested_at,
|
|
75
|
+
latest.updated_at
|
|
76
|
+
from (
|
|
77
|
+
select distinct on (bm.agent_id) bm.*
|
|
78
|
+
from agent_board.board_memberships bm
|
|
79
|
+
where bm.board_id = p_board_id
|
|
80
|
+
order by bm.agent_id, bm.created_at desc
|
|
81
|
+
) latest
|
|
82
|
+
join agent_board.agents a on a.id = latest.agent_id
|
|
83
|
+
where latest.membership_state in ('requested', 'invited')
|
|
84
|
+
order by latest.created_at desc;
|
|
85
|
+
end;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
comment on function agent_board.list_board_pending_memberships(uuid) is
|
|
89
|
+
'Owner/admin-gated reader for a board''s pending (requested + invited) memberships, latest-row-per-agent. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (else 42501). Board-scoped: gate + dedup subquery both filter p_board_id. service_role hits 42501 (null current_agent_id()); operators read the table directly.';
|
|
90
|
+
|
|
91
|
+
revoke all on function agent_board.list_board_pending_memberships(uuid) from public, anon;
|
|
92
|
+
grant execute on function agent_board.list_board_pending_memberships(uuid) to authenticated, service_role;
|
|
93
|
+
|
|
94
|
+
-- 2. resolve_agent_handle (SECURITY INVOKER handle resolver) ------------------------
|
|
95
|
+
create or replace function agent_board.resolve_agent_handle(
|
|
96
|
+
p_handle text
|
|
97
|
+
)
|
|
98
|
+
returns table(
|
|
99
|
+
agent_id uuid,
|
|
100
|
+
handle text,
|
|
101
|
+
display_name text,
|
|
102
|
+
agent_type text
|
|
103
|
+
)
|
|
104
|
+
language sql
|
|
105
|
+
stable
|
|
106
|
+
security invoker
|
|
107
|
+
set search_path = agent_board, public
|
|
108
|
+
as $$
|
|
109
|
+
-- limit 1 is deterministic only via the UNIQUE handle constraint
|
|
110
|
+
-- (20260502000000:8); at most one active agent can own a given handle.
|
|
111
|
+
select a.id, a.handle, a.display_name, a.agent_type
|
|
112
|
+
from agent_board.agents a
|
|
113
|
+
where a.handle = p_handle
|
|
114
|
+
and a.status = 'active'
|
|
115
|
+
limit 1;
|
|
116
|
+
$$;
|
|
117
|
+
|
|
118
|
+
comment on function agent_board.resolve_agent_handle(text) is
|
|
119
|
+
'SECURITY INVOKER handle->identity resolver: returns the 0-or-1 active agent for a handle, under caller RLS (cannot exceed agent_board_authenticated_select_agents). status=active is a UI affordance, not a security boundary. limit 1 deterministic via the UNIQUE handle constraint.';
|
|
120
|
+
|
|
121
|
+
revoke all on function agent_board.resolve_agent_handle(text) from public, anon;
|
|
122
|
+
grant execute on function agent_board.resolve_agent_handle(text) to authenticated, service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.9.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 78,
|
|
50
|
+
"aggregateSha256": "4b6a5435c11341549636dfec4273c5bdc5773be23f8b8667ea816927e9412f87",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -705,6 +705,51 @@
|
|
|
705
705
|
"sourceRef": "message-board",
|
|
706
706
|
"bytes": 3716,
|
|
707
707
|
"sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
"order": 73,
|
|
711
|
+
"id": "20260724000100_agent_board_roomlink_invite_tokens",
|
|
712
|
+
"filename": "20260724000100_agent_board_roomlink_invite_tokens.sql",
|
|
713
|
+
"origin": "message-board-forward",
|
|
714
|
+
"sourceRef": "message-board",
|
|
715
|
+
"bytes": 22491,
|
|
716
|
+
"sha256": "98870e44a71eb648cfefd550f32eb30cc7a25e65ae1a7aa3852a3f9234eb98e2"
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
"order": 74,
|
|
720
|
+
"id": "20260724000200_agent_board_roomlink_enroll_binding",
|
|
721
|
+
"filename": "20260724000200_agent_board_roomlink_enroll_binding.sql",
|
|
722
|
+
"origin": "message-board-forward",
|
|
723
|
+
"sourceRef": "message-board",
|
|
724
|
+
"bytes": 12092,
|
|
725
|
+
"sha256": "60e289d99a212ff63cb99ea275b545f7e9c15ad8cc4e0ba42c98492bae342adc"
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
"order": 75,
|
|
729
|
+
"id": "20260725000100_agent_board_roomdeleg_role_gate",
|
|
730
|
+
"filename": "20260725000100_agent_board_roomdeleg_role_gate.sql",
|
|
731
|
+
"origin": "message-board-forward",
|
|
732
|
+
"sourceRef": "message-board",
|
|
733
|
+
"bytes": 9521,
|
|
734
|
+
"sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
"order": 76,
|
|
738
|
+
"id": "20260726000100_agent_board_localauth_self_enroll",
|
|
739
|
+
"filename": "20260726000100_agent_board_localauth_self_enroll.sql",
|
|
740
|
+
"origin": "message-board-forward",
|
|
741
|
+
"sourceRef": "message-board",
|
|
742
|
+
"bytes": 7323,
|
|
743
|
+
"sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
"order": 77,
|
|
747
|
+
"id": "20260727000100_agent_board_roomuiapi",
|
|
748
|
+
"filename": "20260727000100_agent_board_roomuiapi.sql",
|
|
749
|
+
"origin": "message-board-forward",
|
|
750
|
+
"sourceRef": "message-board",
|
|
751
|
+
"bytes": 5217,
|
|
752
|
+
"sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
|
|
708
753
|
}
|
|
709
754
|
],
|
|
710
755
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
|
|
|
37
37
|
expect(manifest.schemaVersion).toBe(8);
|
|
38
38
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
39
39
|
});
|
|
40
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN forward migrations (migrationCount = prior
|
|
40
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI forward migrations (migrationCount = prior 77 + 1 = 78)', () => {
|
|
41
41
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
42
|
+
expect(manifest.migrationCount).toBe(78);
|
|
43
43
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
44
44
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
45
45
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -78,6 +78,19 @@ describe('manifest integrity', () => {
|
|
|
78
78
|
// bootstrap; grant_board_role activated for authenticated). schema stays 8.
|
|
79
79
|
'20260723000100_agent_board_roomown_owner_seed.sql',
|
|
80
80
|
'20260723000200_agent_board_roomown_grant_role_live.sql',
|
|
81
|
+
// ROOMLINK: shareable board invite links + enroll-on-redeem binding. schema stays 8.
|
|
82
|
+
'20260724000100_agent_board_roomlink_invite_tokens.sql',
|
|
83
|
+
'20260724000200_agent_board_roomlink_enroll_binding.sql',
|
|
84
|
+
// ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
|
|
85
|
+
// fix on invite_board_member/resolve_board_request. schema stays 8.
|
|
86
|
+
'20260725000100_agent_board_roomdeleg_role_gate.sql',
|
|
87
|
+
// LOCALAUTH: Portal-free standalone human self-enroll (self_enroll_human RPC +
|
|
88
|
+
// one-agent-per-real-auth-user backstop). schema stays 8.
|
|
89
|
+
'20260726000100_agent_board_localauth_self_enroll.sql',
|
|
90
|
+
// ROOMUIAPI: two read RPCs for the room console/CLI (owner/admin-gated
|
|
91
|
+
// list_board_pending_memberships + SECURITY INVOKER resolve_agent_handle).
|
|
92
|
+
// schema stays 8.
|
|
93
|
+
'20260727000100_agent_board_roomuiapi.sql',
|
|
81
94
|
]) {
|
|
82
95
|
expect(names.has(f)).toBe(true);
|
|
83
96
|
}
|
|
@@ -15,11 +15,11 @@ begin;
|
|
|
15
15
|
|
|
16
16
|
select plan(28);
|
|
17
17
|
|
|
18
|
-
-- New
|
|
18
|
+
-- New 14-arg signature exists (ROOMLINK 20260724000200 added a trailing invite code). -
|
|
19
19
|
select has_function(
|
|
20
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
|
|
21
|
+
array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]','text'],
|
|
22
|
+
'enroll_federated_identity exists with the ROOMLINK-extended 14-arg PoP signature');
|
|
23
23
|
select hasnt_function(
|
|
24
24
|
'agent_board', 'enroll_federated_identity',
|
|
25
25
|
array['text','text','text','jsonb','text','text','text[]','text[]'],
|
|
@@ -92,12 +92,12 @@ select is(
|
|
|
92
92
|
and not policy_approved),
|
|
93
93
|
1, 'the mock issuer row is untouched (present, supported, inert)');
|
|
94
94
|
|
|
95
|
-
-- enroll_federated_identity is service_role-only (
|
|
95
|
+
-- enroll_federated_identity is service_role-only (ROOMLINK-extended 14-arg signature)
|
|
96
96
|
select function_privs_are(
|
|
97
97
|
'agent_board', 'enroll_federated_identity',
|
|
98
|
-
array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]'],
|
|
98
|
+
array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]','text'],
|
|
99
99
|
'authenticated', array[]::text[],
|
|
100
|
-
'
|
|
100
|
+
'ROOMLINK-extended enroll_federated_identity execute is revoked from authenticated');
|
|
101
101
|
|
|
102
102
|
select finish();
|
|
103
103
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
-- LOCALAUTH pgTAP: self_enroll_human() — Portal-free human self-enrollment.
|
|
2
|
+
--
|
|
3
|
+
-- Harness mirrors agent_board_authbound_boundary.test.sql: seed auth.users via
|
|
4
|
+
-- public.ensure_github_user, fake an authenticated caller with
|
|
5
|
+
-- set local role authenticated + set_config('request.jwt.claim.sub', <uid>),
|
|
6
|
+
-- so auth.uid() = sub. reset role -> superuser (auth.uid() null / service_role).
|
|
7
|
+
--
|
|
8
|
+
-- CRITICAL harness notes:
|
|
9
|
+
-- * L-7: `reset role` does NOT null auth.uid(); request.jwt.claim.sub is set
|
|
10
|
+
-- is_local=true and PERSISTS. Cases 12/13 explicitly clear the sub GUC before
|
|
11
|
+
-- asserting 42501, else the RPC finds the prior uid's agent (vacuous pass).
|
|
12
|
+
-- * L-8: ensure_github_user sets email_confirmed_at = now() unconditionally, so
|
|
13
|
+
-- the unverified case nulls it first.
|
|
14
|
+
|
|
15
|
+
create extension if not exists pgtap with schema extensions;
|
|
16
|
+
set search_path = extensions, public, agent_board;
|
|
17
|
+
|
|
18
|
+
begin;
|
|
19
|
+
|
|
20
|
+
select plan(17);
|
|
21
|
+
|
|
22
|
+
-- ---------------------------------------------------------------------------
|
|
23
|
+
-- Phase 0 (superuser): function metadata + seed
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
-- 1
|
|
27
|
+
select has_function('agent_board', 'self_enroll_human', array['text'], 'self_enroll_human(text) exists');
|
|
28
|
+
-- 2
|
|
29
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'authenticated', array['EXECUTE'],
|
|
30
|
+
'authenticated may EXECUTE self_enroll_human');
|
|
31
|
+
-- 3
|
|
32
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'anon', array[]::text[],
|
|
33
|
+
'anon may NOT EXECUTE self_enroll_human');
|
|
34
|
+
|
|
35
|
+
create temp table _lfix (
|
|
36
|
+
user_a uuid,
|
|
37
|
+
user_b uuid,
|
|
38
|
+
user_c uuid,
|
|
39
|
+
agent_a uuid,
|
|
40
|
+
agent_a2 uuid
|
|
41
|
+
) on commit drop;
|
|
42
|
+
grant all on table _lfix to authenticated, service_role;
|
|
43
|
+
|
|
44
|
+
do $$
|
|
45
|
+
declare
|
|
46
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
47
|
+
v_a uuid;
|
|
48
|
+
v_b uuid;
|
|
49
|
+
v_c uuid;
|
|
50
|
+
begin
|
|
51
|
+
-- verified user (email_confirmed_at defaults to now())
|
|
52
|
+
v_a := public.ensure_github_user(
|
|
53
|
+
'localauth-a-' || v_suffix,
|
|
54
|
+
'localauth-a-' || v_suffix || '@example.test',
|
|
55
|
+
'localauth-a-' || v_suffix,
|
|
56
|
+
'LOCALAUTH A ' || v_suffix);
|
|
57
|
+
|
|
58
|
+
-- unverified user: L-8 — null out the unconditionally-set email_confirmed_at
|
|
59
|
+
v_b := public.ensure_github_user(
|
|
60
|
+
'localauth-b-' || v_suffix,
|
|
61
|
+
'localauth-b-' || v_suffix || '@example.test',
|
|
62
|
+
'localauth-b-' || v_suffix,
|
|
63
|
+
'LOCALAUTH B ' || v_suffix);
|
|
64
|
+
update auth.users set email_confirmed_at = null where id = v_b;
|
|
65
|
+
|
|
66
|
+
-- user with a pre-existing SUSPENDED human agent (no self-reactivation case)
|
|
67
|
+
v_c := public.ensure_github_user(
|
|
68
|
+
'localauth-c-' || v_suffix,
|
|
69
|
+
'localauth-c-' || v_suffix || '@example.test',
|
|
70
|
+
'localauth-c-' || v_suffix,
|
|
71
|
+
'LOCALAUTH C ' || v_suffix);
|
|
72
|
+
insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
73
|
+
values ('human', 'human', 'localauth-suspended-' || v_suffix, 'Suspended Human', 'suspended', v_c, v_c);
|
|
74
|
+
|
|
75
|
+
insert into _lfix(user_a, user_b, user_c) values (v_a, v_b, v_c);
|
|
76
|
+
end
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
-- ---------------------------------------------------------------------------
|
|
80
|
+
-- Phase 1 (authenticated as user_a): enroll + auth-context assertions
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
set local role authenticated;
|
|
83
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
84
|
+
select set_config('request.jwt.claim.sub', (select user_a::text from _lfix), true);
|
|
85
|
+
|
|
86
|
+
do $$
|
|
87
|
+
declare
|
|
88
|
+
v_first uuid;
|
|
89
|
+
v_second uuid;
|
|
90
|
+
begin
|
|
91
|
+
select s.agent_id into v_first from agent_board.self_enroll_human('Alice') s;
|
|
92
|
+
-- idempotent second call for the same uid returns the SAME agent
|
|
93
|
+
select s.agent_id into v_second from agent_board.self_enroll_human('Alice Again') s;
|
|
94
|
+
update _lfix set agent_a = v_first, agent_a2 = v_second;
|
|
95
|
+
end
|
|
96
|
+
$$;
|
|
97
|
+
|
|
98
|
+
-- 4 (part 1 of case 8): current_agent_id() resolves to the freshly-enrolled agent
|
|
99
|
+
select is(agent_board.current_agent_id(), (select agent_a from _lfix),
|
|
100
|
+
'current_agent_id() (no agent_credential_id claim) resolves to the self-enrolled agent');
|
|
101
|
+
-- 5 (case 9): idempotent — second enroll returned the same agent_id
|
|
102
|
+
select is((select agent_a2 from _lfix), (select agent_a from _lfix),
|
|
103
|
+
'second self_enroll_human for the same uid is idempotent (same agent_id)');
|
|
104
|
+
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
-- Phase 2 (authenticated as user_b): enroll unverified user
|
|
107
|
+
-- ---------------------------------------------------------------------------
|
|
108
|
+
select set_config('request.jwt.claim.sub', (select user_b::text from _lfix), true);
|
|
109
|
+
select agent_board.self_enroll_human(null);
|
|
110
|
+
|
|
111
|
+
-- ---------------------------------------------------------------------------
|
|
112
|
+
-- Phase 3 (superuser): row-shape assertions without RLS in the way
|
|
113
|
+
-- ---------------------------------------------------------------------------
|
|
114
|
+
reset role;
|
|
115
|
+
|
|
116
|
+
-- 6/7/8 (case 4): the enrolled row is a human bound to the uid
|
|
117
|
+
select is((select kind::text from agent_board.agents where id = (select agent_a from _lfix)),
|
|
118
|
+
'human', 'self-enrolled agent kind = human');
|
|
119
|
+
select is((select agent_type from agent_board.agents where id = (select agent_a from _lfix)),
|
|
120
|
+
'human', 'self-enrolled agent agent_type = human');
|
|
121
|
+
select is((select auth_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
122
|
+
(select user_a from _lfix), 'self-enrolled agent auth_user_id = auth.uid()');
|
|
123
|
+
-- 9 (case 5): self-owned
|
|
124
|
+
select is((select owner_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
125
|
+
(select user_a from _lfix), 'self-enrolled agent owner_user_id = auth.uid() (self-owned)');
|
|
126
|
+
-- 10 (case 6): active
|
|
127
|
+
select is((select status from agent_board.agents where id = (select agent_a from _lfix)),
|
|
128
|
+
'active', 'self-enrolled agent status = active');
|
|
129
|
+
-- 11 (case 7): verified user -> email_verified BOOLEAN true (never a plaintext email)
|
|
130
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where id = (select agent_a from _lfix)),
|
|
131
|
+
'true', 'verified user -> metadata.localauth.email_verified = true');
|
|
132
|
+
-- 12 (case 10): exactly one active row for the uid
|
|
133
|
+
select is((select count(*) from agent_board.agents where auth_user_id = (select user_a from _lfix) and status = 'active'),
|
|
134
|
+
1::bigint, 'exactly one active agent per uid');
|
|
135
|
+
-- 13 (case 11): unverified user -> email_verified BOOLEAN false
|
|
136
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where auth_user_id = (select user_b from _lfix)),
|
|
137
|
+
'false', 'unverified user -> metadata.localauth.email_verified = false');
|
|
138
|
+
-- 14 (case 15 / L-4b): the DB backstop unique index exists
|
|
139
|
+
select ok(exists(
|
|
140
|
+
select 1 from pg_indexes
|
|
141
|
+
where schemaname = 'agent_board'
|
|
142
|
+
and indexname = 'idx_agent_board_agents_auth_user_id_unique'),
|
|
143
|
+
'L-4b one-agent-per-real-auth-user backstop index exists');
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------------
|
|
146
|
+
-- Phase 4: fail-closed cases
|
|
147
|
+
-- ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
-- 15 (case 12): authenticated role but NO subject (L-7: clear the sub GUC) -> 42501
|
|
150
|
+
set local role authenticated;
|
|
151
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
152
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
153
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
154
|
+
null, 'unauthenticated (no auth.uid()) is rejected with 42501');
|
|
155
|
+
|
|
156
|
+
-- 16 (case 13): service-role / superuser (reset role) + cleared sub -> 42501
|
|
157
|
+
reset role;
|
|
158
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
159
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
160
|
+
null, 'service-role / superuser (auth.uid() null) is rejected with 42501');
|
|
161
|
+
|
|
162
|
+
-- 17 (case 14): a pre-existing non-active identity cannot self-reactivate -> 42501
|
|
163
|
+
set local role authenticated;
|
|
164
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
165
|
+
select set_config('request.jwt.claim.sub', (select user_c::text from _lfix), true);
|
|
166
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
167
|
+
null, 'a suspended (non-active) identity cannot self-reactivate (42501)');
|
|
168
|
+
|
|
169
|
+
select finish();
|
|
170
|
+
|
|
171
|
+
rollback;
|
|
@@ -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;
|