@consiliency/agent-board-schema 1.12.0 → 1.23.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 +160 -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/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/package.json +1 -1
- package/schema-manifest.json +363 -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_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -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_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +51 -5
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -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_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +45 -31
- 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_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
- 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 +27 -11
- 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/agent_board_wake_recovery.test.sql +756 -0
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -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/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -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/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -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
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
-- #153 whole-domain squatting arbitration — reassign a squatted fleet_trust_domain's owner.
|
|
2
|
+
--
|
|
3
|
+
-- #150 (20260829000100) closed the MACHINE-level kill switch: reclaim_fleet_machine can MOVE a
|
|
4
|
+
-- reclaimed machine into a domain the new owner controls, so a squatter who still owns the machine's
|
|
5
|
+
-- ORIGINAL domain can no longer revoke_fleet_subject('trust_domain') to deny it. Two residuals #150
|
|
6
|
+
-- explicitly left open (see its header + issue #153):
|
|
7
|
+
-- (1) the arbitrated new owner may have NO domain to move a reclaimed machine into; and
|
|
8
|
+
-- (2) a squatter can squat a WHOLE domain — there was no operator arbitration path to reassign a
|
|
9
|
+
-- contested domain's OWNER (revoke_fleet_subject('trust_domain') revokes but does not TRANSFER).
|
|
10
|
+
-- This migration adds the domain-level arbitration path for (2), and documents the operator path
|
|
11
|
+
-- for (1) (see the "DOMAINLESS NEW OWNER" note below).
|
|
12
|
+
--
|
|
13
|
+
-- AUTHORITY MODEL (verified against evaluate_fleet_sender_policy / register_fleet_machine /
|
|
14
|
+
-- set_fleet_receive_policy / revoke_fleet_subject). Domain authority is conferred ENTIRELY by
|
|
15
|
+
-- fleet_trust_domains.owner_agent_id — every authz site reads it LIVE:
|
|
16
|
+
-- * revoke_fleet_subject('trust_domain', D): authenticated path requires ftd.owner_agent_id =
|
|
17
|
+
-- current_agent_id() (the squatter's kill switch).
|
|
18
|
+
-- * register_fleet_machine into D: requires ftd.owner_agent_id = actor.
|
|
19
|
+
-- * set_fleet_receive_policy scoped to D: requires ftd.owner_agent_id = actor.
|
|
20
|
+
-- So REASSIGNING owner_agent_id is the exact-and-complete revocation of the squatter's domain
|
|
21
|
+
-- authority: the instant it commits, the squatter fails every one of those checks and the new owner
|
|
22
|
+
-- passes them. There is deliberately NO fleet_revocation_events row written here — mirroring #150's
|
|
23
|
+
-- machine-reclaim divergence rationale: a subject_kind='trust_domain' ledger row OVER-denies (the
|
|
24
|
+
-- policy's trust_domain clause denies EVERY machine in the domain, including the NEW owner's), which
|
|
25
|
+
-- is the opposite of handing the domain over live. The reassignment IS the revocation; the audit
|
|
26
|
+
-- (who/why/prior owner/prior lifecycle) is APPENDED to fleet_trust_domains.metadata.reclaim_history
|
|
27
|
+
-- (same append-not-overwrite shape as #150, so repeated arbitrations do not clobber prior records).
|
|
28
|
+
--
|
|
29
|
+
-- SERIALIZATION CAVEAT (#159 — the claim is NARROWED here, do not overstate it). The ownership
|
|
30
|
+
-- reassignment is atomic AT THE DOMAIN ROW, and reclaim locks that row `for update`. ONE
|
|
31
|
+
-- domain-owner-gated writer is serialized against that lock: revoke_fleet_subject('trust_domain')
|
|
32
|
+
-- takes the same domain-row lock before its authz check (below), so a squatter cannot pass a
|
|
33
|
+
-- stale-owner gate and revoke the just-reclaimed domain. The OTHER two domain-owner-gated writers —
|
|
34
|
+
-- register_fleet_machine and set_fleet_receive_policy — are NOT yet serialized against reclaim: they
|
|
35
|
+
-- authorize on ftd.owner_agent_id without locking the domain row, so a squatter racing an IN-FLIGHT
|
|
36
|
+
-- reclaim can read the pre-commit owner, pass the gate, and land a squatter-owned machine or a
|
|
37
|
+
-- squatter-authored receive policy that SURVIVES the handoff and needs operator cleanup. This is a
|
|
38
|
+
-- narrow, operator-recoverable race, NOT the atomic-transfer-of-all-powers this migration must NOT
|
|
39
|
+
-- claim. Serializing that whole class safely requires re-ordering the merged reclaim_fleet_machine
|
|
40
|
+
-- (its machine-first FOR UPDATE + the implicit FK FOR KEY SHARE on the destination domain from its
|
|
41
|
+
-- trust_domain_id UPDATE make a naive domain-lock on register a machine→domain vs domain→machine
|
|
42
|
+
-- DEADLOCK) and is tracked holistically in #159. Post-commit, all authz reads see the new owner.
|
|
43
|
+
--
|
|
44
|
+
-- OPERATOR-ONLY (arbitration, not self-service). Deciding the rightful owner of a contested domain
|
|
45
|
+
-- requires out-of-band verification only an OPERATOR can perform, so reclaim_fleet_trust_domain is
|
|
46
|
+
-- service_role-only and raises 42501 for any other caller — there is deliberately no owner- or
|
|
47
|
+
-- victim-facing self-reclaim (that would re-create the squatting race in the other direction).
|
|
48
|
+
--
|
|
49
|
+
-- SCOPE / non-goals (matches issue #153's domain-vs-machine split):
|
|
50
|
+
-- * This reassigns the DOMAIN's owner ONLY. It does NOT touch the owner_agent_id of the
|
|
51
|
+
-- constituent fleet_machines — those keep owner=squatter and remain the machine-level
|
|
52
|
+
-- reclaim_fleet_machine (#150) path. Taking a domain does not silently seize every machine in it;
|
|
53
|
+
-- an operator who also wants the machines reclaims them individually. (A squatter left owning
|
|
54
|
+
-- machines in a domain they no longer own can still revoke_fleet_subject('machine', M) on those
|
|
55
|
+
-- machines — a machine-scoped lever, not the domain kill switch this closes.)
|
|
56
|
+
-- * REVOKED-DOMAIN RESIDUAL — a domain the squatter already revoked STAYS revoked after reclaim.
|
|
57
|
+
-- THIS IS INTENTIONAL, NOT AN OVERSIGHT — do not "fix" it by adding a revive here. If a squatter
|
|
58
|
+
-- had ALREADY fired revoke_fleet_subject('trust_domain', D) BEFORE arbitration, D is lifecycle
|
|
59
|
+
-- 'revoked' with a denying fleet_revocation_events row, and the policy's trust_domain clause is a
|
|
60
|
+
-- pure EXISTENCE check with no supersession point (unlike the machine clause's effective_at >
|
|
61
|
+
-- reactivated_at). reclaim reassigns owner REGARDLESS of lifecycle (it does NOT raise on a
|
|
62
|
+
-- revoked domain) and records the prior lifecycle in the audit, but it does NOT revive a
|
|
63
|
+
-- pre-revoked domain. Three reasons this is the correct boundary, not a gap:
|
|
64
|
+
-- (a) SEPARATE OPERATOR ACT. For machines, reactivation (#149, which built the reactivated_at
|
|
65
|
+
-- supersession mechanism as its own phase) and reclaim (#150) are DISTINCT acts — #150 only
|
|
66
|
+
-- bundles revival because #149 shipped the mechanism first. Domains have no such mechanism;
|
|
67
|
+
-- domain-reactivation is the #149-parallel follow-up, tracked separately, NOT this build.
|
|
68
|
+
-- (b) LEDGER AUTHORITY (#141/#149 lesson). The only in-scope ways to revive would be to mutate/
|
|
69
|
+
-- delete the trust_domain revocation event (contradicts append-only ledger authority) or to
|
|
70
|
+
-- add a supersession clause to evaluate_fleet_sender_policy (a change to a load-bearing
|
|
71
|
+
-- security fn). Both are out of scope for an ownership-arbitration build.
|
|
72
|
+
-- (c) SECURITY. Ownership transfer must NOT silently un-revoke a domain that may have been
|
|
73
|
+
-- revoked FOR CAUSE — reviving is a deliberate second decision, not a side effect of who
|
|
74
|
+
-- owns it. The transfer still removes the squatter's ability to fire OR re-fire the kill
|
|
75
|
+
-- switch going forward.
|
|
76
|
+
-- (agent_board_fleet_domain_reclaim.test.sql pins this boundary positively: reclaim of a revoked
|
|
77
|
+
-- domain succeeds + transfers owner + the
|
|
78
|
+
-- domain stays revoked — a known/tested edge, not a silent gap.)
|
|
79
|
+
--
|
|
80
|
+
-- DOMAINLESS NEW OWNER (issue #153 residual 1). No create_fleet_trust_domain RPC exists — trust
|
|
81
|
+
-- domains are seeded by direct table INSERT, and service_role already holds INSERT on
|
|
82
|
+
-- fleet_trust_domains (20260628000004 grant). So the operator path for "new owner has no domain to
|
|
83
|
+
-- move a reclaimed machine into" is: (a) service_role INSERTs an active fleet_trust_domains row owned
|
|
84
|
+
-- by the new owner, then (b) reclaim_fleet_machine(..., p_new_trust_domain_id => <that domain>) moves
|
|
85
|
+
-- the reclaimed machine into it (#150). No redundant RPC is added here — the seed is one INSERT the
|
|
86
|
+
-- operator already has the grant for.
|
|
87
|
+
--
|
|
88
|
+
-- TOCTOU (mirror of #150 MUST-FIX 1). reclaim_fleet_trust_domain reassigns owner under
|
|
89
|
+
-- `select ... for update` on the fleet_trust_domains row. revoke_fleet_subject's trust_domain
|
|
90
|
+
-- ownership check was UNLOCKED, so a squatter's concurrent revoke('trust_domain', D) could pass its
|
|
91
|
+
-- ownership SELECT on the pre-reclaim snapshot and land its lifecycle UPDATE AFTER a concurrent
|
|
92
|
+
-- reclaim reassigned the domain — revoking the domain the operator just handed to the new owner.
|
|
93
|
+
-- revoke_fleet_subject is recreated below to LOCK the owning fleet_trust_domains row BEFORE the
|
|
94
|
+
-- trust_domain ownership check (matching reclaim's lock-first order → they serialize deadlock-free on
|
|
95
|
+
-- the same row). Pinned by the domain_reclaim_revoke_race isolation spec.
|
|
96
|
+
--
|
|
97
|
+
-- #118 discipline: reclaim_fleet_trust_domain (and the recreated revoke_fleet_subject) are SECURITY
|
|
98
|
+
-- DEFINER — restate the revoke-from-public and the grant and the comment in this same migration.
|
|
99
|
+
|
|
100
|
+
create or replace function agent_board.reclaim_fleet_trust_domain(
|
|
101
|
+
p_domain_id uuid,
|
|
102
|
+
p_new_owner_agent_id uuid,
|
|
103
|
+
p_reclaimed_by_agent_id uuid,
|
|
104
|
+
p_reason text default null
|
|
105
|
+
)
|
|
106
|
+
returns table(domain_id uuid, owner_agent_id uuid, lifecycle_state text, prior_owner_agent_id uuid)
|
|
107
|
+
language plpgsql
|
|
108
|
+
security definer
|
|
109
|
+
set search_path = agent_board, public
|
|
110
|
+
as $$
|
|
111
|
+
declare
|
|
112
|
+
v_domain agent_board.fleet_trust_domains%rowtype;
|
|
113
|
+
v_prior_owner uuid;
|
|
114
|
+
v_prior_lifecycle text;
|
|
115
|
+
begin
|
|
116
|
+
-- Operator-only: ownership arbitration for a contested domain is not self-service.
|
|
117
|
+
if not agent_board._is_service_role() then
|
|
118
|
+
raise exception 'reclaim_fleet_trust_domain is operator-only (service_role)' using errcode = '42501';
|
|
119
|
+
end if;
|
|
120
|
+
|
|
121
|
+
-- Provenance is required and must name a real agent (audit lands in metadata).
|
|
122
|
+
if p_reclaimed_by_agent_id is null then
|
|
123
|
+
raise exception 'p_reclaimed_by_agent_id is required' using errcode = '22023';
|
|
124
|
+
end if;
|
|
125
|
+
if not exists (
|
|
126
|
+
select 1 from agent_board.agents a where a.id = p_reclaimed_by_agent_id
|
|
127
|
+
) then
|
|
128
|
+
raise exception 'p_reclaimed_by_agent_id is not a known agent' using errcode = '22023';
|
|
129
|
+
end if;
|
|
130
|
+
|
|
131
|
+
-- The arbitrated new owner must exist (validate before we reassign anything).
|
|
132
|
+
if p_new_owner_agent_id is null then
|
|
133
|
+
raise exception 'p_new_owner_agent_id is required' using errcode = '22023';
|
|
134
|
+
end if;
|
|
135
|
+
if not exists (
|
|
136
|
+
select 1 from agent_board.agents a where a.id = p_new_owner_agent_id
|
|
137
|
+
) then
|
|
138
|
+
raise exception 'p_new_owner_agent_id is not a known agent' using errcode = '22023';
|
|
139
|
+
end if;
|
|
140
|
+
|
|
141
|
+
-- Lock the domain row so this read-reassign serializes against a concurrent
|
|
142
|
+
-- revoke_fleet_subject('trust_domain', D) (which, post-#153, also takes a row lock on this same
|
|
143
|
+
-- fleet_trust_domains row before its ownership check). Without it a stale-owner revoke could
|
|
144
|
+
-- interleave and nuke the just-reassigned domain (see the TOCTOU note in the header).
|
|
145
|
+
select * into v_domain from agent_board.fleet_trust_domains ftd where ftd.id = p_domain_id for update;
|
|
146
|
+
if v_domain.id is null then
|
|
147
|
+
raise exception 'fleet trust domain not found' using errcode = '22023';
|
|
148
|
+
end if;
|
|
149
|
+
|
|
150
|
+
v_prior_owner := v_domain.owner_agent_id;
|
|
151
|
+
v_prior_lifecycle := v_domain.lifecycle_state;
|
|
152
|
+
|
|
153
|
+
-- Reassign ownership. This is the ONLY function-writer of fleet_trust_domains.owner_agent_id
|
|
154
|
+
-- (no RPC INSERTs a domain — see the EC-4 class assertion in the test). owner_agent_id is the
|
|
155
|
+
-- domain's authority surface (header AUTHORITY MODEL): post-commit, every authz site reads the new
|
|
156
|
+
-- owner. The reassignment is atomic at THIS row; it is NOT a fully-serialized transfer of every
|
|
157
|
+
-- domain power (register_fleet_machine / set_fleet_receive_policy are not yet locked against an
|
|
158
|
+
-- in-flight reclaim — see header SERIALIZATION CAVEAT / #159).
|
|
159
|
+
-- LIFECYCLE: reclaim does NOT change lifecycle_state / revoked_at — a domain the squatter already
|
|
160
|
+
-- revoked STAYS revoked. This is INTENTIONAL (see header REVOKED-DOMAIN RESIDUAL): domain-
|
|
161
|
+
-- reactivation is a separate #149-parallel act, and ownership transfer must not silently un-revoke
|
|
162
|
+
-- a domain possibly revoked for cause. Do NOT add a revive here. Leaving both untouched also keeps
|
|
163
|
+
-- fleet_trust_domains_revoked_shape satisfied without extra care.
|
|
164
|
+
-- AUDIT: APPEND to metadata.reclaim_history (never overwrite) so repeated arbitrations preserve
|
|
165
|
+
-- prior records; prior_lifecycle is recorded so the revoked-domain residual is visible in audit.
|
|
166
|
+
update agent_board.fleet_trust_domains ftd
|
|
167
|
+
set owner_agent_id = p_new_owner_agent_id,
|
|
168
|
+
metadata = coalesce(ftd.metadata, '{}'::jsonb)
|
|
169
|
+
|| jsonb_build_object(
|
|
170
|
+
'reclaim_history',
|
|
171
|
+
coalesce(ftd.metadata -> 'reclaim_history', '[]'::jsonb)
|
|
172
|
+
|| jsonb_build_array(jsonb_build_object(
|
|
173
|
+
'reclaimed_by', p_reclaimed_by_agent_id,
|
|
174
|
+
'reclaim_reason', nullif(trim(coalesce(p_reason, '')), ''),
|
|
175
|
+
'prior_owner', v_prior_owner,
|
|
176
|
+
'prior_lifecycle_state', v_prior_lifecycle,
|
|
177
|
+
'reclaimed_at', now()
|
|
178
|
+
))
|
|
179
|
+
)
|
|
180
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true),
|
|
181
|
+
updated_at = now()
|
|
182
|
+
where ftd.id = p_domain_id
|
|
183
|
+
returning * into v_domain;
|
|
184
|
+
|
|
185
|
+
return query select v_domain.id, v_domain.owner_agent_id, v_domain.lifecycle_state, v_prior_owner;
|
|
186
|
+
end;
|
|
187
|
+
$$;
|
|
188
|
+
|
|
189
|
+
-- #118 discipline: a SECURITY DEFINER function grants EXECUTE to PUBLIC on create. Restate the
|
|
190
|
+
-- revokes/grant and the comment. Operator-only ⇒ granted to service_role ALONE (not authenticated).
|
|
191
|
+
revoke all on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) from public, anon, authenticated;
|
|
192
|
+
grant execute on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) to service_role;
|
|
193
|
+
|
|
194
|
+
comment on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) is
|
|
195
|
+
'Operator-only (#153): reclaims a squatted fleet trust domain (whole-domain squatting — the residual #150''s machine-level reclaim could not cover). Reassigns fleet_trust_domains.owner_agent_id to the arbitrated new owner under a row lock — the only function-writer of that column. The reassignment is atomic at the domain row and post-commit every authz site (revoke_fleet_subject(''trust_domain''), register_fleet_machine, set_fleet_receive_policy) reads the new owner. SERIALIZATION CAVEAT (#159): only revoke_fleet_subject(''trust_domain'') is locked against an in-flight reclaim; register_fleet_machine and set_fleet_receive_policy are NOT yet serialized, so a squatter racing an in-flight reclaim may land a machine/policy that survives handoff and needs operator cleanup (tracked in #159). Writes NO fleet_revocation_events row (a trust_domain ledger row over-denies the whole domain incl. the new owner — same divergence rationale as #150); the audit is APPENDED to metadata.reclaim_history. Does NOT reassign the constituent machines'' owners (machine-level reclaim is #150) and INTENTIONALLY does NOT revive a domain the squatter already revoked (a pre-revoked domain STAYS revoked — domain-reactivation is a separate #149-parallel act, and ownership transfer must not silently un-revoke a domain possibly revoked for cause; reassign happens regardless of lifecycle, prior lifecycle is audited). service_role only (raises 42501 otherwise).';
|
|
196
|
+
|
|
197
|
+
-- ============================================================================================
|
|
198
|
+
-- #153 TOCTOU: revoke_fleet_subject('trust_domain') vs reclaim_fleet_trust_domain.
|
|
199
|
+
-- Reproduced VERBATIM from 20260829000100 (#150) EXCEPT: the trust_domain lock block added below,
|
|
200
|
+
-- and the comment. The trust_domain authz branch derives ownership from
|
|
201
|
+
-- fleet_trust_domains.owner_agent_id and previously read it WITHOUT a lock, so a squatter's
|
|
202
|
+
-- revoke('trust_domain', D) could pass its ownership check on the pre-reclaim snapshot and have its
|
|
203
|
+
-- lifecycle UPDATE apply AFTER a concurrent reclaim_fleet_trust_domain reassigned the domain —
|
|
204
|
+
-- nuking the domain the operator just handed to the new owner. We now LOCK the owning
|
|
205
|
+
-- fleet_trust_domains row FIRST (before the authz re-read and before the event write, matching
|
|
206
|
+
-- reclaim's lock-first order → the two serialize deadlock-free on the same row). The machine/harness/
|
|
207
|
+
-- repo_scope lock (#150 MUST-FIX 1) is preserved verbatim. #118 grants/comment restated.
|
|
208
|
+
-- ============================================================================================
|
|
209
|
+
create or replace function agent_board.revoke_fleet_subject(
|
|
210
|
+
p_subject_kind text,
|
|
211
|
+
p_subject_id uuid,
|
|
212
|
+
p_revoked_by_agent_id uuid,
|
|
213
|
+
p_revocation_reason text,
|
|
214
|
+
p_metadata jsonb default '{}'::jsonb
|
|
215
|
+
)
|
|
216
|
+
returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
|
|
217
|
+
language plpgsql
|
|
218
|
+
security definer
|
|
219
|
+
set search_path = agent_board, public
|
|
220
|
+
as $$
|
|
221
|
+
declare
|
|
222
|
+
v_event agent_board.fleet_revocation_events%rowtype;
|
|
223
|
+
v_actor_agent_id uuid;
|
|
224
|
+
begin
|
|
225
|
+
if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
|
|
226
|
+
raise exception 'unsupported fleet revocation subject' using errcode = '22023';
|
|
227
|
+
end if;
|
|
228
|
+
|
|
229
|
+
-- #150 MUST-FIX 1: lock the owning fleet_machines row for machine-derived kinds BEFORE the
|
|
230
|
+
-- unlocked ownership check below, so a concurrent reclaim_fleet_machine (which holds `for update`
|
|
231
|
+
-- on that row) serializes this call and the authz re-read sees committed ownership. Placed ahead
|
|
232
|
+
-- of the event write; reclaim also locks this row first, so acquisition order matches (no cycle).
|
|
233
|
+
if p_subject_kind in ('machine', 'harness', 'repo_scope') then
|
|
234
|
+
declare
|
|
235
|
+
v_lock_machine_id uuid;
|
|
236
|
+
begin
|
|
237
|
+
v_lock_machine_id := case
|
|
238
|
+
when p_subject_kind = 'machine' then p_subject_id
|
|
239
|
+
when p_subject_kind = 'harness' then (
|
|
240
|
+
select fhr.machine_id from agent_board.fleet_harness_registrations fhr where fhr.id = p_subject_id)
|
|
241
|
+
when p_subject_kind = 'repo_scope' then (
|
|
242
|
+
select fhr.machine_id
|
|
243
|
+
from agent_board.fleet_repo_scopes frs
|
|
244
|
+
join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
|
|
245
|
+
where frs.id = p_subject_id)
|
|
246
|
+
end;
|
|
247
|
+
if v_lock_machine_id is not null then
|
|
248
|
+
perform 1 from agent_board.fleet_machines fm where fm.id = v_lock_machine_id for update;
|
|
249
|
+
end if;
|
|
250
|
+
end;
|
|
251
|
+
end if;
|
|
252
|
+
|
|
253
|
+
-- #153 TOCTOU: lock the owning fleet_trust_domains row for the trust_domain kind BEFORE the
|
|
254
|
+
-- unlocked ownership check below, so a concurrent reclaim_fleet_trust_domain (which holds
|
|
255
|
+
-- `for update` on that row) serializes this call and the authz re-read sees committed ownership —
|
|
256
|
+
-- a stale-owner (squatter) revoke is then refused 42501 rather than nuking a just-reclaimed domain.
|
|
257
|
+
-- reclaim also locks this row first, so acquisition order matches (no cycle).
|
|
258
|
+
if p_subject_kind = 'trust_domain' then
|
|
259
|
+
perform 1 from agent_board.fleet_trust_domains ftd where ftd.id = p_subject_id for update;
|
|
260
|
+
end if;
|
|
261
|
+
|
|
262
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
|
|
263
|
+
|
|
264
|
+
if not agent_board._is_service_role() then
|
|
265
|
+
if p_subject_kind = 'trust_domain' and not exists (
|
|
266
|
+
select 1 from agent_board.fleet_trust_domains ftd
|
|
267
|
+
where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
|
|
268
|
+
) then
|
|
269
|
+
raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
|
|
270
|
+
elsif p_subject_kind = 'machine' and not exists (
|
|
271
|
+
select 1 from agent_board.fleet_machines fm
|
|
272
|
+
where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
273
|
+
) then
|
|
274
|
+
raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
|
|
275
|
+
elsif p_subject_kind = 'harness' and not exists (
|
|
276
|
+
select 1
|
|
277
|
+
from agent_board.fleet_harness_registrations fhr
|
|
278
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
279
|
+
where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
280
|
+
) then
|
|
281
|
+
raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
|
|
282
|
+
elsif p_subject_kind = 'repo_scope' and not exists (
|
|
283
|
+
select 1
|
|
284
|
+
from agent_board.fleet_repo_scopes frs
|
|
285
|
+
join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
|
|
286
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
287
|
+
where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
288
|
+
) then
|
|
289
|
+
raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
|
|
290
|
+
elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
|
|
291
|
+
raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
|
|
292
|
+
elsif p_subject_kind = 'signing_key' and not exists (
|
|
293
|
+
select 1 from agent_board.agent_signing_keys ask
|
|
294
|
+
where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
|
|
295
|
+
) then
|
|
296
|
+
raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
|
|
297
|
+
elsif p_subject_kind = 'credential' and not exists (
|
|
298
|
+
select 1 from agent_board.agent_credentials ac
|
|
299
|
+
where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
|
|
300
|
+
) then
|
|
301
|
+
raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
|
|
302
|
+
end if;
|
|
303
|
+
end if;
|
|
304
|
+
|
|
305
|
+
select *
|
|
306
|
+
into v_event
|
|
307
|
+
from agent_board.fleet_revocation_events fre
|
|
308
|
+
where fre.subject_kind = p_subject_kind
|
|
309
|
+
and fre.subject_id = p_subject_id
|
|
310
|
+
limit 1;
|
|
311
|
+
|
|
312
|
+
if v_event.id is null then
|
|
313
|
+
insert into agent_board.fleet_revocation_events(
|
|
314
|
+
subject_kind,
|
|
315
|
+
subject_id,
|
|
316
|
+
trust_domain_id,
|
|
317
|
+
machine_id,
|
|
318
|
+
harness_registration_id,
|
|
319
|
+
repo_scope_id,
|
|
320
|
+
agent_id,
|
|
321
|
+
signing_key_id,
|
|
322
|
+
revoked_by_agent_id,
|
|
323
|
+
revocation_reason,
|
|
324
|
+
metadata
|
|
325
|
+
)
|
|
326
|
+
values (
|
|
327
|
+
p_subject_kind,
|
|
328
|
+
p_subject_id,
|
|
329
|
+
case when p_subject_kind = 'trust_domain' then p_subject_id end,
|
|
330
|
+
case when p_subject_kind = 'machine' then p_subject_id end,
|
|
331
|
+
case when p_subject_kind = 'harness' then p_subject_id end,
|
|
332
|
+
case when p_subject_kind = 'repo_scope' then p_subject_id end,
|
|
333
|
+
case when p_subject_kind = 'agent' then p_subject_id end,
|
|
334
|
+
case when p_subject_kind = 'signing_key' then p_subject_id end,
|
|
335
|
+
p_revoked_by_agent_id,
|
|
336
|
+
trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
337
|
+
coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
338
|
+
)
|
|
339
|
+
returning * into v_event;
|
|
340
|
+
else
|
|
341
|
+
update agent_board.fleet_revocation_events fre
|
|
342
|
+
set effective_at = now(),
|
|
343
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
344
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
345
|
+
metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
346
|
+
where fre.id = v_event.id
|
|
347
|
+
returning * into v_event;
|
|
348
|
+
end if;
|
|
349
|
+
|
|
350
|
+
if p_subject_kind = 'trust_domain' then
|
|
351
|
+
update agent_board.fleet_trust_domains
|
|
352
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
353
|
+
where id = p_subject_id;
|
|
354
|
+
elsif p_subject_kind = 'machine' then
|
|
355
|
+
update agent_board.fleet_machines
|
|
356
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
357
|
+
where id = p_subject_id;
|
|
358
|
+
elsif p_subject_kind = 'harness' then
|
|
359
|
+
update agent_board.fleet_harness_registrations
|
|
360
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
361
|
+
where id = p_subject_id;
|
|
362
|
+
elsif p_subject_kind = 'repo_scope' then
|
|
363
|
+
update agent_board.fleet_repo_scopes
|
|
364
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
365
|
+
where id = p_subject_id;
|
|
366
|
+
elsif p_subject_kind = 'agent' then
|
|
367
|
+
update agent_board.fleet_machine_agents
|
|
368
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
369
|
+
where agent_id = p_subject_id;
|
|
370
|
+
elsif p_subject_kind = 'signing_key' then
|
|
371
|
+
update agent_board.agent_signing_keys
|
|
372
|
+
set status = case when status = 'compromised' then status else 'revoked' end,
|
|
373
|
+
proof_state = 'revoked',
|
|
374
|
+
revoked_at = coalesce(revoked_at, v_event.effective_at),
|
|
375
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
376
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
377
|
+
updated_at = now()
|
|
378
|
+
where id = p_subject_id;
|
|
379
|
+
end if;
|
|
380
|
+
|
|
381
|
+
return query select v_event.id, v_event.subject_kind, v_event.effective_at;
|
|
382
|
+
end;
|
|
383
|
+
$$;
|
|
384
|
+
|
|
385
|
+
revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
|
|
386
|
+
grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
387
|
+
|
|
388
|
+
comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
|
|
389
|
+
'Records a fleet revocation into the append-only fleet_revocation_events ledger (one row per subject) and flips the subject''s lifecycle to revoked. Re-revoking a subject advances the event''s effective_at to now() (#146). For machine-derived subject kinds (machine/harness/repo_scope) it locks the owning fleet_machines row before the ownership check (#150), and for trust_domain it locks the owning fleet_trust_domains row before the ownership check (#153), so a concurrent reclaim_fleet_machine / reclaim_fleet_trust_domain serializes and a stale-owner revoke is refused (42501) rather than nuking a just-reclaimed machine or domain.';
|