Files
buzz/migrations/0061_invite_revocation.sql
Brad Seiler d2c2a74943 fix(relay): revoke invitations durably and fence restricted claims
Keep v1 natural drain with an optional UTC cutoff. Serialize claim and mint policy reads with restriction and owner writes. Share historical revocation repair across migration and desired-state upgrades while retaining deletion fences.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
2026-10-17 17:20:50 +00:00

122 lines
5.6 KiB
PL/PgSQL

ALTER TABLE relay_invites ADD COLUMN revoked_at TIMESTAMPTZ;
-- Serialize invitation admission with restriction and ownership changes per tenant.
CREATE FUNCTION invite_admission_lock(community UUID) RETURNS VOID
LANGUAGE sql AS $$
SELECT pg_advisory_xact_lock(hashtextextended('invite-admission:' || community::text, 0));
$$;
CREATE FUNCTION lock_invite_restrictions() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
PERFORM invite_admission_lock(NEW.community_id);
RETURN NEW;
END
$$;
-- Revocation is durable: neither unban nor expiry restores an issued bearer.
CREATE FUNCTION revoke_banned_issuer_invites() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.banned AND (NEW.ban_expires_at IS NULL OR NEW.ban_expires_at > clock_timestamp()) THEN
UPDATE relay_invites SET revoked_at = clock_timestamp()
WHERE community_id = NEW.community_id AND revoked_at IS NULL
AND (created_by = encode(NEW.pubkey, 'hex') OR created_by IN (
SELECT encode(pubkey, 'hex') FROM users
WHERE community_id = NEW.community_id AND agent_owner_pubkey = NEW.pubkey
));
END IF;
RETURN NEW;
END
$$;
-- Learning a verified owner must not revive invites issued by its restricted agent.
CREATE FUNCTION revoke_restricted_agent_invites() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.agent_owner_pubkey IS NOT NULL AND EXISTS (
SELECT 1 FROM community_bans WHERE community_id = NEW.community_id
AND pubkey = NEW.agent_owner_pubkey AND banned
AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp())
) THEN
UPDATE relay_invites SET revoked_at = clock_timestamp()
WHERE community_id = NEW.community_id AND created_by = encode(NEW.pubkey, 'hex')
AND revoked_at IS NULL;
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans
FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions();
CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans
FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites();
CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users
FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions();
CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users
FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites();
-- Include active bans and recorded prior bans, so lifting a ban before this
-- migration cannot restore an invite issued before that ban. Scope every owner
-- and audit lookup to the invitation's community.
-- Schema migrations hold the exclusive schema-destruction lock. Retain the
-- universal tenant fence too: acquire its shared lock, then read the current
-- generation before applying this narrowly scoped historical security repair.
-- A quiescing tenant may return to active, so skipping it could revive bearers.
CREATE FUNCTION backfill_invite_revocations() RETURNS BIGINT
LANGUAGE plpgsql AS $$
DECLARE
target RECORD;
affected BIGINT;
total BIGINT := 0;
current_generation BIGINT;
prior_community TEXT := current_setting('buzz.deletion_executor_community', true);
prior_generation TEXT := current_setting('buzz.deletion_fence_generation', true);
BEGIN
FOR target IN SELECT id FROM communities
WHERE EXISTS (SELECT 1 FROM relay_invites WHERE community_id = communities.id)
ORDER BY id
LOOP
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target.id));
SELECT deletion_fence_generation INTO STRICT current_generation
FROM communities WHERE id = target.id;
PERFORM set_config('buzz.deletion_executor_community', target.id::text, true);
PERFORM set_config('buzz.deletion_fence_generation', current_generation::text, true);
UPDATE relay_invites i SET revoked_at = clock_timestamp()
WHERE i.community_id = target.id AND i.revoked_at IS NULL AND EXISTS (
SELECT 1 FROM (
SELECT CASE WHEN i.created_by ~ '^[0-9a-f]{64}$'
THEN decode(i.created_by, 'hex') END AS pubkey
UNION ALL
SELECT agent_owner_pubkey FROM users
WHERE community_id = i.community_id AND encode(pubkey, 'hex') = i.created_by
AND agent_owner_pubkey IS NOT NULL
) principal
WHERE EXISTS (
SELECT 1 FROM community_bans b WHERE b.community_id = i.community_id
AND b.pubkey = principal.pubkey AND b.banned
AND (b.ban_expires_at IS NULL OR b.ban_expires_at > clock_timestamp())
) OR EXISTS (
SELECT 1 FROM moderation_actions a WHERE a.community_id = i.community_id
AND a.target_pubkey = principal.pubkey AND a.action IN ('ban', 'resolve:ban')
AND a.created_at >= i.created_at
) OR EXISTS (
SELECT 1 FROM relay_admin_actions a WHERE a.report_community_id = i.community_id
AND a.enforcement_target_pubkey = principal.pubkey AND a.action = 'ban'
AND (a.step_marker = 'mutation_committed' OR a.state = 'succeeded')
AND a.updated_at >= i.created_at
)
)
;
GET DIAGNOSTICS affected = ROW_COUNT;
total := total + affected;
PERFORM set_config('buzz.deletion_executor_community', COALESCE(prior_community, ''), true);
PERFORM set_config('buzz.deletion_fence_generation', COALESCE(prior_generation, ''), true);
END LOOP;
RETURN total;
END
$$;
SELECT backfill_invite_revocations();