mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777)
Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.
## Authentication (`BUZZ_ADMIN_AUTH`)
`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.
- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.
## Roles
Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.
### Relay level (new)
| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |
How a pubkey acquires a relay role (resolution order; config always
outranks DB):
1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`
### Community level (pre-existing, unchanged)
| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |
There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.
## Escalation scoping
The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:
- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and full visibility across every status stays available for
platform-safety and legal review via `scope=all` (which lists reports
regardless of status). `scope` accepts only `all` and is ignored when an
explicit `status` is present.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` are ingested with `status=escalated` rather than `open`, so
the severe class reaches the operator backstop without waiting for a
community admin to forward it. Every other category still lands `open`.
Auto-escalation only sets the queue status — it records no moderator
decision and stamps no resolver, so an auto-escalated report is
indistinguishable downstream from an admin-escalated one: the reopen
route returns it to `open` on the same terms, keyed only on status,
never on how the report became escalated.
## Principal resolution and NIP-98 admission
`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.
## Report resolution, recovery, and enforcement provenance
`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.
Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.
`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.
## Feedback
`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).
## Staffing and probe
`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.
## NIP-11 auto-discovery
The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.
## Operator API origin decoupling
`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.
## Admin-web adaptation
The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.
## Security hardening
Three findings from security review are folded in:
- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.
## Migrations
- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).
`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.
## Production blast radius
A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.
---
Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -680,7 +680,9 @@ CREATE TABLE moderation_reports (
|
||||
-- Reporter's optional free-text context (mod-queue-only; never public).
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')),
|
||||
CHECK (status IN ('open', 'processing', 'resolved', 'dismissed', 'escalated')),
|
||||
-- Non-null when status='processing': the relay_admin_actions row that claimed this report.
|
||||
active_action_id UUID,
|
||||
resolved_by BYTEA,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
-- moderation_actions row that resolved this report, if any.
|
||||
@@ -762,6 +764,9 @@ CREATE TABLE moderation_actions (
|
||||
-- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only,
|
||||
-- the client never learns which.
|
||||
matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')),
|
||||
-- Deployment authority type for HTTP-initiated actions.
|
||||
actor_authority TEXT NOT NULL DEFAULT 'community'
|
||||
CHECK (actor_authority IN ('community', 'relay_operator', 'relay_moderator')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
||||
@@ -829,6 +834,8 @@ CREATE TABLE product_feedback (
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(tags) = 'array'),
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Operator-managed lifecycle status.
|
||||
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'archived')),
|
||||
UNIQUE (event_id)
|
||||
);
|
||||
CREATE INDEX idx_product_feedback_received
|
||||
@@ -1749,3 +1756,142 @@ SELECT attach_community_write_fence('users');
|
||||
SELECT attach_community_write_fence('workflow_approvals');
|
||||
SELECT attach_community_write_fence('workflow_runs');
|
||||
SELECT attach_community_write_fence('workflows');
|
||||
|
||||
-- ── Relay operator/moderator roster ──────────────────────────────────────────
|
||||
-- Deployment-level principals staffed via the admin API. Config-backed operators
|
||||
-- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here;
|
||||
-- they are authoritative in config and outrank any DB row.
|
||||
|
||||
CREATE TABLE relay_operators (
|
||||
pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32),
|
||||
role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),
|
||||
added_by BYTEA NOT NULL CHECK (length(added_by) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_operators', 'deployment-global operator/moderator roster; no community_id intentionally');
|
||||
|
||||
-- ── Relay admin actions (HTTP enforcement state machine) ──────────────────────
|
||||
-- One row per HTTP report-resolution enforcement action. Tracks the durable
|
||||
-- state machine from claim → enforcing → succeeded|failed|cancelled.
|
||||
|
||||
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).
|
||||
-- Values: 'mutation_committed' (core DB mutation done), 'artifacts_done' (tombstone/notice done).
|
||||
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,
|
||||
-- Per-action exclusive lease (migration 0037): fences concurrent same-request
|
||||
-- retries and lets the recovery worker claim/re-drive stranded actions.
|
||||
action_lease_token UUID,
|
||||
action_lease_expires_at TIMESTAMPTZ,
|
||||
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');
|
||||
-- Recovery worker (migration 0037): find stranded actions by lease expiry.
|
||||
CREATE INDEX idx_relay_admin_actions_lease
|
||||
ON relay_admin_actions (action_lease_expires_at)
|
||||
WHERE state IN ('pending', 'enforcing');
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_admin_actions', 'deployment-global enforcement state machine; community_id is embedded in report FK');
|
||||
|
||||
-- ── Relay admin outbox (durable enforcement delivery) ────────────────────────
|
||||
-- Transactional outbox for durable artifact/notice delivery.
|
||||
|
||||
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,
|
||||
-- Retryable delivery with backoff (migration 0037): failures reschedule via
|
||||
-- retry_after rather than terminating immediately.
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
retry_after TIMESTAMPTZ,
|
||||
-- Per-claim ownership fence (migration 0038): completion/failure updates
|
||||
-- require the token written at claim time, so a stale worker cannot overwrite
|
||||
-- a newer worker's terminal update.
|
||||
outbox_claim_token UUID,
|
||||
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 (retry_after, created_at)
|
||||
WHERE state = 'pending';
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue');
|
||||
|
||||
-- ── Relay operator audit (append-only roster mutation trail) ─────────────────
|
||||
-- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the
|
||||
-- deployment-wide root of trust and its mutations overwrite/remove in place;
|
||||
-- this append-only trail records who granted, elevated, or revoked whom, and
|
||||
-- when, so privilege changes are as auditable as the enforcement actions those
|
||||
-- principals perform. Written only inside the upsert/delete transactions; no
|
||||
-- UPDATE/DELETE path.
|
||||
|
||||
CREATE TABLE relay_operator_audit (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
|
||||
op TEXT NOT NULL CHECK (op IN ('grant', 'revoke')),
|
||||
prev_role TEXT CHECK (prev_role IN ('operator', 'moderator')),
|
||||
new_role TEXT CHECK (new_role IN ('operator', 'moderator')),
|
||||
-- created_at is wall-clock occurrence time (clock_timestamp()), informational
|
||||
-- only — not monotonic, so it never establishes order. `seq` is the sole
|
||||
-- chronology key: mutations write their audit row under the serializing lock,
|
||||
-- so identity order equals the true privilege chain. Reads use ORDER BY seq.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
seq BIGINT GENERATED ALWAYS AS IDENTITY
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_operator_audit_target
|
||||
ON relay_operator_audit (target_pubkey, seq);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally');
|
||||
|
||||
Reference in New Issue
Block a user