@consiliency/agent-board-schema 1.10.0 → 1.12.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/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
- package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
- package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +12 -2
- package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
- package/tests/agent_board_gatenull_membstate.test.sql +352 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
-- GATENULL — distinguish the service_role bypass from an unresolvable authenticated principal.
|
|
2
|
+
-- Tracks message-board#58.
|
|
3
|
+
--
|
|
4
|
+
-- THE DEFECT. Board-mutating RPCs use `if v_actor is not null then <authz> end if` as the
|
|
5
|
+
-- service_role bypass, where `v_actor := agent_board.current_agent_id()`. current_agent_id()
|
|
6
|
+
-- resolves only an ACTIVE agent, so it returns NULL for TWO very different callers:
|
|
7
|
+
-- (1) a genuine service_role / operator call — the bypass is intended; and
|
|
8
|
+
-- (2) an AUTHENTICATED principal with no active agent — a suspended/revoked agent holding a
|
|
9
|
+
-- still-valid JWT, a signed-in human with no agents row at all, or an active agent whose
|
|
10
|
+
-- JWT `agent_credential_id` claim names a revoked/expired credential.
|
|
11
|
+
-- Class (2) therefore takes the service_role bypass and skips authorization entirely.
|
|
12
|
+
--
|
|
13
|
+
-- SEVERITY IS NOT SESSION-BOUNDED (corrected during panel review). LOCALAUTH (20260726000100)
|
|
14
|
+
-- exists so browser humans sign in with THIS project's own Supabase Auth; such a user, BEFORE
|
|
15
|
+
-- self_enroll_human, is role=authenticated with no agents row — i.e. class (2) — while holding a
|
|
16
|
+
-- GoTrue session with a REFRESH token, renewable indefinitely. A suspended human is refused
|
|
17
|
+
-- reactivation (20260726000100:63-81) and so remains in class (2) permanently. Confirmed against
|
|
18
|
+
-- the live board in a rolled-back transaction: a signed-in principal with NO agent row joined a
|
|
19
|
+
-- private `invite_only` board with no invite, via the join_board visibility guard below.
|
|
20
|
+
--
|
|
21
|
+
-- THE FIX (deliberately MINIMAL — panel rejected a broader rewrite). Each function gains ONE
|
|
22
|
+
-- guard, placed FIRST, and every existing `if v_actor is not null then ...` block is left
|
|
23
|
+
-- BYTE-IDENTICAL:
|
|
24
|
+
--
|
|
25
|
+
-- if v_actor is null and not agent_board._is_service_role() then
|
|
26
|
+
-- raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
27
|
+
-- end if;
|
|
28
|
+
--
|
|
29
|
+
-- After it, a non-service caller is guaranteed to have a non-null actor, so the untouched
|
|
30
|
+
-- `if v_actor is not null then <authz>` block now runs for EVERY authenticated caller. Behavior
|
|
31
|
+
-- is unchanged for: service_role with a null actor (create-on-behalf-of preserved), service_role
|
|
32
|
+
-- WITH a non-null actor (the checks keep running exactly as today — a broader
|
|
33
|
+
-- `if not _is_service_role()` wrapper would have WRONGLY skipped them, and would have turned
|
|
34
|
+
-- tests/agent_board_board_role.test.sql red), and any authenticated caller with an active agent.
|
|
35
|
+
-- Exactly one class changes: class (2) now fails closed.
|
|
36
|
+
--
|
|
37
|
+
-- GUARD PLACEMENT is deliberate: FIRST, before any table lookup. Raising at the original guard
|
|
38
|
+
-- site would leave `P0002 'board not found'` reachable ahead of the 42501, giving an
|
|
39
|
+
-- unauthorized principal a board/agent existence oracle. Only class (2) sees the reordering, and
|
|
40
|
+
-- that class currently has full write access — so nothing legitimate regresses.
|
|
41
|
+
--
|
|
42
|
+
-- MESSAGE reuses the canonical 'current agent principal not resolved' / 42501 of
|
|
43
|
+
-- _resolved_actor_agent_id (20260512000000:35-38) rather than minting a fifth phrasing.
|
|
44
|
+
--
|
|
45
|
+
-- SCOPE (audited at HEAD by last-definer; three panel seats independently confirmed no fifth
|
|
46
|
+
-- function carries this pattern): join_board (TWO sites), request_join_board, invite_board_member,
|
|
47
|
+
-- resolve_board_request. The issue named only the latter two. Bodies are reproduced from their
|
|
48
|
+
-- HEAD definers — invite/resolve from ROOMDELEG 20260725000100 (NOT the superseded 20260717000100
|
|
49
|
+
-- copies, which would silently revert the R-DELEG-1 creator-bypass fix), join/request from
|
|
50
|
+
-- 20260515000000. Signatures and RETURNS TABLE lists are reproduced VERBATIM: a changed arg list
|
|
51
|
+
-- would create an OVERLOAD instead of replacing, a changed return list hard-errors.
|
|
52
|
+
--
|
|
53
|
+
-- RELATED, NOT FIXED HERE: update_board_membership has NO authorization at all (a strictly
|
|
54
|
+
-- stronger primitive for the same attacker) — hardened in the sibling migration 20260730000200
|
|
55
|
+
-- (MEMBSTATE, #75). Caller-supplied actor override in resolve_recipients /
|
|
56
|
+
-- expand_recipients_dry_run is a different family — #76 (ACTORSPOOF).
|
|
57
|
+
--
|
|
58
|
+
-- Additive, forward-only. Body-only change with unchanged signatures, so NO PostgREST reload is
|
|
59
|
+
-- required. Writes no agent_board.schema_version (stays 8).
|
|
60
|
+
|
|
61
|
+
-- 1. join_board — TWO guarded sites (actor-mismatch, and the visibility/joinability gate). ------
|
|
62
|
+
create or replace function agent_board.join_board(
|
|
63
|
+
p_board_id uuid,
|
|
64
|
+
p_agent_id uuid,
|
|
65
|
+
p_membership_state text default 'joined',
|
|
66
|
+
p_created_by_agent_id uuid default null,
|
|
67
|
+
p_metadata jsonb default '{}'::jsonb
|
|
68
|
+
)
|
|
69
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
|
|
70
|
+
language plpgsql
|
|
71
|
+
security definer
|
|
72
|
+
set search_path = agent_board, public
|
|
73
|
+
as $$
|
|
74
|
+
declare
|
|
75
|
+
v_board agent_board.boards%rowtype;
|
|
76
|
+
v_existing_state text;
|
|
77
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
78
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
79
|
+
begin
|
|
80
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
81
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
82
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
83
|
+
end if;
|
|
84
|
+
|
|
85
|
+
if p_membership_state not in ('joined', 'subscribed') then
|
|
86
|
+
raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
|
|
87
|
+
end if;
|
|
88
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
89
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
90
|
+
end if;
|
|
91
|
+
if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
92
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
93
|
+
end if;
|
|
94
|
+
|
|
95
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
96
|
+
if v_board.id is null then
|
|
97
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
98
|
+
end if;
|
|
99
|
+
|
|
100
|
+
select bm.membership_state
|
|
101
|
+
into v_existing_state
|
|
102
|
+
from agent_board.board_memberships bm
|
|
103
|
+
where bm.board_id = p_board_id
|
|
104
|
+
and bm.agent_id = p_agent_id
|
|
105
|
+
order by bm.created_at desc
|
|
106
|
+
limit 1;
|
|
107
|
+
|
|
108
|
+
if v_actor is not null and v_actor <> p_agent_id and v_actor <> v_board.created_by_agent_id then
|
|
109
|
+
raise exception 'board membership actor mismatch' using errcode = '42501';
|
|
110
|
+
end if;
|
|
111
|
+
|
|
112
|
+
-- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
|
|
113
|
+
-- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
|
|
114
|
+
-- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
|
|
115
|
+
-- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
|
|
116
|
+
-- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
|
|
117
|
+
-- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
|
|
118
|
+
-- A 'removed' agent is also excluded from self-joining even an OPEN board: re-entry after a kick
|
|
119
|
+
-- must go through invite/approval (the R-LINK-7 principle, 20260724000100:315-335), and
|
|
120
|
+
-- _upsert_board_membership_state does not reset `role`, so a re-joining removed owner/admin would
|
|
121
|
+
-- otherwise return at its former rank.
|
|
122
|
+
if v_actor is not null
|
|
123
|
+
and v_actor = p_agent_id
|
|
124
|
+
and v_board.created_by_agent_id <> v_actor
|
|
125
|
+
and not (
|
|
126
|
+
coalesce(v_existing_state, '') <> 'removed'
|
|
127
|
+
and (
|
|
128
|
+
v_board.visibility = 'open'
|
|
129
|
+
or (v_board.visibility = 'invite_only' and coalesce(v_existing_state, '') = 'invited')
|
|
130
|
+
or coalesce(v_existing_state, '') in ('joined', 'subscribed')
|
|
131
|
+
)
|
|
132
|
+
) then
|
|
133
|
+
raise exception 'board is not joinable by this agent' using errcode = 'P0002';
|
|
134
|
+
end if;
|
|
135
|
+
|
|
136
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
137
|
+
p_board_id,
|
|
138
|
+
p_agent_id,
|
|
139
|
+
p_membership_state,
|
|
140
|
+
coalesce(p_created_by_agent_id, p_agent_id),
|
|
141
|
+
p_metadata
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return query
|
|
145
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
146
|
+
end;
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
-- 2. request_join_board ------------------------------------------------------------------------
|
|
150
|
+
create or replace function agent_board.request_join_board(
|
|
151
|
+
p_board_id uuid,
|
|
152
|
+
p_agent_id uuid,
|
|
153
|
+
p_metadata jsonb default '{}'::jsonb
|
|
154
|
+
)
|
|
155
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
|
|
156
|
+
language plpgsql
|
|
157
|
+
security definer
|
|
158
|
+
set search_path = agent_board, public
|
|
159
|
+
as $$
|
|
160
|
+
declare
|
|
161
|
+
v_board agent_board.boards%rowtype;
|
|
162
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
163
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
164
|
+
begin
|
|
165
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
166
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
167
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
168
|
+
end if;
|
|
169
|
+
|
|
170
|
+
select * into v_board
|
|
171
|
+
from agent_board.boards
|
|
172
|
+
where id = p_board_id
|
|
173
|
+
and status = 'active'
|
|
174
|
+
and visibility in ('open', 'invite_only');
|
|
175
|
+
|
|
176
|
+
if v_board.id is null then
|
|
177
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
178
|
+
end if;
|
|
179
|
+
if v_actor is not null and v_actor <> p_agent_id then
|
|
180
|
+
raise exception 'board request actor mismatch' using errcode = '42501';
|
|
181
|
+
end if;
|
|
182
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
183
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
184
|
+
end if;
|
|
185
|
+
|
|
186
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
187
|
+
p_board_id,
|
|
188
|
+
p_agent_id,
|
|
189
|
+
'requested',
|
|
190
|
+
p_agent_id,
|
|
191
|
+
p_metadata
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return query
|
|
195
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
196
|
+
end;
|
|
197
|
+
$$;
|
|
198
|
+
|
|
199
|
+
-- 3. invite_board_member (body from ROOMDELEG 20260725000100 — R-DELEG-1 preserved) -------------
|
|
200
|
+
create or replace function agent_board.invite_board_member(
|
|
201
|
+
p_board_id uuid,
|
|
202
|
+
p_agent_id uuid,
|
|
203
|
+
p_created_by_agent_id uuid,
|
|
204
|
+
p_metadata jsonb default '{}'::jsonb
|
|
205
|
+
)
|
|
206
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
|
|
207
|
+
language plpgsql
|
|
208
|
+
security definer
|
|
209
|
+
set search_path = agent_board, public
|
|
210
|
+
as $$
|
|
211
|
+
declare
|
|
212
|
+
v_board agent_board.boards%rowtype;
|
|
213
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
214
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
215
|
+
begin
|
|
216
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
217
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
218
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
219
|
+
end if;
|
|
220
|
+
|
|
221
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
222
|
+
if v_board.id is null then
|
|
223
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
224
|
+
end if;
|
|
225
|
+
if v_actor is not null then
|
|
226
|
+
if v_board.role_gate_enforced then
|
|
227
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
228
|
+
-- must hold an active owner/admin membership.
|
|
229
|
+
if not exists (
|
|
230
|
+
select 1
|
|
231
|
+
from agent_board.board_memberships bm
|
|
232
|
+
where bm.board_id = p_board_id
|
|
233
|
+
and bm.agent_id = v_actor
|
|
234
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
235
|
+
and bm.role in ('owner', 'admin')
|
|
236
|
+
) then
|
|
237
|
+
raise exception 'board invite requires owner or admin role' using errcode = '42501';
|
|
238
|
+
end if;
|
|
239
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
240
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
241
|
+
if not exists (
|
|
242
|
+
select 1
|
|
243
|
+
from agent_board.board_memberships bm
|
|
244
|
+
where bm.board_id = p_board_id
|
|
245
|
+
and bm.agent_id = v_actor
|
|
246
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
247
|
+
) then
|
|
248
|
+
raise exception 'board invite requires active membership' using errcode = '42501';
|
|
249
|
+
end if;
|
|
250
|
+
end if;
|
|
251
|
+
end if;
|
|
252
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
253
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
254
|
+
end if;
|
|
255
|
+
if not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
256
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
257
|
+
end if;
|
|
258
|
+
|
|
259
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
260
|
+
p_board_id,
|
|
261
|
+
p_agent_id,
|
|
262
|
+
'invited',
|
|
263
|
+
p_created_by_agent_id,
|
|
264
|
+
p_metadata
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return query
|
|
268
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
269
|
+
end;
|
|
270
|
+
$$;
|
|
271
|
+
|
|
272
|
+
-- 4. resolve_board_request (body from ROOMDELEG 20260725000100 — R-DELEG-1 preserved) -----------
|
|
273
|
+
create or replace function agent_board.resolve_board_request(
|
|
274
|
+
p_board_id uuid,
|
|
275
|
+
p_agent_id uuid,
|
|
276
|
+
p_membership_state text,
|
|
277
|
+
p_created_by_agent_id uuid,
|
|
278
|
+
p_metadata jsonb default '{}'::jsonb
|
|
279
|
+
)
|
|
280
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
|
|
281
|
+
language plpgsql
|
|
282
|
+
security definer
|
|
283
|
+
set search_path = agent_board, public
|
|
284
|
+
as $$
|
|
285
|
+
declare
|
|
286
|
+
v_board agent_board.boards%rowtype;
|
|
287
|
+
v_existing_state text;
|
|
288
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
289
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
290
|
+
begin
|
|
291
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
292
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
293
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
294
|
+
end if;
|
|
295
|
+
|
|
296
|
+
if p_membership_state not in ('joined', 'subscribed', 'removed') then
|
|
297
|
+
raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
|
|
298
|
+
end if;
|
|
299
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
300
|
+
if v_board.id is null then
|
|
301
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
302
|
+
end if;
|
|
303
|
+
if v_actor is not null then
|
|
304
|
+
if v_board.role_gate_enforced then
|
|
305
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
306
|
+
-- must hold an active owner/admin membership.
|
|
307
|
+
if not exists (
|
|
308
|
+
select 1
|
|
309
|
+
from agent_board.board_memberships bm
|
|
310
|
+
where bm.board_id = p_board_id
|
|
311
|
+
and bm.agent_id = v_actor
|
|
312
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
313
|
+
and bm.role in ('owner', 'admin')
|
|
314
|
+
) then
|
|
315
|
+
raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
|
|
316
|
+
end if;
|
|
317
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
318
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
319
|
+
if not exists (
|
|
320
|
+
select 1
|
|
321
|
+
from agent_board.board_memberships bm
|
|
322
|
+
where bm.board_id = p_board_id
|
|
323
|
+
and bm.agent_id = v_actor
|
|
324
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
325
|
+
) then
|
|
326
|
+
raise exception 'board request resolution requires active membership' using errcode = '42501';
|
|
327
|
+
end if;
|
|
328
|
+
end if;
|
|
329
|
+
end if;
|
|
330
|
+
|
|
331
|
+
select bm.membership_state
|
|
332
|
+
into v_existing_state
|
|
333
|
+
from agent_board.board_memberships bm
|
|
334
|
+
where bm.board_id = p_board_id
|
|
335
|
+
and bm.agent_id = p_agent_id
|
|
336
|
+
order by bm.created_at desc
|
|
337
|
+
limit 1;
|
|
338
|
+
|
|
339
|
+
-- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
|
|
340
|
+
-- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
|
|
341
|
+
-- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
|
|
342
|
+
-- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
|
|
343
|
+
if coalesce(v_existing_state, '') not in ('requested', 'invited') then
|
|
344
|
+
raise exception 'pending board membership not found' using errcode = 'P0002';
|
|
345
|
+
end if;
|
|
346
|
+
|
|
347
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
348
|
+
p_board_id,
|
|
349
|
+
p_agent_id,
|
|
350
|
+
p_membership_state,
|
|
351
|
+
p_created_by_agent_id,
|
|
352
|
+
p_metadata
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return query
|
|
356
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
357
|
+
end;
|
|
358
|
+
$$;
|
|
359
|
+
|
|
360
|
+
-- Re-assert grants (create or replace preserves them, but be explicit so a fresh replay is
|
|
361
|
+
-- order-independent) — house convention, 20260725000100:229-232.
|
|
362
|
+
revoke all on function agent_board.join_board(uuid, uuid, text, uuid, jsonb) from public, anon;
|
|
363
|
+
revoke all on function agent_board.request_join_board(uuid, uuid, jsonb) from public, anon;
|
|
364
|
+
revoke all on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) from public, anon;
|
|
365
|
+
revoke all on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) from public, anon;
|
|
366
|
+
grant execute on function agent_board.join_board(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
|
|
367
|
+
grant execute on function agent_board.request_join_board(uuid, uuid, jsonb) to authenticated, service_role;
|
|
368
|
+
grant execute on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) to authenticated, service_role;
|
|
369
|
+
grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
-- MEMBSTATE — give update_board_membership an authorization model. Tracks message-board#75.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT. agent_board.update_board_membership (HEAD: 20260515000000_agent_board_boardopt.sql
|
|
4
|
+
-- :203-238) is SECURITY DEFINER, granted to `authenticated` (:486), and performs NO authorization
|
|
5
|
+
-- of any kind — its only precondition is that a (board_id, agent_id) membership row EXISTS. It
|
|
6
|
+
-- then hands that row to _upsert_board_membership_state, which accepts any of
|
|
7
|
+
-- requested|invited|joined|subscribed|left|removed.
|
|
8
|
+
--
|
|
9
|
+
-- This is NOT the GATENULL (#58) null-actor pattern and is strictly worse: it needs no bypass at
|
|
10
|
+
-- all. An ordinary ACTIVE authenticated principal (current_agent_id() resolves, _is_service_role()
|
|
11
|
+
-- false) can rewrite ANY membership on ANY board. All four plan-panel seats independently flagged
|
|
12
|
+
-- it; both chains were confirmed against the live board in rolled-back transactions:
|
|
13
|
+
--
|
|
14
|
+
-- 1. EVICTION / TAKEOVER — update_board_membership(board, <owner>, 'removed') by an agent with
|
|
15
|
+
-- NO membership on that board succeeded; the owner's state became 'removed', stripping the
|
|
16
|
+
-- `joined` standing that the owner-loop RLS and every owner-gated RPC key on.
|
|
17
|
+
-- 2. SELF-APPROVAL INTO A PRIVATE BOARD — request_join_board(invite_only board, self) (a
|
|
18
|
+
-- legitimate call producing 'requested') followed by update_board_membership(board, self,
|
|
19
|
+
-- 'joined') succeeded, and the board feed then read successfully. This bypasses the entire
|
|
20
|
+
-- invite/approval path (invite_board_member / resolve_board_request / invite links) that the
|
|
21
|
+
-- authority model exists to enforce, and defeats the ROOMDELEG role gate for the join path.
|
|
22
|
+
--
|
|
23
|
+
-- It also re-opens, on a direct path, the kicked-agent re-entry hazard that _redeem_invite_for_agent
|
|
24
|
+
-- deliberately defends on the invite path (R-LINK-7, 20260724000100:315-335).
|
|
25
|
+
--
|
|
26
|
+
-- THE MODEL. A purpose-built gate (the panel explicitly rejected reusing GATENULL's transform here
|
|
27
|
+
-- — that only distinguishes the null actor and would leave every ACTIVE-caller chain above open):
|
|
28
|
+
--
|
|
29
|
+
-- * service_role / operator -> unchanged full power (create/modify-on-behalf-of).
|
|
30
|
+
-- * authenticated, unresolved actor -> 42501, same posture and message as GATENULL (#58).
|
|
31
|
+
-- * authenticated acting on ITS OWN row-> only SELF-SERVICE transitions:
|
|
32
|
+
-- joined <-> subscribed, and joined|subscribed|invited|requested -> left. NOT from
|
|
33
|
+
-- 'removed': erasing a 'removed' marker would launder a kick into invite-link re-entry
|
|
34
|
+
-- (R-LINK-7, 20260724000100:323-335), since memberships are updated IN PLACE.
|
|
35
|
+
-- Notably NOT requested|invited|left|removed -> joined|subscribed: promotion into a board is
|
|
36
|
+
-- the approval path's job (join_board / resolve_board_request / redeem_invite_link), which
|
|
37
|
+
-- enforces visibility, invite state, gates and ceilings. This is what closes chain 2 and the
|
|
38
|
+
-- kicked-agent re-entry.
|
|
39
|
+
-- * authenticated acting on ANOTHER agent's row -> requires an ACTIVE owner/admin membership on
|
|
40
|
+
-- that board AND strict rank superiority over the target (owner=4 > admin=3 > member=2 >
|
|
41
|
+
-- observer=1). There is deliberately NO creator exemption here (unlike invite_board_member /
|
|
42
|
+
-- resolve_board_request): this RPC is the schema's ONLY eviction path — no sibling can move a
|
|
43
|
+
-- JOINED member to removed — so a demoted or removed original creator must not retain eviction
|
|
44
|
+
-- power, and an admin must not be able to evict the owner. This closes chain 1.
|
|
45
|
+
--
|
|
46
|
+
-- ORDERING / DISCLOSURE. The unresolved-actor 42501 is raised FIRST, and for a caller acting on
|
|
47
|
+
-- ANOTHER agent's row the board + owner/admin authority check runs BEFORE the membership lookup —
|
|
48
|
+
-- so an unauthorized caller cannot distinguish P0002 'board membership not found' from 42501 and
|
|
49
|
+
-- cannot probe whether an arbitrary (board_id, agent_id) pair has a membership row. The self
|
|
50
|
+
-- branch takes NO board lookup at all, so leave_board keeps working on an ARCHIVED board
|
|
51
|
+
-- (20260512000002:154-158 delegates here); P0002 'board membership not found' is otherwise
|
|
52
|
+
-- preserved for every caller that reaches it.
|
|
53
|
+
--
|
|
54
|
+
-- COMPATIBILITY. No signature or RETURNS TABLE change (a changed arg list would create an OVERLOAD
|
|
55
|
+
-- rather than replace; a changed return list hard-errors), so no client/mcp/cli surface change and
|
|
56
|
+
-- no PostgREST reload. `leave_board` (20260512000002) remains the ergonomic self-`left` path and is
|
|
57
|
+
-- untouched; the self-service transitions above deliberately keep update_board_membership able to
|
|
58
|
+
-- express it too, so no existing legitimate caller loses a capability.
|
|
59
|
+
--
|
|
60
|
+
-- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
|
|
61
|
+
|
|
62
|
+
create or replace function agent_board.update_board_membership(
|
|
63
|
+
p_board_id uuid,
|
|
64
|
+
p_agent_id uuid,
|
|
65
|
+
p_membership_state text,
|
|
66
|
+
p_metadata jsonb default '{}'::jsonb
|
|
67
|
+
)
|
|
68
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
|
|
69
|
+
language plpgsql
|
|
70
|
+
security definer
|
|
71
|
+
set search_path = agent_board, public
|
|
72
|
+
as $$
|
|
73
|
+
declare
|
|
74
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
75
|
+
v_board agent_board.boards%rowtype;
|
|
76
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
77
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
78
|
+
v_existing_state text;
|
|
79
|
+
v_target_role text;
|
|
80
|
+
v_actor_role text;
|
|
81
|
+
begin
|
|
82
|
+
-- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
|
|
83
|
+
-- cannot be used as a membership-existence oracle.
|
|
84
|
+
if v_actor is null and not v_is_service then
|
|
85
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
86
|
+
end if;
|
|
87
|
+
|
|
88
|
+
-- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
|
|
89
|
+
-- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
|
|
90
|
+
-- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
|
|
91
|
+
-- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
|
|
92
|
+
-- own membership, and the service path is the operator.)
|
|
93
|
+
if not v_is_service and v_actor <> p_agent_id then
|
|
94
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
95
|
+
if v_board.id is null then
|
|
96
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
97
|
+
end if;
|
|
98
|
+
select bm.role
|
|
99
|
+
into v_actor_role
|
|
100
|
+
from agent_board.board_memberships bm
|
|
101
|
+
where bm.board_id = p_board_id
|
|
102
|
+
and bm.agent_id = v_actor
|
|
103
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
104
|
+
limit 1;
|
|
105
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
106
|
+
raise exception 'board membership change requires owner or admin role'
|
|
107
|
+
using errcode = '42501';
|
|
108
|
+
end if;
|
|
109
|
+
end if;
|
|
110
|
+
|
|
111
|
+
-- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
|
|
112
|
+
-- rank check below.
|
|
113
|
+
select bm.membership_state, bm.role
|
|
114
|
+
into v_existing_state, v_target_role
|
|
115
|
+
from agent_board.board_memberships bm
|
|
116
|
+
where bm.board_id = p_board_id
|
|
117
|
+
and bm.agent_id = p_agent_id
|
|
118
|
+
order by bm.created_at desc
|
|
119
|
+
limit 1;
|
|
120
|
+
if v_existing_state is null then
|
|
121
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
122
|
+
end if;
|
|
123
|
+
|
|
124
|
+
-- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
|
|
125
|
+
-- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
|
|
126
|
+
if not v_is_service then
|
|
127
|
+
if v_actor = p_agent_id then
|
|
128
|
+
-- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
|
|
129
|
+
-- for the authority checks below, and requiring an ACTIVE board here would strand an agent
|
|
130
|
+
-- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
|
|
131
|
+
-- delegates straight into this RPC, 20260512000002:154-158).
|
|
132
|
+
--
|
|
133
|
+
-- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
|
|
134
|
+
-- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
|
|
135
|
+
-- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
|
|
136
|
+
-- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
|
|
137
|
+
-- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
|
|
138
|
+
-- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
|
|
139
|
+
if not (
|
|
140
|
+
(p_membership_state = 'left'
|
|
141
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
142
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
143
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
144
|
+
) then
|
|
145
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
146
|
+
using errcode = '42501';
|
|
147
|
+
end if;
|
|
148
|
+
else
|
|
149
|
+
-- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
|
|
150
|
+
-- requirement already ran in step (2) (authority before disclosure). This RPC is the
|
|
151
|
+
-- schema's only eviction path — no sibling can move a JOINED member to removed
|
|
152
|
+
-- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
|
|
153
|
+
-- gated more strictly than its siblings, with NO creator exemption: a bare
|
|
154
|
+
-- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
|
|
155
|
+
-- membership at create_board time (20260723000100), so a legitimate creator holds one, while
|
|
156
|
+
-- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
|
|
157
|
+
--
|
|
158
|
+
-- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
|
|
159
|
+
-- could evict the OWNER, or admins could evict each other.
|
|
160
|
+
if agent_board._role_rank(v_actor_role)
|
|
161
|
+
<= agent_board._role_rank(coalesce(v_target_role, '')) then
|
|
162
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
163
|
+
using errcode = '42501';
|
|
164
|
+
end if;
|
|
165
|
+
end if;
|
|
166
|
+
end if;
|
|
167
|
+
|
|
168
|
+
select * into v_membership
|
|
169
|
+
from agent_board._upsert_board_membership_state(
|
|
170
|
+
p_board_id,
|
|
171
|
+
p_agent_id,
|
|
172
|
+
p_membership_state,
|
|
173
|
+
null,
|
|
174
|
+
p_metadata
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return query
|
|
178
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
179
|
+
end;
|
|
180
|
+
$$;
|
|
181
|
+
|
|
182
|
+
comment on function agent_board.update_board_membership(uuid, uuid, text, jsonb) is
|
|
183
|
+
'Updates a board membership state. MEMBSTATE (#75): service_role retains full '
|
|
184
|
+
'modify-on-behalf-of; an authenticated caller with an unresolved actor fails closed (42501); a '
|
|
185
|
+
'caller acting on its OWN membership may only leave or toggle joined<->subscribed (promotion '
|
|
186
|
+
'into a board belongs to the join/approval path, so a requested/invited/left/removed principal '
|
|
187
|
+
'cannot self-approve or re-enter); a caller acting on ANOTHER agent''s membership must hold an '
|
|
188
|
+
'active owner/admin membership AND strictly higher board rank than the target (no creator '
|
|
189
|
+
'exemption: this is the only eviction path, so a demoted creator keeps no power and an admin '
|
|
190
|
+
'cannot evict the owner).';
|
|
191
|
+
|
|
192
|
+
-- Re-assert grants (create or replace preserves them, but be explicit so a fresh replay is
|
|
193
|
+
-- order-independent) — house convention, 20260725000100:229-232.
|
|
194
|
+
revoke all on function agent_board.update_board_membership(uuid, uuid, text, jsonb) from public, anon;
|
|
195
|
+
grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb) 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.12.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.12.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": 82,
|
|
50
|
+
"aggregateSha256": "f6248b647fc1de859d68650ad8f9e88964820642da712893476429a4edb341f9",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -759,6 +759,33 @@
|
|
|
759
759
|
"sourceRef": "message-board",
|
|
760
760
|
"bytes": 15404,
|
|
761
761
|
"sha256": "5f300449c29ec05c2cd3c2cd7eca622ca41e8b32b8856c5bc0695b5750a196e7"
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
"order": 79,
|
|
765
|
+
"id": "20260729000100_agent_board_fedsession_autojoin_default_board",
|
|
766
|
+
"filename": "20260729000100_agent_board_fedsession_autojoin_default_board.sql",
|
|
767
|
+
"origin": "message-board-forward",
|
|
768
|
+
"sourceRef": "message-board",
|
|
769
|
+
"bytes": 18252,
|
|
770
|
+
"sha256": "6f3a2db6c7349119b3893fe530788236ef5ccf4e6b0f3e21235497b8b7b232c0"
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
"order": 80,
|
|
774
|
+
"id": "20260730000100_agent_board_gatenull_service_role_bypass",
|
|
775
|
+
"filename": "20260730000100_agent_board_gatenull_service_role_bypass.sql",
|
|
776
|
+
"origin": "message-board-forward",
|
|
777
|
+
"sourceRef": "message-board",
|
|
778
|
+
"bytes": 17187,
|
|
779
|
+
"sha256": "db8b2a93996c1d61bd34fc58a5e4b3056563c53bb2eb20cbb4a3b0b799a0d91e"
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
"order": 81,
|
|
783
|
+
"id": "20260730000200_agent_board_membstate_authz",
|
|
784
|
+
"filename": "20260730000200_agent_board_membstate_authz.sql",
|
|
785
|
+
"origin": "message-board-forward",
|
|
786
|
+
"sourceRef": "message-board",
|
|
787
|
+
"bytes": 11567,
|
|
788
|
+
"sha256": "a4d935614f970a946f27a5925c0166e1d41510a82078e559fb769f4ea39afc2e"
|
|
762
789
|
}
|
|
763
790
|
],
|
|
764
791
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
|
|
|
37
37
|
expect(manifest.schemaVersion).toBe(8);
|
|
38
38
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
39
39
|
});
|
|
40
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION forward migrations (migrationCount = prior
|
|
40
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE forward migrations (migrationCount = prior 80 + 2 = 82)', () => {
|
|
41
41
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
42
|
+
expect(manifest.migrationCount).toBe(82);
|
|
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,
|
|
@@ -95,6 +95,16 @@ describe('manifest integrity', () => {
|
|
|
95
95
|
// backing the /portal-session Option A self-signed access-token minter. Additive
|
|
96
96
|
// (adds a nullable mode column to enrollment_federation_audit); schema stays 8.
|
|
97
97
|
'20260728000100_agent_board_enroll_federated_human.sql',
|
|
98
|
+
// FEDSESSION-AUTOJOIN: per-issuer default_board_id + auto-join a fresh fed-human to it
|
|
99
|
+
// (baseline member, first-time-only, non-fatal) inside enroll_federated_human. Additive;
|
|
100
|
+
// schema stays 8.
|
|
101
|
+
'20260729000100_agent_board_fedsession_autojoin_default_board.sql',
|
|
102
|
+
// GATENULL (#58): service_role-bypass hardening — an authenticated principal with no
|
|
103
|
+
// ACTIVE agent no longer takes the null-actor bypass on the four board-mutating RPCs.
|
|
104
|
+
'20260730000100_agent_board_gatenull_service_role_bypass.sql',
|
|
105
|
+
// MEMBSTATE (#75): update_board_membership gains an authorization model (it previously
|
|
106
|
+
// had none) — self-service transitions only, owner/admin for another agent's row.
|
|
107
|
+
'20260730000200_agent_board_membstate_authz.sql',
|
|
98
108
|
]) {
|
|
99
109
|
expect(names.has(f)).toBe(true);
|
|
100
110
|
}
|