@consiliency/agent-board-schema 1.12.0 → 1.22.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 +124 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +282 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
-- OBSGATE-2 (#123) pgTAP: the board posting rule now applies to every thread-authoring RPC,
|
|
2
|
+
-- not just post_message. An observer with board-thread access cannot create tasks, delegate
|
|
3
|
+
-- tasks, add artifacts, raise conflicts, or raise escalations into a board-linked thread; a
|
|
4
|
+
-- non-observer member, board-less threads, and service_role are unaffected.
|
|
5
|
+
--
|
|
6
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
|
|
7
|
+
-- request.jwt.claim.sub picks the actor.
|
|
8
|
+
--
|
|
9
|
+
-- The DENY assertions are the mutation pins (each raises 42501 via _current_agent_can_post_to_
|
|
10
|
+
-- thread). delegate_task is pinned to prove the TRANSITIVE close through create_task. The ALLOW
|
|
11
|
+
-- twins guard against over-block/regression, including the internal create_tasking_delivery path.
|
|
12
|
+
create extension if not exists pgtap with schema extensions;
|
|
13
|
+
set search_path = extensions, public, agent_board;
|
|
14
|
+
|
|
15
|
+
begin;
|
|
16
|
+
|
|
17
|
+
select plan(11);
|
|
18
|
+
|
|
19
|
+
create temp table _o2_fixture (
|
|
20
|
+
board uuid,
|
|
21
|
+
member_agent uuid,
|
|
22
|
+
observer_agent uuid,
|
|
23
|
+
board_thread uuid,
|
|
24
|
+
boardless_thread uuid,
|
|
25
|
+
parent_task uuid,
|
|
26
|
+
member_user uuid,
|
|
27
|
+
observer_user uuid
|
|
28
|
+
) on commit drop;
|
|
29
|
+
|
|
30
|
+
do $$
|
|
31
|
+
declare
|
|
32
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
33
|
+
v_member_user uuid;
|
|
34
|
+
v_observer_user uuid;
|
|
35
|
+
v_creator uuid := gen_random_uuid();
|
|
36
|
+
v_member uuid := gen_random_uuid();
|
|
37
|
+
v_observer uuid := gen_random_uuid();
|
|
38
|
+
v_board uuid;
|
|
39
|
+
v_board_thread uuid := gen_random_uuid();
|
|
40
|
+
v_boardless_thread uuid := gen_random_uuid();
|
|
41
|
+
v_parent_task uuid;
|
|
42
|
+
begin
|
|
43
|
+
v_member_user := public.ensure_github_user('o2-member-' || v_suffix, 'o2-member-' || v_suffix || '@example.test', 'o2-member-' || v_suffix, 'O2 Member');
|
|
44
|
+
v_observer_user := public.ensure_github_user('o2-observer-' || v_suffix, 'o2-observer-' || v_suffix || '@example.test', 'o2-observer-' || v_suffix, 'O2 Observer');
|
|
45
|
+
|
|
46
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
47
|
+
values
|
|
48
|
+
(v_creator, 'agent', 'o2-creator-' || v_suffix, 'O2 Creator', 'active', null, null),
|
|
49
|
+
(v_member, 'agent', 'o2-member-' || v_suffix, 'O2 Member', 'active', v_member_user, v_member_user),
|
|
50
|
+
(v_observer, 'agent', 'o2-observer-' || v_suffix, 'O2 Observer', 'active', v_observer_user, v_observer_user);
|
|
51
|
+
|
|
52
|
+
select board_id into v_board
|
|
53
|
+
from agent_board.create_board(v_creator, 'o2-board-' || v_suffix, 'O2 Board', 'obsgate2 test', '{}'::jsonb, 'private');
|
|
54
|
+
|
|
55
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
56
|
+
values
|
|
57
|
+
(v_board, v_member, 'joined', v_creator, 'member'),
|
|
58
|
+
(v_board, v_observer, 'joined', v_creator, 'observer');
|
|
59
|
+
|
|
60
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
|
|
61
|
+
values
|
|
62
|
+
(v_board_thread, 'coordination', v_member, 'O2 board thread'),
|
|
63
|
+
(v_boardless_thread, 'coordination', v_member, 'O2 board-less thread');
|
|
64
|
+
|
|
65
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
66
|
+
perform set_config('request.jwt.claim.sub', v_member_user::text, true);
|
|
67
|
+
-- Fan a board message so the board thread is stamped and the observer gains thread access.
|
|
68
|
+
perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
|
|
69
|
+
-- The member seeds a parent task (non-observer, permitted) for the delegate_task test.
|
|
70
|
+
select task_id into v_parent_task
|
|
71
|
+
from agent_board.create_task(v_board_thread, v_member, 'parent task', 'd');
|
|
72
|
+
|
|
73
|
+
insert into _o2_fixture values
|
|
74
|
+
(v_board, v_member, v_observer, v_board_thread, v_boardless_thread, v_parent_task, v_member_user, v_observer_user);
|
|
75
|
+
end
|
|
76
|
+
$$;
|
|
77
|
+
|
|
78
|
+
-- DENY (mutation pins): an observer cannot author into the board thread by any door ----------
|
|
79
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
80
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o2_fixture)::text, true);
|
|
81
|
+
|
|
82
|
+
select throws_ok(
|
|
83
|
+
(select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'obs task', 'd')$q$,
|
|
84
|
+
board_thread, observer_agent) from _o2_fixture),
|
|
85
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
86
|
+
'observer cannot create_task in a board thread');
|
|
87
|
+
|
|
88
|
+
select throws_ok(
|
|
89
|
+
(select format($q$select agent_board.delegate_task(%L::uuid, %L::uuid, 'obs deleg', 'd', %L::uuid)$q$,
|
|
90
|
+
parent_task, observer_agent, member_agent) from _o2_fixture),
|
|
91
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
92
|
+
'observer cannot delegate_task into a board thread (transitive via create_task)');
|
|
93
|
+
|
|
94
|
+
select throws_ok(
|
|
95
|
+
(select format($q$select agent_board.add_artifact(%L::uuid, 'https://x.test/a', 'url', 'external_url', %L::uuid)$q$,
|
|
96
|
+
observer_agent, board_thread) from _o2_fixture),
|
|
97
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
98
|
+
'observer cannot add_artifact to a board thread');
|
|
99
|
+
|
|
100
|
+
select throws_ok(
|
|
101
|
+
(select format($q$select agent_board.raise_conflict(%L::uuid, %L::uuid, 'obs conflict')$q$,
|
|
102
|
+
board_thread, observer_agent) from _o2_fixture),
|
|
103
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
104
|
+
'observer cannot raise_conflict in a board thread');
|
|
105
|
+
|
|
106
|
+
select throws_ok(
|
|
107
|
+
(select format($q$select agent_board.raise_escalation(%L::uuid, %L::uuid, 'obs escalation')$q$,
|
|
108
|
+
board_thread, observer_agent) from _o2_fixture),
|
|
109
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
110
|
+
'observer cannot raise_escalation in a board thread');
|
|
111
|
+
|
|
112
|
+
-- ALLOW twins: a non-observer member is unaffected ------------------------------------------
|
|
113
|
+
select set_config('request.jwt.claim.sub', (select member_user from _o2_fixture)::text, true);
|
|
114
|
+
|
|
115
|
+
select lives_ok(
|
|
116
|
+
(select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'member task', 'd')$q$,
|
|
117
|
+
board_thread, member_agent) from _o2_fixture),
|
|
118
|
+
'a member may create_task in a board thread');
|
|
119
|
+
|
|
120
|
+
select lives_ok(
|
|
121
|
+
(select format($q$select agent_board.add_artifact(%L::uuid, 'https://x.test/b', 'url', 'external_url', %L::uuid)$q$,
|
|
122
|
+
member_agent, board_thread) from _o2_fixture),
|
|
123
|
+
'a member may add_artifact to a board thread');
|
|
124
|
+
|
|
125
|
+
-- thread-only (no task): also exercises the v_event no-task fix folded into this migration.
|
|
126
|
+
select lives_ok(
|
|
127
|
+
(select format($q$select agent_board.raise_escalation(%L::uuid, %L::uuid, 'member escalation')$q$,
|
|
128
|
+
board_thread, member_agent) from _o2_fixture),
|
|
129
|
+
'a member may raise_escalation (thread-only) in a board thread');
|
|
130
|
+
|
|
131
|
+
-- regression: the internal create_tasking_delivery -> create_task path still works for a member
|
|
132
|
+
select lives_ok(
|
|
133
|
+
(select format($q$select agent_board.create_tasking_delivery(%L::uuid, %L::uuid, 'member tasking', 'd', null, %L::uuid)$q$,
|
|
134
|
+
board_thread, member_agent, board) from _o2_fixture),
|
|
135
|
+
'a member may create_tasking_delivery (internal create_task) — gate does not break the tasking path');
|
|
136
|
+
|
|
137
|
+
-- board-less thread: no board link -> access-only, no role gate (no regression) --------------
|
|
138
|
+
select lives_ok(
|
|
139
|
+
(select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'boardless task', 'd')$q$,
|
|
140
|
+
boardless_thread, member_agent) from _o2_fixture),
|
|
141
|
+
'a board-less thread is unaffected by the posting gate (access-only)');
|
|
142
|
+
|
|
143
|
+
-- service_role carve-out --------------------------------------------------------------------
|
|
144
|
+
select set_config('request.jwt.claim.role', 'service_role', true);
|
|
145
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
146
|
+
select lives_ok(
|
|
147
|
+
(select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'svc task', 'd')$q$,
|
|
148
|
+
board_thread, observer_agent) from _o2_fixture),
|
|
149
|
+
'service_role may author into a board thread regardless of role (carve-out)');
|
|
150
|
+
|
|
151
|
+
select * from finish();
|
|
152
|
+
|
|
153
|
+
rollback;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
-- OBSGATE-3 (#125) pgTAP: task-lifecycle mutation on a board-thread task requires a
|
|
2
|
+
-- non-observer board role OR being the task's assignee/creator. A board observer cannot
|
|
3
|
+
-- mutate a task it is not a party to (append_task_event and its callers acquire_claim /
|
|
4
|
+
-- resolve_conflict / resolve_escalation), but a legitimate non-member ASSIGNEE still can.
|
|
5
|
+
--
|
|
6
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
|
|
7
|
+
-- request.jwt.claim.sub picks the actor.
|
|
8
|
+
--
|
|
9
|
+
-- The DENY assertions are the mutation pins (append_task_event's mutate-task gate). The
|
|
10
|
+
-- assignee/creator ALLOW twins are the over-block guards — the whole point of the
|
|
11
|
+
-- assignee-aware rule (a blanket board-role gate would lock out a non-member assignee).
|
|
12
|
+
create extension if not exists pgtap with schema extensions;
|
|
13
|
+
set search_path = extensions, public, agent_board;
|
|
14
|
+
|
|
15
|
+
begin;
|
|
16
|
+
|
|
17
|
+
select plan(11);
|
|
18
|
+
|
|
19
|
+
create temp table _o3_fixture (
|
|
20
|
+
board uuid,
|
|
21
|
+
member_agent uuid,
|
|
22
|
+
observer_agent uuid,
|
|
23
|
+
nonmember_agent uuid,
|
|
24
|
+
member_task uuid,
|
|
25
|
+
assignee_task uuid,
|
|
26
|
+
observer_task uuid,
|
|
27
|
+
boardless_task uuid,
|
|
28
|
+
conflict_id uuid,
|
|
29
|
+
escalation_id uuid,
|
|
30
|
+
member_user uuid,
|
|
31
|
+
observer_user uuid,
|
|
32
|
+
nonmember_user uuid
|
|
33
|
+
) on commit drop;
|
|
34
|
+
|
|
35
|
+
do $$
|
|
36
|
+
declare
|
|
37
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
38
|
+
v_member_user uuid; v_observer_user uuid; v_nonmember_user uuid;
|
|
39
|
+
v_creator uuid := gen_random_uuid();
|
|
40
|
+
v_member uuid := gen_random_uuid();
|
|
41
|
+
v_observer uuid := gen_random_uuid();
|
|
42
|
+
v_nonmember uuid := gen_random_uuid();
|
|
43
|
+
v_board uuid;
|
|
44
|
+
v_board_thread uuid := gen_random_uuid();
|
|
45
|
+
v_boardless_thread uuid := gen_random_uuid();
|
|
46
|
+
v_member_task uuid; v_assignee_task uuid; v_observer_task uuid; v_boardless_task uuid;
|
|
47
|
+
v_conflict uuid; v_escalation uuid;
|
|
48
|
+
begin
|
|
49
|
+
v_member_user := public.ensure_github_user('o3-member-' || v_suffix, 'o3-member-' || v_suffix || '@example.test', 'o3-member-' || v_suffix, 'O3 Member');
|
|
50
|
+
v_observer_user := public.ensure_github_user('o3-observer-' || v_suffix, 'o3-observer-' || v_suffix || '@example.test', 'o3-observer-' || v_suffix, 'O3 Observer');
|
|
51
|
+
v_nonmember_user := public.ensure_github_user('o3-nonmember-' || v_suffix, 'o3-nonmember-' || v_suffix || '@example.test', 'o3-nonmember-' || v_suffix, 'O3 Nonmember');
|
|
52
|
+
|
|
53
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
54
|
+
values
|
|
55
|
+
(v_creator, 'agent', 'o3-creator-' || v_suffix, 'O3 Creator', 'active', null, null),
|
|
56
|
+
(v_member, 'agent', 'o3-member-' || v_suffix, 'O3 Member', 'active', v_member_user, v_member_user),
|
|
57
|
+
(v_observer, 'agent', 'o3-observer-' || v_suffix, 'O3 Observer', 'active', v_observer_user, v_observer_user),
|
|
58
|
+
(v_nonmember, 'agent', 'o3-nonmember-' || v_suffix, 'O3 Nonmember', 'active', v_nonmember_user, v_nonmember_user);
|
|
59
|
+
|
|
60
|
+
select board_id into v_board
|
|
61
|
+
from agent_board.create_board(v_creator, 'o3-board-' || v_suffix, 'O3 Board', 'obsgate3 test', '{}'::jsonb, 'private');
|
|
62
|
+
|
|
63
|
+
-- member is a board member; observer is an observer; nonmember is NOT on the board.
|
|
64
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
65
|
+
values
|
|
66
|
+
(v_board, v_member, 'joined', v_creator, 'member'),
|
|
67
|
+
(v_board, v_observer, 'joined', v_creator, 'observer');
|
|
68
|
+
|
|
69
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
|
|
70
|
+
values
|
|
71
|
+
(v_board_thread, 'coordination', v_member, 'O3 board thread'),
|
|
72
|
+
(v_boardless_thread, 'coordination', v_member, 'O3 board-less thread');
|
|
73
|
+
|
|
74
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
75
|
+
perform set_config('request.jwt.claim.sub', v_member_user::text, true);
|
|
76
|
+
perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
|
|
77
|
+
|
|
78
|
+
-- member creates the board-thread tasks (non-observer, permitted by #123).
|
|
79
|
+
select task_id into v_member_task from agent_board.create_task(v_board_thread, v_member, 'member task', 'd');
|
|
80
|
+
select task_id into v_assignee_task from agent_board.create_task(v_board_thread, v_member, 'assignee task', 'd', v_nonmember);
|
|
81
|
+
select task_id into v_observer_task from agent_board.create_task(v_board_thread, v_member, 'observer-assigned task', 'd', v_observer);
|
|
82
|
+
select task_id into v_boardless_task from agent_board.create_task(v_boardless_thread, v_member, 'boardless task', 'd');
|
|
83
|
+
-- member raises a conflict + escalation on the member task (task-attached, so they append events).
|
|
84
|
+
select conflict_id into v_conflict from agent_board.raise_conflict(v_board_thread, v_member, 'c', 'other', 'medium', v_member_task);
|
|
85
|
+
select escalation_id into v_escalation from agent_board.raise_escalation(v_board_thread, v_member, 'e', 'other', 'medium', v_member_task);
|
|
86
|
+
|
|
87
|
+
insert into _o3_fixture values
|
|
88
|
+
(v_board, v_member, v_observer, v_nonmember, v_member_task, v_assignee_task, v_observer_task,
|
|
89
|
+
v_boardless_task, v_conflict, v_escalation, v_member_user, v_observer_user, v_nonmember_user);
|
|
90
|
+
end
|
|
91
|
+
$$;
|
|
92
|
+
|
|
93
|
+
-- DENY (mutation pins): an observer cannot mutate a board-thread task it is not a party to ----
|
|
94
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
95
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o3_fixture)::text, true);
|
|
96
|
+
|
|
97
|
+
select throws_ok(
|
|
98
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
99
|
+
member_task, observer_agent) from _o3_fixture),
|
|
100
|
+
'42501', 'task mutation requires a non-observer board role, or task assignment/creation',
|
|
101
|
+
'observer cannot append_task_event to a board-thread task it is not a party to');
|
|
102
|
+
|
|
103
|
+
select throws_ok(
|
|
104
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'custom', 'k1', now() + interval '1 hour', %L::uuid)$q$,
|
|
105
|
+
observer_agent, member_task) from _o3_fixture),
|
|
106
|
+
'42501', 'task mutation requires a non-observer board role, or task assignment/creation',
|
|
107
|
+
'observer cannot acquire_claim on a board-thread task (transitive)');
|
|
108
|
+
|
|
109
|
+
select throws_ok(
|
|
110
|
+
(select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
|
|
111
|
+
conflict_id, observer_agent) from _o3_fixture),
|
|
112
|
+
'42501', 'task mutation requires a non-observer board role, or task assignment/creation',
|
|
113
|
+
'observer cannot resolve_conflict on a board-thread task (transitive)');
|
|
114
|
+
|
|
115
|
+
select throws_ok(
|
|
116
|
+
(select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
|
|
117
|
+
escalation_id, observer_agent) from _o3_fixture),
|
|
118
|
+
'42501', 'task mutation requires a non-observer board role, or task assignment/creation',
|
|
119
|
+
'observer cannot resolve_escalation on a board-thread task (transitive)');
|
|
120
|
+
|
|
121
|
+
-- ALLOW twins: the assignee/creator carve-out preserves legitimate workers -------------------
|
|
122
|
+
-- A NON-board-member assignee may work their assigned task (the over-block this rule prevents).
|
|
123
|
+
select set_config('request.jwt.claim.sub', (select nonmember_user from _o3_fixture)::text, true);
|
|
124
|
+
select lives_ok(
|
|
125
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
126
|
+
assignee_task, nonmember_agent) from _o3_fixture),
|
|
127
|
+
'a non-member ASSIGNEE may mutate their assigned board-thread task (carve-out)');
|
|
128
|
+
|
|
129
|
+
-- An observer who is the ASSIGNEE may mutate that task (assignment authorizes it).
|
|
130
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o3_fixture)::text, true);
|
|
131
|
+
select lives_ok(
|
|
132
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
133
|
+
observer_task, observer_agent) from _o3_fixture),
|
|
134
|
+
'an observer who is the assignee may mutate that task (carve-out)');
|
|
135
|
+
|
|
136
|
+
-- A non-observer member may mutate any board-thread task.
|
|
137
|
+
select set_config('request.jwt.claim.sub', (select member_user from _o3_fixture)::text, true);
|
|
138
|
+
select lives_ok(
|
|
139
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
140
|
+
member_task, member_agent) from _o3_fixture),
|
|
141
|
+
'a non-observer member may mutate a board-thread task');
|
|
142
|
+
|
|
143
|
+
select lives_ok(
|
|
144
|
+
(select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
|
|
145
|
+
conflict_id, member_agent) from _o3_fixture),
|
|
146
|
+
'a member may resolve_conflict on a board-thread task (transitive path works for a legit actor)');
|
|
147
|
+
|
|
148
|
+
-- board-less task: no board link -> access-only, no role gate (no regression).
|
|
149
|
+
select lives_ok(
|
|
150
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
151
|
+
boardless_task, member_agent) from _o3_fixture),
|
|
152
|
+
'a board-less task is unaffected by the mutate gate (access-only)');
|
|
153
|
+
|
|
154
|
+
-- service_role carve-out.
|
|
155
|
+
select set_config('request.jwt.claim.role', 'service_role', true);
|
|
156
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
157
|
+
select lives_ok(
|
|
158
|
+
(select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
|
|
159
|
+
member_task, observer_agent) from _o3_fixture),
|
|
160
|
+
'service_role may mutate a board-thread task regardless of role (carve-out)');
|
|
161
|
+
|
|
162
|
+
-- the new SECURITY DEFINER predicate carries no PUBLIC EXECUTE (MBHARDEN #118).
|
|
163
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
164
|
+
select ok(
|
|
165
|
+
not has_function_privilege('public', 'agent_board._current_agent_can_mutate_task(uuid)', 'EXECUTE'),
|
|
166
|
+
'_current_agent_can_mutate_task carries no PUBLIC EXECUTE (class invariant)');
|
|
167
|
+
|
|
168
|
+
select * from finish();
|
|
169
|
+
|
|
170
|
+
rollback;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
-- OBSGATE-4 (#127) pgTAP: a claim on a TASK resource requires task-mutate rights (closing the
|
|
2
|
+
-- squat), and a task-less resolve_conflict/resolve_escalation is gated on the board posting rule
|
|
3
|
+
-- (and no longer errors on the no-task path).
|
|
4
|
+
--
|
|
5
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
|
|
6
|
+
-- request.jwt.claim.sub picks the actor.
|
|
7
|
+
--
|
|
8
|
+
-- DENY assertions are the mutation pins (append/_current_agent_can_mutate_task on acquire; the
|
|
9
|
+
-- posting gate on task-less resolve). ALLOW twins guard over-block and prove the v_event no-task fix.
|
|
10
|
+
create extension if not exists pgtap with schema extensions;
|
|
11
|
+
set search_path = extensions, public, agent_board;
|
|
12
|
+
|
|
13
|
+
begin;
|
|
14
|
+
|
|
15
|
+
select plan(13);
|
|
16
|
+
|
|
17
|
+
create temp table _o4_fixture (
|
|
18
|
+
board uuid,
|
|
19
|
+
member_agent uuid,
|
|
20
|
+
observer_agent uuid,
|
|
21
|
+
nonmember_agent uuid,
|
|
22
|
+
member_task uuid,
|
|
23
|
+
member_task2 uuid,
|
|
24
|
+
assignee_task uuid,
|
|
25
|
+
restricted_task uuid,
|
|
26
|
+
board_thread uuid,
|
|
27
|
+
taskless_conflict uuid,
|
|
28
|
+
taskless_escalation uuid,
|
|
29
|
+
member_user uuid,
|
|
30
|
+
observer_user uuid,
|
|
31
|
+
nonmember_user uuid
|
|
32
|
+
) on commit drop;
|
|
33
|
+
|
|
34
|
+
do $$
|
|
35
|
+
declare
|
|
36
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
37
|
+
v_member_user uuid; v_observer_user uuid; v_nonmember_user uuid;
|
|
38
|
+
v_creator uuid := gen_random_uuid();
|
|
39
|
+
v_member uuid := gen_random_uuid();
|
|
40
|
+
v_observer uuid := gen_random_uuid();
|
|
41
|
+
v_nonmember uuid := gen_random_uuid();
|
|
42
|
+
v_board uuid;
|
|
43
|
+
v_board_thread uuid := gen_random_uuid();
|
|
44
|
+
v_member_task uuid; v_member_task2 uuid; v_assignee_task uuid; v_restricted_task uuid;
|
|
45
|
+
v_restricted_thread uuid := gen_random_uuid();
|
|
46
|
+
v_conflict uuid; v_escalation uuid;
|
|
47
|
+
begin
|
|
48
|
+
v_member_user := public.ensure_github_user('o4-member-' || v_suffix, 'o4-member-' || v_suffix || '@example.test', 'o4-member-' || v_suffix, 'O4 Member');
|
|
49
|
+
v_observer_user := public.ensure_github_user('o4-observer-' || v_suffix, 'o4-observer-' || v_suffix || '@example.test', 'o4-observer-' || v_suffix, 'O4 Observer');
|
|
50
|
+
v_nonmember_user := public.ensure_github_user('o4-nonmember-' || v_suffix, 'o4-nonmember-' || v_suffix || '@example.test', 'o4-nonmember-' || v_suffix, 'O4 Nonmember');
|
|
51
|
+
|
|
52
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
53
|
+
values
|
|
54
|
+
(v_creator, 'agent', 'o4-creator-' || v_suffix, 'O4 Creator', 'active', null, null),
|
|
55
|
+
(v_member, 'agent', 'o4-member-' || v_suffix, 'O4 Member', 'active', v_member_user, v_member_user),
|
|
56
|
+
(v_observer, 'agent', 'o4-observer-' || v_suffix, 'O4 Observer', 'active', v_observer_user, v_observer_user),
|
|
57
|
+
(v_nonmember, 'agent', 'o4-nonmember-' || v_suffix, 'O4 Nonmember', 'active', v_nonmember_user, v_nonmember_user);
|
|
58
|
+
|
|
59
|
+
select board_id into v_board
|
|
60
|
+
from agent_board.create_board(v_creator, 'o4-board-' || v_suffix, 'O4 Board', 'obsgate4 test', '{}'::jsonb, 'private');
|
|
61
|
+
|
|
62
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
63
|
+
values
|
|
64
|
+
(v_board, v_member, 'joined', v_creator, 'member'),
|
|
65
|
+
(v_board, v_observer, 'joined', v_creator, 'observer');
|
|
66
|
+
|
|
67
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
|
|
68
|
+
values (v_board_thread, 'coordination', v_member, 'O4 board thread');
|
|
69
|
+
|
|
70
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
71
|
+
perform set_config('request.jwt.claim.sub', v_member_user::text, true);
|
|
72
|
+
perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
|
|
73
|
+
|
|
74
|
+
select task_id into v_member_task from agent_board.create_task(v_board_thread, v_member, 'member task', 'd');
|
|
75
|
+
select task_id into v_member_task2 from agent_board.create_task(v_board_thread, v_member, 'member task 2', 'd');
|
|
76
|
+
select task_id into v_assignee_task from agent_board.create_task(v_board_thread, v_member, 'assignee task', 'd', v_nonmember);
|
|
77
|
+
-- task-LESS conflict + escalation on the board thread (raised by the member; #123 permits).
|
|
78
|
+
select conflict_id into v_conflict from agent_board.raise_conflict(v_board_thread, v_member, 'c');
|
|
79
|
+
select escalation_id into v_escalation from agent_board.raise_escalation(v_board_thread, v_member, 'e');
|
|
80
|
+
|
|
81
|
+
-- A RESTRICTED thread the member is NOT on, board-stamped, with a task. The member holds board
|
|
82
|
+
-- send-role (so _current_agent_can_mutate_task is true via _current_agent_can_post_to_thread)
|
|
83
|
+
-- but has NO thread access — the case where mutate != access. Set up as service_role.
|
|
84
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, access_mode)
|
|
85
|
+
values (v_restricted_thread, 'coordination', v_creator, 'O4 restricted thread', 'restricted');
|
|
86
|
+
insert into agent_board.thread_memberships(thread_id, agent_id, role, granted_by_agent_id)
|
|
87
|
+
values (v_restricted_thread, v_creator, 'member', v_creator);
|
|
88
|
+
perform set_config('request.jwt.claim.role', 'service_role', true);
|
|
89
|
+
perform set_config('request.jwt.claim.sub', '', true);
|
|
90
|
+
perform agent_board.post_board_message(v_restricted_thread, v_creator, v_board, 'seed', 'seed');
|
|
91
|
+
select task_id into v_restricted_task from agent_board.create_task(v_restricted_thread, v_creator, 'restricted task', 'd');
|
|
92
|
+
|
|
93
|
+
insert into _o4_fixture values
|
|
94
|
+
(v_board, v_member, v_observer, v_nonmember, v_member_task, v_member_task2, v_assignee_task,
|
|
95
|
+
v_restricted_task, v_board_thread, v_conflict, v_escalation, v_member_user, v_observer_user, v_nonmember_user);
|
|
96
|
+
end
|
|
97
|
+
$$;
|
|
98
|
+
|
|
99
|
+
-- DENY (mutation pins): a task-resource claim requires task-mutate rights -----
|
|
100
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
101
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
|
|
102
|
+
|
|
103
|
+
-- the squat: resource_kind='task', resource_key=<task id>, NO task/thread context
|
|
104
|
+
select throws_ok(
|
|
105
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
|
|
106
|
+
observer_agent, member_task::text) from _o4_fixture),
|
|
107
|
+
'42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
|
|
108
|
+
'observer cannot squat a task-resource claim with no context (DoS closed)');
|
|
109
|
+
|
|
110
|
+
select throws_ok(
|
|
111
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', null, %L::uuid)$q$,
|
|
112
|
+
observer_agent, member_task::text, board_thread) from _o4_fixture),
|
|
113
|
+
'42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
|
|
114
|
+
'observer cannot squat a task-resource claim via the thread path either');
|
|
115
|
+
|
|
116
|
+
select throws_ok(
|
|
117
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
|
|
118
|
+
observer_agent, member_task::text, member_task, board_thread) from _o4_fixture),
|
|
119
|
+
'42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
|
|
120
|
+
'observer cannot acquire a task-bound claim on a board-thread task');
|
|
121
|
+
|
|
122
|
+
-- cross-task: the gate authorizes the LOCKED task (resource_key), not p_task_id. An assignee with
|
|
123
|
+
-- mutate rights on their OWN task cannot squat a DIFFERENT task's lock by passing their task as
|
|
124
|
+
-- p_task_id. (nonmember is assignee of assignee_task, but not a party to member_task2.)
|
|
125
|
+
select set_config('request.jwt.claim.sub', (select nonmember_user from _o4_fixture)::text, true);
|
|
126
|
+
select throws_ok(
|
|
127
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
|
|
128
|
+
nonmember_agent, member_task2::text, assignee_task, board_thread) from _o4_fixture),
|
|
129
|
+
'42501', NULL,
|
|
130
|
+
'an assignee cannot squat a different task''s lock via p_task_id (gate keys on resource_key)');
|
|
131
|
+
|
|
132
|
+
-- restricted thread: a board member with mutate (send-role) but NO thread access cannot squat the
|
|
133
|
+
-- restricted task's claim — the gate composes ACCESS + MUTATE like append_task_event (round-5 codex).
|
|
134
|
+
select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
|
|
135
|
+
select throws_ok(
|
|
136
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
|
|
137
|
+
member_agent, restricted_task::text) from _o4_fixture),
|
|
138
|
+
'42501', 'task not accessible to current agent',
|
|
139
|
+
'a board member without restricted-thread access cannot squat that task''s claim (access gate)');
|
|
140
|
+
|
|
141
|
+
-- ALLOW twins -------------------------------------------------------------------------------
|
|
142
|
+
select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
|
|
143
|
+
select lives_ok(
|
|
144
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
|
|
145
|
+
member_agent, member_task::text, member_task, board_thread) from _o4_fixture),
|
|
146
|
+
'a member may acquire a task-resource claim');
|
|
147
|
+
|
|
148
|
+
select set_config('request.jwt.claim.sub', (select nonmember_user from _o4_fixture)::text, true);
|
|
149
|
+
select lives_ok(
|
|
150
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid)$q$,
|
|
151
|
+
nonmember_agent, assignee_task::text, assignee_task) from _o4_fixture),
|
|
152
|
+
'a non-member ASSIGNEE may acquire their assigned task''s resource claim (carve-out)');
|
|
153
|
+
|
|
154
|
+
-- task-less resolve: observer denied, member allowed (+ v_event no-task fix) ------------------
|
|
155
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
|
|
156
|
+
select throws_ok(
|
|
157
|
+
(select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
|
|
158
|
+
taskless_conflict, observer_agent) from _o4_fixture),
|
|
159
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
160
|
+
'observer cannot resolve a task-less conflict on a board thread');
|
|
161
|
+
|
|
162
|
+
select throws_ok(
|
|
163
|
+
(select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
|
|
164
|
+
taskless_escalation, observer_agent) from _o4_fixture),
|
|
165
|
+
'42501', 'thread posting requires a non-observer board role',
|
|
166
|
+
'observer cannot resolve a task-less escalation on a board thread');
|
|
167
|
+
|
|
168
|
+
select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
|
|
169
|
+
select lives_ok(
|
|
170
|
+
(select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
|
|
171
|
+
taskless_conflict, member_agent) from _o4_fixture),
|
|
172
|
+
'a member may resolve a task-less conflict (v_event no-task fix — no 55000)');
|
|
173
|
+
|
|
174
|
+
select lives_ok(
|
|
175
|
+
(select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
|
|
176
|
+
taskless_escalation, member_agent) from _o4_fixture),
|
|
177
|
+
'a member may resolve a task-less escalation (v_event no-task fix)');
|
|
178
|
+
|
|
179
|
+
-- scope boundary: a NON-task resource claim is not gated by OBSGATE (out of scope) -----------
|
|
180
|
+
select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
|
|
181
|
+
select lives_ok(
|
|
182
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'custom', 'o4-custom-key', now() + interval '1 hour')$q$,
|
|
183
|
+
observer_agent) from _o4_fixture),
|
|
184
|
+
'a non-task resource claim is not gated by OBSGATE-4 (coordination-authz, out of scope)');
|
|
185
|
+
|
|
186
|
+
-- service_role carve-out.
|
|
187
|
+
select set_config('request.jwt.claim.role', 'service_role', true);
|
|
188
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
189
|
+
select lives_ok(
|
|
190
|
+
(select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
|
|
191
|
+
member_agent, member_task2::text) from _o4_fixture),
|
|
192
|
+
'service_role may acquire a task-resource claim regardless of role (carve-out)');
|
|
193
|
+
|
|
194
|
+
select * from finish();
|
|
195
|
+
|
|
196
|
+
rollback;
|
|
@@ -53,8 +53,8 @@ begin
|
|
|
53
53
|
-- A SECOND signing key (key_version 2; enroll auto-enrolled version 1) + a pending
|
|
54
54
|
-- challenge bound to it, so the mint step below can drive PoP -> mint deterministically.
|
|
55
55
|
insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
|
|
56
|
-
values (v_key, v_agent, 'primary', 2,
|
|
57
|
-
|
|
56
|
+
values (v_key, v_agent, 'primary', 2, public.test_pubkey(v_key::text), 'raw_ed25519_base64url',
|
|
57
|
+
public.test_key_fingerprint(v_key::text), 'active', 'proven');
|
|
58
58
|
insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at, consumed_at)
|
|
59
59
|
values (v_ch, v_key, v_agent, v_ch::text, 'agent-signing-key-proof.v1:'||v_ch||':n', 'pending', now() + interval '10 minutes', null);
|
|
60
60
|
|
|
@@ -97,6 +97,17 @@ select lives_ok(
|
|
|
97
97
|
select set_config('request.jwt.claim.role', '', true);
|
|
98
98
|
select set_config('request.jwt.claim.sub', '', true);
|
|
99
99
|
|
|
100
|
+
-- The send LANDED, not merely raised no exception (message-board#87): the prior
|
|
101
|
+
-- lives_ok proves no error, but the header's "board writes are unaffected" claim
|
|
102
|
+
-- needs the fan-out write to be observable. Assert the board post produced board
|
|
103
|
+
-- delivery rows for the board — the write path executed Portal-offline.
|
|
104
|
+
select ok(
|
|
105
|
+
exists(
|
|
106
|
+
select 1 from agent_board.delivery_recipients
|
|
107
|
+
where board_id = (select board from _pofx)
|
|
108
|
+
and delivery_mode = 'board'),
|
|
109
|
+
'the Portal-offline board-member send landed board delivery rows (board writes unaffected)');
|
|
110
|
+
|
|
100
111
|
-- revoke: the lifecycle terminates cleanly; the agent is no longer active. ---------
|
|
101
112
|
select lives_ok(
|
|
102
113
|
(select format($q$select agent_board.revoke_agent(%L::uuid, 'portal-offline lifecycle test')$q$, agent) from _pofx),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- MBHARDEN / #118 — no agent_board SECURITY DEFINER function may carry a PUBLIC EXECUTE
|
|
2
|
+
-- ACL. CREATE FUNCTION defaults EXECUTE to PUBLIC; a SECURITY DEFINER function runs as the
|
|
3
|
+
-- schema owner, so a stray PUBLIC grant lets any authenticated caller drive owner-privileged
|
|
4
|
+
-- logic. #97 closed mark_retention_metadata; 20260812000100 closes the other five.
|
|
5
|
+
--
|
|
6
|
+
-- Assertions are catalog-derived (the durable class invariant) plus two named pins for the
|
|
7
|
+
-- genuine holes, and one over-revoke guard. Each is mutation-pinned:
|
|
8
|
+
-- * removing ANY of the five revokes -> the class count becomes >= 1 -> assert 1 fails.
|
|
9
|
+
-- * removing the _record_signed_write_evidence / _resolve_addressing_recipient_rows revoke
|
|
10
|
+
-- -> its named assert flips to true -> fails.
|
|
11
|
+
-- * "fixing" the invariant by nuking authenticated grants instead of PUBLIC -> assert 4
|
|
12
|
+
-- (an intentionally authenticated SECURITY DEFINER RPC) fails.
|
|
13
|
+
|
|
14
|
+
create extension if not exists pgtap with schema extensions;
|
|
15
|
+
set search_path = extensions, public;
|
|
16
|
+
|
|
17
|
+
begin;
|
|
18
|
+
|
|
19
|
+
select plan(4);
|
|
20
|
+
|
|
21
|
+
-- The class invariant. PUBLIC is the `=X` pseudo-grantee; has_function_privilege('public',…)
|
|
22
|
+
-- is true ONLY when PUBLIC itself holds EXECUTE, so intentional named grants (e.g. to
|
|
23
|
+
-- authenticated) do not trip this.
|
|
24
|
+
select is(
|
|
25
|
+
(select count(*)::int
|
|
26
|
+
from pg_proc p
|
|
27
|
+
where p.pronamespace = 'agent_board'::regnamespace
|
|
28
|
+
and p.prosecdef
|
|
29
|
+
and has_function_privilege('public', p.oid, 'EXECUTE')),
|
|
30
|
+
0,
|
|
31
|
+
'class invariant: no agent_board SECURITY DEFINER function carries a PUBLIC EXECUTE ACL');
|
|
32
|
+
|
|
33
|
+
-- Named pin: the forge hole. Authenticated must not be able to INSERT forged signed-write
|
|
34
|
+
-- attestations by calling the evidence recorder directly.
|
|
35
|
+
select ok(
|
|
36
|
+
not has_function_privilege('authenticated', 'agent_board._record_signed_write_evidence(text,uuid,text,jsonb,jsonb)', 'EXECUTE'),
|
|
37
|
+
'authenticated cannot execute _record_signed_write_evidence (attestation forge)');
|
|
38
|
+
|
|
39
|
+
-- Named pin: the enumeration leak. Authenticated must not be able to run the actor-spoofable
|
|
40
|
+
-- directory resolver directly, bypassing resolve_recipients'' session rebind.
|
|
41
|
+
select ok(
|
|
42
|
+
not has_function_privilege('authenticated', 'agent_board._resolve_addressing_recipient_rows(uuid,jsonb)', 'EXECUTE'),
|
|
43
|
+
'authenticated cannot execute _resolve_addressing_recipient_rows (directory leak)');
|
|
44
|
+
|
|
45
|
+
-- Over-revoke guard: the invariant targets PUBLIC, not intended named grants. A SECURITY
|
|
46
|
+
-- DEFINER RPC deliberately granted to authenticated must keep working.
|
|
47
|
+
select ok(
|
|
48
|
+
has_function_privilege('authenticated', 'agent_board.get_schema_version()', 'EXECUTE'),
|
|
49
|
+
'intended authenticated grant on a SECURITY DEFINER RPC is preserved');
|
|
50
|
+
|
|
51
|
+
select * from finish();
|
|
52
|
+
|
|
53
|
+
rollback;
|