@consiliency/agent-board-schema 1.27.0 → 1.35.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 +32 -0
- package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -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/package.json +1 -1
- package/schema-manifest.json +75 -3
- package/src/tooling.test.mjs +47 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- 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_ledger_authority.test.sql +1081 -0
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signer_lifecycle.test.sql +29 -6
- package/tests/agent_board_source_strip_helper.test.sql +157 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/catalog/p_metadata_parameters.json +20 -0
- package/tests/fixtures/test-prereqs.sql +420 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -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 +405 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -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,1081 @@
|
|
|
1
|
+
-- #276 pgTAP: a fleet-revoked agent loses BOARD AUTHORITY, not only send.
|
|
2
|
+
--
|
|
3
|
+
-- The claim under test: an agent carrying an agent-subject `fleet_revocation_events` row cannot
|
|
4
|
+
-- complete a board-role grant, a board authority-ceiling change, or a role-gate flip — on the
|
|
5
|
+
-- unsigned `authenticated` route or the signed gateway route — while an agent that was never in
|
|
6
|
+
-- a fleet is completely unaffected.
|
|
7
|
+
--
|
|
8
|
+
-- THE DIVERGENCE, asserted rather than described. `revoke_fleet_subject('agent', X)` leaves
|
|
9
|
+
-- `agents.status = 'active'` and X's signing keys live; only the ledger row and the bindings
|
|
10
|
+
-- change. Section 4 asserts that precondition POSITIVELY before probing the refusal, so a
|
|
11
|
+
-- future change that (say) also flipped `agents.status` would make these tests pass for the
|
|
12
|
+
-- WRONG reason and section 4 is what would catch it.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT THIS FILE CAN AND CANNOT REACH. A pgTAP file runs in ONE transaction, so it proves the
|
|
15
|
+
-- PREDICATE (a ledger-revoked grantor is refused) but structurally cannot produce the
|
|
16
|
+
-- interleaving (`revoke_fleet_subject` committing between an authority write's ledger read and
|
|
17
|
+
-- its commit) or the `agents` FOR SHARE lock that serializes it. That lives in
|
|
18
|
+
-- tests/isolation/specs/ledger_authority_revoke.spec, driven by isolationtester across
|
|
19
|
+
-- committed sessions.
|
|
20
|
+
--
|
|
21
|
+
-- Methodology, inherited from agent_board_signer_lifecycle.test.sql:
|
|
22
|
+
-- * RPC probes are driven by the JWT claim blob only; SET LOCAL ROLE is never used, since it
|
|
23
|
+
-- would revoke _is_service_role() and mask the branch behind a permission error.
|
|
24
|
+
-- * Allow-side probes are lives_ok(...) then a SEPARATE state check. A bare
|
|
25
|
+
-- `select ... from <raising rpc>(...)` ABORTS the transaction instead of failing it,
|
|
26
|
+
-- silently dropping every later assertion (#113, CLAUDE.md rule 3).
|
|
27
|
+
-- * Every refusal probe is followed by a state check, because a gate that raises AND writes
|
|
28
|
+
-- is the failure mode throws_like alone cannot see.
|
|
29
|
+
-- * The class assertions in section 9 are catalog-derived (pg_proc + stripped_body), not a
|
|
30
|
+
-- list written here, so a NEW authority write that forgets the gate fails and NAMES ITSELF
|
|
31
|
+
-- (CLAUDE.md rule 1). Each remaining exemption is asserted POSITIVELY — by properties it
|
|
32
|
+
-- actually has — never by the absence of a match.
|
|
33
|
+
--
|
|
34
|
+
-- #280 (20260911000100) AMENDS THIS FILE'S CLAIM. `create_board` was #276's ONE
|
|
35
|
+
-- authenticated-callable exemption, and its residual was asserted here as BEHAVIOUR — a
|
|
36
|
+
-- ledger-revoked agent COULD create a board and be seeded owner — precisely so that closing #280
|
|
37
|
+
-- would be a deliberate test change rather than a silent behaviour change. The owner ratified
|
|
38
|
+
-- "revoked means revoked", so those assertions are FLIPPED in section 11 and the EXEMPTION B
|
|
39
|
+
-- block that licensed them is DELETED rather than left standing: its three properties are still
|
|
40
|
+
-- true, but they exist to license an exemption that no longer exists, and a live assertion that
|
|
41
|
+
-- licenses nothing is this repo's recorded failure shape. The exemption list is now THREE and
|
|
42
|
+
-- the ledger-consult set is now SIX; both are derived from pg_catalog on every run, so those two
|
|
43
|
+
-- edits must agree with the shipped schema or the difference NAMES create_board.
|
|
44
|
+
|
|
45
|
+
create extension if not exists pgtap with schema extensions;
|
|
46
|
+
set search_path = extensions, public, agent_board;
|
|
47
|
+
|
|
48
|
+
begin;
|
|
49
|
+
|
|
50
|
+
-- 88 at #276; #280 deletes 7 (EXEMPTION B1/B2/B3, the three RESIDUAL assertions, and a fourth
|
|
51
|
+
-- residual companion in section 10 that named only the slug) and adds 10 in section 11.
|
|
52
|
+
select plan(91);
|
|
53
|
+
|
|
54
|
+
create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
|
|
55
|
+
begin
|
|
56
|
+
perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',p_sub)::text, true);
|
|
57
|
+
end $$;
|
|
58
|
+
create function pg_temp.service() returns void language plpgsql as $$
|
|
59
|
+
begin
|
|
60
|
+
perform set_config('request.jwt.claims', '', true);
|
|
61
|
+
end $$;
|
|
62
|
+
|
|
63
|
+
-- Fixture -------------------------------------------------------------------
|
|
64
|
+
-- agent 1 = FLEET agent, owner of board 1, bound to machines A and B. The #276 subject.
|
|
65
|
+
-- agent 2 = grantee, an active member of every board here.
|
|
66
|
+
-- agent 3 = NON-FLEET agent, owner of board 2. It has NO fleet_machine_agents row at all —
|
|
67
|
+
-- the exemption category judgement 2 is about.
|
|
68
|
+
-- agent 4 = FLEET agent, owner of board 3, bound to machines A and B. Used for the
|
|
69
|
+
-- multi-binding case: its binding on machine A is revoked, B stays active.
|
|
70
|
+
-- agent 5 = operator principal that performs the revocations.
|
|
71
|
+
|
|
72
|
+
insert into auth.users(id, instance_id, aud, role, email)
|
|
73
|
+
select ('27600000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
|
|
74
|
+
'00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
|
|
75
|
+
'k276u'||n||'@t.test'
|
|
76
|
+
from generate_series(1,6) n;
|
|
77
|
+
|
|
78
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
|
|
79
|
+
select ('27610000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid,
|
|
80
|
+
'agent','agent','k276-agent-'||n,'K276 Agent '||n,'active',
|
|
81
|
+
('27600000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid
|
|
82
|
+
from generate_series(1,6) n;
|
|
83
|
+
|
|
84
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
|
|
85
|
+
values
|
|
86
|
+
('27620000-0000-4000-8000-000000000001','k276-board-1','K276 Board 1','27610000-0000-4000-8000-000000000001','private','active'),
|
|
87
|
+
('27620000-0000-4000-8000-000000000002','k276-board-2','K276 Board 2','27610000-0000-4000-8000-000000000003','private','active'),
|
|
88
|
+
('27620000-0000-4000-8000-000000000003','k276-board-3','K276 Board 3','27610000-0000-4000-8000-000000000004','private','active'),
|
|
89
|
+
-- board 4 exists solely for the invite ALLOW side: its owner (agent 6) is never revoked
|
|
90
|
+
-- and never role-clamped, so a failure there is the gate and not fixture coupling.
|
|
91
|
+
('27620000-0000-4000-8000-000000000004','k276-board-4','K276 Board 4','27610000-0000-4000-8000-000000000006','private','active');
|
|
92
|
+
|
|
93
|
+
insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
|
|
94
|
+
values
|
|
95
|
+
('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000001','owner','joined','27610000-0000-4000-8000-000000000001'),
|
|
96
|
+
('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000001'),
|
|
97
|
+
('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003','owner','joined','27610000-0000-4000-8000-000000000003'),
|
|
98
|
+
('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000003'),
|
|
99
|
+
('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000004','owner','joined','27610000-0000-4000-8000-000000000004'),
|
|
100
|
+
('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000004'),
|
|
101
|
+
('27620000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000006','owner','joined','27610000-0000-4000-8000-000000000006');
|
|
102
|
+
|
|
103
|
+
-- Agent 1's live signing key: the signed route needs a decision naming a live, owned key so
|
|
104
|
+
-- that a refusal can only be the #276 gate and never #269's revalidation.
|
|
105
|
+
insert into agent_board.agent_signing_keys(
|
|
106
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
107
|
+
status, proof_state, expires_at, revoked_at)
|
|
108
|
+
values
|
|
109
|
+
('27630000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000001',
|
|
110
|
+
'primary', 1, public.test_pubkey('k276-a'), 'raw_ed25519_base64url',
|
|
111
|
+
public.test_key_fingerprint('k276-a'), 'active', 'proven', null, null);
|
|
112
|
+
|
|
113
|
+
-- Fleet substrate: one trust domain, two ACTIVE machines, and bindings for agents 1 and 4 on
|
|
114
|
+
-- BOTH machines. Two machines is what makes judgement 1 testable at all.
|
|
115
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
116
|
+
values ('27640000-0000-4000-8000-000000000001','k276-domain','K276 Domain','tailnet',
|
|
117
|
+
'27610000-0000-4000-8000-000000000005','active', now());
|
|
118
|
+
|
|
119
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state, enrolled_at)
|
|
120
|
+
values
|
|
121
|
+
('27650000-0000-4000-8000-00000000000a','27640000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005','k276-machine-a','fp-k276-a','active', now()),
|
|
122
|
+
('27650000-0000-4000-8000-00000000000b','27640000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005','k276-machine-b','fp-k276-b','active', now());
|
|
123
|
+
|
|
124
|
+
insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, agent_role, lifecycle_state)
|
|
125
|
+
values
|
|
126
|
+
('27660000-0000-4000-8000-00000000001a','27650000-0000-4000-8000-00000000000a','27610000-0000-4000-8000-000000000001','phase_worker','active'),
|
|
127
|
+
('27660000-0000-4000-8000-00000000001b','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000001','phase_worker','active'),
|
|
128
|
+
('27660000-0000-4000-8000-00000000004a','27650000-0000-4000-8000-00000000000a','27610000-0000-4000-8000-000000000004','phase_worker','active'),
|
|
129
|
+
('27660000-0000-4000-8000-00000000004b','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000004','phase_worker','active');
|
|
130
|
+
|
|
131
|
+
create function pg_temp.gwa() returns jsonb language sql immutable as $$
|
|
132
|
+
select jsonb_build_object(
|
|
133
|
+
'agent_id', '27610000-0000-4000-8000-000000000001',
|
|
134
|
+
'signing_key_id', '27630000-0000-4000-8000-000000000001',
|
|
135
|
+
'key_id', 'primary',
|
|
136
|
+
'key_version', 1,
|
|
137
|
+
'algorithm', 'Ed25519',
|
|
138
|
+
'verification_status', 'verified',
|
|
139
|
+
'payload_digest', 'sha256:' || repeat('a', 64),
|
|
140
|
+
'signature_digest', 'sha256:' || repeat('b', 64),
|
|
141
|
+
'canonicalization_version', 'agent-provenance-jcs.v1',
|
|
142
|
+
'enforcement_mode', 'require_for_agent_writes',
|
|
143
|
+
'redacted', true);
|
|
144
|
+
$$;
|
|
145
|
+
|
|
146
|
+
create function pg_temp.grant_payload(p_role text) returns jsonb language sql immutable as $$
|
|
147
|
+
select jsonb_build_object(
|
|
148
|
+
'p_board_id', '27620000-0000-4000-8000-000000000001',
|
|
149
|
+
'p_agent_id', '27610000-0000-4000-8000-000000000002',
|
|
150
|
+
'p_grantee_role', p_role);
|
|
151
|
+
$$;
|
|
152
|
+
|
|
153
|
+
create function pg_temp.role_on(p_board uuid, p_agent uuid) returns text language sql stable as $$
|
|
154
|
+
select role from agent_board.board_memberships where board_id = p_board and agent_id = p_agent;
|
|
155
|
+
$$;
|
|
156
|
+
|
|
157
|
+
-- ===========================================================================
|
|
158
|
+
-- 1. The gate helper exists, is unreachable by a caller, and is documented.
|
|
159
|
+
-- A SECURITY DEFINER function grants EXECUTE to PUBLIC by default and this one locks and
|
|
160
|
+
-- reads rows for an arbitrary agent id with no authorization of its own (#118).
|
|
161
|
+
-- ===========================================================================
|
|
162
|
+
|
|
163
|
+
select has_function('agent_board', '_assert_actor_not_fleet_revoked', array['uuid','text'],
|
|
164
|
+
'the fleet-revocation gate helper exists');
|
|
165
|
+
|
|
166
|
+
select is(
|
|
167
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
168
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
169
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
170
|
+
where p.oid = 'agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure
|
|
171
|
+
and a.privilege_type = 'EXECUTE'),
|
|
172
|
+
'postgres,service_role',
|
|
173
|
+
'the gate helper executes for service_role only (never PUBLIC/anon/authenticated)');
|
|
174
|
+
|
|
175
|
+
select isnt(
|
|
176
|
+
(select obj_description('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure, 'pg_proc')),
|
|
177
|
+
null,
|
|
178
|
+
'the gate helper carries its COMMENT');
|
|
179
|
+
|
|
180
|
+
-- The null branch is unreachable from all three chokepoints (each resolves or gates its actor
|
|
181
|
+
-- first), so it is pinned by calling the helper DIRECTLY rather than left as a branch no
|
|
182
|
+
-- mutation could falsify (CLAUDE.md rule 2).
|
|
183
|
+
select pg_temp.service();
|
|
184
|
+
|
|
185
|
+
select throws_like(
|
|
186
|
+
$$select agent_board._assert_actor_not_fleet_revoked(null::uuid, 'probe')$$,
|
|
187
|
+
'%fleet_revoked_unresolved%',
|
|
188
|
+
'the gate fails closed on a null actor');
|
|
189
|
+
|
|
190
|
+
-- ===========================================================================
|
|
191
|
+
-- 2. ALLOW SIDE, unsigned and signed: a fleet-bound but NOT-revoked owner is untouched.
|
|
192
|
+
-- This is the blast-radius assertion — the gate must cost an unrevoked fleet agent nothing.
|
|
193
|
+
-- ===========================================================================
|
|
194
|
+
|
|
195
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
196
|
+
|
|
197
|
+
select lives_ok(
|
|
198
|
+
$$create temp table _c1 as
|
|
199
|
+
select * from agent_board.grant_board_role(
|
|
200
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
201
|
+
'an ACTIVE, fleet-BOUND, unrevoked owner still completes an unsigned grant');
|
|
202
|
+
|
|
203
|
+
select is(
|
|
204
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
205
|
+
'admin',
|
|
206
|
+
'and it actually raised the grantee role');
|
|
207
|
+
|
|
208
|
+
select pg_temp.service();
|
|
209
|
+
|
|
210
|
+
select lives_ok(
|
|
211
|
+
$$create temp table _sg as
|
|
212
|
+
select * from agent_board.signed_grant_board_role(pg_temp.grant_payload('member'), pg_temp.gwa())$$,
|
|
213
|
+
'and the SIGNED route by the same unrevoked owner still completes');
|
|
214
|
+
|
|
215
|
+
select is(
|
|
216
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
217
|
+
'member',
|
|
218
|
+
'and the signed grant actually wrote the grantee role');
|
|
219
|
+
|
|
220
|
+
-- ===========================================================================
|
|
221
|
+
-- 3. JUDGEMENT 2, asserted POSITIVELY: a principal with NO fleet binding is unaffected.
|
|
222
|
+
-- A predicate phrased as "has an active binding" would refuse this grant. Never asserted by
|
|
223
|
+
-- the absence of a match — agent 3 completes a real grant and the role is read back.
|
|
224
|
+
-- ===========================================================================
|
|
225
|
+
|
|
226
|
+
select is(
|
|
227
|
+
(select count(*)::int from agent_board.fleet_machine_agents
|
|
228
|
+
where agent_id = '27610000-0000-4000-8000-000000000003'),
|
|
229
|
+
0,
|
|
230
|
+
'agent 3 has NO fleet binding at all — the exemption category, established before it is used');
|
|
231
|
+
|
|
232
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000003');
|
|
233
|
+
|
|
234
|
+
select lives_ok(
|
|
235
|
+
$$create temp table _c2 as
|
|
236
|
+
select * from agent_board.grant_board_role(
|
|
237
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
238
|
+
'a NON-FLEET owner still completes a grant [judgement 2: no capability regression]');
|
|
239
|
+
|
|
240
|
+
select is(
|
|
241
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002'),
|
|
242
|
+
'admin',
|
|
243
|
+
'and the non-fleet grant actually wrote the role');
|
|
244
|
+
|
|
245
|
+
select lives_ok(
|
|
246
|
+
$$create temp table _c3 as
|
|
247
|
+
select * from agent_board.set_board_role_gate('27620000-0000-4000-8000-000000000002', true)$$,
|
|
248
|
+
'a NON-FLEET owner still flips the role gate');
|
|
249
|
+
|
|
250
|
+
select lives_ok(
|
|
251
|
+
$$create temp table _c4 as
|
|
252
|
+
select * from agent_board.set_board_authority_ceiling('27620000-0000-4000-8000-000000000002','owner')$$,
|
|
253
|
+
'a NON-FLEET owner still sets the board authority ceiling');
|
|
254
|
+
|
|
255
|
+
select is(
|
|
256
|
+
(select max_grantable_role from agent_board.board_authority_ceilings
|
|
257
|
+
where board_id = '27620000-0000-4000-8000-000000000002'),
|
|
258
|
+
'owner',
|
|
259
|
+
'and the non-fleet ceiling write landed');
|
|
260
|
+
|
|
261
|
+
-- ===========================================================================
|
|
262
|
+
-- 4. THE PRECONDITION, asserted before the refusal is probed. revoke_fleet_subject('agent')
|
|
263
|
+
-- changes the ledger and the bindings and NOTHING else — the agent stays active and its
|
|
264
|
+
-- signing key stays live. Without this, every refusal below could be passing because some
|
|
265
|
+
-- OTHER predicate started failing, which is the "succeeds/fails for the wrong reason"
|
|
266
|
+
-- trap this repo has been bitten by.
|
|
267
|
+
-- ===========================================================================
|
|
268
|
+
|
|
269
|
+
select pg_temp.service();
|
|
270
|
+
|
|
271
|
+
select lives_ok(
|
|
272
|
+
$$create temp table _rv as
|
|
273
|
+
select * from agent_board.revoke_fleet_subject(
|
|
274
|
+
'agent','27610000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005',
|
|
275
|
+
'k276 operator revocation')$$,
|
|
276
|
+
'revoke_fleet_subject(agent, 1) succeeds');
|
|
277
|
+
|
|
278
|
+
select is(
|
|
279
|
+
(select count(*)::int from agent_board.fleet_revocation_events
|
|
280
|
+
where subject_kind = 'agent' and subject_id = '27610000-0000-4000-8000-000000000001'),
|
|
281
|
+
1,
|
|
282
|
+
'it wrote exactly one agent-subject ledger row');
|
|
283
|
+
|
|
284
|
+
select is(
|
|
285
|
+
(select status from agent_board.agents where id = '27610000-0000-4000-8000-000000000001'),
|
|
286
|
+
'active',
|
|
287
|
+
'PRECONDITION: the revoked agent is STILL active — the ledger row is the only authority signal');
|
|
288
|
+
|
|
289
|
+
select is(
|
|
290
|
+
(select status from agent_board.agent_signing_keys where id = '27630000-0000-4000-8000-000000000001'),
|
|
291
|
+
'active',
|
|
292
|
+
'PRECONDITION: its signing key is STILL live, so #269 revalidation cannot be what refuses below');
|
|
293
|
+
|
|
294
|
+
select is(
|
|
295
|
+
(select role from agent_board.board_memberships
|
|
296
|
+
where board_id = '27620000-0000-4000-8000-000000000001'
|
|
297
|
+
and agent_id = '27610000-0000-4000-8000-000000000001'),
|
|
298
|
+
'owner',
|
|
299
|
+
'PRECONDITION: it is STILL a board owner, so the owner gate cannot be what refuses below');
|
|
300
|
+
|
|
301
|
+
-- ===========================================================================
|
|
302
|
+
-- 5. THE REFUSAL: unsigned route. This is the wider door — `authenticated`, no gateway.
|
|
303
|
+
-- ===========================================================================
|
|
304
|
+
|
|
305
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
306
|
+
|
|
307
|
+
select throws_like(
|
|
308
|
+
$$select * from agent_board.grant_board_role(
|
|
309
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
310
|
+
'%fleet_revoked:%grant_board_role%',
|
|
311
|
+
'a ledger-revoked grantor cannot complete an UNSIGNED grant');
|
|
312
|
+
|
|
313
|
+
select is(
|
|
314
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
315
|
+
'member',
|
|
316
|
+
'and the refused unsigned grant wrote nothing');
|
|
317
|
+
|
|
318
|
+
-- ===========================================================================
|
|
319
|
+
-- 6. THE REFUSAL: signed route. Inherited by construction — the check lives INSIDE
|
|
320
|
+
-- grant_board_role, and signed_grant_board_role is its only other referrer in the database.
|
|
321
|
+
-- Asserted anyway, because "by construction" is a claim until something fails on it.
|
|
322
|
+
-- ===========================================================================
|
|
323
|
+
|
|
324
|
+
select pg_temp.service();
|
|
325
|
+
|
|
326
|
+
select throws_like(
|
|
327
|
+
$$select * from agent_board.signed_grant_board_role(pg_temp.grant_payload('owner'), pg_temp.gwa())$$,
|
|
328
|
+
'%fleet_revoked:%grant_board_role%',
|
|
329
|
+
'a ledger-revoked signer cannot complete a SIGNED grant either [Portal #362 divergence test]');
|
|
330
|
+
|
|
331
|
+
select is(
|
|
332
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
333
|
+
'member',
|
|
334
|
+
'and the refused signed grant wrote nothing');
|
|
335
|
+
|
|
336
|
+
-- IDENTITY, not a count. `count(*) = 1` would still read 1 if the ALLOW-side write stopped
|
|
337
|
+
-- recording and the REFUSAL path started recording instead — the two errors cancel, and the
|
|
338
|
+
-- assertion cannot tell the difference. Pinning the surviving row's id to the one the
|
|
339
|
+
-- section-2 allow-side call returned makes both halves observable: a second row breaks the
|
|
340
|
+
-- count half, a substituted row breaks the identity half.
|
|
341
|
+
select is(
|
|
342
|
+
(select string_agg(se.id::text, ',' order by se.id) from agent_board.signature_evidence se
|
|
343
|
+
where se.operation = 'grant_board_role'),
|
|
344
|
+
(select sg.signature_evidence_id::text from _sg sg),
|
|
345
|
+
'and it recorded no NEW evidence row — the only grant_board_role evidence in the database is '
|
|
346
|
+
'still exactly the row section 2''s allow-side signed write returned');
|
|
347
|
+
|
|
348
|
+
select throws_like(
|
|
349
|
+
$$select * from agent_board.signed_set_board_authority_ceiling(
|
|
350
|
+
jsonb_build_object('p_board_id','27620000-0000-4000-8000-000000000001',
|
|
351
|
+
'p_max_grantable_role','observer'),
|
|
352
|
+
pg_temp.gwa())$$,
|
|
353
|
+
'%fleet_revoked:%set_board_authority_ceiling%',
|
|
354
|
+
'and the SIGNED ceiling wrapper is refused for the same principal');
|
|
355
|
+
|
|
356
|
+
-- A service_role gateway that SUPPLIES the revoked principal as the grantor argument is
|
|
357
|
+
-- refused too: the gate binds the principal the write is attributed to, not the connection role.
|
|
358
|
+
select throws_like(
|
|
359
|
+
$$select * from agent_board.grant_board_role(
|
|
360
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','owner',
|
|
361
|
+
'27610000-0000-4000-8000-000000000001')$$,
|
|
362
|
+
'%fleet_revoked:%',
|
|
363
|
+
'service_role supplying the revoked principal as p_grantor_agent_id is refused too');
|
|
364
|
+
|
|
365
|
+
-- Back to the authenticated owner: the signed probes above ran as service_role, and the two
|
|
366
|
+
-- setters gate on current_agent_id(), which service_role does not have.
|
|
367
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
368
|
+
|
|
369
|
+
-- The two SETTERS are probed last in this section deliberately. Each refusal probe leaves a
|
|
370
|
+
-- write behind when the gate is removed, and a ceiling written by an earlier probe would then
|
|
371
|
+
-- make the signed grant below fail on _grant_within_ceiling instead of on the gate -- masking
|
|
372
|
+
-- the evidence-row assertion from every mutation. Ordering is load-bearing, not cosmetic.
|
|
373
|
+
select throws_like(
|
|
374
|
+
$$select * from agent_board.set_board_authority_ceiling(
|
|
375
|
+
'27620000-0000-4000-8000-000000000001','observer')$$,
|
|
376
|
+
'%fleet_revoked:%set_board_authority_ceiling%',
|
|
377
|
+
'a ledger-revoked owner cannot change the board authority ceiling');
|
|
378
|
+
|
|
379
|
+
select is(
|
|
380
|
+
(select count(*)::int from agent_board.board_authority_ceilings
|
|
381
|
+
where board_id = '27620000-0000-4000-8000-000000000001'),
|
|
382
|
+
0,
|
|
383
|
+
'and the refused ceiling change wrote no ceiling row (the raise rolled the upsert back)');
|
|
384
|
+
|
|
385
|
+
select throws_like(
|
|
386
|
+
$$select * from agent_board.set_board_role_gate('27620000-0000-4000-8000-000000000001', true)$$,
|
|
387
|
+
'%fleet_revoked:%set_board_role_gate%',
|
|
388
|
+
'a ledger-revoked owner cannot flip the board role gate');
|
|
389
|
+
|
|
390
|
+
select is(
|
|
391
|
+
(select role_gate_enforced from agent_board.boards where id = '27620000-0000-4000-8000-000000000001'),
|
|
392
|
+
false,
|
|
393
|
+
'and the refused gate flip left the boards row alone (the raise rolled the UPDATE back)');
|
|
394
|
+
|
|
395
|
+
-- ===========================================================================
|
|
396
|
+
-- 7. JUDGEMENT 1 — the multi-binding case, and the agreement with the send path.
|
|
397
|
+
--
|
|
398
|
+
-- Agent 4 is bound to machines A and B. Its binding on A is revoked permanently; B stays
|
|
399
|
+
-- active. That is a PER-MACHINE fact. evaluate_fleet_sender_policy answers it by candidate
|
|
400
|
+
-- selection (it finds B and proceeds), so authority must too. Only the AGENT-SUBJECT ledger
|
|
401
|
+
-- row is agent-wide, and then both must refuse. Both halves are asserted on BOTH paths, so
|
|
402
|
+
-- the two can never be right separately and wrong together.
|
|
403
|
+
-- ===========================================================================
|
|
404
|
+
|
|
405
|
+
select pg_temp.service();
|
|
406
|
+
|
|
407
|
+
select lives_ok(
|
|
408
|
+
$$create temp table _rb as
|
|
409
|
+
select * from agent_board.revoke_fleet_machine_agent_binding(
|
|
410
|
+
'27660000-0000-4000-8000-00000000004a','27610000-0000-4000-8000-000000000005',
|
|
411
|
+
'k276 per-machine binding revocation')$$,
|
|
412
|
+
'agent 4''s binding on machine A is revoked; its binding on machine B stays active');
|
|
413
|
+
|
|
414
|
+
select is(
|
|
415
|
+
(select lifecycle_state from agent_board.fleet_machine_agents
|
|
416
|
+
where id = '27660000-0000-4000-8000-00000000004b'),
|
|
417
|
+
'active',
|
|
418
|
+
'PRECONDITION: the machine-B binding really is still active (the multi-binding shape exists)');
|
|
419
|
+
|
|
420
|
+
select is(
|
|
421
|
+
(select denial_class from agent_board.evaluate_fleet_sender_policy(
|
|
422
|
+
'27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000002',
|
|
423
|
+
'27650000-0000-4000-8000-00000000000b')),
|
|
424
|
+
'recipient_policy_denied',
|
|
425
|
+
'SEND PATH: one revoked binding does not make agent 4 machine_revoked — it gets past the '
|
|
426
|
+
'revocation check on its surviving binding');
|
|
427
|
+
|
|
428
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
429
|
+
|
|
430
|
+
select lives_ok(
|
|
431
|
+
$$create temp table _c5 as
|
|
432
|
+
select * from agent_board.grant_board_role(
|
|
433
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
434
|
+
'AUTHORITY PATH agrees: one revoked binding does not remove agent 4''s ability to grant');
|
|
435
|
+
|
|
436
|
+
select is(
|
|
437
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002'),
|
|
438
|
+
'admin',
|
|
439
|
+
'and that grant really landed');
|
|
440
|
+
|
|
441
|
+
-- Now the AGENT-WIDE act, on the same agent, with a binding still nominally in play.
|
|
442
|
+
select pg_temp.service();
|
|
443
|
+
|
|
444
|
+
-- Mint an admin link for agent 4 while it is STILL unrevoked. Section 10 redeems it: this is
|
|
445
|
+
-- the outstanding-link case, the one the mint gate structurally cannot reach and the reason
|
|
446
|
+
-- both halves of the invite route are gated.
|
|
447
|
+
select lives_ok(
|
|
448
|
+
$$create temp table _pre as select * from agent_board.create_invite_link(
|
|
449
|
+
'27620000-0000-4000-8000-000000000003','admin','joined',5, now() + interval '2 days',
|
|
450
|
+
'27610000-0000-4000-8000-000000000004')$$,
|
|
451
|
+
'agent 4 mints an admin invite link BEFORE it is revoked');
|
|
452
|
+
|
|
453
|
+
select lives_ok(
|
|
454
|
+
$$create temp table _rv4 as
|
|
455
|
+
select * from agent_board.revoke_fleet_subject(
|
|
456
|
+
'agent','27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000005',
|
|
457
|
+
'k276 agent-wide revocation')$$,
|
|
458
|
+
'agent 4 is now revoked as an agent-SUBJECT');
|
|
459
|
+
|
|
460
|
+
select is(
|
|
461
|
+
(select denial_class from agent_board.evaluate_fleet_sender_policy(
|
|
462
|
+
'27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000002',
|
|
463
|
+
'27650000-0000-4000-8000-00000000000b')),
|
|
464
|
+
'machine_revoked',
|
|
465
|
+
'SEND PATH: the agent-subject ledger row denies agent 4 on the machine it was still bound to');
|
|
466
|
+
|
|
467
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
468
|
+
|
|
469
|
+
select throws_like(
|
|
470
|
+
$$select * from agent_board.grant_board_role(
|
|
471
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
472
|
+
'%fleet_revoked:%',
|
|
473
|
+
'AUTHORITY PATH agrees: the agent-subject row denies agent 4 too [judgement 1: one answer]');
|
|
474
|
+
|
|
475
|
+
select is(
|
|
476
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002'),
|
|
477
|
+
'admin',
|
|
478
|
+
'and the refused grant left agent 2 where the earlier grant put it');
|
|
479
|
+
|
|
480
|
+
-- ===========================================================================
|
|
481
|
+
-- 8. NO SUPERSESSION CLAUSE, mirrored from the send path. reactivate_fleet_machine stamps a
|
|
482
|
+
-- supersession point that the sender policy honours for MACHINE-subject events only; it
|
|
483
|
+
-- never un-revokes a sender agent. A future `effective_at` comparison added to the gate
|
|
484
|
+
-- would let a reactivated machine silently restore a revoked agent's authority, so the
|
|
485
|
+
-- absence is pinned as a behaviour, not left to the comment.
|
|
486
|
+
-- ===========================================================================
|
|
487
|
+
|
|
488
|
+
select pg_temp.service();
|
|
489
|
+
|
|
490
|
+
select lives_ok(
|
|
491
|
+
$$create temp table _rm as
|
|
492
|
+
select * from agent_board.revoke_fleet_subject(
|
|
493
|
+
'machine','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000005',
|
|
494
|
+
'k276 machine revocation')$$,
|
|
495
|
+
'machine B is revoked, so it has a machine-subject ledger row that CAN be superseded');
|
|
496
|
+
|
|
497
|
+
select lives_ok(
|
|
498
|
+
$$create temp table _ra as
|
|
499
|
+
select * from agent_board.reactivate_fleet_machine(
|
|
500
|
+
'27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000005','k276 reactivation')$$,
|
|
501
|
+
'and it is reactivated, stamping the supersession point AFTER agent 4''s agent-subject revocation');
|
|
502
|
+
|
|
503
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
504
|
+
|
|
505
|
+
select throws_like(
|
|
506
|
+
$$select * from agent_board.grant_board_role(
|
|
507
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
508
|
+
'%fleet_revoked:%',
|
|
509
|
+
'a machine reactivation does NOT restore a ledger-revoked agent''s authority (no supersession)');
|
|
510
|
+
|
|
511
|
+
-- ===========================================================================
|
|
512
|
+
-- 9. CLASS ASSERTIONS (catalog-derived, CLAUDE.md rule 1).
|
|
513
|
+
--
|
|
514
|
+
-- The class is not "the three functions #276 happened to edit". It is: every agent_board
|
|
515
|
+
-- function that WRITES a board-authority relation, derived from pg_proc by scanning stripped
|
|
516
|
+
-- bodies (never raw prosrc — #196/#199, a comment or a literal naming a marker must not be
|
|
517
|
+
-- able to satisfy any of these). Both sides are derived and compared, so a new authority
|
|
518
|
+
-- write that omits the gate appears in one set and not the other and the difference NAMES it.
|
|
519
|
+
--
|
|
520
|
+
-- THE MEMBERSHIPS ARM MATCHES ANY INSERT, and the reason is a defect this assertion had.
|
|
521
|
+
-- It first matched `update … set … role =` only. Review found `create_board` — which is
|
|
522
|
+
-- EXECUTE-able by `authenticated` — seeding `role = 'owner'` with an INSERT, and its
|
|
523
|
+
-- `on conflict … do update set role = 'owner'` is an UPDATE of bm.role that the UPDATE-shaped
|
|
524
|
+
-- regex STILL could not match, because the table name precedes `do update`. The description
|
|
525
|
+
-- said "WRITE" while the regex checked one syntax: a comment claiming more than the assertion
|
|
526
|
+
-- does, which is this repo's recorded failure shape.
|
|
527
|
+
--
|
|
528
|
+
-- Shape-blind costs nothing and is strictly stronger. Measured: the any-insert form names the
|
|
529
|
+
-- SAME five membership writers a role-token form does. It subsumes on-conflict by
|
|
530
|
+
-- construction (that clause exists only inside an insert) and it also catches a positional
|
|
531
|
+
-- insert naming no columns, which any column-matching form would miss.
|
|
532
|
+
-- ===========================================================================
|
|
533
|
+
|
|
534
|
+
select is(
|
|
535
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
536
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
537
|
+
where n.nspname = 'agent_board'
|
|
538
|
+
and (agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_memberships\M'
|
|
539
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
|
|
540
|
+
or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
|
|
541
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
|
|
542
|
+
or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')),
|
|
543
|
+
'_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,'
|
|
544
|
+
|| '_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board,'
|
|
545
|
+
|| 'create_invite_link,grant_board_role,set_board_authority_ceiling,set_board_role_gate',
|
|
546
|
+
'the functions that WRITE a board-authority relation — in ANY shape, including a mintable '
|
|
547
|
+
'invite preset_role — are exactly these nine');
|
|
548
|
+
|
|
549
|
+
-- EITHER helper counts as consulting the ledger: the invite redeem path calls the UNLOCKED
|
|
550
|
+
-- predicate (see the migration header), so a side that matched only the share-locking assert
|
|
551
|
+
-- would report the gated redeem path as ungated. The two helpers are excluded because they ARE
|
|
552
|
+
-- the gate — _assert_actor_not_fleet_revoked calls _actor_fleet_revoked, so it matches its own
|
|
553
|
+
-- regex.
|
|
554
|
+
select is(
|
|
555
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
556
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
557
|
+
where n.nspname = 'agent_board'
|
|
558
|
+
and p.proname not in ('_assert_actor_not_fleet_revoked', '_actor_fleet_revoked')
|
|
559
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('),
|
|
560
|
+
'_redeem_unbounded_invite_for_agent,create_board,create_invite_link,grant_board_role,'
|
|
561
|
+
|| 'set_board_authority_ceiling,set_board_role_gate',
|
|
562
|
+
'the functions that consult the fleet revocation ledger are exactly these six (#280 added '
|
|
563
|
+
'create_board)');
|
|
564
|
+
|
|
565
|
+
-- The gate proper: the two sets must differ by EXACTLY the three asserted exemptions, and any
|
|
566
|
+
-- other member of the difference is NAMED in the failure. Each excluded name is licensed by a
|
|
567
|
+
-- positive disposition below, never by the absence of a match. create_board was the fourth
|
|
568
|
+
-- until #280 gated it; removing it from this list is what makes the migration's claim checkable
|
|
569
|
+
-- rather than asserted — if the gate were dropped from the function, create_board reappears in
|
|
570
|
+
-- the difference and this assertion prints its name.
|
|
571
|
+
select is(
|
|
572
|
+
(select string_agg(x.proname || ':' || x.why, '; ' order by x.proname)
|
|
573
|
+
from (
|
|
574
|
+
select p.proname,
|
|
575
|
+
case when agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('
|
|
576
|
+
then 'consults the ledger but writes no authority relation'
|
|
577
|
+
else 'writes an authority relation with NO ledger gate'
|
|
578
|
+
end as why
|
|
579
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
580
|
+
where n.nspname = 'agent_board'
|
|
581
|
+
and p.proname not in (
|
|
582
|
+
-- the three asserted exemptions (create_board was the fourth until #280)...
|
|
583
|
+
'_upsert_board_membership_state',
|
|
584
|
+
'_fedsession_autojoin_default_board', 'bootstrap_board_owner_memberships',
|
|
585
|
+
-- ...and the two helpers, which ARE the gate rather than users of it
|
|
586
|
+
'_assert_actor_not_fleet_revoked', '_actor_fleet_revoked')
|
|
587
|
+
and ((agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_memberships\M'
|
|
588
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
|
|
589
|
+
or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
|
|
590
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
|
|
591
|
+
or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')
|
|
592
|
+
<> (agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('))
|
|
593
|
+
) x),
|
|
594
|
+
null,
|
|
595
|
+
'no function writes an authority relation without the ledger gate, or vice versa');
|
|
596
|
+
|
|
597
|
+
-- ---------------------------------------------------------------------------
|
|
598
|
+
-- EXEMPTION A — _upsert_board_membership_state (the unauthorized membership primitive).
|
|
599
|
+
--
|
|
600
|
+
-- The signature is asserted as an EXACT STRING, not as a `role` word match. `_` is a word
|
|
601
|
+
-- CHARACTER in POSIX regex, so there is no word boundary between `p_` and `role`:
|
|
602
|
+
-- 'p_role text' ~ '\mrole\M' -> false
|
|
603
|
+
-- 'p_grantee_role text' ~ '\mrole\M' -> false
|
|
604
|
+
-- and every parameter in this codebase is `p_*`. The word-match form this assertion first used
|
|
605
|
+
-- would therefore have stayed GREEN while somebody added `p_role` and threaded it into the role
|
|
606
|
+
-- write — destroying the very claim the assertion exists to make.
|
|
607
|
+
--
|
|
608
|
+
-- string_agg over ALL oids of the name, not a single-row lookup: `create or replace` cannot add
|
|
609
|
+
-- a parameter, so that mutation can only land as a new OVERLOAD, and a scalar subquery over two
|
|
610
|
+
-- oids ABORTS the file instead of failing one assertion (CLAUDE.md rule 3).
|
|
611
|
+
-- ---------------------------------------------------------------------------
|
|
612
|
+
select is(
|
|
613
|
+
(select string_agg(pg_get_function_identity_arguments(p.oid), ' | ' order by p.oid)
|
|
614
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
615
|
+
where n.nspname = 'agent_board' and p.proname = '_upsert_board_membership_state'),
|
|
616
|
+
'p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, '
|
|
617
|
+
|| 'p_metadata jsonb, p_membership_id uuid, p_event_kind text',
|
|
618
|
+
'EXEMPTION A1: _upsert_board_membership_state takes EXACTLY these parameters — no role among '
|
|
619
|
+
'them, and no overload carrying one');
|
|
620
|
+
|
|
621
|
+
select is(
|
|
622
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
623
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
624
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
625
|
+
where p.oid = (select p2.oid from pg_proc p2 join pg_namespace n2 on n2.oid = p2.pronamespace
|
|
626
|
+
where n2.nspname = 'agent_board' and p2.proname = '_upsert_board_membership_state')
|
|
627
|
+
and a.privilege_type = 'EXECUTE'),
|
|
628
|
+
'postgres,service_role',
|
|
629
|
+
'EXEMPTION A2: and it is not EXECUTE-able by authenticated, anon or PUBLIC');
|
|
630
|
+
|
|
631
|
+
select pg_temp.service();
|
|
632
|
+
|
|
633
|
+
select lives_ok(
|
|
634
|
+
$$create temp table _cl as select agent_board._upsert_board_membership_state(
|
|
635
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
|
|
636
|
+
'left','27610000-0000-4000-8000-000000000003','{}'::jsonb)$$,
|
|
637
|
+
'EXEMPTION A3 (setup): the owner of board 2 is moved to a terminal state');
|
|
638
|
+
|
|
639
|
+
select lives_ok(
|
|
640
|
+
$$create temp table _cl2 as select agent_board._upsert_board_membership_state(
|
|
641
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
|
|
642
|
+
'joined','27610000-0000-4000-8000-000000000003','{}'::jsonb)$$,
|
|
643
|
+
'EXEMPTION A3 (act): and re-entered as active');
|
|
644
|
+
|
|
645
|
+
select is(
|
|
646
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003'),
|
|
647
|
+
'member',
|
|
648
|
+
'EXEMPTION A3: its only role write is the ROLERESET clamp to the table default — it DEMOTES '
|
|
649
|
+
'an owner and can never confer authority, which is why it needs no ledger gate');
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
-- EXEMPTION A4 — the claim A1 could not make on its own.
|
|
653
|
+
--
|
|
654
|
+
-- A1 pins the HELPER's signature, and A3 demonstrates the ROLERESET clamp. Neither establishes
|
|
655
|
+
-- the broader sentence the exemption actually rests on: "no caller can hand this helper a role".
|
|
656
|
+
-- A1 covers the helper; A3 covers one code path. The gap is every OTHER function that reaches
|
|
657
|
+
-- the primitive — a caller that gained a role parameter and threaded it through would leave
|
|
658
|
+
-- both green while making the exemption false.
|
|
659
|
+
--
|
|
660
|
+
-- So the whole caller set is swept, in the EXACT-signature form rather than a word match (`_`
|
|
661
|
+
-- is a word character in POSIX regex, so `\mrole\M` does not match `p_role`, and every
|
|
662
|
+
-- parameter here is `p_*` — the same evasion G2 closed). A new caller, a removed caller, or an
|
|
663
|
+
-- existing caller gaining ANY parameter all change this string, and the failure prints the set.
|
|
664
|
+
--
|
|
665
|
+
-- Swept and clean at the time of writing: not one of the five declares a role-bearing
|
|
666
|
+
-- parameter. `invite_board_member` and `update_board_membership` admit at the membership floor
|
|
667
|
+
-- or move membership STATE; a `member` holds no authority to grant anything onward. That is why
|
|
668
|
+
-- they are outside the ratified criterion and are recorded as a bounded residual in the
|
|
669
|
+
-- migration header rather than gated here.
|
|
670
|
+
select is(
|
|
671
|
+
(select string_agg(p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', ' | '
|
|
672
|
+
order by p.proname)
|
|
673
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
674
|
+
where n.nspname = 'agent_board'
|
|
675
|
+
and p.proname <> '_upsert_board_membership_state'
|
|
676
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m_upsert_board_membership_state\s*\('),
|
|
677
|
+
'invite_board_member(p_board_id uuid, p_agent_id uuid, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
678
|
+
|| ' | join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
679
|
+
|| ' | request_join_board(p_board_id uuid, p_agent_id uuid, p_metadata jsonb)'
|
|
680
|
+
|| ' | resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
681
|
+
|| ' | update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb)',
|
|
682
|
+
'EXEMPTION A4: EVERY caller of _upsert_board_membership_state takes EXACTLY these parameters '
|
|
683
|
+
'— not one declares a role, so "no caller can hand it one" is checked rather than argued');
|
|
684
|
+
|
|
685
|
+
-- ---------------------------------------------------------------------------
|
|
686
|
+
-- EXEMPTIONS C and D — the two remaining service_role-only seeders. (What used to be
|
|
687
|
+
-- exemption C, _redeem_unbounded_invite_for_agent, is now GATED — see section 10; what used to
|
|
688
|
+
-- be exemption B, create_board, is now GATED too — see section 11, which is where its old
|
|
689
|
+
-- RESIDUAL assertions were flipped. Its EXEMPTION B1/B2/B3 properties were deleted rather than
|
|
690
|
+
-- kept: they are still true, but they existed to license an exemption that no longer exists.)
|
|
691
|
+
-- ---------------------------------------------------------------------------
|
|
692
|
+
select is(
|
|
693
|
+
(select string_agg(p.proname || '=' ||
|
|
694
|
+
has_function_privilege('authenticated', p.oid, 'EXECUTE')::text, ',' order by p.proname)
|
|
695
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
696
|
+
where n.nspname = 'agent_board'
|
|
697
|
+
and p.proname in ('_fedsession_autojoin_default_board','bootstrap_board_owner_memberships')),
|
|
698
|
+
'_fedsession_autojoin_default_board=false,bootstrap_board_owner_memberships=false',
|
|
699
|
+
'EXEMPTIONS C/D: neither remaining seeder is EXECUTE-able by authenticated');
|
|
700
|
+
|
|
701
|
+
select ok(
|
|
702
|
+
agent_board_test.stripped_body('agent_board._fedsession_autojoin_default_board(agent_board.identity_providers,uuid)'::regprocedure, true)
|
|
703
|
+
like '%p_provider.default_board_id, p_agent_id, ''member'', ''joined''%',
|
|
704
|
+
'EXEMPTION C: the fedsession autojoin seeds the literal member — the BASE role, which confers '
|
|
705
|
+
'no authority at all');
|
|
706
|
+
|
|
707
|
+
select is(
|
|
708
|
+
(select string_agg(pg_get_function_identity_arguments(p.oid), ' | ' order by p.oid)
|
|
709
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
710
|
+
where n.nspname = 'agent_board' and p.proname = 'bootstrap_board_owner_memberships'),
|
|
711
|
+
'',
|
|
712
|
+
'EXEMPTION D: bootstrap_board_owner_memberships takes NO arguments — it backfills the board '
|
|
713
|
+
'own recorded owner_agent_id and can name no other principal');
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
-- ===========================================================================
|
|
717
|
+
-- 10. G3 — THE INVITE ROUTE. An admin-preset link is a deferred grant of admin, so a
|
|
718
|
+
-- ledger-revoked agent must not be able to mint one, and an outstanding one must stop
|
|
719
|
+
-- conferring. Both halves are driven, and the discriminating pair against the adjacent
|
|
720
|
+
-- R-LINK-2 check is asserted explicitly: the creator here is ACTIVE (so R-LINK-2 passes)
|
|
721
|
+
-- and only the ledger row refuses.
|
|
722
|
+
--
|
|
723
|
+
-- Agent 3 is the NON-FLEET owner of board 2 and is used for the allow side; agent 1 is the
|
|
724
|
+
-- ledger-revoked owner of board 1. Agent 5 redeems.
|
|
725
|
+
-- ===========================================================================
|
|
726
|
+
|
|
727
|
+
-- ALLOW SIDE FIRST: an unrevoked owner still mints and the link still redeems, so the gate
|
|
728
|
+
-- cannot be passing below merely because invites are broken.
|
|
729
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000006');
|
|
730
|
+
|
|
731
|
+
select lives_ok(
|
|
732
|
+
$$create temp table _mk as select * from agent_board.create_invite_link(
|
|
733
|
+
'27620000-0000-4000-8000-000000000004','admin','joined',5,
|
|
734
|
+
now() + interval '2 days')$$,
|
|
735
|
+
'ALLOW: a NON-REVOKED owner still mints an admin invite link');
|
|
736
|
+
|
|
737
|
+
select is(
|
|
738
|
+
(select preset_role from _mk),
|
|
739
|
+
'admin',
|
|
740
|
+
'and the minted link really carries the admin preset');
|
|
741
|
+
|
|
742
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000005');
|
|
743
|
+
|
|
744
|
+
select lives_ok(
|
|
745
|
+
$$create temp table _rd as select * from agent_board.redeem_invite_link((select code from _mk))$$,
|
|
746
|
+
'ALLOW: and it redeems, conferring admin on the redeemer');
|
|
747
|
+
|
|
748
|
+
select is(
|
|
749
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000005'),
|
|
750
|
+
'admin',
|
|
751
|
+
'and the admin seat really landed — this is the capability #276 must not regress');
|
|
752
|
+
|
|
753
|
+
-- MINT, REFUSED. Agent 1 is ledger-revoked (section 4) and still an ACTIVE owner of board 1.
|
|
754
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
755
|
+
|
|
756
|
+
select throws_like(
|
|
757
|
+
$$select * from agent_board.create_invite_link(
|
|
758
|
+
'27620000-0000-4000-8000-000000000001','admin','joined',5, now() + interval '2 days')$$,
|
|
759
|
+
'%fleet_revoked:%create_invite_link%',
|
|
760
|
+
'G3 MINT: a ledger-revoked owner cannot mint an admin invite link');
|
|
761
|
+
|
|
762
|
+
select is(
|
|
763
|
+
(select count(*)::int from agent_board.board_invite_tokens
|
|
764
|
+
where board_id = '27620000-0000-4000-8000-000000000001'),
|
|
765
|
+
0,
|
|
766
|
+
'and the refused mint left NO token behind — the raise discarded it, so no code escaped');
|
|
767
|
+
|
|
768
|
+
select throws_like(
|
|
769
|
+
$$select * from agent_board.create_invite_link(
|
|
770
|
+
'27620000-0000-4000-8000-000000000001','member','joined',5, now() + interval '2 days')$$,
|
|
771
|
+
'%fleet_revoked:%create_invite_link%',
|
|
772
|
+
'and a member-preset link is refused too: the gate is on the principal, not on the preset');
|
|
773
|
+
|
|
774
|
+
-- REDEEM, REFUSED — via a link minted BEFORE the creator was revoked, which is the case the
|
|
775
|
+
-- mint gate cannot reach and the reason both halves exist.
|
|
776
|
+
-- The link was minted in section 7 while agent 4 was still unrevoked; agent 4 has since been
|
|
777
|
+
-- ledger-revoked there. Assert the discriminating precondition: R-LINK-2
|
|
778
|
+
-- passes for it, so a refusal below can only be the #276 clause.
|
|
779
|
+
select is(
|
|
780
|
+
(select status from agent_board.agents where id = '27610000-0000-4000-8000-000000000004'),
|
|
781
|
+
'active',
|
|
782
|
+
'PRECONDITION: the link creator is STILL active, so R-LINK-2 passes and only the ledger '
|
|
783
|
+
'clause can refuse the redemption');
|
|
784
|
+
|
|
785
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000005');
|
|
786
|
+
|
|
787
|
+
select throws_like(
|
|
788
|
+
$$select * from agent_board.redeem_invite_link((select code from _pre))$$,
|
|
789
|
+
'invite is not usable',
|
|
790
|
+
'G3 REDEEM: an outstanding link from a ledger-revoked creator stops conferring — and raises '
|
|
791
|
+
'the UNIFORM message, not a fleet_revoked prefix, so a code-bearer learns nothing about the '
|
|
792
|
+
'creator lifecycle');
|
|
793
|
+
|
|
794
|
+
select is(
|
|
795
|
+
(select count(*)::int from agent_board.board_memberships
|
|
796
|
+
where board_id = '27620000-0000-4000-8000-000000000003'
|
|
797
|
+
and agent_id = '27610000-0000-4000-8000-000000000005'),
|
|
798
|
+
0,
|
|
799
|
+
'and the refused redemption wrote NO membership');
|
|
800
|
+
|
|
801
|
+
select is(
|
|
802
|
+
(select uses_count from agent_board.board_invite_tokens where code = (select code from _pre)),
|
|
803
|
+
0,
|
|
804
|
+
'and consumed no use');
|
|
805
|
+
|
|
806
|
+
select is(
|
|
807
|
+
(select count(*)::int from agent_board.board_invite_redemptions r
|
|
808
|
+
join agent_board.board_invite_tokens t on t.id = r.invite_id
|
|
809
|
+
where t.code = (select code from _pre)),
|
|
810
|
+
0,
|
|
811
|
+
'and left no redemption ledger row');
|
|
812
|
+
|
|
813
|
+
-- The REDEEMER's own revocation is NOT a bar: receiving a membership is not conferring one.
|
|
814
|
+
select pg_temp.service();
|
|
815
|
+
|
|
816
|
+
select lives_ok(
|
|
817
|
+
$$create temp table _mk2 as select * from agent_board.create_invite_link(
|
|
818
|
+
'27620000-0000-4000-8000-000000000004','member','joined',5, now() + interval '2 days',
|
|
819
|
+
'27610000-0000-4000-8000-000000000006')$$,
|
|
820
|
+
'a NON-revoked creator mints a second link on board 4');
|
|
821
|
+
|
|
822
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
823
|
+
|
|
824
|
+
select lives_ok(
|
|
825
|
+
$$create temp table _rd2 as select * from agent_board.redeem_invite_link((select code from _mk2))$$,
|
|
826
|
+
'a ledger-revoked REDEEMER may still redeem: receiving authority is not conferring it, and '
|
|
827
|
+
'the gate is keyed on the creator');
|
|
828
|
+
|
|
829
|
+
select is(
|
|
830
|
+
(select membership_state from agent_board.board_memberships
|
|
831
|
+
where board_id = '27620000-0000-4000-8000-000000000004'
|
|
832
|
+
and agent_id = '27610000-0000-4000-8000-000000000001'),
|
|
833
|
+
'joined',
|
|
834
|
+
'and that redemption really placed the revoked redeemer');
|
|
835
|
+
|
|
836
|
+
-- #280 DELETED a fourth residual assertion that stood here: "on the board it just created, a
|
|
837
|
+
-- revoked agent still cannot mint a link either". It was found by the SUITE, not by reading —
|
|
838
|
+
-- it names no function, only the slug `k276-residual-board`, so a grep for `create_board` missed
|
|
839
|
+
-- it and it went red as a null board id. Recorded because it is CLAUDE.md rule 1 in miniature:
|
|
840
|
+
-- the class of things to change came from a list written by hand, and the one member phrased
|
|
841
|
+
-- differently was the one that escaped. It is deleted rather than re-pointed: with the board it
|
|
842
|
+
-- referenced gone, the claim it made is exactly the G3 MINT assertion above (a ledger-revoked
|
|
843
|
+
-- owner cannot mint on a board it owns), driven there against board 1.
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
-- The RESIDUAL BOUND rests on a lock that is already in this function: the token FOR UPDATE is
|
|
847
|
+
-- its FIRST statement, before the ledger read, so two redemptions of the SAME token cannot both
|
|
848
|
+
-- sit between their ledger read and their commit. Delete that ordering and the disclosure in
|
|
849
|
+
-- the migration header ("at most one per outstanding token") stops being true, which is why it
|
|
850
|
+
-- is pinned here rather than asserted in prose. Both markers guarded with `> 0` (#198).
|
|
851
|
+
-- Two claims, not one, and the first version of this assertion made only the second. It
|
|
852
|
+
-- compared the POSITION of the token table reference against the ledger read — a proxy for
|
|
853
|
+
-- "locks the token first" that says nothing about whether the read is a lock at all.
|
|
854
|
+
-- Mutation-tested: demoting `for update` to a plain read left it GREEN while destroying the
|
|
855
|
+
-- per-token bound it exists to defend. It now requires the token select to BE a lock, requires
|
|
856
|
+
-- that lock to be the function's only one (so `for update` is an unambiguous position marker),
|
|
857
|
+
-- and only then compares positions. Both markers guarded with `> 0` (#198).
|
|
858
|
+
select ok(
|
|
859
|
+
agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure)
|
|
860
|
+
~ 'from agent_board\.board_invite_tokens t[^;]*\mfor update\M'
|
|
861
|
+
and (select count(*) from regexp_matches(
|
|
862
|
+
agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
863
|
+
'for update', 'g')) = 1
|
|
864
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
865
|
+
'for update') > 0
|
|
866
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
867
|
+
'agent_board._actor_fleet_revoked(') > 0
|
|
868
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
869
|
+
'for update')
|
|
870
|
+
< strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
871
|
+
'agent_board._actor_fleet_revoked('),
|
|
872
|
+
'the redeem path LOCKS the token (FOR UPDATE, its only row lock) before it reads the ledger — '
|
|
873
|
+
'which is what bounds the accepted window to one redemption PER OUTSTANDING TOKEN rather '
|
|
874
|
+
'than per creator');
|
|
875
|
+
|
|
876
|
+
-- FLEET ONBOARDING INHERITS THE REDEEM GATE, through _redeem_fleet_invite_for_agent. This is a
|
|
877
|
+
-- real behaviour change on a live path: an onboarding invite minted by a since-revoked owner
|
|
878
|
+
-- stops admitting. Asserted from the call graph rather than left for an operator to discover.
|
|
879
|
+
select ok(
|
|
880
|
+
agent_board_test.stripped_body('agent_board._redeem_fleet_invite_for_agent(text,uuid)'::regprocedure)
|
|
881
|
+
~ '\m_redeem_unbounded_invite_for_agent\s*\(',
|
|
882
|
+
'the FLEET redemption core delegates to the gated unbounded core, so fleet onboarding '
|
|
883
|
+
'inherits the creator-revocation gate by construction');
|
|
884
|
+
|
|
885
|
+
select is(
|
|
886
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
887
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
888
|
+
where n.nspname = 'agent_board'
|
|
889
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m_redeem_unbounded_invite_for_agent\s*\('),
|
|
890
|
+
'_redeem_fleet_invite_for_agent,_redeem_invite_for_agent',
|
|
891
|
+
'and those are the only two callers of it, so no redemption route bypasses the gate');
|
|
892
|
+
|
|
893
|
+
-- The gate's serialization rests on a claim about OTHER functions: every writer of an
|
|
894
|
+
-- agent-subject ledger row takes the agents row lock BEFORE the write, so the FOR SHARE in the
|
|
895
|
+
-- helper conflicts with it. Pinned from the catalog, so a future ledger writer that skips the
|
|
896
|
+
-- lock fails here and names itself instead of silently reopening the TOCTOU.
|
|
897
|
+
select is(
|
|
898
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
899
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
900
|
+
where n.nspname = 'agent_board'
|
|
901
|
+
and agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.fleet_revocation_events\M'),
|
|
902
|
+
'_revoke_agent_principal,revoke_fleet_subject',
|
|
903
|
+
'the writers of the fleet revocation ledger are exactly these two');
|
|
904
|
+
|
|
905
|
+
-- Both markers guarded with `> 0`: a bare `strpos(A) < strpos(B)` is `0 < N` = TRUE when the
|
|
906
|
+
-- LEFT marker is absent, so it passes on a body it no longer matches (#198).
|
|
907
|
+
select ok(
|
|
908
|
+
strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
909
|
+
'agent_board.agents a where a.id = p_subject_id for update') > 0
|
|
910
|
+
and strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
911
|
+
'insert into agent_board.fleet_revocation_events') > 0
|
|
912
|
+
and strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
913
|
+
'agent_board.agents a where a.id = p_subject_id for update')
|
|
914
|
+
< strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
915
|
+
'insert into agent_board.fleet_revocation_events'),
|
|
916
|
+
'revoke_fleet_subject locks the agents row BEFORE writing the ledger — what the gate''s FOR SHARE conflicts with');
|
|
917
|
+
|
|
918
|
+
select ok(
|
|
919
|
+
strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
920
|
+
'agent_board.agents a where a.id = p_agent_id for update') > 0
|
|
921
|
+
and strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
922
|
+
'insert into agent_board.fleet_revocation_events') > 0
|
|
923
|
+
and strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
924
|
+
'agent_board.agents a where a.id = p_agent_id for update')
|
|
925
|
+
< strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
926
|
+
'insert into agent_board.fleet_revocation_events'),
|
|
927
|
+
'_revoke_agent_principal locks the agents row BEFORE writing the ledger too');
|
|
928
|
+
|
|
929
|
+
-- The lock the gate itself takes, and its MODE. FOR KEY SHARE would not conflict with the FOR
|
|
930
|
+
-- NO KEY UPDATE a plain status change takes; the isolation spec is what proves the conflict,
|
|
931
|
+
-- this is what stops the mode being quietly weakened.
|
|
932
|
+
select ok(
|
|
933
|
+
agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure)
|
|
934
|
+
~ 'agent_board\.agents a where a\.id = p_actor_agent_id for share',
|
|
935
|
+
'the gate takes FOR SHARE on the actor''s agents row (not KEY SHARE, not a bare read)');
|
|
936
|
+
|
|
937
|
+
select ok(
|
|
938
|
+
strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
939
|
+
'for share') > 0
|
|
940
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
941
|
+
'agent_board._actor_fleet_revoked(') > 0
|
|
942
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
943
|
+
'for share')
|
|
944
|
+
< strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
945
|
+
'agent_board._actor_fleet_revoked('),
|
|
946
|
+
'and it takes that lock BEFORE reading the ledger, which is the whole point of taking it');
|
|
947
|
+
|
|
948
|
+
-- The split, asserted from the catalog. The predicate lives in ONE function so the locked and
|
|
949
|
+
-- unlocked callers cannot drift into asking two different questions, and the UNLOCKED one must
|
|
950
|
+
-- stay unlocked — an `agents` lock reappearing in it is the deadlock the header enumerates.
|
|
951
|
+
select ok(
|
|
952
|
+
agent_board_test.stripped_body('agent_board._actor_fleet_revoked(uuid)'::regprocedure)
|
|
953
|
+
~ 'agent_board\.fleet_revocation_events'
|
|
954
|
+
and agent_board_test.stripped_body('agent_board._actor_fleet_revoked(uuid)'::regprocedure)
|
|
955
|
+
!~ '\mfor\s+(share|update|key\s+share|no\s+key\s+update)\M',
|
|
956
|
+
'the unlocked predicate reads the ledger and takes NO row lock — what makes it safe to call '
|
|
957
|
+
'from the invite redeem path, which is reached holding a boards lock');
|
|
958
|
+
|
|
959
|
+
select is(
|
|
960
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
961
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
962
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
963
|
+
where p.oid = 'agent_board._actor_fleet_revoked(uuid)'::regprocedure
|
|
964
|
+
and a.privilege_type = 'EXECUTE'),
|
|
965
|
+
'postgres,service_role',
|
|
966
|
+
'and the unlocked predicate is service_role-only, like the assert it was split out of');
|
|
967
|
+
|
|
968
|
+
-- The gate must not have acquired a supersession clause. `effective_at` appearing in the body
|
|
969
|
+
-- would mean a machine reactivation could restore a revoked agent's authority.
|
|
970
|
+
select ok(
|
|
971
|
+
agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure)
|
|
972
|
+
!~ '\m(effective_at|reactivated_at)\M',
|
|
973
|
+
'the gate has no supersession clause, matching the send path''s agent-subject predicate');
|
|
974
|
+
|
|
975
|
+
-- ===========================================================================
|
|
976
|
+
-- 11. #280 — create_board is GATED. These assertions are the FLIP of the RESIDUAL pair that
|
|
977
|
+
-- lived in what was EXEMPTION B: this file previously asserted, as behaviour, that a
|
|
978
|
+
-- ledger-revoked agent COULD create a board and be seeded its owner. #276's lane wrote it
|
|
979
|
+
-- that way deliberately so that closing #280 would be a visible test change. This is that
|
|
980
|
+
-- change.
|
|
981
|
+
--
|
|
982
|
+
-- THE ALLOW SIDE COMES FIRST AND MATTERS MORE THAN THE REFUSAL. create_board is the only
|
|
983
|
+
-- gated site every ordinary user reaches, on the CLI's default post-enroll intent among
|
|
984
|
+
-- other paths, so the expensive failure here is not "a revoked agent slipped through" but
|
|
985
|
+
-- "nobody can create a board any more". JUDGEMENT 2 of the #276 header — a principal that
|
|
986
|
+
-- was never in a fleet has no ledger row and passes by construction — is therefore asserted
|
|
987
|
+
-- POSITIVELY, on BOTH routes (authenticated and the service_role create-on-behalf-of the
|
|
988
|
+
-- gateway uses), by driving a real creation and reading the owner seat back. Never by the
|
|
989
|
+
-- absence of a refusal.
|
|
990
|
+
--
|
|
991
|
+
-- Every refusal probe is followed by a STATE check, because a gate that raises AND writes is
|
|
992
|
+
-- the failure throws_like alone cannot see. Here the state check is the operation's own
|
|
993
|
+
-- outcome: no boards row for the slug (which is also why no membership can exist).
|
|
994
|
+
-- ===========================================================================
|
|
995
|
+
|
|
996
|
+
-- ALLOW, ROUTE 1: the NON-FLEET agent (agent 3 has no fleet_machine_agents row at all).
|
|
997
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000003');
|
|
998
|
+
|
|
999
|
+
select lives_ok(
|
|
1000
|
+
$$create temp table _cb280a as select * from agent_board.create_board(
|
|
1001
|
+
'27610000-0000-4000-8000-000000000003','k280-allow-board','K280 Allow Board')$$,
|
|
1002
|
+
'ALLOW (#280): a NON-FLEET agent still creates a board — the capability this gate must not '
|
|
1003
|
+
'regress, driven rather than inferred from the absence of a refusal');
|
|
1004
|
+
|
|
1005
|
+
select is(
|
|
1006
|
+
(select bm.role from agent_board.board_memberships bm
|
|
1007
|
+
join agent_board.boards b on b.id = bm.board_id
|
|
1008
|
+
where b.slug = 'k280-allow-board'
|
|
1009
|
+
and bm.agent_id = '27610000-0000-4000-8000-000000000003'),
|
|
1010
|
+
'owner',
|
|
1011
|
+
'and its owner seat really landed, so the allow side is the whole operation and not just the '
|
|
1012
|
+
'absence of a raise');
|
|
1013
|
+
|
|
1014
|
+
-- ALLOW, ROUTE 2: service_role acting on behalf of a non-revoked principal — the gateway path.
|
|
1015
|
+
select pg_temp.service();
|
|
1016
|
+
|
|
1017
|
+
select lives_ok(
|
|
1018
|
+
$$create temp table _cb280b as select * from agent_board.create_board(
|
|
1019
|
+
'27610000-0000-4000-8000-000000000003','k280-allow-board-svc','K280 Allow Board Svc')$$,
|
|
1020
|
+
'ALLOW (#280): and service_role create-on-behalf-of a non-revoked agent still works');
|
|
1021
|
+
|
|
1022
|
+
select is(
|
|
1023
|
+
(select bm.role from agent_board.board_memberships bm
|
|
1024
|
+
join agent_board.boards b on b.id = bm.board_id
|
|
1025
|
+
where b.slug = 'k280-allow-board-svc'
|
|
1026
|
+
and bm.agent_id = '27610000-0000-4000-8000-000000000003'),
|
|
1027
|
+
'owner',
|
|
1028
|
+
'and that owner seat landed too — the gate binds the PRINCIPAL, so a clean principal is '
|
|
1029
|
+
'unaffected on either route');
|
|
1030
|
+
|
|
1031
|
+
-- REFUSED, ROUTE 1: the ledger-revoked agent 1 (revoked in section 4; agents.status still
|
|
1032
|
+
-- 'active', which is this migration's precondition and what makes the refusal the LEDGER's).
|
|
1033
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
1034
|
+
|
|
1035
|
+
select throws_like(
|
|
1036
|
+
$$select * from agent_board.create_board(
|
|
1037
|
+
'27610000-0000-4000-8000-000000000001','k280-refused-board','K280 Refused Board')$$,
|
|
1038
|
+
'%fleet_revoked:%create_board%',
|
|
1039
|
+
'#280: a ledger-revoked agent can no longer create a board (this is the flipped RESIDUAL)');
|
|
1040
|
+
|
|
1041
|
+
select is(
|
|
1042
|
+
(select count(*)::int from agent_board.boards where slug = 'k280-refused-board'),
|
|
1043
|
+
0,
|
|
1044
|
+
'and the refused creation left NO board behind — the raise rolled back the boards insert, so '
|
|
1045
|
+
'the owner seat and the owner_seed event cannot exist either');
|
|
1046
|
+
|
|
1047
|
+
-- REFUSED, the SLUG-COLLISION branch. Agent 1 holds an ACTIVE owner membership on board 1, so
|
|
1048
|
+
-- it passes the owner check and would previously have renamed/reopened it. This is the scope
|
|
1049
|
+
-- consequence the migration header records explicitly: the gate covers BOTH branches.
|
|
1050
|
+
select throws_like(
|
|
1051
|
+
$$select * from agent_board.create_board(
|
|
1052
|
+
'27610000-0000-4000-8000-000000000001','k276-board-1','K276 Board 1 RENAMED')$$,
|
|
1053
|
+
'%fleet_revoked:%create_board%',
|
|
1054
|
+
'#280: and the slug-COLLISION branch is refused too — a revoked owner cannot reopen or '
|
|
1055
|
+
'rename an existing board it still owns');
|
|
1056
|
+
|
|
1057
|
+
select is(
|
|
1058
|
+
(select b.name from agent_board.boards b where b.slug = 'k276-board-1'),
|
|
1059
|
+
'K276 Board 1',
|
|
1060
|
+
'and that board is UNCHANGED: the raise rolled the collision-branch UPDATE back, which is a '
|
|
1061
|
+
'state check the message alone could not make');
|
|
1062
|
+
|
|
1063
|
+
-- REFUSED, ROUTE 2: service_role supplying a REVOKED p_created_by_agent_id. The check binds the
|
|
1064
|
+
-- principal the write is attributed to, not the connection role — a gateway acting for a
|
|
1065
|
+
-- revoked agent is the case #276 was filed about, restated here for the one function #276 left.
|
|
1066
|
+
select pg_temp.service();
|
|
1067
|
+
|
|
1068
|
+
select throws_like(
|
|
1069
|
+
$$select * from agent_board.create_board(
|
|
1070
|
+
'27610000-0000-4000-8000-000000000001','k280-refused-svc','K280 Refused Svc')$$,
|
|
1071
|
+
'%fleet_revoked:%create_board%',
|
|
1072
|
+
'#280: service_role create-on-behalf-of a REVOKED agent is refused — the gate binds the '
|
|
1073
|
+
'principal, never the connection role');
|
|
1074
|
+
|
|
1075
|
+
select is(
|
|
1076
|
+
(select count(*)::int from agent_board.boards where slug = 'k280-refused-svc'),
|
|
1077
|
+
0,
|
|
1078
|
+
'and that one left no board behind either');
|
|
1079
|
+
|
|
1080
|
+
select finish();
|
|
1081
|
+
rollback;
|