@consiliency/agent-board-schema 1.10.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 +136 -0
- package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
- package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
- package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
- package/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 +309 -3
- package/src/tooling.test.mjs +123 -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_fedsession_autojoin.test.sql +181 -0
- 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_gatenull_membstate.test.sql +352 -0
- 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,67 @@
|
|
|
1
|
+
# CLAIMGATE/CLAIMKEY (#133) MUST-FIX 1 — renew_claim canonicalization is race-safe.
|
|
2
|
+
#
|
|
3
|
+
# Two ACTIVE claims name the SAME physical machine via distinct legacy keys that both
|
|
4
|
+
# canonicalize to 'fpRace' ('machine:fpRace' and ' fpRace '). Renewed concurrently, each
|
|
5
|
+
# session's canonicalizing UPDATE targets 'fpRace' on claims_active_resource_unique. A
|
|
6
|
+
# check-then-act guard would let both observe the slot free at READ COMMITTED and one would
|
|
7
|
+
# raise 23505 — the panel's must-fix. The shipped renew wraps the UPDATE in a unique_violation
|
|
8
|
+
# handler that retries ONCE keeping the current key, so the loser does NOT raise: it renews the
|
|
9
|
+
# lease and keeps its legacy key (the deferred alias ENFORCE closes).
|
|
10
|
+
#
|
|
11
|
+
# WHAT THIS PINS (a TRACE, per Postgres's own isolation suite): session rb's renew BLOCKS on
|
|
12
|
+
# ra's uncommitted index entry (isolationtester prints `<waiting ...>`), and after ra commits rb
|
|
13
|
+
# COMPLETES returning its kept legacy key ' fpRace ' — no error line. Both leases stay active.
|
|
14
|
+
#
|
|
15
|
+
# MUTATION SIGNAL (verified in the PR): drop the `exception when unique_violation ... retry`
|
|
16
|
+
# handler (or stop it catching unique_violation) → rb no longer completes cleanly; the trace
|
|
17
|
+
# shows `ERROR: duplicate key value violates unique constraint "claims_active_resource_unique"`
|
|
18
|
+
# and the golden diverges. Both sessions run as service_role (renew's ownership check is bypassed
|
|
19
|
+
# for the operator carve-out; canonicalization is actor-independent).
|
|
20
|
+
|
|
21
|
+
setup
|
|
22
|
+
{
|
|
23
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status)
|
|
24
|
+
values ('c1a70000-0000-4000-8000-000000000001', 'agent', 'ck-race-agent', 'CK Race Agent', 'active');
|
|
25
|
+
insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_token, lease_expires_at) values
|
|
26
|
+
('c1a70000-0000-4000-8000-000000000001', 'machine', 'machine:fpRace',
|
|
27
|
+
'00000000-0000-4000-8000-0000000000c1', now() + interval '1 hour'),
|
|
28
|
+
('c1a70000-0000-4000-8000-000000000001', 'machine', ' fpRace ',
|
|
29
|
+
'00000000-0000-4000-8000-0000000000c2', now() + interval '1 hour');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
teardown
|
|
33
|
+
{
|
|
34
|
+
delete from agent_board.claims where agent_id = 'c1a70000-0000-4000-8000-000000000001';
|
|
35
|
+
delete from agent_board.agents where id = 'c1a70000-0000-4000-8000-000000000001';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# ra renews the 'machine:fpRace' row -> canonical 'fpRace' is free at its snapshot, so it takes
|
|
39
|
+
# the slot and holds the uncommitted index entry.
|
|
40
|
+
session "ra"
|
|
41
|
+
step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
42
|
+
step "ra_renew" {
|
|
43
|
+
select resource_key
|
|
44
|
+
from agent_board.renew_claim('00000000-0000-4000-8000-0000000000c1', now() + interval '2 hours');
|
|
45
|
+
}
|
|
46
|
+
step "ra_commit" { COMMIT; }
|
|
47
|
+
|
|
48
|
+
# rb renews the ' fpRace ' row -> canonicalizes to the SAME 'fpRace'; its UPDATE blocks on ra's
|
|
49
|
+
# index entry, then on ra commit hits unique_violation and the handler retries keeping ' fpRace '.
|
|
50
|
+
session "rb"
|
|
51
|
+
step "rb_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
52
|
+
step "rb_renew" {
|
|
53
|
+
select resource_key
|
|
54
|
+
from agent_board.renew_claim('00000000-0000-4000-8000-0000000000c2', now() + interval '2 hours');
|
|
55
|
+
}
|
|
56
|
+
step "rb_commit" { COMMIT; }
|
|
57
|
+
|
|
58
|
+
# checker: both leases remain active; ra healed to 'fpRace', rb kept its legacy ' fpRace '.
|
|
59
|
+
session "chk"
|
|
60
|
+
step "chk" {
|
|
61
|
+
select resource_key
|
|
62
|
+
from agent_board.claims
|
|
63
|
+
where lease_token in ('00000000-0000-4000-8000-0000000000c1','00000000-0000-4000-8000-0000000000c2')
|
|
64
|
+
order by resource_key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
permutation "ra_begin" "ra_renew" "rb_begin" "rb_renew" "ra_commit" "rb_commit" "chk"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# message-board#115 — the consent-approval SET-lock serialises a concurrent withdrawal.
|
|
2
|
+
#
|
|
3
|
+
# resolve_board_request opens with an UNFILTERED, id-ordered `for update` over the
|
|
4
|
+
# WHOLE (board, agent) membership set, BEFORE it reads consent or writes. That
|
|
5
|
+
# breadth is load-bearing: a concurrent self-withdrawal on a DIFFERENT physical row
|
|
6
|
+
# (the active one) than the pending row being resolved must serialise behind it
|
|
7
|
+
# (message-board#109). A pending-only / filtered / LIMITed lock would leave the
|
|
8
|
+
# active row free and reopen the race.
|
|
9
|
+
#
|
|
10
|
+
# WHAT THIS PINS (and, deliberately, what it does NOT): on current code a lost
|
|
11
|
+
# UPDATE is independently prevented by membership_id threading (the sink acts on the
|
|
12
|
+
# exact row the resolver validated), so dropping the lock does NOT corrupt the final
|
|
13
|
+
# state — it changes SERIALIZATION. The observable that regresses is the BLOCK
|
|
14
|
+
# itself: with the lock, the withdrawer waits; without it (dropped OR narrowed), the
|
|
15
|
+
# withdrawer proceeds immediately. isolationtester records that as the presence vs
|
|
16
|
+
# absence of `<waiting ...>` in the trace, and the golden diff is the signal. This is
|
|
17
|
+
# exactly how Postgres's own isolation suite pins lock behaviour.
|
|
18
|
+
#
|
|
19
|
+
# THE SURGICAL PERMUTATION: the subject S carries a legal (active A, pending P)
|
|
20
|
+
# duplicate. The approver resolves S to 'joined' — with an active row present this is
|
|
21
|
+
# the Option-1 path: resolve acquires the SET-lock and returns the ACTIVE row A
|
|
22
|
+
# untouched. (Post-#116 it also retires the dangling pending row P via the sink — a
|
|
23
|
+
# write to P, never to A.) So resolve never WRITES A; the SET-lock is the ONLY thing
|
|
24
|
+
# it does to A, isolating the lock as the single variable. The withdrawer withdraws
|
|
25
|
+
# the ACTIVE row A; it blocks on the SET-lock, then applies after commit.
|
|
26
|
+
#
|
|
27
|
+
# Both sessions run as service_role — the SET-lock sits AFTER the actor check and is
|
|
28
|
+
# actor-independent, so this needs no auth.users identity substrate; the lock trace
|
|
29
|
+
# is identical to the authenticated path.
|
|
30
|
+
#
|
|
31
|
+
# MUTATION SIGNAL (a TRACE diff): drop the `for update` block, OR narrow it with
|
|
32
|
+
# `and bm.membership_state in ('requested','invited')` — either leaves A unlocked, so
|
|
33
|
+
# w_leave no longer shows `<waiting ...>` and the golden diverges. Both mutations are
|
|
34
|
+
# verified in the PR.
|
|
35
|
+
|
|
36
|
+
setup
|
|
37
|
+
{
|
|
38
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status) values
|
|
39
|
+
('115a0000-0000-4000-8000-000000000001', 'agent', 'iso115-owner', 'Iso115 Owner', 'active'),
|
|
40
|
+
('115a0000-0000-4000-8000-000000000002', 'agent', 'iso115-subject', 'Iso115 Subject', 'active');
|
|
41
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
|
|
42
|
+
values ('115b0000-0000-4000-8000-000000000001', 'iso115-board', 'Iso115 Board',
|
|
43
|
+
'115a0000-0000-4000-8000-000000000001', 'private', 'active');
|
|
44
|
+
-- Legal (active, pending) duplicate for the subject: A is joined (active), P is a
|
|
45
|
+
-- separate pending request. The active-unique index permits it (only A is active).
|
|
46
|
+
insert into agent_board.board_memberships(id, board_id, agent_id, membership_state, role, created_by_agent_id) values
|
|
47
|
+
('115c0000-0000-4000-8000-0000000000aa', '115b0000-0000-4000-8000-000000000001',
|
|
48
|
+
'115a0000-0000-4000-8000-000000000002', 'joined', 'member', '115a0000-0000-4000-8000-000000000002'),
|
|
49
|
+
('115c0000-0000-4000-8000-0000000000bb', '115b0000-0000-4000-8000-000000000001',
|
|
50
|
+
'115a0000-0000-4000-8000-000000000002', 'requested', 'member', '115a0000-0000-4000-8000-000000000002');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
teardown
|
|
54
|
+
{
|
|
55
|
+
delete from agent_board.board_membership_events where board_id = '115b0000-0000-4000-8000-000000000001';
|
|
56
|
+
delete from agent_board.board_memberships where board_id = '115b0000-0000-4000-8000-000000000001';
|
|
57
|
+
delete from agent_board.boards where id = '115b0000-0000-4000-8000-000000000001';
|
|
58
|
+
delete from agent_board.agents where id in ('115a0000-0000-4000-8000-000000000001','115a0000-0000-4000-8000-000000000002');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# approver resolves the subject to 'joined'. With active row A present this is the
|
|
62
|
+
# Option-1 path — it acquires the whole-set `for update` and returns A untouched
|
|
63
|
+
# (retiring the dangling pending row P via the sink, but never writing A).
|
|
64
|
+
session "approver"
|
|
65
|
+
step "a_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
66
|
+
step "a_resolve" {
|
|
67
|
+
select membership_state
|
|
68
|
+
from agent_board.resolve_board_request(
|
|
69
|
+
'115b0000-0000-4000-8000-000000000001',
|
|
70
|
+
'115a0000-0000-4000-8000-000000000002',
|
|
71
|
+
'joined',
|
|
72
|
+
'115a0000-0000-4000-8000-000000000001');
|
|
73
|
+
}
|
|
74
|
+
step "a_commit" { COMMIT; }
|
|
75
|
+
|
|
76
|
+
# withdrawer withdraws the subject's ACTIVE membership (row A). Its write blocks on
|
|
77
|
+
# the approver's whole-set lock until the approver commits.
|
|
78
|
+
session "withdrawer"
|
|
79
|
+
step "w_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
80
|
+
step "w_leave" {
|
|
81
|
+
select membership_state
|
|
82
|
+
from agent_board.update_board_membership(
|
|
83
|
+
'115b0000-0000-4000-8000-000000000001',
|
|
84
|
+
'115a0000-0000-4000-8000-000000000002',
|
|
85
|
+
'left');
|
|
86
|
+
}
|
|
87
|
+
step "w_commit" { COMMIT; }
|
|
88
|
+
|
|
89
|
+
# checker asserts the active row landed 'left' — the withdrawal applied, serialised
|
|
90
|
+
# after the approval, not overwritten.
|
|
91
|
+
session "checker"
|
|
92
|
+
step "chk" {
|
|
93
|
+
select membership_state
|
|
94
|
+
from agent_board.board_memberships
|
|
95
|
+
where id = '115c0000-0000-4000-8000-0000000000aa';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
permutation "a_begin" "a_resolve" "w_begin" "w_leave" "a_commit" "w_commit" "chk"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# message-board#105 — membership writers emit the ACTUAL row state, not a literal.
|
|
2
|
+
#
|
|
3
|
+
# bootstrap_board_owner_memberships upserts the owner membership with
|
|
4
|
+
# `INSERT ... ON CONFLICT (board_id, agent_id) WHERE (active) DO UPDATE SET role='owner'`
|
|
5
|
+
# and emits `RETURNING bm.membership_state` (the ACTUAL post-upsert state), NOT a
|
|
6
|
+
# literal 'joined'. The ON CONFLICT branch lifts only the role, so a membership a
|
|
7
|
+
# concurrent session already made 'subscribed' STAYS subscribed — and the emitted
|
|
8
|
+
# ledger event must say 'subscribed', not 'joined'.
|
|
9
|
+
#
|
|
10
|
+
# That conflict branch is only reachable under concurrency (a single-transaction
|
|
11
|
+
# pgTAP file cannot produce it): bootstrap's eligibility read must see the owner as
|
|
12
|
+
# having NO active membership (so it enters the loop and attempts the INSERT), yet
|
|
13
|
+
# its INSERT must collide with an active row. We force it deterministically:
|
|
14
|
+
#
|
|
15
|
+
# maker holds an UNCOMMITTED 'subscribed' owner membership. Its row is invisible
|
|
16
|
+
# to boot's eligibility read (READ COMMITTED), so boot enters the loop; boot's
|
|
17
|
+
# INSERT then BLOCKS on maker's uncommitted entry in the partial active-unique
|
|
18
|
+
# index (isolationtester reports <waiting>). maker commits; boot's ON CONFLICT
|
|
19
|
+
# resolves against the now-committed 'subscribed' row and emits result_state
|
|
20
|
+
# 'subscribed'.
|
|
21
|
+
#
|
|
22
|
+
# MUTATION SIGNAL (a VALUE diff): revert the emit to a literal 'joined' and the
|
|
23
|
+
# checker step reports 'joined' instead of 'subscribed' — the golden diverges.
|
|
24
|
+
#
|
|
25
|
+
# bootstrap()'s output is filtered to this board and projects only the two FIXED
|
|
26
|
+
# UUIDs (never the random membership_id) so the golden is deterministic.
|
|
27
|
+
|
|
28
|
+
setup
|
|
29
|
+
{
|
|
30
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status)
|
|
31
|
+
values ('105a0000-0000-4000-8000-000000000001', 'agent', 'iso105-owner', 'Iso105 Owner', 'active');
|
|
32
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
|
|
33
|
+
values ('105b0000-0000-4000-8000-000000000001', 'iso105-board', 'Iso105 Board',
|
|
34
|
+
'105a0000-0000-4000-8000-000000000001', 'private', 'active');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
teardown
|
|
38
|
+
{
|
|
39
|
+
delete from agent_board.board_membership_events where board_id = '105b0000-0000-4000-8000-000000000001';
|
|
40
|
+
delete from agent_board.board_memberships where board_id = '105b0000-0000-4000-8000-000000000001';
|
|
41
|
+
delete from agent_board.boards where id = '105b0000-0000-4000-8000-000000000001';
|
|
42
|
+
delete from agent_board.agents where id = '105a0000-0000-4000-8000-000000000001';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# maker creates (but does not yet commit) an ACTIVE 'subscribed' owner membership.
|
|
46
|
+
session "maker"
|
|
47
|
+
step "m_begin" { BEGIN; }
|
|
48
|
+
step "m_sub" {
|
|
49
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, role, created_by_agent_id)
|
|
50
|
+
values ('105b0000-0000-4000-8000-000000000001', '105a0000-0000-4000-8000-000000000001',
|
|
51
|
+
'subscribed', 'member', '105a0000-0000-4000-8000-000000000001');
|
|
52
|
+
}
|
|
53
|
+
step "m_commit" { COMMIT; }
|
|
54
|
+
|
|
55
|
+
# boot runs the operator bootstrap (service_role-only). Projects only the two fixed
|
|
56
|
+
# UUIDs for THIS board so the trace is deterministic.
|
|
57
|
+
session "boot"
|
|
58
|
+
step "b_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
59
|
+
step "b_boot" {
|
|
60
|
+
select board_id, owner_agent_id
|
|
61
|
+
from agent_board.bootstrap_board_owner_memberships()
|
|
62
|
+
where board_id = '105b0000-0000-4000-8000-000000000001';
|
|
63
|
+
}
|
|
64
|
+
step "b_commit" { COMMIT; }
|
|
65
|
+
|
|
66
|
+
# checker reads the emitted ledger event: its result_state must be the ACTUAL row
|
|
67
|
+
# state ('subscribed'), not the literal 'joined'.
|
|
68
|
+
session "checker"
|
|
69
|
+
step "chk" {
|
|
70
|
+
select result_state, event_kind, provenance
|
|
71
|
+
from agent_board.board_membership_events
|
|
72
|
+
where board_id = '105b0000-0000-4000-8000-000000000001'
|
|
73
|
+
and agent_id = '105a0000-0000-4000-8000-000000000001'
|
|
74
|
+
and event_kind = 'operator_repair'
|
|
75
|
+
order by seq desc
|
|
76
|
+
limit 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
permutation "m_begin" "m_sub" "b_begin" "b_boot" "m_commit" "b_commit" "chk"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# #153 TOCTOU — reclaim_fleet_trust_domain serializes against a concurrent squatter
|
|
2
|
+
# revoke('trust_domain'), and revoke re-checks domain ownership UNDER a row lock.
|
|
3
|
+
#
|
|
4
|
+
# revoke_fleet_subject's trust_domain-ownership authz check was UNLOCKED: a squatter could call
|
|
5
|
+
# revoke('trust_domain', D); its ownership SELECT reads owner=squatter (passes) with no lock, then
|
|
6
|
+
# its lifecycle UPDATE blocks on reclaim's `select ... for update` and — pre-fix — applies AFTER
|
|
7
|
+
# reclaim commits (owner=new_owner), revoking the domain the operator just handed over (a
|
|
8
|
+
# DoS-flavoured defeat of reclaim's atomicity — the exact whole-domain analogue of #150's machine
|
|
9
|
+
# TOCTOU). The fix takes `select ... for update` on the owning fleet_trust_domains row BEFORE the
|
|
10
|
+
# ownership check, so revoke serializes behind reclaim and the re-read sees committed ownership
|
|
11
|
+
# (owner=new_owner) → the squatter's revoke is refused 42501.
|
|
12
|
+
#
|
|
13
|
+
# P1 — reclaim-vs-squatter-revoke (the pin). reclaim (service_role) takes the lock and holds it;
|
|
14
|
+
# the squatter's authenticated revoke BLOCKS at its own FOR UPDATE (isolationtester prints
|
|
15
|
+
# `<waiting ...>`), then after reclaim commits RE-READS ownership under the lock, sees owner=
|
|
16
|
+
# new_owner, and RAISES `trust domain is not owned by current_agent_id()`. FINAL: owner=new_owner,
|
|
17
|
+
# lifecycle=active — the reclaimed domain is NOT nuked.
|
|
18
|
+
#
|
|
19
|
+
# MUTATION SIGNAL: drop the trust_domain FOR UPDATE lock from revoke_fleet_subject → the squatter's
|
|
20
|
+
# ownership SELECT reads the stale (pre-commit) owner=squatter and PASSES without waiting; revoke
|
|
21
|
+
# then blocks at its lifecycle UPDATE, and after reclaim commits it SUCCEEDS (returns an event id)
|
|
22
|
+
# and the checker shows lifecycle=revoked / owner=new_owner — the trace diverges from this golden.
|
|
23
|
+
|
|
24
|
+
setup
|
|
25
|
+
{
|
|
26
|
+
insert into auth.users(id) values ('b2000000-0000-4000-8000-000000000002');
|
|
27
|
+
|
|
28
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id) values
|
|
29
|
+
('a2000000-0000-4000-8000-000000000001', 'agent', 'domreclaim-iso-operator', 'Dom Reclaim Iso Operator', 'active', null),
|
|
30
|
+
('a2000000-0000-4000-8000-000000000002', 'agent', 'domreclaim-iso-squatter', 'Dom Reclaim Iso Squatter', 'active', 'b2000000-0000-4000-8000-000000000002'),
|
|
31
|
+
('a2000000-0000-4000-8000-000000000003', 'agent', 'domreclaim-iso-newowner', 'Dom Reclaim Iso New Owner', 'active', null);
|
|
32
|
+
|
|
33
|
+
-- An active domain owned by the squatter (the squatted domain).
|
|
34
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
35
|
+
values ('d2000000-0000-4000-8000-000000000001', 'domreclaim-iso-domain', 'Dom Reclaim Iso Domain', 'tailnet',
|
|
36
|
+
'a2000000-0000-4000-8000-000000000002', 'active', now());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
teardown
|
|
40
|
+
{
|
|
41
|
+
delete from agent_board.fleet_revocation_events where subject_id = 'd2000000-0000-4000-8000-000000000001';
|
|
42
|
+
delete from agent_board.fleet_trust_domains where id = 'd2000000-0000-4000-8000-000000000001';
|
|
43
|
+
delete from agent_board.agents where id in (
|
|
44
|
+
'a2000000-0000-4000-8000-000000000001',
|
|
45
|
+
'a2000000-0000-4000-8000-000000000002',
|
|
46
|
+
'a2000000-0000-4000-8000-000000000003');
|
|
47
|
+
delete from auth.users where id = 'b2000000-0000-4000-8000-000000000002';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ra: the operator reclaim (service_role). Its `select ... for update` locks the domain row until
|
|
51
|
+
# ra commits, reassigning ownership to the new owner.
|
|
52
|
+
session "ra"
|
|
53
|
+
step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
54
|
+
step "ra_reclaim" {
|
|
55
|
+
select owner_agent_id
|
|
56
|
+
from agent_board.reclaim_fleet_trust_domain(
|
|
57
|
+
'd2000000-0000-4000-8000-000000000001', 'a2000000-0000-4000-8000-000000000003',
|
|
58
|
+
'a2000000-0000-4000-8000-000000000001', 'iso operator domain reclaim');
|
|
59
|
+
}
|
|
60
|
+
step "ra_commit" { COMMIT; }
|
|
61
|
+
|
|
62
|
+
# rb: the squatter's revoke via the authenticated domain-owner path. With the fix it BLOCKS at its
|
|
63
|
+
# own FOR UPDATE, then re-reads committed ownership and is refused 42501.
|
|
64
|
+
session "rb"
|
|
65
|
+
step "rb_begin" {
|
|
66
|
+
BEGIN;
|
|
67
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
68
|
+
SET LOCAL "request.jwt.claim.sub" = 'b2000000-0000-4000-8000-000000000002';
|
|
69
|
+
}
|
|
70
|
+
step "rb_revoke" {
|
|
71
|
+
select revocation_event_id
|
|
72
|
+
from agent_board.revoke_fleet_subject(
|
|
73
|
+
'trust_domain', 'd2000000-0000-4000-8000-000000000001', 'a2000000-0000-4000-8000-000000000002',
|
|
74
|
+
'iso squatter domain revoke', jsonb_build_object('redacted', true));
|
|
75
|
+
}
|
|
76
|
+
step "rb_commit" { COMMIT; }
|
|
77
|
+
|
|
78
|
+
# checker: final ownership + lifecycle of the contested domain.
|
|
79
|
+
session "chk"
|
|
80
|
+
step "chk" {
|
|
81
|
+
select owner_agent_id, lifecycle_state
|
|
82
|
+
from agent_board.fleet_trust_domains
|
|
83
|
+
where id = 'd2000000-0000-4000-8000-000000000001';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# P1: reclaim holds the lock; the squatter's revoke blocks then is refused → domain stays active
|
|
87
|
+
# under the new owner (the revoke is NOT applied).
|
|
88
|
+
permutation "ra_begin" "ra_reclaim" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# #146 MUST-FIX 3 (concurrency) — reactivate_fleet_machine serializes against a concurrent
|
|
2
|
+
# revoke, and re-checks the revoked precondition UNDER a row lock.
|
|
3
|
+
#
|
|
4
|
+
# reactivate_fleet_machine's read-decide-write was unconditional and unlocked: a revoke that
|
|
5
|
+
# committed mid-operation could be silently overwritten (machine ends active with reactivated_at
|
|
6
|
+
# newer than the revoke's event — the revoke, possibly a security response, is lost). The fix
|
|
7
|
+
# takes `select ... for update` on the fleet_machines row before the still-revoked re-check, so
|
|
8
|
+
# the whole operation serializes against revoke_fleet_subject (whose fleet_machines UPDATE also
|
|
9
|
+
# locks that row).
|
|
10
|
+
#
|
|
11
|
+
# Two permutations, both service_role (reactivate is operator-only; revoke is operator here):
|
|
12
|
+
#
|
|
13
|
+
# P1 — reactivate-vs-revoke (the panel's requested pin). reactivate takes the lock and holds
|
|
14
|
+
# it; the concurrent revoke BLOCKS (isolationtester prints `<waiting ...>`), then applies after
|
|
15
|
+
# reactivate commits. FINAL: the machine is revoked and evaluate denies (machine_revoked) — the
|
|
16
|
+
# revoke is NOT lost. Combined with MUST-FIX 1 (revoke advances effective_at=now()), the late
|
|
17
|
+
# revoke both flips lifecycle AND post-dates reactivated_at, so both guards deny.
|
|
18
|
+
#
|
|
19
|
+
# P2 — reactivate-vs-reactivate (the for-update MUTATION signal). The second reactivate blocks
|
|
20
|
+
# on the first's lock, then RE-READS under the lock: the machine is now active, so its
|
|
21
|
+
# still-revoked re-check fails and it RAISES `fleet machine is not revoked; nothing to
|
|
22
|
+
# reactivate`. MUTATION SIGNAL: drop `for update` from reactivate's read → the second reactivate
|
|
23
|
+
# reads the stale (pre-commit) revoked snapshot, its re-check passes, and it succeeds instead of
|
|
24
|
+
# raising — the `<waiting>`/error trace diverges from this golden.
|
|
25
|
+
|
|
26
|
+
setup
|
|
27
|
+
{
|
|
28
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status) values
|
|
29
|
+
('a0000000-0000-4000-8000-000000000001', 'agent', 'react-iso-owner', 'React Iso Owner', 'active'),
|
|
30
|
+
('a0000000-0000-4000-8000-000000000002', 'agent', 'react-iso-sender', 'React Iso Sender', 'active'),
|
|
31
|
+
('a0000000-0000-4000-8000-000000000003', 'agent', 'react-iso-recip', 'React Iso Recipient', 'active');
|
|
32
|
+
|
|
33
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
34
|
+
values ('d0000000-0000-4000-8000-000000000001', 'react-iso-domain', 'React Iso Domain', 'tailnet',
|
|
35
|
+
'a0000000-0000-4000-8000-000000000001', 'active', now());
|
|
36
|
+
|
|
37
|
+
-- A machine that is already revoked (with a PRIOR machine-revocation event, effective 1h ago).
|
|
38
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
|
|
39
|
+
lifecycle_state, enrollment_source, enrolled_at, revoked_at, reactivated_at, metadata)
|
|
40
|
+
values ('60000000-0000-4000-8000-000000000001', 'd0000000-0000-4000-8000-000000000001',
|
|
41
|
+
'a0000000-0000-4000-8000-000000000001', 'react-iso-m', 'sha256:react-iso-m',
|
|
42
|
+
'revoked', 'portal', now(), now(), null, jsonb_build_object('redacted', true, 'metadata-safe', true));
|
|
43
|
+
|
|
44
|
+
insert into agent_board.fleet_machine_agents(machine_id, agent_id, agent_role)
|
|
45
|
+
values ('60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000002', 'phase_worker');
|
|
46
|
+
|
|
47
|
+
insert into agent_board.fleet_revocation_events(id, subject_kind, subject_id, machine_id, revoked_by_agent_id, revocation_reason, effective_at, metadata)
|
|
48
|
+
values ('e0000000-0000-4000-8000-000000000001', 'machine', '60000000-0000-4000-8000-000000000001', '60000000-0000-4000-8000-000000000001',
|
|
49
|
+
'a0000000-0000-4000-8000-000000000001', 'iso initial revoke', now() - interval '1 hour',
|
|
50
|
+
jsonb_build_object('redacted', true, 'metadata-safe', true));
|
|
51
|
+
|
|
52
|
+
-- Allow-by-default receive policy so a non-revoked bound machine would pass; the only lever
|
|
53
|
+
-- the checker exercises is the machine-revocation state.
|
|
54
|
+
insert into agent_board.fleet_receive_policies(recipient_agent_id, policy_name, default_action, require_signed_sender, lifecycle_state, metadata)
|
|
55
|
+
values ('a0000000-0000-4000-8000-000000000003', 'react iso allow', 'allow', false, 'active', jsonb_build_object());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
teardown
|
|
59
|
+
{
|
|
60
|
+
delete from agent_board.fleet_revocation_events where subject_id = '60000000-0000-4000-8000-000000000001';
|
|
61
|
+
delete from agent_board.fleet_machine_agents where machine_id = '60000000-0000-4000-8000-000000000001';
|
|
62
|
+
delete from agent_board.fleet_machines where id = '60000000-0000-4000-8000-000000000001';
|
|
63
|
+
delete from agent_board.fleet_receive_policies where recipient_agent_id = 'a0000000-0000-4000-8000-000000000003';
|
|
64
|
+
delete from agent_board.fleet_trust_domains where id = 'd0000000-0000-4000-8000-000000000001';
|
|
65
|
+
delete from agent_board.agents where id in (
|
|
66
|
+
'a0000000-0000-4000-8000-000000000001',
|
|
67
|
+
'a0000000-0000-4000-8000-000000000002',
|
|
68
|
+
'a0000000-0000-4000-8000-000000000003');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# ra: the reactivation. Its `select ... for update` locks the machine row until ra commits.
|
|
72
|
+
session "ra"
|
|
73
|
+
step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
74
|
+
step "ra_react" {
|
|
75
|
+
select lifecycle_state
|
|
76
|
+
from agent_board.reactivate_fleet_machine(
|
|
77
|
+
'60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001', 'iso reactivate');
|
|
78
|
+
}
|
|
79
|
+
step "ra_commit" { COMMIT; }
|
|
80
|
+
|
|
81
|
+
# rb: a competing writer — either a revoke (P1) or a second reactivate (P2). Its row write
|
|
82
|
+
# blocks on ra's lock until ra commits.
|
|
83
|
+
session "rb"
|
|
84
|
+
step "rb_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
85
|
+
step "rb_revoke" {
|
|
86
|
+
select revocation_event_id
|
|
87
|
+
from agent_board.revoke_fleet_subject(
|
|
88
|
+
'machine', '60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001',
|
|
89
|
+
'iso re-revoke', jsonb_build_object('redacted', true));
|
|
90
|
+
}
|
|
91
|
+
step "rb_react" {
|
|
92
|
+
select lifecycle_state
|
|
93
|
+
from agent_board.reactivate_fleet_machine(
|
|
94
|
+
'60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001', 'iso second reactivate');
|
|
95
|
+
}
|
|
96
|
+
step "rb_commit" { COMMIT; }
|
|
97
|
+
|
|
98
|
+
# checker: final machine lifecycle + the policy verdict for the bound sender.
|
|
99
|
+
session "chk"
|
|
100
|
+
step "chk" {
|
|
101
|
+
select fm.lifecycle_state,
|
|
102
|
+
(select denial_class
|
|
103
|
+
from agent_board.evaluate_fleet_sender_policy(
|
|
104
|
+
'a0000000-0000-4000-8000-000000000002', 'a0000000-0000-4000-8000-000000000003',
|
|
105
|
+
fm.id, null, 'send_direct_message', null, false)) as denial_class
|
|
106
|
+
from agent_board.fleet_machines fm
|
|
107
|
+
where fm.id = '60000000-0000-4000-8000-000000000001';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# P1: reactivate holds the lock; the revoke blocks then applies → final revoked + denied.
|
|
111
|
+
permutation "ra_begin" "ra_react" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"
|
|
112
|
+
|
|
113
|
+
# P2: reactivate holds the lock; the second reactivate blocks, re-reads under the lock, and
|
|
114
|
+
# raises (nothing to reactivate) → the for-update mutation signal.
|
|
115
|
+
permutation "ra_begin" "ra_react" "rb_begin" "rb_react" "ra_commit" "rb_commit" "chk"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# #150 MUST-FIX 1 (codex) — reclaim_fleet_machine serializes against a concurrent squatter
|
|
2
|
+
# revoke('machine'), and revoke re-checks machine ownership UNDER a row lock.
|
|
3
|
+
#
|
|
4
|
+
# revoke_fleet_subject's machine-ownership authz check was UNLOCKED: a squatter could call
|
|
5
|
+
# revoke('machine', M); its ownership SELECT reads owner=squatter (passes) with no lock, then its
|
|
6
|
+
# lifecycle UPDATE blocks on reclaim's `select ... for update` and — pre-fix — applies AFTER
|
|
7
|
+
# reclaim commits (owner=new_owner), revoking the machine the operator just handed over (a
|
|
8
|
+
# DoS-flavoured defeat of reclaim's atomicity). The fix takes `select ... for update` on the owning
|
|
9
|
+
# fleet_machines row BEFORE the ownership check, so revoke serializes behind reclaim and the
|
|
10
|
+
# re-read sees committed ownership (owner=new_owner) → the squatter's revoke is refused 42501.
|
|
11
|
+
#
|
|
12
|
+
# P1 — reclaim-vs-squatter-revoke (the pin). reclaim (service_role) takes the lock and holds it;
|
|
13
|
+
# the squatter's authenticated revoke BLOCKS at its own FOR UPDATE (isolationtester prints
|
|
14
|
+
# `<waiting ...>`), then after reclaim commits RE-READS ownership under the lock, sees owner=
|
|
15
|
+
# new_owner, and RAISES `machine is not owned by current_agent_id()`. FINAL: owner=new_owner,
|
|
16
|
+
# lifecycle=active — the reclaimed machine is NOT nuked.
|
|
17
|
+
#
|
|
18
|
+
# MUTATION SIGNAL: drop the FOR UPDATE lock from revoke_fleet_subject → the squatter's ownership
|
|
19
|
+
# SELECT reads the stale (pre-commit) owner=squatter and PASSES without waiting; revoke then blocks
|
|
20
|
+
# at its lifecycle UPDATE, and after reclaim commits it SUCCEEDS (returns an event id) and the
|
|
21
|
+
# checker shows lifecycle=revoked / owner=new_owner — the trace diverges from this golden.
|
|
22
|
+
|
|
23
|
+
setup
|
|
24
|
+
{
|
|
25
|
+
insert into auth.users(id) values ('b1000000-0000-4000-8000-000000000002');
|
|
26
|
+
|
|
27
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id) values
|
|
28
|
+
('a1000000-0000-4000-8000-000000000001', 'agent', 'reclaim-iso-operator', 'Reclaim Iso Operator', 'active', null),
|
|
29
|
+
('a1000000-0000-4000-8000-000000000002', 'agent', 'reclaim-iso-squatter', 'Reclaim Iso Squatter', 'active', 'b1000000-0000-4000-8000-000000000002'),
|
|
30
|
+
('a1000000-0000-4000-8000-000000000003', 'agent', 'reclaim-iso-newowner', 'Reclaim Iso New Owner', 'active', null);
|
|
31
|
+
|
|
32
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
33
|
+
values ('d1000000-0000-4000-8000-000000000001', 'reclaim-iso-domain', 'Reclaim Iso Domain', 'tailnet',
|
|
34
|
+
'a1000000-0000-4000-8000-000000000002', 'active', now());
|
|
35
|
+
|
|
36
|
+
-- An active machine owned by the squatter (the pre-registered/squatted fingerprint).
|
|
37
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
|
|
38
|
+
lifecycle_state, enrollment_source, enrolled_at, revoked_at, metadata)
|
|
39
|
+
values ('61000000-0000-4000-8000-000000000001', 'd1000000-0000-4000-8000-000000000001',
|
|
40
|
+
'a1000000-0000-4000-8000-000000000002', 'reclaim-iso-m', 'sha256:reclaim-iso-m',
|
|
41
|
+
'active', 'portal', now(), null, jsonb_build_object('redacted', true, 'metadata-safe', true));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
teardown
|
|
45
|
+
{
|
|
46
|
+
delete from agent_board.fleet_revocation_events where subject_id = '61000000-0000-4000-8000-000000000001';
|
|
47
|
+
delete from agent_board.fleet_machines where id = '61000000-0000-4000-8000-000000000001';
|
|
48
|
+
delete from agent_board.fleet_trust_domains where id = 'd1000000-0000-4000-8000-000000000001';
|
|
49
|
+
delete from agent_board.agents where id in (
|
|
50
|
+
'a1000000-0000-4000-8000-000000000001',
|
|
51
|
+
'a1000000-0000-4000-8000-000000000002',
|
|
52
|
+
'a1000000-0000-4000-8000-000000000003');
|
|
53
|
+
delete from auth.users where id = 'b1000000-0000-4000-8000-000000000002';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# ra: the operator reclaim (service_role). Its `select ... for update` locks the machine row until
|
|
57
|
+
# ra commits, reassigning ownership to the new owner.
|
|
58
|
+
session "ra"
|
|
59
|
+
step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
60
|
+
step "ra_reclaim" {
|
|
61
|
+
select owner_agent_id
|
|
62
|
+
from agent_board.reclaim_fleet_machine(
|
|
63
|
+
'61000000-0000-4000-8000-000000000001', 'a1000000-0000-4000-8000-000000000003',
|
|
64
|
+
'a1000000-0000-4000-8000-000000000001', 'iso operator reclaim');
|
|
65
|
+
}
|
|
66
|
+
step "ra_commit" { COMMIT; }
|
|
67
|
+
|
|
68
|
+
# rb: the squatter's revoke via the authenticated domain-owner path. With the fix it BLOCKS at its
|
|
69
|
+
# own FOR UPDATE, then re-reads committed ownership and is refused 42501.
|
|
70
|
+
session "rb"
|
|
71
|
+
step "rb_begin" {
|
|
72
|
+
BEGIN;
|
|
73
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
74
|
+
SET LOCAL "request.jwt.claim.sub" = 'b1000000-0000-4000-8000-000000000002';
|
|
75
|
+
}
|
|
76
|
+
step "rb_revoke" {
|
|
77
|
+
select revocation_event_id
|
|
78
|
+
from agent_board.revoke_fleet_subject(
|
|
79
|
+
'machine', '61000000-0000-4000-8000-000000000001', 'a1000000-0000-4000-8000-000000000002',
|
|
80
|
+
'iso squatter revoke', jsonb_build_object('redacted', true));
|
|
81
|
+
}
|
|
82
|
+
step "rb_commit" { COMMIT; }
|
|
83
|
+
|
|
84
|
+
# checker: final ownership + lifecycle of the contested machine.
|
|
85
|
+
session "chk"
|
|
86
|
+
step "chk" {
|
|
87
|
+
select owner_agent_id, lifecycle_state
|
|
88
|
+
from agent_board.fleet_machines
|
|
89
|
+
where id = '61000000-0000-4000-8000-000000000001';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# P1: reclaim holds the lock; the squatter's revoke blocks then is refused → machine stays active
|
|
93
|
+
# under the new owner (the revoke is NOT applied).
|
|
94
|
+
permutation "ra_begin" "ra_reclaim" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- message-board#103 — pre-MBCONSENT-A seed: a legacy PENDING membership that predates the consent
|
|
2
|
+
-- ledger, so the shipped backfill (20260806000100, board-authored neutral event per pending
|
|
3
|
+
-- subject) has a real row to act on. Applied by the test runner immediately BEFORE that migration
|
|
4
|
+
-- (schema is at the migration-N-1 state here — board_membership_events does not exist yet).
|
|
5
|
+
--
|
|
6
|
+
-- Distinctive fixed ids (bf103-*) so the row does not collide with any test's own fixtures. The
|
|
7
|
+
-- board's creator differs from the member, satisfying the backfill's board-authored predicate.
|
|
8
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
|
|
9
|
+
values
|
|
10
|
+
('bf103aaa-0000-0000-0000-000000000001', 'agent', 'agent', 'bf103-creator', 'BF103 Creator', 'active'),
|
|
11
|
+
('bf103aaa-0000-0000-0000-000000000002', 'agent', 'agent', 'bf103-member', 'BF103 Member', 'active')
|
|
12
|
+
on conflict (id) do nothing;
|
|
13
|
+
|
|
14
|
+
insert into agent_board.boards(id, slug, name, description, created_by_agent_id)
|
|
15
|
+
values ('bf103bbb-0000-0000-0000-0000000000b1', 'bf103-board', 'BF103 Board', 'consent backfill seed', 'bf103aaa-0000-0000-0000-000000000001')
|
|
16
|
+
on conflict (id) do nothing;
|
|
17
|
+
|
|
18
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
19
|
+
values ('bf103bbb-0000-0000-0000-0000000000b1', 'bf103aaa-0000-0000-0000-000000000002', 'requested', 'bf103aaa-0000-0000-0000-000000000001', 'member');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- Pre-migration seed (message-board#103 hook) for the MINTHARDEN fingerprint backfill.
|
|
2
|
+
--
|
|
3
|
+
-- Runs immediately BEFORE 20260822000200 — i.e. AFTER 20260822000100 installed the
|
|
4
|
+
-- integrity trigger and helpers. It simulates a row that predates the trigger: a
|
|
5
|
+
-- string-era mint child signing key with a CANONICAL public_key but a fingerprint
|
|
6
|
+
-- derived over the base64url STRING (the 20260718001000:203 bug). Such a row can only
|
|
7
|
+
-- exist by bypassing the new trigger, so it is inserted under session_replication_role=
|
|
8
|
+
-- replica — exactly reproducing a legacy row created before the trigger existed.
|
|
9
|
+
--
|
|
10
|
+
-- The backfill (20260822000200) must re-derive its fingerprint to the raw-byte value;
|
|
11
|
+
-- agent_board_mint_harden_backfill.test.sql asserts the SHIPPED backfill did so
|
|
12
|
+
-- (behavioural — a mutation removing the UPDATE leaves the stale string fingerprint and
|
|
13
|
+
-- the assertion fails). Distinctive fixed ids so nothing collides with other fixtures.
|
|
14
|
+
do $$
|
|
15
|
+
declare
|
|
16
|
+
v_owner uuid;
|
|
17
|
+
v_agent uuid := 'fb111111-0000-4b11-8000-000000000200';
|
|
18
|
+
v_key uuid := 'fb111111-0000-4b11-8000-000000000201';
|
|
19
|
+
v_pub text := public.test_pubkey('fbfill-legacy-1');
|
|
20
|
+
-- The OLD string-era derivation: sha256 over the base64url STRING bytes (not the raw key).
|
|
21
|
+
v_string_fp text := 'sha256:' || rtrim(translate(encode(
|
|
22
|
+
extensions.digest(convert_to(public.test_pubkey('fbfill-legacy-1'), 'UTF8'), 'sha256'), 'base64'), '+/', '-_'), '=');
|
|
23
|
+
begin
|
|
24
|
+
v_owner := public.ensure_github_user(
|
|
25
|
+
'fbfill-legacy-owner', 'fbfill-legacy-owner@example.test', 'fbfill-legacy-owner', 'FB Legacy Owner');
|
|
26
|
+
|
|
27
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
28
|
+
values (v_agent, 'agent', 'agent'::agent_board.agent_kind, 'fbfill-legacy-agent', 'FB Legacy Agent',
|
|
29
|
+
'active', v_owner, v_owner);
|
|
30
|
+
|
|
31
|
+
-- Bypass the 20260822000100 integrity trigger to plant the legacy (string-fingerprint) row.
|
|
32
|
+
set local session_replication_role = replica;
|
|
33
|
+
insert into agent_board.agent_signing_keys(
|
|
34
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state, metadata)
|
|
35
|
+
values (v_key, v_agent, 'delegated-ephemeral', 1, v_pub, 'raw_ed25519_base64url', v_string_fp,
|
|
36
|
+
'active', 'proven', jsonb_build_object('legacyStringFingerprint', true));
|
|
37
|
+
set local session_replication_role = default;
|
|
38
|
+
end
|
|
39
|
+
$$;
|