Files
buzz/migrations/0033_relay_admin_actions.sql
DuncanandWill Pfleger feaeb32f57 fix(relay): claim NIP-98 replay slot only after authorization, attribute cancels
Two defects from the kalvin-agent security review of the admin moderation API.

Replay-before-authorization: authorize_nip98 claimed the deployment-scoped
replay ID immediately after crypto verification, before resolve_admin_principal
ran the roster check. Any validly-signing but unrostered key (every
WARP-admitted laptop) could allocate replay slots at request rate. Split the
NIP-98 path into verify-only (authorize_nip98, returns pubkey + event id) and a
separate claim_nip98_replay called only after principal resolution succeeds, so
an unrostered signer never consumes a slot. Fail-closed Redis behavior and the
deployment-scoped key format are unchanged.

Cancel actor trail: cancel_report discarded the resolved principal and
cancel_action persisted nothing about who cancelled — the one mutation with no
actor attribution while BUZZ_AUDIT_ENABLED=false. Add a cancelled_by column to
relay_admin_actions (mirroring moderation_reports.resolved_by), stamped in the
cancel UPDATE and surfaced through AdminActionDto.cancelledBy. Migration 0033 is
branch-local and unshipped, so the column is added in place with matching
schema.sql; the pgschema parity test round-trips it through bin/pgschema.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-14 16:01:19 -04:00

85 lines
4.1 KiB
SQL

-- HTTP report-resolution enforcement state machine (Phase 2 admin auth).
--
-- relay_admin_actions: one row per HTTP-initiated enforcement action.
-- relay_admin_outbox: durable delivery queue for tombstones, system messages,
-- and reporter notice DMs.
--
-- Both tables are deployment-global (no community_id direct column; the report
-- FK carries community provenance). Registered in _operator_global_tables and
-- in the hardcoded parser list in crates/buzz-db/src/migration.rs.
CREATE TABLE relay_admin_actions (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL,
report_community_id UUID NOT NULL,
-- Client-generated idempotency key (signed in NIP-98 request body).
request_id UUID NOT NULL,
-- Principal who claimed the report.
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
actor_role TEXT NOT NULL CHECK (actor_role IN ('operator', 'moderator')),
-- The enforcement action requested.
action TEXT NOT NULL,
reason TEXT,
-- Timeout expiration for timeout actions; NULL otherwise.
timeout_until TIMESTAMPTZ,
-- Durable state machine: pending → enforcing → succeeded|failed|cancelled.
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'enforcing', 'succeeded', 'failed', 'cancelled')),
-- Step marker: the last durably committed mutation step (NULL = none yet).
step_marker TEXT CHECK (step_marker IN ('mutation_committed', 'artifacts_done')),
-- Principal who cancelled a pre-mutation failed action; NULL until cancelled.
-- Attributes the cancel transition on the action row itself, mirroring
-- moderation_reports.resolved_by for report resolution.
cancelled_by BYTEA CHECK (cancelled_by IS NULL OR length(cancelled_by) = 32),
-- Error from the last failure, if any.
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Report-scoped idempotency: one action per (report, request_id).
UNIQUE (report_community_id, report_id, request_id),
FOREIGN KEY (report_community_id, report_id)
REFERENCES moderation_reports (community_id, id)
);
CREATE INDEX idx_relay_admin_actions_report
ON relay_admin_actions (report_community_id, report_id);
CREATE INDEX idx_relay_admin_actions_state
ON relay_admin_actions (state)
WHERE state IN ('pending', 'enforcing');
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('relay_admin_actions',
'deployment-global enforcement state machine; community provenance via report FK');
-- ── Relay admin outbox ────────────────────────────────────────────────────────
CREATE TABLE relay_admin_outbox (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
action_id UUID NOT NULL REFERENCES relay_admin_actions(id),
-- Delivery task type: 'tombstone' | 'system_message' | 'reporter_notice'.
task_type TEXT NOT NULL,
-- Task payload (JSON).
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Lease-based delivery: held_by identifies the worker pod.
held_by TEXT,
lease_expires_at TIMESTAMPTZ,
-- Delivery state: pending → delivered | failed.
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'delivered', 'failed')),
-- Deduplication key: prevents re-creating an artifact after delivery.
dedup_key TEXT UNIQUE,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_relay_admin_outbox_action
ON relay_admin_outbox (action_id);
CREATE INDEX idx_relay_admin_outbox_pending
ON relay_admin_outbox (lease_expires_at)
WHERE state = 'pending';
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('relay_admin_outbox',
'deployment-global enforcement artifact delivery queue');