@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,432 @@
|
|
|
1
|
+
-- #146 operator squatting-reclaim — reassign a squatted machine + revoke squatter access.
|
|
2
|
+
--
|
|
3
|
+
-- machine_fingerprint is GLOBALLY unique. #141 (20260823000100) made register_fleet_machine
|
|
4
|
+
-- raise 42501 on a cross-owner fingerprint conflict (ownership is immutable via re-register).
|
|
5
|
+
-- That correctly stops post-registration hijacking, but it also means: if a SQUATTER registers
|
|
6
|
+
-- a victim's fingerprint FIRST, the victim is locked out permanently — the only prior recovery
|
|
7
|
+
-- was service_role table surgery. This migration adds the authorized reclaim path.
|
|
8
|
+
--
|
|
9
|
+
-- Ownership here is ARBITRATION, not self-service: deciding the rightful owner of a contested
|
|
10
|
+
-- fingerprint requires out-of-band verification that only an OPERATOR can perform. So
|
|
11
|
+
-- reclaim_fleet_machine is service_role-only and raises 42501 for any other caller — there is
|
|
12
|
+
-- deliberately no owner-facing or victim-facing self-reclaim (that would just re-create the
|
|
13
|
+
-- squatting race in the other direction).
|
|
14
|
+
--
|
|
15
|
+
-- Reclaim does three things atomically under a row lock:
|
|
16
|
+
-- 1. Reassigns owner_agent_id to the arbitrated new owner. This is the ONLY writer besides
|
|
17
|
+
-- register_fleet_machine that changes owner_agent_id (see the comment at the UPDATE).
|
|
18
|
+
-- 2. Revokes the SQUATTER's access to THIS machine by flipping every ACTIVE
|
|
19
|
+
-- fleet_machine_agents binding on it to 'revoked'. This is SCOPED to the machine — it is
|
|
20
|
+
-- deliberately NOT revoke_fleet_subject('agent', ...), which is GLOBAL (it flips every one
|
|
21
|
+
-- of that agent's bindings on every machine and writes a subject_kind='agent' ledger row
|
|
22
|
+
-- that evaluate_fleet_sender_policy honors WITHOUT machine scope). A squatter can be an
|
|
23
|
+
-- otherwise-legitimate fleet member; reclaiming one machine must not deny them everywhere.
|
|
24
|
+
-- NOTE (divergence from the task suggestion, on correctness grounds): we do NOT write a
|
|
25
|
+
-- fleet_revocation_events row per revoked binding. fleet_revocation_events has no
|
|
26
|
+
-- binding/machine-agent subject_kind (the enum is trust_domain/machine/harness/repo_scope/
|
|
27
|
+
-- agent/signing_key/credential), and every available subject_kind has a policy side effect
|
|
28
|
+
-- that OVER-denies: 'agent' denies the squatter as a sender on ALL their machines;
|
|
29
|
+
-- 'machine' would deny the NEW owner too. For a binding, lifecycle_state IS the authority
|
|
30
|
+
-- by design — BOTH readers that gate the squatter (_can_claim_resource and the policy's
|
|
31
|
+
-- binding lookup) read fma.lifecycle_state='active' directly — so flipping it is the
|
|
32
|
+
-- correct mechanism, not a shortcut around the ledger. The ledger-authority principle from
|
|
33
|
+
-- #141/#146 governs machine/domain/agent subjects, not per-binding access. The reclaim
|
|
34
|
+
-- audit (who/why/prior owner/revoked agents) is recorded on fleet_machines.metadata.
|
|
35
|
+
-- 3. Leaves the machine ACTIVE for the new owner. If the squatted machine had been revoked,
|
|
36
|
+
-- reclaim sets lifecycle_state='active', clears revoked_at, and stamps reactivated_at=now()
|
|
37
|
+
-- so any prior machine-revocation event is superseded (same supersession mechanism as
|
|
38
|
+
-- reactivate_fleet_machine, #146) and evaluate_fleet_sender_policy lets the new owner use
|
|
39
|
+
-- it. reclaim does NOT read machine-revocation events itself.
|
|
40
|
+
--
|
|
41
|
+
-- TRUST-DOMAIN KILL SWITCH (#150 panel MUST-FIX). A reclaimed machine still points at its
|
|
42
|
+
-- original trust_domain_id, which the SQUATTER may still OWN. A trust-domain owner can
|
|
43
|
+
-- revoke_fleet_subject('trust_domain', ...) on their own domain (the authenticated domain-owner
|
|
44
|
+
-- path), and evaluate_fleet_sender_policy's trust_domain clause then denies EVERY machine in that
|
|
45
|
+
-- domain — including the reclaimed one. So if reclaim leaves the machine in the squatter's domain,
|
|
46
|
+
-- the squatter retains a kill switch: the new owner can send only until the squatter revokes the
|
|
47
|
+
-- domain. To close it, reclaim takes an OPTIONAL p_new_trust_domain_id:
|
|
48
|
+
-- * When PROVIDED, it is validated (exists, active, owned by the NEW owner) and the machine is
|
|
49
|
+
-- MOVED into it, so the reclaimed machine sits in a domain the new owner controls — the
|
|
50
|
+
-- squatter's domain revocation can no longer reach it.
|
|
51
|
+
-- * When NULL, the machine STAYS in its current (squatter-owned) domain and the squatter RETAINS
|
|
52
|
+
-- the kill switch described above. This is an accepted, DOCUMENTED residual: reclaiming a whole
|
|
53
|
+
-- squatted DOMAIN (and the case where the new owner has no domain to move into) is broader
|
|
54
|
+
-- ownership arbitration filed as a separate follow-up, out of scope here. Operators reclaiming
|
|
55
|
+
-- into a squatter-owned domain should pass p_new_trust_domain_id.
|
|
56
|
+
--
|
|
57
|
+
-- SCOPE / non-goals (matches the issue): reclaim reassigns the MACHINE (owner, and optionally the
|
|
58
|
+
-- trust domain per above). It does NOT revoke the trust domain itself, and does NOT revoke the
|
|
59
|
+
-- machine subject (the new owner keeps it active). Whole-domain squatting arbitration is a separate
|
|
60
|
+
-- follow-up.
|
|
61
|
+
--
|
|
62
|
+
-- #150 codex red-team MUST-FIXes folded in here:
|
|
63
|
+
-- * MUST-FIX 1 — revoke_fleet_subject TOCTOU: its machine-ownership check was unlocked, so a
|
|
64
|
+
-- squatter's concurrent revoke('machine', M) could pass on the pre-reclaim snapshot and land
|
|
65
|
+
-- its lifecycle UPDATE AFTER reclaim, nuking the just-handed-over machine. revoke_fleet_subject
|
|
66
|
+
-- is recreated below to LOCK the owning fleet_machines row before the ownership check (a
|
|
67
|
+
-- concurrent reclaim serializes it; a stale-owner revoke is then refused 42501). Pinned by the
|
|
68
|
+
-- reclaim_revoke_race isolation spec.
|
|
69
|
+
-- * MUST-FIX 2 — a squatter's ACTIVE machine CLAIM survived reclaim (claims key on the bare
|
|
70
|
+
-- fingerprint and reclaim never touched agent_board.claims), so the new owner's acquire_claim
|
|
71
|
+
-- raised 23505 until lease expiry. reclaim now releases the squatter's active machine claim(s)
|
|
72
|
+
-- on the reclaimed fingerprint (step 2b).
|
|
73
|
+
--
|
|
74
|
+
-- #118 discipline: reclaim_fleet_machine (and the recreated revoke_fleet_subject) are SECURITY
|
|
75
|
+
-- DEFINER — restate the revoke-from-public and the grant and the comment in this same migration.
|
|
76
|
+
|
|
77
|
+
-- reactivate_fleet_machine (20260826000100) introduced fleet_machines.reactivated_at as the
|
|
78
|
+
-- machine-revocation supersession point. reclaim also stamps it (step 3), so widen the column
|
|
79
|
+
-- comment. (Additive: the supersession semantics are unchanged.)
|
|
80
|
+
comment on column agent_board.fleet_machines.reactivated_at is
|
|
81
|
+
'Supersession point (#146): machine-revocation events with effective_at <= this are ignored by evaluate_fleet_sender_policy and v_fleet_machine_status. Set by reactivate_fleet_machine and reclaim_fleet_machine; null means never reactivated/reclaimed.';
|
|
82
|
+
|
|
83
|
+
create or replace function agent_board.reclaim_fleet_machine(
|
|
84
|
+
p_machine_id uuid,
|
|
85
|
+
p_new_owner_agent_id uuid,
|
|
86
|
+
p_reclaimed_by_agent_id uuid,
|
|
87
|
+
p_reason text default null,
|
|
88
|
+
p_new_trust_domain_id uuid default null
|
|
89
|
+
)
|
|
90
|
+
returns table(machine_id uuid, owner_agent_id uuid, trust_domain_id uuid, lifecycle_state text, revoked_binding_count integer)
|
|
91
|
+
language plpgsql
|
|
92
|
+
security definer
|
|
93
|
+
set search_path = agent_board, public
|
|
94
|
+
as $$
|
|
95
|
+
declare
|
|
96
|
+
v_machine agent_board.fleet_machines%rowtype;
|
|
97
|
+
v_prior_owner uuid;
|
|
98
|
+
v_prior_domain uuid;
|
|
99
|
+
v_revoked_count integer;
|
|
100
|
+
v_revoked_agents uuid[];
|
|
101
|
+
v_released_claims integer;
|
|
102
|
+
begin
|
|
103
|
+
-- Operator-only: ownership arbitration for a contested fingerprint is not self-service.
|
|
104
|
+
if not agent_board._is_service_role() then
|
|
105
|
+
raise exception 'reclaim_fleet_machine is operator-only (service_role)' using errcode = '42501';
|
|
106
|
+
end if;
|
|
107
|
+
|
|
108
|
+
-- Provenance is required and must name a real agent (audit lands in metadata).
|
|
109
|
+
if p_reclaimed_by_agent_id is null then
|
|
110
|
+
raise exception 'p_reclaimed_by_agent_id is required' using errcode = '22023';
|
|
111
|
+
end if;
|
|
112
|
+
if not exists (
|
|
113
|
+
select 1 from agent_board.agents a where a.id = p_reclaimed_by_agent_id
|
|
114
|
+
) then
|
|
115
|
+
raise exception 'p_reclaimed_by_agent_id is not a known agent' using errcode = '22023';
|
|
116
|
+
end if;
|
|
117
|
+
|
|
118
|
+
-- The arbitrated new owner must exist (validate before we revoke anything).
|
|
119
|
+
if p_new_owner_agent_id is null then
|
|
120
|
+
raise exception 'p_new_owner_agent_id is required' using errcode = '22023';
|
|
121
|
+
end if;
|
|
122
|
+
if not exists (
|
|
123
|
+
select 1 from agent_board.agents a where a.id = p_new_owner_agent_id
|
|
124
|
+
) then
|
|
125
|
+
raise exception 'p_new_owner_agent_id is not a known agent' using errcode = '22023';
|
|
126
|
+
end if;
|
|
127
|
+
|
|
128
|
+
-- Kill-switch close (#150 panel): if a destination trust domain is given, it must be an ACTIVE
|
|
129
|
+
-- domain OWNED BY THE NEW OWNER — otherwise the move would either park the machine somewhere the
|
|
130
|
+
-- new owner cannot control or leave it in a domain a third party (e.g. the squatter) can revoke,
|
|
131
|
+
-- which is exactly the kill switch this parameter exists to close. Validated before any writes.
|
|
132
|
+
if p_new_trust_domain_id is not null and not exists (
|
|
133
|
+
select 1 from agent_board.fleet_trust_domains ftd
|
|
134
|
+
where ftd.id = p_new_trust_domain_id
|
|
135
|
+
and ftd.lifecycle_state = 'active'
|
|
136
|
+
and ftd.owner_agent_id = p_new_owner_agent_id
|
|
137
|
+
) then
|
|
138
|
+
raise exception 'p_new_trust_domain_id must be an active trust domain owned by the new owner' using errcode = '42501';
|
|
139
|
+
end if;
|
|
140
|
+
|
|
141
|
+
-- Lock the machine row so this read-revoke-reassign serializes against a concurrent
|
|
142
|
+
-- register_fleet_machine / revoke_fleet_subject / reactivate_fleet_machine (each of which
|
|
143
|
+
-- also takes a row lock on fleet_machines). Without it a concurrent revoke or re-register
|
|
144
|
+
-- could interleave and be lost. (Reuses the reactivation race lesson, #146.)
|
|
145
|
+
select * into v_machine from agent_board.fleet_machines fm where fm.id = p_machine_id for update;
|
|
146
|
+
if v_machine.id is null then
|
|
147
|
+
raise exception 'fleet machine not found' using errcode = '22023';
|
|
148
|
+
end if;
|
|
149
|
+
|
|
150
|
+
v_prior_owner := v_machine.owner_agent_id;
|
|
151
|
+
v_prior_domain := v_machine.trust_domain_id;
|
|
152
|
+
|
|
153
|
+
-- (2) Revoke the squatter's access to THIS machine only: flip every currently-active binding
|
|
154
|
+
-- on it to 'revoked'. All bindings present at reclaim time were established under the prior
|
|
155
|
+
-- (squatter) owner; the new owner starts from a clean machine and creates their own bindings.
|
|
156
|
+
-- lifecycle_state is the binding's authority (read directly by _can_claim_resource and by
|
|
157
|
+
-- evaluate_fleet_sender_policy) so this is the complete access removal — see the header note
|
|
158
|
+
-- on why no fleet_revocation_events row is written per binding. revoked_at is set to satisfy
|
|
159
|
+
-- fleet_machine_agents_revoked_shape. Defensive (#150 codex): exclude the NEW owner, so an
|
|
160
|
+
-- operator reclaiming a machine on which the new owner already holds a binding does not revoke
|
|
161
|
+
-- the new owner's own access as a side effect.
|
|
162
|
+
with revoked as (
|
|
163
|
+
update agent_board.fleet_machine_agents fma
|
|
164
|
+
set lifecycle_state = 'revoked',
|
|
165
|
+
revoked_at = now(),
|
|
166
|
+
updated_at = now()
|
|
167
|
+
where fma.machine_id = p_machine_id
|
|
168
|
+
and fma.lifecycle_state = 'active'
|
|
169
|
+
and fma.agent_id <> p_new_owner_agent_id
|
|
170
|
+
returning fma.agent_id
|
|
171
|
+
)
|
|
172
|
+
select count(*)::integer, coalesce(array_agg(agent_id), '{}'::uuid[])
|
|
173
|
+
into v_revoked_count, v_revoked_agents
|
|
174
|
+
from revoked;
|
|
175
|
+
|
|
176
|
+
-- (2b) MUST-FIX 2 (#150 codex): release the squatter's ACTIVE machine claim(s) on the reclaimed
|
|
177
|
+
-- fingerprint. Machine claims key on the bare globally-unique fingerprint (claimgate/CLAIMKEY),
|
|
178
|
+
-- and reclaim reassigns ownership + revokes bindings but does NOT touch agent_board.claims — so a
|
|
179
|
+
-- squatter holding an active claim keeps the lock and the new owner's acquire_claim raises 23505
|
|
180
|
+
-- until lease expiry. Releasing it (status='released') frees the active-claim uniqueness slot so
|
|
181
|
+
-- the new owner can acquire immediately. Alias-aware match on the canonical key (same helper
|
|
182
|
+
-- acquire_claim's conflict check uses). The new owner's own claim (if any) is preserved.
|
|
183
|
+
with released as (
|
|
184
|
+
update agent_board.claims c
|
|
185
|
+
set status = 'released',
|
|
186
|
+
released_at = now(),
|
|
187
|
+
updated_at = now(),
|
|
188
|
+
metadata = coalesce(c.metadata, '{}'::jsonb)
|
|
189
|
+
|| jsonb_build_object('released_by_reclaim', p_reclaimed_by_agent_id, 'released_at', now())
|
|
190
|
+
where c.resource_kind = 'machine'
|
|
191
|
+
and c.status = 'active'
|
|
192
|
+
and c.released_at is null
|
|
193
|
+
and c.agent_id <> p_new_owner_agent_id
|
|
194
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key)
|
|
195
|
+
= agent_board._canonical_claim_key('machine', v_machine.machine_fingerprint)
|
|
196
|
+
returning c.id
|
|
197
|
+
)
|
|
198
|
+
select count(*)::integer into v_released_claims from released;
|
|
199
|
+
|
|
200
|
+
-- (1)+(3) Reassign ownership, optionally MOVE the trust domain (kill-switch close), and leave the
|
|
201
|
+
-- machine active for the new owner.
|
|
202
|
+
-- owner_agent_id CHANGE: reclaim_fleet_machine is the ONLY writer besides register_fleet_machine
|
|
203
|
+
-- (INSERT) that assigns owner_agent_id. It is operator-only and is the arbitration escape hatch
|
|
204
|
+
-- for a squatted fingerprint that #141 otherwise locks permanently.
|
|
205
|
+
-- trust_domain_id: moved to p_new_trust_domain_id when provided (validated above); coalesce keeps
|
|
206
|
+
-- the current domain when NULL (documented retained kill switch — see header).
|
|
207
|
+
-- reactivated_at=now() supersedes any prior machine-revocation event so a squatted-then-revoked
|
|
208
|
+
-- machine is usable by the new owner (same mechanism as reactivate_fleet_machine).
|
|
209
|
+
-- AUDIT (#150 panel SHOULD-FIX): APPEND a record to metadata.reclaim_history rather than
|
|
210
|
+
-- overwriting flat keys, so repeated reclaims do not clobber prior audit.
|
|
211
|
+
update agent_board.fleet_machines fm
|
|
212
|
+
set owner_agent_id = p_new_owner_agent_id,
|
|
213
|
+
trust_domain_id = coalesce(p_new_trust_domain_id, fm.trust_domain_id),
|
|
214
|
+
lifecycle_state = 'active',
|
|
215
|
+
revoked_at = null,
|
|
216
|
+
reactivated_at = now(),
|
|
217
|
+
metadata = coalesce(fm.metadata, '{}'::jsonb)
|
|
218
|
+
|| jsonb_build_object(
|
|
219
|
+
'reclaim_history',
|
|
220
|
+
coalesce(fm.metadata -> 'reclaim_history', '[]'::jsonb)
|
|
221
|
+
|| jsonb_build_array(jsonb_build_object(
|
|
222
|
+
'reclaimed_by', p_reclaimed_by_agent_id,
|
|
223
|
+
'reclaim_reason', nullif(trim(coalesce(p_reason, '')), ''),
|
|
224
|
+
'prior_owner', v_prior_owner,
|
|
225
|
+
'prior_trust_domain_id', v_prior_domain,
|
|
226
|
+
'new_trust_domain_id', coalesce(p_new_trust_domain_id, v_prior_domain),
|
|
227
|
+
'domain_moved', p_new_trust_domain_id is not null,
|
|
228
|
+
'reclaimed_at', now(),
|
|
229
|
+
'revoked_bindings', to_jsonb(v_revoked_agents),
|
|
230
|
+
'released_claims', v_released_claims
|
|
231
|
+
))
|
|
232
|
+
)
|
|
233
|
+
|| jsonb_build_object('redacted', true, 'metadata-safe', true),
|
|
234
|
+
updated_at = now()
|
|
235
|
+
where fm.id = p_machine_id
|
|
236
|
+
returning * into v_machine;
|
|
237
|
+
|
|
238
|
+
return query select v_machine.id, v_machine.owner_agent_id, v_machine.trust_domain_id, v_machine.lifecycle_state, v_revoked_count;
|
|
239
|
+
end;
|
|
240
|
+
$$;
|
|
241
|
+
|
|
242
|
+
-- #118 discipline: a SECURITY DEFINER function grants EXECUTE to PUBLIC on create. Restate the
|
|
243
|
+
-- revokes/grant and the comment. Operator-only ⇒ granted to service_role ALONE (not authenticated).
|
|
244
|
+
revoke all on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) from public, anon, authenticated;
|
|
245
|
+
grant execute on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) to service_role;
|
|
246
|
+
|
|
247
|
+
comment on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) is
|
|
248
|
+
'Operator-only (#146): reclaims a squatted fleet machine (a globally-unique fingerprint pre-registered by someone other than its rightful owner, which #141 otherwise locks permanently). Reassigns owner_agent_id to the arbitrated new owner (the only writer besides register_fleet_machine that changes ownership), revokes every active fleet_machine_agents binding on THIS machine so the squatter loses access (machine-scoped, NOT a global revoke_fleet_subject(''agent'')), and leaves the machine active for the new owner (stamps reactivated_at to supersede any prior machine-revocation event). Optional p_new_trust_domain_id (#150 kill-switch close): when given, validated (active + owned by the new owner) and the machine is MOVED into it so the squatter''s domain-revocation can no longer deny it; when NULL the machine stays in its current domain and the squatter RETAINS a trust-domain kill switch (documented residual; whole-domain squatting arbitration is a separate follow-up). service_role only (raises 42501 otherwise); never revokes the trust domain or the machine subject. Also releases the squatter''s active machine claim(s) on the reclaimed fingerprint so the new owner can acquire immediately (#150). Reclaim audit is APPENDED to fleet_machines.metadata.reclaim_history (repeated reclaims do not clobber prior records).';
|
|
249
|
+
|
|
250
|
+
-- ============================================================================================
|
|
251
|
+
-- #150 MUST-FIX 1 (codex): revoke_fleet_subject TOCTOU vs reclaim_fleet_machine.
|
|
252
|
+
-- Reproduced VERBATIM from 20260826000100 EXCEPT the added row-lock block below. The machine,
|
|
253
|
+
-- harness and repo_scope authz branches derive ownership from fleet_machines.owner_agent_id and
|
|
254
|
+
-- previously read it WITHOUT a lock, so a squatter's revoke('machine', M) could pass its ownership
|
|
255
|
+
-- check on the pre-reclaim snapshot and have its lifecycle UPDATE apply AFTER a concurrent reclaim
|
|
256
|
+
-- reassigned the machine — nuking the machine the operator just handed to the new owner. We now
|
|
257
|
+
-- LOCK the owning fleet_machines row FIRST (before the authz re-read and before the event write,
|
|
258
|
+
-- matching reclaim's lock-first order → the two serialize deadlock-free on the same row). #118
|
|
259
|
+
-- grants/comment restated.
|
|
260
|
+
-- ============================================================================================
|
|
261
|
+
create or replace function agent_board.revoke_fleet_subject(
|
|
262
|
+
p_subject_kind text,
|
|
263
|
+
p_subject_id uuid,
|
|
264
|
+
p_revoked_by_agent_id uuid,
|
|
265
|
+
p_revocation_reason text,
|
|
266
|
+
p_metadata jsonb default '{}'::jsonb
|
|
267
|
+
)
|
|
268
|
+
returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
|
|
269
|
+
language plpgsql
|
|
270
|
+
security definer
|
|
271
|
+
set search_path = agent_board, public
|
|
272
|
+
as $$
|
|
273
|
+
declare
|
|
274
|
+
v_event agent_board.fleet_revocation_events%rowtype;
|
|
275
|
+
v_actor_agent_id uuid;
|
|
276
|
+
begin
|
|
277
|
+
if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
|
|
278
|
+
raise exception 'unsupported fleet revocation subject' using errcode = '22023';
|
|
279
|
+
end if;
|
|
280
|
+
|
|
281
|
+
-- #150 MUST-FIX 1: lock the owning fleet_machines row for machine-derived kinds BEFORE the
|
|
282
|
+
-- unlocked ownership check below, so a concurrent reclaim_fleet_machine (which holds `for update`
|
|
283
|
+
-- on that row) serializes this call and the authz re-read sees committed ownership. Placed ahead
|
|
284
|
+
-- of the event write; reclaim also locks this row first, so acquisition order matches (no cycle).
|
|
285
|
+
if p_subject_kind in ('machine', 'harness', 'repo_scope') then
|
|
286
|
+
declare
|
|
287
|
+
v_lock_machine_id uuid;
|
|
288
|
+
begin
|
|
289
|
+
v_lock_machine_id := case
|
|
290
|
+
when p_subject_kind = 'machine' then p_subject_id
|
|
291
|
+
when p_subject_kind = 'harness' then (
|
|
292
|
+
select fhr.machine_id from agent_board.fleet_harness_registrations fhr where fhr.id = p_subject_id)
|
|
293
|
+
when p_subject_kind = 'repo_scope' then (
|
|
294
|
+
select fhr.machine_id
|
|
295
|
+
from agent_board.fleet_repo_scopes frs
|
|
296
|
+
join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
|
|
297
|
+
where frs.id = p_subject_id)
|
|
298
|
+
end;
|
|
299
|
+
if v_lock_machine_id is not null then
|
|
300
|
+
perform 1 from agent_board.fleet_machines fm where fm.id = v_lock_machine_id for update;
|
|
301
|
+
end if;
|
|
302
|
+
end;
|
|
303
|
+
end if;
|
|
304
|
+
|
|
305
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
|
|
306
|
+
|
|
307
|
+
if not agent_board._is_service_role() then
|
|
308
|
+
if p_subject_kind = 'trust_domain' and not exists (
|
|
309
|
+
select 1 from agent_board.fleet_trust_domains ftd
|
|
310
|
+
where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
|
|
311
|
+
) then
|
|
312
|
+
raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
|
|
313
|
+
elsif p_subject_kind = 'machine' and not exists (
|
|
314
|
+
select 1 from agent_board.fleet_machines fm
|
|
315
|
+
where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
316
|
+
) then
|
|
317
|
+
raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
|
|
318
|
+
elsif p_subject_kind = 'harness' and not exists (
|
|
319
|
+
select 1
|
|
320
|
+
from agent_board.fleet_harness_registrations fhr
|
|
321
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
322
|
+
where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
323
|
+
) then
|
|
324
|
+
raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
|
|
325
|
+
elsif p_subject_kind = 'repo_scope' and not exists (
|
|
326
|
+
select 1
|
|
327
|
+
from agent_board.fleet_repo_scopes frs
|
|
328
|
+
join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
|
|
329
|
+
join agent_board.fleet_machines fm on fm.id = fhr.machine_id
|
|
330
|
+
where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
|
|
331
|
+
) then
|
|
332
|
+
raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
|
|
333
|
+
elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
|
|
334
|
+
raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
|
|
335
|
+
elsif p_subject_kind = 'signing_key' and not exists (
|
|
336
|
+
select 1 from agent_board.agent_signing_keys ask
|
|
337
|
+
where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
|
|
338
|
+
) then
|
|
339
|
+
raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
|
|
340
|
+
elsif p_subject_kind = 'credential' and not exists (
|
|
341
|
+
select 1 from agent_board.agent_credentials ac
|
|
342
|
+
where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
|
|
343
|
+
) then
|
|
344
|
+
raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
|
|
345
|
+
end if;
|
|
346
|
+
end if;
|
|
347
|
+
|
|
348
|
+
select *
|
|
349
|
+
into v_event
|
|
350
|
+
from agent_board.fleet_revocation_events fre
|
|
351
|
+
where fre.subject_kind = p_subject_kind
|
|
352
|
+
and fre.subject_id = p_subject_id
|
|
353
|
+
limit 1;
|
|
354
|
+
|
|
355
|
+
if v_event.id is null then
|
|
356
|
+
insert into agent_board.fleet_revocation_events(
|
|
357
|
+
subject_kind,
|
|
358
|
+
subject_id,
|
|
359
|
+
trust_domain_id,
|
|
360
|
+
machine_id,
|
|
361
|
+
harness_registration_id,
|
|
362
|
+
repo_scope_id,
|
|
363
|
+
agent_id,
|
|
364
|
+
signing_key_id,
|
|
365
|
+
revoked_by_agent_id,
|
|
366
|
+
revocation_reason,
|
|
367
|
+
metadata
|
|
368
|
+
)
|
|
369
|
+
values (
|
|
370
|
+
p_subject_kind,
|
|
371
|
+
p_subject_id,
|
|
372
|
+
case when p_subject_kind = 'trust_domain' then p_subject_id end,
|
|
373
|
+
case when p_subject_kind = 'machine' then p_subject_id end,
|
|
374
|
+
case when p_subject_kind = 'harness' then p_subject_id end,
|
|
375
|
+
case when p_subject_kind = 'repo_scope' then p_subject_id end,
|
|
376
|
+
case when p_subject_kind = 'agent' then p_subject_id end,
|
|
377
|
+
case when p_subject_kind = 'signing_key' then p_subject_id end,
|
|
378
|
+
p_revoked_by_agent_id,
|
|
379
|
+
trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
380
|
+
coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
381
|
+
)
|
|
382
|
+
returning * into v_event;
|
|
383
|
+
else
|
|
384
|
+
update agent_board.fleet_revocation_events fre
|
|
385
|
+
set effective_at = now(),
|
|
386
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
387
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
388
|
+
metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
|
|
389
|
+
where fre.id = v_event.id
|
|
390
|
+
returning * into v_event;
|
|
391
|
+
end if;
|
|
392
|
+
|
|
393
|
+
if p_subject_kind = 'trust_domain' then
|
|
394
|
+
update agent_board.fleet_trust_domains
|
|
395
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
396
|
+
where id = p_subject_id;
|
|
397
|
+
elsif p_subject_kind = 'machine' then
|
|
398
|
+
update agent_board.fleet_machines
|
|
399
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
400
|
+
where id = p_subject_id;
|
|
401
|
+
elsif p_subject_kind = 'harness' then
|
|
402
|
+
update agent_board.fleet_harness_registrations
|
|
403
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
404
|
+
where id = p_subject_id;
|
|
405
|
+
elsif p_subject_kind = 'repo_scope' then
|
|
406
|
+
update agent_board.fleet_repo_scopes
|
|
407
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
408
|
+
where id = p_subject_id;
|
|
409
|
+
elsif p_subject_kind = 'agent' then
|
|
410
|
+
update agent_board.fleet_machine_agents
|
|
411
|
+
set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
|
|
412
|
+
where agent_id = p_subject_id;
|
|
413
|
+
elsif p_subject_kind = 'signing_key' then
|
|
414
|
+
update agent_board.agent_signing_keys
|
|
415
|
+
set status = case when status = 'compromised' then status else 'revoked' end,
|
|
416
|
+
proof_state = 'revoked',
|
|
417
|
+
revoked_at = coalesce(revoked_at, v_event.effective_at),
|
|
418
|
+
revoked_by_agent_id = p_revoked_by_agent_id,
|
|
419
|
+
revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
|
|
420
|
+
updated_at = now()
|
|
421
|
+
where id = p_subject_id;
|
|
422
|
+
end if;
|
|
423
|
+
|
|
424
|
+
return query select v_event.id, v_event.subject_kind, v_event.effective_at;
|
|
425
|
+
end;
|
|
426
|
+
$$;
|
|
427
|
+
|
|
428
|
+
revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
|
|
429
|
+
grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
430
|
+
|
|
431
|
+
comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
|
|
432
|
+
'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), so a concurrent reclaim_fleet_machine serializes and a stale-owner revoke is refused (42501) rather than nuking a just-reclaimed machine.';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- DELEGSOLEWRITER (#152) — make upsert_delegation_policy the SOLE writer of
|
|
3
|
+
-- agent_board.delegation_policies by revoking service_role's direct INSERT/UPDATE.
|
|
4
|
+
-- =============================================================================
|
|
5
|
+
-- Defense-in-depth follow-up to #142 (20260828000100, operator caps are a durable
|
|
6
|
+
-- ceiling). #142 stores the operator's hard ceiling in operator_max_active_children /
|
|
7
|
+
-- operator_child_ttl_ceiling, which the OPERATOR branch of upsert_delegation_policy
|
|
8
|
+
-- ESTABLISHES and the self-service branch PRESERVES; mint_delegated_child then clamps
|
|
9
|
+
-- the self-service effective cap to least(self, operator ceiling, 25/24h). That closes
|
|
10
|
+
-- the LOCKED threat model (an authenticated root has SELECT-only on the table, so it
|
|
11
|
+
-- cannot loosen the ceiling through the function).
|
|
12
|
+
--
|
|
13
|
+
-- THE RESIDUAL DOOR (operator misuse, not a self-service escalation): service_role still
|
|
14
|
+
-- held a DIRECT insert/update grant (20260717000400:42). An operator using raw SQL could
|
|
15
|
+
-- set delegation_policies.max_active_children / .child_ttl_ceiling WITHOUT populating the
|
|
16
|
+
-- operator_* ceiling columns; a later self-service upsert then PRESERVES the NULL ceiling
|
|
17
|
+
-- while the mint reads NULL as "no operator ceiling" and the root widens back to 25/24h.
|
|
18
|
+
-- The operator's intent is not durable when it bypasses the function.
|
|
19
|
+
--
|
|
20
|
+
-- WHY THE REVOKE IS SAFE (verified against pg_catalog on the live schema):
|
|
21
|
+
-- * upsert_delegation_policy is the ONLY function that writes the table
|
|
22
|
+
-- (INSERT/UPDATE), and it is SECURITY DEFINER OWNED BY the table owner (postgres) —
|
|
23
|
+
-- it runs with the owner's implicit privileges, NOT service_role's grant. Revoking
|
|
24
|
+
-- the grant does not touch it. (renew_delegated_grant and mint_delegated_child only
|
|
25
|
+
-- reference the table in SELECT/lock and write delegation_grants, not this table.)
|
|
26
|
+
-- * The mint-child Edge Function and every supabase/functions/* path use RPCs only —
|
|
27
|
+
-- no raw table write to delegation_policies.
|
|
28
|
+
-- * The pgTAP suite connects as postgres, the table OWNER, so its direct-insert test
|
|
29
|
+
-- seeds keep working via ownership (owner privileges are implicit and unaffected by a
|
|
30
|
+
-- grant revoke); no test seed needed migration to the function.
|
|
31
|
+
--
|
|
32
|
+
-- After this, an operator setting caps MUST go through upsert_delegation_policy, whose
|
|
33
|
+
-- operator branch synchronizes the operator_* ceiling — so the #142 durable ceiling can
|
|
34
|
+
-- never be left NULL by a legitimate operator write. "The writer is THE writer, enforced
|
|
35
|
+
-- not conventional" (CLAUDE.md rule #1).
|
|
36
|
+
--
|
|
37
|
+
-- SELECT is deliberately KEPT (readers/operators still read the table). Additive,
|
|
38
|
+
-- forward-only ACL change; schema_version stays 8.
|
|
39
|
+
-- =============================================================================
|
|
40
|
+
|
|
41
|
+
revoke insert, update on agent_board.delegation_policies from service_role;
|