@consiliency/agent-board-schema 1.28.0 → 1.38.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 +40 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +83 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
- package/tests/agent_board_ledger_authority.test.sql +142 -71
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +256 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +442 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
-- Consiliency/message-board#287 — a read receipt is a MEMO of access, never a SOURCE of it.
|
|
2
|
+
--
|
|
3
|
+
-- Two properties are pinned here and they pull in opposite directions, which is why they share
|
|
4
|
+
-- a file:
|
|
5
|
+
--
|
|
6
|
+
-- THE GATE a service_role caller may no longer name a THIRD PARTY in mark_read unless
|
|
7
|
+
-- that agent already passes the thread-access predicate. Before
|
|
8
|
+
-- 20260912000100, `mark_read(T, B, 0)` under service_role wrote a receipt for
|
|
9
|
+
-- an agent B with no relationship to T -- and because a receipt row is itself
|
|
10
|
+
-- an inherit-mode disjunct, B then PASSED the predicate. The memo minted a
|
|
11
|
+
-- grant.
|
|
12
|
+
--
|
|
13
|
+
-- THE GRANDFATHER the receipt disjunct STAYS, and it is permanent. Nothing deletes a
|
|
14
|
+
-- read_receipts row, so leaving a board or losing a task assignment does NOT
|
|
15
|
+
-- revoke access to a thread already read. "You keep what you saw." Two shipped
|
|
16
|
+
-- flows depend on it and are asserted POSITIVELY below, because they are what a
|
|
17
|
+
-- well-meaning cleanup of the disjunct would break.
|
|
18
|
+
--
|
|
19
|
+
-- The file runs as the postgres db role and simulates callers the way
|
|
20
|
+
-- agent_board_thread_acl.test.sql does: request.jwt.claim.role / .sub. Note that with NO role
|
|
21
|
+
-- claim, _is_service_role() is ALREADY true for session_user postgres, so the service_role
|
|
22
|
+
-- claim is set explicitly wherever it matters rather than relied on implicitly.
|
|
23
|
+
--
|
|
24
|
+
-- MUTATION EVIDENCE — measured, not predicted, and re-measured after each plan change.
|
|
25
|
+
-- All 36 assertions RAN in every row below (`ran=36`); a truncated plan is the failure mode
|
|
26
|
+
-- this table exists to rule out, and it is checked per row rather than inferred.
|
|
27
|
+
--
|
|
28
|
+
-- a delete the subject-gate `if` from mark_read
|
|
29
|
+
-- -> not ok 2, 3, 4 (the mint reopens), 24, 26 (the class assertion names it)
|
|
30
|
+
-- b re-add `_is_service_role() or` to _agent_can_access_thread
|
|
31
|
+
-- -> not ok 1, 2, 3, 4, 8, 24 -- the gate goes vacuous; assertion 1 exists for this
|
|
32
|
+
-- c revert the tasks disjunct to current_agent_id()
|
|
33
|
+
-- -> not ok 16, 17, 19 -- grandfather flow B collapses
|
|
34
|
+
-- d revert the board-delivery disjunct to _current_agent_has_board_access
|
|
35
|
+
-- -> not ok 8 -- the disjunct starts answering about the SESSION, not the subject
|
|
36
|
+
-- e remove the read_receipts disjunct entirely
|
|
37
|
+
-- -> not ok 13, 14, 15, 19 -- BOTH grandfather flows collapse
|
|
38
|
+
-- f1 a writer spelled `merge into agent_board.read_receipts`
|
|
39
|
+
-- -> not ok 25 (names it), 26, 27
|
|
40
|
+
-- f2 a writer spelled `update only agent_board.read_receipts`
|
|
41
|
+
-- -> not ok 25, 26
|
|
42
|
+
-- f3 a writer spelled `delete from only "agent_board" . "read_receipts"`
|
|
43
|
+
-- -> not ok 25, 26, 27
|
|
44
|
+
-- f4 the EXISTING member mark_read's own write respelled
|
|
45
|
+
-- `merge into only "agent_board"."read_receipts"` -- rule 2: mutate a member, do not only
|
|
46
|
+
-- add a new one
|
|
47
|
+
-- -> not ok 2, 3, 4, 24, 26, 27
|
|
48
|
+
-- g `grant insert on agent_board.read_receipts to public`
|
|
49
|
+
-- -> not ok 28, 30, 32, 33 -- both privilege arms
|
|
50
|
+
-- h add a receipt reaper (`delete from read_receipts`)
|
|
51
|
+
-- -> not ok 25, 26, 27
|
|
52
|
+
-- i `grant insert` to a BRAND-NEW role
|
|
53
|
+
-- -> not ok 33 only. has_table_privilege(service_role,INSERT) stayed FALSE, so the
|
|
54
|
+
-- effective arm alone would have shipped this green -- this is why the set form exists
|
|
55
|
+
-- j `grant insert` to `anon` (a live Supabase role the effective arm never names)
|
|
56
|
+
-- -> not ok 33 only
|
|
57
|
+
-- k `grant insert` to a role, then that role granted TO service_role (role MEMBERSHIP)
|
|
58
|
+
-- -> not ok 28, 33. The ACL names only the member role, so the set form reports the
|
|
59
|
+
-- wrong subject; has_table_privilege(service_role) = true is the one that answers
|
|
60
|
+
-- the real question -- this is why the effective arm exists
|
|
61
|
+
-- l a TRUNCATE-based receipt reaper (`truncate agent_board.read_receipts`)
|
|
62
|
+
-- -> not ok 27. Before `truncate` joined that alternation this was GREEN while the
|
|
63
|
+
-- assertion claimed permanence
|
|
64
|
+
-- l2 a TRUNCATE-based receipt reaper spelled `truncate table only agent_board.read_receipts`
|
|
65
|
+
-- -- ADDED under message-board#291, which found this hole in its own transplanted copy of
|
|
66
|
+
-- this pattern and fixed BOTH rather than leaving the known one on main
|
|
67
|
+
-- -> not ok 27. It was GREEN before that fix: the alternation was written
|
|
68
|
+
-- `truncate(\s+only)?\s+(table\s+)?`, i.e. ONLY before TABLE, but PostgreSQL's
|
|
69
|
+
-- syntax is `TRUNCATE [ TABLE ] [ ONLY ] name` -- TABLE FIRST. Measured on PG17:
|
|
70
|
+
-- `truncate agent_board.read_receipts` / `truncate only …` / `truncate table …` all
|
|
71
|
+
-- matched, and `truncate table only …` -- equally legal -- did NOT. A SECURITY
|
|
72
|
+
-- DEFINER reaper spelled that way shipped green past the assertion that claims the
|
|
73
|
+
-- receipt grandfather is permanent. The alternation is now
|
|
74
|
+
-- `(\s+table)?(\s+only)?`, which accepts both keywords in the legal order, either
|
|
75
|
+
-- alone, or neither.
|
|
76
|
+
-- m a writer that CALLS _agent_can_access_thread and IGNORES the result
|
|
77
|
+
-- -> not ok 25, 26. The previous assertion 26 predicate (a bare qualified call) MATCHED
|
|
78
|
+
-- this function, i.e. it would have read as gated and shipped GREEN
|
|
79
|
+
-- n `revoke select on read_receipts from service_role` (over-revoke guard)
|
|
80
|
+
-- -> not ok 29
|
|
81
|
+
-- o a writer on a DIFFERENT table named `read_receipts_archive`
|
|
82
|
+
-- -> ran=36, notok=0. A NEGATIVE pin: the guard must stay GREEN. Under the previous
|
|
83
|
+
-- unbounded pattern this function MATCHED, i.e. assertion 25 went red and named a
|
|
84
|
+
-- function that never touches read_receipts -- a false accusation.
|
|
85
|
+
-- o-control a REAL writer added in the same transaction as o
|
|
86
|
+
-- -> not ok 25, 26. Without this, o's green is indistinguishable from a pattern that
|
|
87
|
+
-- matches nothing at all; a negative pin needs a positive control beside it. -- permanence, hence the grandfather, would stop being true
|
|
88
|
+
--
|
|
89
|
+
-- TWO CONTRASTS THAT ARE THE POINT OF THE LAST REVISION, both measured directly:
|
|
90
|
+
-- * f1-f3 were each INVISIBLE to the previous regex. Evaluating the old and new patterns
|
|
91
|
+
-- over the same five spellings: the old matched only the plain
|
|
92
|
+
-- `insert into agent_board.read_receipts` control and missed all four others; the new
|
|
93
|
+
-- matched all five.
|
|
94
|
+
-- * under g, the direct-grantee query these assertions replaced still reported
|
|
95
|
+
-- service_role = 'SELECT' while has_table_privilege reported INSERT = true. The old
|
|
96
|
+
-- assertion 28 would have stayed GREEN while its own message was false.
|
|
97
|
+
--
|
|
98
|
+
-- Assertions 29 and 31 are the positive SELECT guards (mutation n), and 34 is the positive
|
|
99
|
+
-- owner-exemption guard: they red if the write-revoke is over-applied to the read path or if
|
|
100
|
+
-- the postgres exclusion above stops being an owner exemption.
|
|
101
|
+
--
|
|
102
|
+
-- i/j/k are the three-way proof that NEITHER privilege arm subsumes the other: i and j red
|
|
103
|
+
-- only the set form, k reds the effective form on the subject that matters. Dropping either
|
|
104
|
+
-- arm leaves a real route uncovered.
|
|
105
|
+
|
|
106
|
+
create extension if not exists pgtap with schema extensions;
|
|
107
|
+
set search_path = extensions, public, agent_board;
|
|
108
|
+
|
|
109
|
+
begin;
|
|
110
|
+
|
|
111
|
+
select plan(36);
|
|
112
|
+
|
|
113
|
+
create temp table _rsg (
|
|
114
|
+
thread_plain uuid,
|
|
115
|
+
thread_board uuid,
|
|
116
|
+
thread_task uuid,
|
|
117
|
+
board_id uuid,
|
|
118
|
+
agent_owner uuid,
|
|
119
|
+
agent_strange uuid,
|
|
120
|
+
agent_joiner uuid,
|
|
121
|
+
agent_reasgn uuid,
|
|
122
|
+
user_owner uuid,
|
|
123
|
+
user_strange uuid,
|
|
124
|
+
task_id uuid
|
|
125
|
+
) on commit drop;
|
|
126
|
+
|
|
127
|
+
-- Receipt captures land in a table that EXISTS BEFORE any RPC runs. `create temp table _x as
|
|
128
|
+
-- select * from <rpc>(...)` inside lives_ok looks equivalent, but when the call raises, the
|
|
129
|
+
-- table is never created and the NEXT assertion -- a plain select from it -- errors and aborts
|
|
130
|
+
-- the whole file, so the remaining assertions never run (14 of 31, observed under mutation e).
|
|
131
|
+
-- lives_ok's subtransaction contains the raise; a missing row then reads as null and FAILS a
|
|
132
|
+
-- named assertion instead of killing the plan.
|
|
133
|
+
create temp table _rsg_receipt (
|
|
134
|
+
label text primary key,
|
|
135
|
+
thread_id uuid,
|
|
136
|
+
agent_id uuid,
|
|
137
|
+
last_read_message_sequence bigint,
|
|
138
|
+
last_read_at timestamptz
|
|
139
|
+
) on commit drop;
|
|
140
|
+
|
|
141
|
+
do $$
|
|
142
|
+
declare
|
|
143
|
+
v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
144
|
+
v_u_owner uuid;
|
|
145
|
+
v_u_stran uuid;
|
|
146
|
+
v_u_join uuid;
|
|
147
|
+
v_u_reasg uuid;
|
|
148
|
+
v_owner uuid := gen_random_uuid();
|
|
149
|
+
v_stranger uuid := gen_random_uuid();
|
|
150
|
+
v_joiner uuid := gen_random_uuid();
|
|
151
|
+
v_reasgn uuid := gen_random_uuid();
|
|
152
|
+
v_t_plain uuid := gen_random_uuid();
|
|
153
|
+
v_t_board uuid := gen_random_uuid();
|
|
154
|
+
v_t_task uuid := gen_random_uuid();
|
|
155
|
+
v_board uuid := gen_random_uuid();
|
|
156
|
+
v_memb uuid := gen_random_uuid();
|
|
157
|
+
v_msg uuid := gen_random_uuid();
|
|
158
|
+
v_task uuid := gen_random_uuid();
|
|
159
|
+
begin
|
|
160
|
+
v_u_owner := public.ensure_github_user('rsg-owner-' || v_sfx, 'rsg-owner-' || v_sfx || '@example.test', 'rsg-owner-' || v_sfx, 'RSG Owner');
|
|
161
|
+
v_u_stran := public.ensure_github_user('rsg-stran-' || v_sfx, 'rsg-stran-' || v_sfx || '@example.test', 'rsg-stran-' || v_sfx, 'RSG Stranger');
|
|
162
|
+
v_u_join := public.ensure_github_user('rsg-join-' || v_sfx, 'rsg-join-' || v_sfx || '@example.test', 'rsg-join-' || v_sfx, 'RSG Joiner');
|
|
163
|
+
v_u_reasg := public.ensure_github_user('rsg-reasg-' || v_sfx, 'rsg-reasg-' || v_sfx || '@example.test', 'rsg-reasg-' || v_sfx, 'RSG Reassigned');
|
|
164
|
+
|
|
165
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
166
|
+
values
|
|
167
|
+
(v_owner, 'agent', 'rsg-owner-' || v_sfx, 'RSG Owner', 'active', v_u_owner, v_u_owner),
|
|
168
|
+
(v_stranger, 'agent', 'rsg-stran-' || v_sfx, 'RSG Stranger', 'active', v_u_stran, v_u_stran),
|
|
169
|
+
(v_joiner, 'agent', 'rsg-join-' || v_sfx, 'RSG Joiner', 'active', v_u_join, v_u_join),
|
|
170
|
+
(v_reasgn, 'agent', 'rsg-reasg-' || v_sfx, 'RSG Reassigned', 'active', v_u_reasg, v_u_reasg);
|
|
171
|
+
|
|
172
|
+
-- Three inherit threads. last_message_sequence is set high so mark_read's
|
|
173
|
+
-- least(p_seq, last_message_sequence) never silently clamps a cursor assertion.
|
|
174
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, last_message_sequence)
|
|
175
|
+
values
|
|
176
|
+
(v_t_plain, 'coordination', v_owner, 'RSG plain thread', 9),
|
|
177
|
+
(v_t_board, 'coordination', v_owner, 'RSG board thread', 9),
|
|
178
|
+
(v_t_task, 'task', v_owner, 'RSG task thread', 9);
|
|
179
|
+
|
|
180
|
+
-- The board thread: a board-delivered message. The board-delivery disjunct keys on
|
|
181
|
+
-- delivery_recipients.board_id + the message's thread, NOT on recipient_agent_id, so the
|
|
182
|
+
-- late joiner reaches it through board membership alone -- which is the flow being pinned.
|
|
183
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility)
|
|
184
|
+
values (v_board, 'rsg-board-' || v_sfx, 'RSG Board', v_owner, 'open');
|
|
185
|
+
|
|
186
|
+
insert into agent_board.board_memberships(id, board_id, agent_id, membership_state, role)
|
|
187
|
+
values (v_memb, v_board, v_owner, 'joined', 'owner');
|
|
188
|
+
|
|
189
|
+
insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
|
|
190
|
+
values (v_msg, v_t_board, v_owner, 1, 'comment', 'board post');
|
|
191
|
+
|
|
192
|
+
insert into agent_board.delivery_recipients(delivery_mode, recipient_agent_id, source_kind, source_message_id, board_id, board_membership_id)
|
|
193
|
+
values ('board', v_owner, 'message', v_msg, v_board, v_memb);
|
|
194
|
+
|
|
195
|
+
-- The task thread: v_reasgn is the assignee (the task disjunct), and will be reassigned away.
|
|
196
|
+
insert into agent_board.tasks(id, thread_id, created_by_agent_id, assigned_agent_id, title, status, priority, orchestration_mode)
|
|
197
|
+
values (v_task, v_t_task, v_owner, v_reasgn, 'RSG task', 'pending', 'medium', 'distributed');
|
|
198
|
+
|
|
199
|
+
insert into _rsg values
|
|
200
|
+
(v_t_plain, v_t_board, v_t_task, v_board, v_owner, v_stranger, v_joiner, v_reasgn, v_u_owner, v_u_stran, v_task);
|
|
201
|
+
end
|
|
202
|
+
$$;
|
|
203
|
+
|
|
204
|
+
select set_config('request.jwt.claim.role', 'service_role', true);
|
|
205
|
+
|
|
206
|
+
-- ============================================================================
|
|
207
|
+
-- 1-4. THE GATE: service_role cannot mint a receipt for an agent with no access
|
|
208
|
+
-- ============================================================================
|
|
209
|
+
-- Assertion 1 is the SHORT-CIRCUIT INVARIANT and it is deliberately evaluated under a
|
|
210
|
+
-- service_role claim: _agent_can_access_thread must carry NO _is_service_role() bypass, or the
|
|
211
|
+
-- gate below is vacuous while every allow-side assertion in this file stays green.
|
|
212
|
+
select is(
|
|
213
|
+
agent_board._agent_can_access_thread(
|
|
214
|
+
(select thread_plain from _rsg), (select agent_strange from _rsg)),
|
|
215
|
+
false,
|
|
216
|
+
'invariant: _agent_can_access_thread answers about the SUBJECT under a service_role claim (no short-circuit)');
|
|
217
|
+
|
|
218
|
+
select throws_ok(
|
|
219
|
+
$$ select * from agent_board.mark_read(
|
|
220
|
+
(select thread_plain from _rsg), (select agent_strange from _rsg), 0) $$,
|
|
221
|
+
'42501',
|
|
222
|
+
'read receipt subject has no access to thread',
|
|
223
|
+
'service_role CANNOT mint a receipt for an agent with no access to the thread');
|
|
224
|
+
|
|
225
|
+
select is(
|
|
226
|
+
(select count(*)::int from agent_board.read_receipts rr
|
|
227
|
+
where rr.thread_id = (select thread_plain from _rsg)
|
|
228
|
+
and rr.agent_id = (select agent_strange from _rsg)),
|
|
229
|
+
0,
|
|
230
|
+
'the refused call wrote NO read_receipts row for the named stranger');
|
|
231
|
+
|
|
232
|
+
select is(
|
|
233
|
+
agent_board._agent_can_access_thread(
|
|
234
|
+
(select thread_plain from _rsg), (select agent_strange from _rsg)),
|
|
235
|
+
false,
|
|
236
|
+
'the stranger still has no thread access after the refused call (the false->true transition is closed)');
|
|
237
|
+
|
|
238
|
+
-- ============================================================================
|
|
239
|
+
-- 5-7. The allow side: an agent that DOES have access still gets its receipt
|
|
240
|
+
-- ============================================================================
|
|
241
|
+
-- #113: the call goes through lives_ok and the assertions read the captured row. A bare
|
|
242
|
+
-- is((select ... from mark_read(...))) would ABORT the whole file on a raise, emitting no
|
|
243
|
+
-- `not ok` -- an over-refusing gate would then look like a pass.
|
|
244
|
+
select lives_ok(
|
|
245
|
+
$$ insert into _rsg_receipt
|
|
246
|
+
select 'owner', * from agent_board.mark_read(
|
|
247
|
+
(select thread_plain from _rsg), (select agent_owner from _rsg), 1) $$,
|
|
248
|
+
'service_role marks read for the thread creator [call succeeds]');
|
|
249
|
+
|
|
250
|
+
select is(
|
|
251
|
+
(select agent_id from _rsg_receipt where label = 'owner'),
|
|
252
|
+
(select agent_owner from _rsg),
|
|
253
|
+
'the receipt subject is the named agent');
|
|
254
|
+
|
|
255
|
+
select is(
|
|
256
|
+
(select count(*)::int from agent_board.read_receipts rr
|
|
257
|
+
where rr.thread_id = (select thread_plain from _rsg)
|
|
258
|
+
and rr.agent_id = (select agent_owner from _rsg)),
|
|
259
|
+
1,
|
|
260
|
+
'a receipt row IS written for an agent that already has access');
|
|
261
|
+
|
|
262
|
+
-- ============================================================================
|
|
263
|
+
-- 8-15. GRANDFATHER FLOW A — the late board joiner
|
|
264
|
+
-- ============================================================================
|
|
265
|
+
-- Joins a board after the history was posted, reads a thread, then leaves. The board-delivery
|
|
266
|
+
-- disjunct evaporates on leaving; the receipt is what keeps the thread readable.
|
|
267
|
+
select is(
|
|
268
|
+
agent_board._agent_can_access_thread(
|
|
269
|
+
(select thread_board from _rsg), (select agent_joiner from _rsg)),
|
|
270
|
+
false,
|
|
271
|
+
'flow A precondition: before joining the board, the joiner has no access to the board thread');
|
|
272
|
+
|
|
273
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
|
|
274
|
+
select board_id, agent_joiner, 'joined', 'member' from _rsg;
|
|
275
|
+
|
|
276
|
+
select is(
|
|
277
|
+
agent_board._agent_can_access_thread(
|
|
278
|
+
(select thread_board from _rsg), (select agent_joiner from _rsg)),
|
|
279
|
+
true,
|
|
280
|
+
'flow A: a late joiner reaches the board thread through the board-delivery disjunct');
|
|
281
|
+
|
|
282
|
+
select lives_ok(
|
|
283
|
+
$$ insert into _rsg_receipt
|
|
284
|
+
select 'joiner', * from agent_board.mark_read(
|
|
285
|
+
(select thread_board from _rsg), (select agent_joiner from _rsg), 2) $$,
|
|
286
|
+
'flow A: service_role marks read on behalf of the late joiner [call succeeds]');
|
|
287
|
+
|
|
288
|
+
select is(
|
|
289
|
+
(select last_read_message_sequence from _rsg_receipt where label = 'joiner'),
|
|
290
|
+
2::bigint,
|
|
291
|
+
'flow A: the joiner receipt records the cursor it was given');
|
|
292
|
+
|
|
293
|
+
update agent_board.board_memberships
|
|
294
|
+
set membership_state = 'left', left_at = now()
|
|
295
|
+
where board_id = (select board_id from _rsg)
|
|
296
|
+
and agent_id = (select agent_joiner from _rsg);
|
|
297
|
+
|
|
298
|
+
select is(
|
|
299
|
+
agent_board._agent_has_board_access(
|
|
300
|
+
(select board_id from _rsg), (select agent_joiner from _rsg)),
|
|
301
|
+
false,
|
|
302
|
+
'flow A: after leaving, the board disjunct no longer holds for the joiner');
|
|
303
|
+
|
|
304
|
+
select is(
|
|
305
|
+
agent_board._agent_can_access_thread(
|
|
306
|
+
(select thread_board from _rsg), (select agent_joiner from _rsg)),
|
|
307
|
+
true,
|
|
308
|
+
'GRANDFATHER A: leaving the board does NOT revoke access to a thread already read');
|
|
309
|
+
|
|
310
|
+
select lives_ok(
|
|
311
|
+
$$ insert into _rsg_receipt
|
|
312
|
+
select 'joiner-after-leaving', * from agent_board.mark_read(
|
|
313
|
+
(select thread_board from _rsg), (select agent_joiner from _rsg), 5) $$,
|
|
314
|
+
'GRANDFATHER A: the cursor still advances on grandfathered access [call succeeds]');
|
|
315
|
+
|
|
316
|
+
select is(
|
|
317
|
+
(select last_read_message_sequence from _rsg_receipt where label = 'joiner-after-leaving'),
|
|
318
|
+
5::bigint,
|
|
319
|
+
'GRANDFATHER A: the subject gate accepts the receipt disjunct as the subject''s access');
|
|
320
|
+
|
|
321
|
+
-- ============================================================================
|
|
322
|
+
-- 16-19. GRANDFATHER FLOW B — the reassigned task assignee
|
|
323
|
+
-- ============================================================================
|
|
324
|
+
select is(
|
|
325
|
+
agent_board._agent_can_access_thread(
|
|
326
|
+
(select thread_task from _rsg), (select agent_reasgn from _rsg)),
|
|
327
|
+
true,
|
|
328
|
+
'flow B precondition: the assignee reaches the task thread through the task disjunct');
|
|
329
|
+
|
|
330
|
+
select lives_ok(
|
|
331
|
+
$$ insert into _rsg_receipt
|
|
332
|
+
select 'assignee', * from agent_board.mark_read(
|
|
333
|
+
(select thread_task from _rsg), (select agent_reasgn from _rsg), 3) $$,
|
|
334
|
+
'flow B: service_role marks read on behalf of the assignee [call succeeds]');
|
|
335
|
+
|
|
336
|
+
update agent_board.tasks
|
|
337
|
+
set assigned_agent_id = (select agent_owner from _rsg)
|
|
338
|
+
where id = (select task_id from _rsg);
|
|
339
|
+
|
|
340
|
+
select is(
|
|
341
|
+
(select exists (
|
|
342
|
+
select 1 from agent_board.tasks t
|
|
343
|
+
where t.thread_id = (select thread_task from _rsg)
|
|
344
|
+
and (t.created_by_agent_id = (select agent_reasgn from _rsg)
|
|
345
|
+
or t.assigned_agent_id = (select agent_reasgn from _rsg)))),
|
|
346
|
+
false,
|
|
347
|
+
'flow B: after reassignment the task disjunct no longer holds for the former assignee');
|
|
348
|
+
|
|
349
|
+
select is(
|
|
350
|
+
agent_board._agent_can_access_thread(
|
|
351
|
+
(select thread_task from _rsg), (select agent_reasgn from _rsg)),
|
|
352
|
+
true,
|
|
353
|
+
'GRANDFATHER B: losing the task assignment does NOT revoke access to a thread already read');
|
|
354
|
+
|
|
355
|
+
-- ============================================================================
|
|
356
|
+
-- 20-23. The authenticated path is unchanged
|
|
357
|
+
-- ============================================================================
|
|
358
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
359
|
+
select set_config('request.jwt.claim.sub', (select user_owner from _rsg)::text, true);
|
|
360
|
+
|
|
361
|
+
select throws_ok(
|
|
362
|
+
$$ select * from agent_board.mark_read(
|
|
363
|
+
(select thread_plain from _rsg), (select agent_strange from _rsg), 1) $$,
|
|
364
|
+
'42501',
|
|
365
|
+
'p_agent_id must match current_agent_id()',
|
|
366
|
+
'authenticated still cannot name a third party at all -- refused before the subject gate');
|
|
367
|
+
|
|
368
|
+
select lives_ok(
|
|
369
|
+
$$ insert into _rsg_receipt
|
|
370
|
+
select 'authenticated-self', * from agent_board.mark_read(
|
|
371
|
+
(select thread_plain from _rsg), (select agent_owner from _rsg), 4) $$,
|
|
372
|
+
'authenticated marks its OWN read unchanged [call succeeds]');
|
|
373
|
+
|
|
374
|
+
select is(
|
|
375
|
+
(select last_read_message_sequence from _rsg_receipt where label = 'authenticated-self'),
|
|
376
|
+
4::bigint,
|
|
377
|
+
'authenticated cursor advances exactly as before');
|
|
378
|
+
|
|
379
|
+
select is(
|
|
380
|
+
agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
|
|
381
|
+
true,
|
|
382
|
+
'the recomposed _current_agent_can_access_thread still admits the creator under authenticated');
|
|
383
|
+
|
|
384
|
+
select set_config('request.jwt.claim.sub', (select user_strange from _rsg)::text, true);
|
|
385
|
+
|
|
386
|
+
select is(
|
|
387
|
+
agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
|
|
388
|
+
false,
|
|
389
|
+
'the recomposed _current_agent_can_access_thread still denies an unrelated agent under authenticated');
|
|
390
|
+
|
|
391
|
+
-- ============================================================================
|
|
392
|
+
-- 25-36. Catalog-derived class + the invariants that keep the gate real
|
|
393
|
+
-- ============================================================================
|
|
394
|
+
select set_config('request.jwt.claim.role', 'service_role', true);
|
|
395
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
396
|
+
|
|
397
|
+
-- The completeness claim is "mark_read is the ONLY route to a read_receipts row", and it is
|
|
398
|
+
-- backed from BOTH sides: the catalog function scan below, and the effective-privilege
|
|
399
|
+
-- assertions after it (no role holds a write privilege, by ANY route, so a direct write is
|
|
400
|
+
-- impossible and the SECURITY DEFINER bodies are the whole surface).
|
|
401
|
+
--
|
|
402
|
+
-- WHAT THE SCAN COVERS, stated exactly rather than generously. It is a REGEX over stripped
|
|
403
|
+
-- function source, so its completeness is the completeness of the pattern, and a spelling the
|
|
404
|
+
-- pattern does not know is a SILENT miss -- not a loud one. An earlier version of this comment
|
|
405
|
+
-- claimed "a wider scan can only produce a LOUD false positive, never a silent miss"; that
|
|
406
|
+
-- argument is about WIDENING and says nothing about the NARROWING gaps the pattern had. It
|
|
407
|
+
-- did, and these are VERIFIED-LEGAL spellings on PG17, not hypotheticals: `update only
|
|
408
|
+
-- read_receipts`, `delete from only read_receipts`, `merge into read_receipts` and `merge into
|
|
409
|
+
-- only read_receipts` (MERGE was absent from the alternation entirely), `insert into
|
|
410
|
+
-- agent_board."read_receipts"`, `"agent_board" . "read_receipts"`. Each was invisible and left
|
|
411
|
+
-- the assertion green. (`insert into only` is NOT one of them -- ONLY is not accepted after
|
|
412
|
+
-- INSERT INTO. The pattern tolerates it anyway because refusing it would buy nothing; the
|
|
413
|
+
-- claim here is confined to spellings Postgres actually parses.) The pattern below now covers
|
|
414
|
+
-- ONLY, MERGE, double-quoted identifier parts and whitespace around the schema dot, and the
|
|
415
|
+
-- mutations exercise them against a REAL member as well as a new one.
|
|
416
|
+
--
|
|
417
|
+
-- ONE MORE WAS MISSING AND IS NOW COVERED (message-board#291). The permanence scan below spelled
|
|
418
|
+
-- its TRUNCATE arm `truncate(\s+only)?\s+(table\s+)?`, which has PostgreSQL's keyword order
|
|
419
|
+
-- BACKWARDS: the syntax is `TRUNCATE [ TABLE ] [ ONLY ] name`, TABLE first. So
|
|
420
|
+
-- `truncate table only agent_board.read_receipts` -- legal, verified on PG17 -- was invisible,
|
|
421
|
+
-- and a reaper spelled that way would have shipped green past the assertion claiming the
|
|
422
|
+
-- grandfather is permanent. Fixed here rather than on the branch that found it, because the
|
|
423
|
+
-- defect is identical in both files and leaving the known one on main is worse than the scope
|
|
424
|
+
-- mix. Mutation l2 pins it.
|
|
425
|
+
--
|
|
426
|
+
-- It still is not a parser, and it HAD one known blind spot, now closed by message-board#293.
|
|
427
|
+
-- A function declared with an SQL-standard `BEGIN ATOMIC` body keeps its body in
|
|
428
|
+
-- pg_proc.prosqlbody and leaves prosrc EMPTY -- verified directly on this cluster: a BEGIN
|
|
429
|
+
-- ATOMIC function reading read_receipts showed prosrc empty, prosqlbody non-null, and
|
|
430
|
+
-- stripped_body ''. A writer written that way was invisible to assertions 25, 26 and 27 alike.
|
|
431
|
+
--
|
|
432
|
+
-- It was fixed in the SHARED helper (tests/fixtures/test-prereqs.sql), not here: stripped_body
|
|
433
|
+
-- had a LIVE use in 22 test files including this one (counted from the catalog of files, not
|
|
434
|
+
-- estimated; #293's own text said 19 and an earlier draft of this comment guessed "roughly
|
|
435
|
+
-- ten"), so a local patch would have fixed one caller and left 21 open -- the partial-fix shape
|
|
436
|
+
-- CLAUDE.md rule 1 bans. stripped_body now falls back to the DEPARSED prosqlbody and RAISES on
|
|
437
|
+
-- a body it can read by neither route, so an unreadable body is loud instead of silently
|
|
438
|
+
-- matching nothing. Pinned by SQLBODY-1..-11 in agent_board_source_strip_helper.test.sql,
|
|
439
|
+
-- SQLBODY-11 being an exact-set writer scan of this file's own shape.
|
|
440
|
+
--
|
|
441
|
+
-- What that does NOT buy: for such a function these regexes read Postgres' DEPARSE of the
|
|
442
|
+
-- parse tree rather than the author's text, and no agent_board function is written that way
|
|
443
|
+
-- today, so the shape remains UNEXERCISED against real schema code -- a fact about today's
|
|
444
|
+
-- catalog, not a property of the scan.
|
|
445
|
+
--
|
|
446
|
+
-- What keeps the completeness claim honest despite all of the above is the SECOND LEG: no
|
|
447
|
+
-- grantee holds a write privilege on the table by ANY route (assertions 29, 31, 32, 33), so an
|
|
448
|
+
-- unmatched writer would have to be a SECURITY DEFINER function -- exactly the population this
|
|
449
|
+
-- scans -- and the exact-set shape means a widened pattern fails LOUDLY and names what it
|
|
450
|
+
-- caught rather than passing.
|
|
451
|
+
--
|
|
452
|
+
-- THE RIGHT-HAND BOUNDARY, and why it is an ALTERNATION rather than a trailing \M. Without a
|
|
453
|
+
-- boundary the pattern also matched `read_receipts_archive` -- a DIFFERENT table whose name
|
|
454
|
+
-- merely starts with this one. That direction is fail-CLOSED (a spurious member reds the
|
|
455
|
+
-- exact-set assertion and names a function, rather than hiding one), so it never threatened
|
|
456
|
+
-- the completeness claim. It is fixed anyway for a second-order reason: a future author who
|
|
457
|
+
-- adds a read_receipts_archive table would get a FALSE accusation from this guard, and the
|
|
458
|
+
-- natural response to a guard that cries wolf is to loosen it. That is how a real guard dies.
|
|
459
|
+
--
|
|
460
|
+
-- `_` is a word character in Postgres regex, so \M (end-of-word) correctly refuses
|
|
461
|
+
-- `read_receipts_archive`. The trap is the QUOTED form: with a MANDATORY closing quote,
|
|
462
|
+
-- `"read_receipts"\M` FAILS, because after `"` no word ends there -- verified directly.
|
|
463
|
+
-- Writing `"?read_receipts"?\M` happens to work, but only because the optional `"?`
|
|
464
|
+
-- BACKTRACKS to empty so \M lands between `s` and `"` -- i.e. on the quoted form the pattern
|
|
465
|
+
-- silently stops requiring the closing quote at all. Correct today, and far too subtle to
|
|
466
|
+
-- leave for the next editor. The alternation states the two cases separately: unquoted ends
|
|
467
|
+
-- at \M, quoted ends at its own mandatory closing quote, which IS the boundary.
|
|
468
|
+
--
|
|
469
|
+
-- Verified over 14 spellings, 8 that MUST match and 6 that MUST NOT (`read_receipts_archive`
|
|
470
|
+
-- bare / schema-qualified / quoted, `read_receipts2`, `old_read_receipts`, and a plain SELECT,
|
|
471
|
+
-- which is a read and not a write).
|
|
472
|
+
--
|
|
473
|
+
-- The scan keeps STRING LITERALS (stripped_body's second argument) on purpose: a writer
|
|
474
|
+
-- assembled as dynamic SQL lives in a literal. Comments are still stripped, so prose naming
|
|
475
|
+
-- the pattern cannot satisfy it.
|
|
476
|
+
select is(
|
|
477
|
+
(select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
|
|
478
|
+
from pg_proc p
|
|
479
|
+
where p.pronamespace = 'agent_board'::regnamespace
|
|
480
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
481
|
+
~ '(insert\s+into|merge\s+into|update|delete\s+from)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'),
|
|
482
|
+
'mark_read',
|
|
483
|
+
'catalog: mark_read is the ONLY agent_board function that writes read_receipts -- a new writer names itself here');
|
|
484
|
+
|
|
485
|
+
select is(
|
|
486
|
+
(select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
|
|
487
|
+
from pg_proc p
|
|
488
|
+
where p.pronamespace = 'agent_board'::regnamespace
|
|
489
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
490
|
+
~ '(insert\s+into|merge\s+into|update|delete\s+from)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'
|
|
491
|
+
and lower(agent_board_test.stripped_body(p.oid))
|
|
492
|
+
!~ 'not\s+agent_board\._agent_can_access_thread\s*\('),
|
|
493
|
+
'(none)',
|
|
494
|
+
'catalog: every read_receipts writer contains a REFUSING reference to _agent_can_access_thread (source shape only -- assertion 2 is what proves it refuses)');
|
|
495
|
+
|
|
496
|
+
-- WHAT ASSERTION 26 DOES AND DOES NOT ESTABLISH. It matches `not
|
|
497
|
+
-- agent_board._agent_can_access_thread(` in the writer's body -- a source SHAPE, so it sees
|
|
498
|
+
-- the refusing form rather than a bare mention, but it still cannot see whether the branch
|
|
499
|
+
-- raises, or whether the result is discarded. Its message used to say every writer "gates its
|
|
500
|
+
-- named subject", which is more than a regex can know: that is the "comment claims what no
|
|
501
|
+
-- assertion does" tell. The message now says what it checks. The BEHAVIOUR -- that the gate
|
|
502
|
+
-- actually refuses -- is assertion 2, which calls mark_read for a subject with no access and
|
|
503
|
+
-- requires the 42501; mutation a (deleting the gate) reds 2 and 26 together, so the pair is
|
|
504
|
+
-- not redundant: 26 is the CLASS half (a NEW writer that skips the gate) and 2 is the
|
|
505
|
+
-- BEHAVIOURAL half (this writer really refuses).
|
|
506
|
+
|
|
507
|
+
-- The permanence half of the grandfather, asserted rather than asserted-about: if a future
|
|
508
|
+
-- migration adds a receipt reaper, "you keep what you saw" stops being true and this names it.
|
|
509
|
+
-- TRUNCATE is in the alternation because it removes every row without a DELETE: the assertion
|
|
510
|
+
-- claims permanence, so scanning only `delete from` would have claimed more than it checked.
|
|
511
|
+
select is(
|
|
512
|
+
(select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
|
|
513
|
+
from pg_proc p
|
|
514
|
+
where p.pronamespace = 'agent_board'::regnamespace
|
|
515
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
516
|
+
~ '(delete\s+from|merge\s+into|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'),
|
|
517
|
+
'(none)',
|
|
518
|
+
'catalog: no agent_board function deletes or truncates a read receipt -- the grandfather is permanent by construction');
|
|
519
|
+
|
|
520
|
+
-- EFFECTIVE privilege, not the direct grant. information_schema.role_table_grants filtered on
|
|
521
|
+
-- `grantee = '<role>'` shows only grants made TO THAT NAME, so `grant insert on
|
|
522
|
+
-- agent_board.read_receipts to public` hands service_role a working INSERT while a
|
|
523
|
+
-- direct-grantee query stays green -- and the assertion's own message ("SELECT and only
|
|
524
|
+
-- SELECT") would then be false. That is the same PUBLIC-privilege fail-open class as #118, and
|
|
525
|
+
-- the first version of these two assertions had it. has_table_privilege resolves PUBLIC and
|
|
526
|
+
-- role membership, so it answers the question the message actually asks. Mutation g
|
|
527
|
+
-- (`grant insert ... to public`) reds assertions 29 and 31 and would NOT have reded the
|
|
528
|
+
-- direct-grantee form -- that contrast is the whole reason for the change.
|
|
529
|
+
select is(
|
|
530
|
+
(select string_agg(priv, ',' order by priv)
|
|
531
|
+
from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
|
|
532
|
+
where has_table_privilege('service_role', 'agent_board.read_receipts', priv)),
|
|
533
|
+
null,
|
|
534
|
+
'service_role holds NO effective write privilege on read_receipts, by any route incl. PUBLIC');
|
|
535
|
+
|
|
536
|
+
select ok(
|
|
537
|
+
has_table_privilege('service_role', 'agent_board.read_receipts', 'SELECT'),
|
|
538
|
+
'service_role does hold SELECT on read_receipts (the read path is not over-revoked)');
|
|
539
|
+
|
|
540
|
+
select is(
|
|
541
|
+
(select string_agg(priv, ',' order by priv)
|
|
542
|
+
from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
|
|
543
|
+
where has_table_privilege('authenticated', 'agent_board.read_receipts', priv)),
|
|
544
|
+
null,
|
|
545
|
+
'authenticated holds NO effective write privilege on read_receipts, by any route incl. PUBLIC');
|
|
546
|
+
|
|
547
|
+
select ok(
|
|
548
|
+
has_table_privilege('authenticated', 'agent_board.read_receipts', 'SELECT'),
|
|
549
|
+
'authenticated does hold SELECT on read_receipts (the read path is not over-revoked)');
|
|
550
|
+
|
|
551
|
+
-- The PUBLIC pseudo-role directly: `=arwd` on the table would give EVERY role a write, present
|
|
552
|
+
-- and future, including roles this file does not name.
|
|
553
|
+
select is(
|
|
554
|
+
(select string_agg(priv, ',' order by priv)
|
|
555
|
+
from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'SELECT']) as priv
|
|
556
|
+
where has_table_privilege('public', 'agent_board.read_receipts', priv)),
|
|
557
|
+
null,
|
|
558
|
+
'PUBLIC holds NO privilege at all on read_receipts -- no role gains a write through it');
|
|
559
|
+
|
|
560
|
+
-- THE SET FORM -- and why BOTH arms are needed, measured rather than assumed.
|
|
561
|
+
--
|
|
562
|
+
-- The has_table_privilege assertions above are a HAND LIST of two grantees (CLAUDE.md rule 1):
|
|
563
|
+
-- an INSERT granted to `anon`, or to a brand-new role, self-names NOWHERE in them. This arm is
|
|
564
|
+
-- the rule-1-correct shape -- the set of grantees holding any non-SELECT privilege on the
|
|
565
|
+
-- table, derived from the ACL, so a new grantee FAILS THIS AND NAMES ITSELF.
|
|
566
|
+
--
|
|
567
|
+
-- Neither arm subsumes the other. Measured on this cluster, granting INSERT to a new role
|
|
568
|
+
-- `rsg_newrole`:
|
|
569
|
+
-- set form -> rsg_newrole:INSERT (RED)
|
|
570
|
+
-- has_table_privilege(service_role,INSERT) -> false (still green)
|
|
571
|
+
-- and granting INSERT to PUBLIC:
|
|
572
|
+
-- set form -> PUBLIC:INSERT (RED)
|
|
573
|
+
-- has_table_privilege(service_role,INSERT) -> true (RED)
|
|
574
|
+
-- PUBLIC happens to appear in the ACL as its own grantee, so the set form catches that case
|
|
575
|
+
-- too; what it CANNOT catch is privilege reaching a role through ROLE MEMBERSHIP, where the
|
|
576
|
+
-- ACL names only the member role and never service_role:
|
|
577
|
+
-- grant insert to rsg_newrole; grant rsg_newrole to service_role;
|
|
578
|
+
-- set form -> rsg_newrole:INSERT (names the wrong thing)
|
|
579
|
+
-- has_table_privilege(service_role,INSERT) -> true (names the real answer)
|
|
580
|
+
-- So: the set form answers "has anyone been granted a write", the effective form answers "can
|
|
581
|
+
-- THESE two roles write". Both, or a route is uncovered.
|
|
582
|
+
--
|
|
583
|
+
-- The ACL is read through aclexplode(pg_class.relacl) rather than
|
|
584
|
+
-- information_schema.role_table_grants, because that view is filtered to roles the CURRENT
|
|
585
|
+
-- user can see -- a privilege-dependent visibility filter has no place inside a completeness
|
|
586
|
+
-- claim. `postgres` (the table owner, whose full ownership ACL is expected) is the one
|
|
587
|
+
-- exemption, and it is stated POSITIVELY below rather than by absence of a match.
|
|
588
|
+
select is(
|
|
589
|
+
(select coalesce(string_agg(distinct coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type,
|
|
590
|
+
', ' order by coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type),
|
|
591
|
+
'(none)')
|
|
592
|
+
from pg_class c
|
|
593
|
+
cross join aclexplode(c.relacl) a
|
|
594
|
+
left join pg_roles g on g.oid = a.grantee
|
|
595
|
+
where c.oid = 'agent_board.read_receipts'::regclass
|
|
596
|
+
and a.privilege_type <> 'SELECT'
|
|
597
|
+
and coalesce(g.rolname, 'PUBLIC') <> 'postgres'),
|
|
598
|
+
'(none)',
|
|
599
|
+
'catalog: NO grantee holds a non-SELECT privilege on read_receipts -- a new one names itself here');
|
|
600
|
+
|
|
601
|
+
-- The owner exemption, asserted positively: `postgres` is expected to hold the full ownership
|
|
602
|
+
-- ACL, and saying so is what keeps the exclusion above from being a place to hide a grantee.
|
|
603
|
+
select ok(
|
|
604
|
+
has_table_privilege('postgres', 'agent_board.read_receipts', 'INSERT')
|
|
605
|
+
and has_table_privilege('postgres', 'agent_board.read_receipts', 'DELETE'),
|
|
606
|
+
'the one excluded grantee, postgres, is the table OWNER and does hold the full ACL as expected');
|
|
607
|
+
|
|
608
|
+
-- The board helper carries the same no-short-circuit invariant, for the same reason.
|
|
609
|
+
select is(
|
|
610
|
+
agent_board._agent_has_board_access(
|
|
611
|
+
(select board_id from _rsg), (select agent_strange from _rsg)),
|
|
612
|
+
false,
|
|
613
|
+
'invariant: _agent_has_board_access answers about the SUBJECT under a service_role claim (no short-circuit)');
|
|
614
|
+
|
|
615
|
+
-- Over-revoke guard: the short-circuit belongs in the SESSION predicate and must stay there.
|
|
616
|
+
-- "Fixing" the invariant by deleting it everywhere would break every live service_role caller.
|
|
617
|
+
select is(
|
|
618
|
+
agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
|
|
619
|
+
true,
|
|
620
|
+
'the _is_service_role() short-circuit is PRESERVED in the session predicate where it belongs');
|
|
621
|
+
|
|
622
|
+
select * from finish();
|
|
623
|
+
|
|
624
|
+
rollback;
|