From 032198ce43680947c288bfd219d6a9b81d882348 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 7 Oct 2026 16:07:38 -0400 Subject: [PATCH] feat(relay): add community reads to the admin API (#8011) The in-app Admin Console needs to work on any community the relay serves, not only the one the desktop is connected to. This adds four read-only routes under `/api/admin/v1` that the stacked desktop PR builds on, puts every admin route under one explicit access rule, and binds every admin request's signature to its real method, which fixes signed `HEAD` on reads. ## Access rule | Mode | Moderation reads | Writes | Staffing (`/operators`) | |---|---|---|---| | `nip98` (default) | signed, any staff role | signed, role-checked | signed Operator | | `disabled` | served to anyone who can reach the relay | `403` | `403` | "Moderation reads" means every read: reports, feedback and attachments, restrictions, and the four community routes below. `nip98` behavior is unchanged, and the Operator API (`/operator/*`) is untouched. **Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four new reads** (community directory, member search, member lookup without staff status, and stored events by ID) with no config change. The startup `WARN`, `docs/admin/README.md`, `.env.example`, `deploy/compose/.env.example` and the `just admin` comment list exactly what disabled mode exposes, say it should be reachable only by people allowed to see all moderation data in every community, and say the whole relay port must be private: the admin API is on the relay's main listener and its hostname check uses the caller's `Host` header. `just admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is exactly `nip98`, so an unset, padded or misspelled value stays local. In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted` (disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any rostered Operator or Moderator). Each route makes one check: **view** (`authorize()` succeeding), **act** (`AdminAccess::act()`, must be `Staff`, then the existing role checks) or **operator** (`AdminAccess::operator()`, must be an Operator). Act and operator refuse `NetworkTrusted` with `403 this endpoint requires BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous caller. `every_route_makes_its_check_in_both_auth_modes` lists every mounted admin route with its check and asserts the result in both modes, and `every_route_is_listed` fails when `router()` mounts a route that list does not name. ## Routes All four live in `api/admin/reads.rs`; `mod.rs` only registers them. Their queries reject unknown fields, like `ReportQuery`. The three `?communityHost=` routes share `CommunityQuery`, which now rejects unknown fields too, so the existing unban, untimeout and direct-action routes that use it do the same; their desktop callers send only `communityHost`. Every admin route that takes `limit` validates it with the module's shared `limit()` helper, so an out-of-range value is `400 invalid_limit`. | Route | Returns | |---|---| | `GET /communities?q=&cursor=&limit=` | `{items: [{id, host, icon\|null}], nextCursor\|null}` | | `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey, displayName\|null, nip05\|null, avatarUrl\|null}]}` | | `GET /members/{pubkey}?communityHost=` | `{pubkey, profile: {displayName, nip05, avatarUrl, about}\|null, role: "owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff: bool\|null}` | | `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content, createdAt, deletedAt\|null, channelId\|null}` | - **One community per read.** Community-scoped routes bind `communityHost` through the tenant binder (`400 unknown_community_host`) and read only that community's `users`, `relay_members`, `community_bans` and `events` rows. - **Directory.** It uses the binder's liveness rule (not archived, not deleted, `deletion_state = 'active'`), so every listed host can be opened. `q` is an escaped, case-insensitive host prefix of at most 255 characters (`400 invalid_query`). Results are ordered and paged by `lower(host)`, which is unique, so the opaque base64url cursor carries only the last host; a malformed cursor is `400 invalid_cursor`. `limit` is 1 to 100 with a default of 50. - **Member search.** It reuses `search_users`, which already escapes, binds and scopes, with no second escaping layer. `q` is trimmed and must be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50 (default 20). It searches community profiles, so former members appear. - **Member lookup.** `isStaff` uses the same roster lookup as the direct-action staff guard and is returned to signed staff only: it is `null` in disabled mode, where the roster is never read. A roster lookup failure is a 500, never "not staff". - **Event preview.** It reads the event by `(community_id, id)` and never returns a private kind (see below). A malformed id, an id stored only in another community and a private kind are all `404 event_not_found`, the same answer as an absent event. ## Private events Admin reads never reveal events the relay keeps private. `admin_hidden_kinds()` combines the four private kind sets in `buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`, `P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is hidden, including events tagged `["shared","true"]`; the shared tag is never evaluated. Every HTTP response applies it the way it treats a missing event: event preview is `404 event_not_found`, report detail returns the report with `message: null` and `targetAuthorPubkey: null`, and the report list returns a null target author. Moderators can still act on these events, they just cannot read them. Report detail is one query with the hidden-kind list as a parameter, exposed two ways: `admin_get_report` hides private kinds and backs every HTTP response (GET detail, the resolve response, reopen, cancel and the reporter-notice worker), while `admin_get_report_for_enforcement` reads the target unfiltered and is used only by the resolve handler's enforcement load (the staff-target check and ban, timeout and kick target derivation) and by the recovery worker for interrupted actions. Its result is never serialized, so a ban from a report on a private persona bans the real author while GET still shows nothing. ## Method binding Axum serves `HEAD` through `GET` handlers, but the read handlers authorized with a literal `"GET"`, so a correctly signed `HEAD` was rejected. Admin reads now authorize through `authorize_read` and writes through `authorize_write`, both of which use the request's real method and full target, so no handler hardcodes a method string. A database error behind any admin route is now logged before it becomes a 500. ## Directory query plan `idx_communities_host` is on `lower(host)` in the default collation, so the prefix `LIKE` is a filter, not an index condition. The plan walks the index in order and stops once it has `limit` matches. A common prefix is cheap (on local Postgres 16 with 21,359 communities, a first page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no matches walks every index entry, so its cost grows with the number of communities. A `COLLATE "C"` index would turn the prefix into an index range; it is left out because the admin directory is low-traffic and the cost is small at current scale. The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`, so it remains an index condition under a generic plan: ```text -> Bitmap Index Scan on idx_communities_host Index Cond: (lower((host)::text) > COALESCE($2, ''::text)) ``` --------- Signed-off-by: Will Pfleger Signed-off-by: Will Pfleger Co-authored-by: Duncan Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- .env.example | 13 +- Justfile | 19 +- admin-web/src/App.tsx | 3 +- crates/buzz-db/src/store/admin_moderation.rs | 271 ++++- crates/buzz-db/src/store/user.rs | 2 +- crates/buzz-relay/src/api/admin/auth.rs | 130 ++- crates/buzz-relay/src/api/admin/direct.rs | 25 +- crates/buzz-relay/src/api/admin/error.rs | 3 +- crates/buzz-relay/src/api/admin/mod.rs | 595 +++++++---- crates/buzz-relay/src/api/admin/reads.rs | 255 +++++ .../buzz-relay/src/api/admin/reads_tests.rs | 968 ++++++++++++++++++ crates/buzz-relay/src/config.rs | 22 +- .../src/handlers/admin_action_worker.rs | 2 +- deploy/compose/.env.example | 9 +- docs/admin/README.md | 113 +- 15 files changed, 2126 insertions(+), 304 deletions(-) create mode 100644 crates/buzz-relay/src/api/admin/reads.rs create mode 100644 crates/buzz-relay/src/api/admin/reads_tests.rs diff --git a/.env.example b/.env.example index 3b7aca356d..d3deb6f536 100644 --- a/.env.example +++ b/.env.example @@ -119,9 +119,16 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # the console without manual URL entry. # RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] # -# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only): -# Set only when the admin API is already protected at the network layer -# (VPN, private ingress). The relay logs a WARN on every startup. +# Option B — BUZZ_ADMIN_AUTH=disabled (read-only, no authentication): +# Anyone who can reach the relay can read, in every community: reports (with +# reporter notes and reported message content, deleted included), feedback and +# attachments, bans and timeouts with reasons, the community directory, member +# search and profiles (not staff status), and any stored message by ID. +# Writes and staffing are refused. +# Only people allowed to see all of that should be able to reach it. The admin +# API is on the relay's main listener and its hostname check uses the caller's +# Host header, so the whole relay port must be private, not just the admin +# hostname. The relay logs a WARN on every startup. # `just admin` defaults to this mode for local review. # # Directory holding the built dashboard assets (`pnpm -C admin-web build`). diff --git a/Justfile b/Justfile index f01458f449..67f5c391be 100644 --- a/Justfile +++ b/Justfile @@ -675,13 +675,24 @@ admin: bootstrap _ensure-migrations pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}" - # Default to disabled auth locally: localhost is the network boundary and a - # NIP-07 signer extension can't be assumed in dev. Override per run with - # BUZZ_ADMIN_AUTH=nip98 (plus RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) - # to exercise the authenticated path. + # Default to disabled auth locally: a NIP-07 signer extension can't be + # assumed in dev. Disabled mode serves every moderation read (reports, + # feedback, restrictions, community directory, member profiles, any stored + # message by ID) to anyone who can reach the relay port and refuses writes + # and staffing, so it binds the relay to 127.0.0.1 (port from .env) + # unless the mode is exactly nip98: a padded or misspelled value stays + # local. Override per run with BUZZ_ADMIN_AUTH=nip98 (plus + # RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) to exercise the + # authenticated path; that keeps BUZZ_BIND_ADDR as configured. export BUZZ_ADMIN_AUTH="${BUZZ_ADMIN_AUTH:-disabled}" + if [[ "$BUZZ_ADMIN_AUTH" != nip98 ]]; then + bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" + relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 + export BUZZ_BIND_ADDR="127.0.0.1:${relay_port}" + fi echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports" echo "Auth mode: ${BUZZ_ADMIN_AUTH} (set BUZZ_ADMIN_AUTH=nip98 to require a signed operator)" + echo "Relay bind: ${BUZZ_BIND_ADDR:-0.0.0.0:3000}" cargo run -p buzz-relay # Seed deterministic reports and product feedback for local admin dashboard review diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index bc22f61561..980ccf68c6 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -199,8 +199,7 @@ function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) { ) : (

- Message content is unavailable. It may have expired or - been removed from event storage. + Message content is unavailable.

)} diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index a6a92c6a9a..1c7de25cc0 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -4,6 +4,7 @@ //! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in //! [`crate::moderation`] tenant-fenced. +use buzz_core::kind::{AUTHOR_ONLY_KINDS, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use serde::Serialize; @@ -16,6 +17,20 @@ use crate::Db; /// Maximum rows accepted by one admin query. pub const MAX_PAGE_SIZE: i64 = 200; +/// Kinds no admin read may reveal: author-only, result-gated, `#p`-gated and +/// shared-gated events. The whole shared-gated set is hidden, shared or not. +/// Moderators can still act on these kinds (direct delete by event ID, report +/// enforcement); they just cannot read them, so admin reads treat them as absent. +fn admin_hidden_kinds() -> Vec { + AUTHOR_ONLY_KINDS + .iter() + .chain(RESULT_GATED_KINDS) + .chain(P_GATED_KINDS) + .chain(SHARED_GATED_KINDS) + .map(|&kind| kind as i32) + .collect() +} + fn bounded_limit(limit: i64) -> i64 { limit.clamp(1, MAX_PAGE_SIZE) } @@ -209,6 +224,7 @@ pub async fn list_reports( WHERE r.target_kind = 'event' AND e.community_id = r.community_id AND e.id = r.target_event_id + AND e.kind <> ALL($10) ORDER BY e.created_at DESC LIMIT 1 ) target ON TRUE @@ -232,13 +248,19 @@ pub async fn list_reports( .bind(cursor_time) .bind(cursor_id) .bind(bounded_limit(limit)) + .bind(admin_hidden_kinds()) .fetch_all(pool) .await?; rows.into_iter().map(row_to_report).collect() } /// Fetch one report globally by its row id, including its event target content. -pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { +/// Targets whose kind is in `hidden_kinds` come back as `message: None`. +async fn get_report( + pool: &PgPool, + report_id: Uuid, + hidden_kinds: &[i32], +) -> Result> { let row = sqlx::query( r#" SELECT r.id, r.community_id, c.host AS community_host, @@ -271,6 +293,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result ALL($2) ORDER BY e.created_at DESC LIMIT 1 ) target ON TRUE @@ -290,6 +313,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result Result { }) } +/// Active community row in the admin community directory. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminCommunity { + /// Community identifier. + pub id: Uuid, + /// Served host. + pub host: String, + /// Workspace icon URL, when set. + pub icon: Option, +} + +/// List active communities whose host starts with `prefix` (case-insensitive, +/// matched literally), ordered by `lower(host)` and resumed after the +/// lowercased host `after`. `lower(host)` is unique, so it alone is a total +/// order. Uses the tenant binder's liveness rule, so every row can be bound. +pub async fn list_communities( + pool: &PgPool, + prefix: &str, + after: Option<&str>, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, host, icon + FROM communities + WHERE archived_at IS NULL + AND deleted_at IS NULL + AND deletion_state = 'active' + AND lower(host) LIKE lower($1) || '%' ESCAPE '\' + AND lower(host) > COALESCE($2, '') + ORDER BY lower(host) + LIMIT $3 + "#, + ) + .bind(crate::user::escape_like(prefix)) + .bind(after) + .bind(bounded_limit(limit)) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(AdminCommunity { + id: row.try_get("id")?, + host: row.try_get("host")?, + icon: row.try_get("icon")?, + }) + }) + .collect() +} + +/// One stored event, read inside a single community for a delete preview. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminEventPreview { + /// Event id (hex). + pub id: String, + /// Author public key (hex). + pub author_pubkey: String, + /// Event kind. + pub kind: i32, + /// Event content. + pub content: String, + /// Timestamp signed into the event. + pub created_at: DateTime, + /// Soft-deletion time, when deleted. + pub deleted_at: Option>, + /// Channel the event belongs to, if any. + pub channel_id: Option, +} + +/// Fetch event `id` from `community` only (same lookup as the report detail). +/// +/// Kinds in [`admin_hidden_kinds`] are never returned: the relay must not +/// reveal that such an event exists to anyone but its permitted readers, so a +/// matching row is reported as absent, exactly as the report list and detail +/// treat a hidden target. +pub async fn get_event_preview( + pool: &PgPool, + community_id: Uuid, + id: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, pubkey, kind, content, created_at, deleted_at, channel_id + FROM events + WHERE community_id = $1 AND id = $2 AND kind <> ALL($3) + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(community_id) + .bind(id) + .bind(admin_hidden_kinds()) + .fetch_optional(pool) + .await?; + row.map(|row| { + Ok(AdminEventPreview { + id: hex::encode(row.try_get::, _>("id")?), + author_pubkey: hex::encode(row.try_get::, _>("pubkey")?), + kind: row.try_get("kind")?, + content: row.try_get("content")?, + created_at: row.try_get("created_at")?, + deleted_at: row.try_get("deleted_at")?, + channel_id: row.try_get("channel_id")?, + }) + }) + .transpose() +} + impl Db { /// List reports for the deployment-global read-only admin plane. #[allow(clippy::too_many_arguments)] @@ -453,10 +587,22 @@ impl Db { .await } - /// Fetch one report for the deployment-global read-only admin plane. + /// Fetch one report for any admin HTTP response. Hidden-kind targets come + /// back as `message: None`, exactly like a missing event. #[datastore_span(name = "admin_get_report", system = "postgresql")] pub async fn admin_get_report(&self, id: Uuid) -> Result> { - get_report(&self.pool, id).await + get_report(&self.pool, id, &admin_hidden_kinds()).await + } + + /// Fetch one report with its target unfiltered, for report enforcement and + /// action recovery only, which need the real target author. Never serialize + /// this result into an HTTP response; use [`Self::admin_get_report`]. + #[datastore_span(name = "admin_get_report_for_enforcement", system = "postgresql")] + pub async fn admin_get_report_for_enforcement( + &self, + id: Uuid, + ) -> Result> { + get_report(&self.pool, id, &[]).await } /// List feedback for the deployment-global read-only admin plane. @@ -470,6 +616,27 @@ impl Db { pub async fn admin_get_feedback(&self, id: Uuid) -> Result> { get_feedback(&self.pool, id).await } + + /// Page the admin community directory. + #[datastore_span(name = "admin_list_communities", system = "postgresql")] + pub async fn admin_list_communities( + &self, + prefix: &str, + after: Option<&str>, + limit: i64, + ) -> Result> { + list_communities(&self.pool, prefix, after, limit).await + } + + /// Fetch one event inside one community for the admin delete preview. + #[datastore_span(name = "admin_get_event_preview", system = "postgresql")] + pub async fn admin_get_event_preview( + &self, + community_id: Uuid, + id: &[u8], + ) -> Result> { + get_event_preview(&self.pool, community_id, id).await + } } #[cfg(test)] @@ -620,7 +787,7 @@ mod postgres_tests { .await; let report_id = insert_event_report(&pool, report_community, &event_id).await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); @@ -707,6 +874,92 @@ mod postgres_tests { delete_report_fixture(&pool, other_community).await; } + /// A report on a hidden-kind event exposes neither the event's content nor + /// its author, in the list or the detail; a report on a message does. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_reads_hide_private_kind_targets() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "hidden-target").await; + let author = [5_u8; 32]; + let mut hidden = Vec::new(); + // Enumerate the core sets directly so a set dropped from + // `admin_hidden_kinds()` still gets a fixture and fails the test. + let private_kinds = AUTHOR_ONLY_KINDS + .iter() + .chain(RESULT_GATED_KINDS) + .chain(P_GATED_KINDS) + .chain(SHARED_GATED_KINDS) + .map(|&kind| kind as i32); + for (i, kind) in private_kinds.enumerate() { + let event_id = vec![0x40 + i as u8; 32]; + sqlx::query( + r#" + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) + VALUES ($1, $2, $3, now(), $4, '[["shared","true"]]'::jsonb, 'private', $5) + "#, + ) + .bind(community_id) + .bind(&event_id) + .bind(author) + .bind(kind) + .bind(vec![3_u8; 64]) + .execute(&pool) + .await + .expect("insert hidden event"); + hidden.push(( + kind, + insert_event_report(&pool, community_id, &event_id).await, + )); + } + let visible_id = vec![0x3f_u8; 32]; + insert_event(&pool, community_id, &visible_id, &author, "public", None).await; + let visible = insert_event_report(&pool, community_id, &visible_id).await; + + let reports = list_reports( + &pool, + Some(community_id), + None, + None, + None, + None, + None, + None, + 100, + ) + .await + .expect("list reports"); + let author_of = |id: Uuid| { + reports + .iter() + .find(|r| r.id == id) + .expect("report listed") + .target_author_pubkey + .clone() + }; + for &(kind, report_id) in &hidden { + assert_eq!(author_of(report_id), None, "list, kind {kind}"); + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) + .await + .expect("query report") + .expect("report exists"); + assert!(detail.message.is_none(), "detail, kind {kind}"); + } + assert_eq!(author_of(visible), Some(hex::encode(author))); + let detail = get_report(&pool, visible, &admin_hidden_kinds()) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.message.expect("message shown").content, "public"); + + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete event fixtures"); + delete_report_fixture(&pool, community_id).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn report_detail_has_no_message_for_non_event_target() { @@ -714,7 +967,7 @@ mod postgres_tests { let community_id = insert_community(&pool, "pubkey-target").await; let report_id = insert_pubkey_report(&pool, community_id).await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); @@ -732,7 +985,7 @@ mod postgres_tests { let missing_event_id = vec![8_u8; 32]; let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); @@ -799,7 +1052,7 @@ mod postgres_tests { .await; set_report_status(&pool, community_id, report_id, "dismissed").await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); @@ -839,7 +1092,7 @@ mod postgres_tests { .await; set_report_status(&pool, community_id, report_id, "resolved").await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); @@ -873,7 +1126,7 @@ mod postgres_tests { ) .await; - let detail = get_report(&pool, report_id) + let detail = get_report(&pool, report_id, &admin_hidden_kinds()) .await .expect("query report") .expect("report exists"); diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 72b74c26f9..e6f9ad4cd5 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -229,7 +229,7 @@ pub async fn get_user_by_nip05( /// /// Without this, a search query of `"%"` would match every row (full table /// scan) and `"_"` would act as a single-character wildcard. -fn escape_like(input: &str) -> String { +pub(crate) fn escape_like(input: &str) -> String { input .replace('\\', "\\\\") .replace('%', "\\%") diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs index 0c0b826255..89961eb1a9 100644 --- a/crates/buzz-relay/src/api/admin/auth.rs +++ b/crates/buzz-relay/src/api/admin/auth.rs @@ -1,6 +1,15 @@ //! Authentication and principal resolution for the deployment-admin API. //! -//! # NIP-98 mode (mutations available) +//! Every route makes one of three checks against the [`AdminAccess`] that +//! [`authorize`] returns: +//! +//! | Check | Routes | `nip98` | `disabled` | +//! |-------|--------|---------|------------| +//! | view | every moderation read | any staff role | served to any caller | +//! | act | every mutation | staff, then role checks | 403 | +//! | operator | `/operators` | Operator | 403 | +//! +//! # NIP-98 mode (default) //! //! Every request carries `Authorization: Nostr `. After //! verifying the signature, timestamp, `u` tag, method tag, and (for @@ -21,13 +30,15 @@ //! Config outranks DB: a `relay_operators` DB row for a config-backed //! Operator pubkey is ignored; it never demotes a config grant. //! -//! # disabled mode (read-only) +//! # disabled mode (network-trusted, read-only) //! -//! `authorize()` succeeds for read requests but returns `None` for the -//! principal — mutations and staffing routes call -//! [`require_mutation_principal`], which 403s on `None`. +//! `authorize()` checks no credential and returns +//! [`AdminAccess::NetworkTrusted`]: whoever can reach the relay may view every +//! moderation read. There is no identity, so [`AdminAccess::act`] and +//! [`AdminAccess::operator`] refuse with 403; an anonymous caller is never given +//! a principal. -use axum::http::{header, HeaderMap}; +use axum::http::{header, HeaderMap, Method, Uri}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; @@ -67,8 +78,7 @@ pub enum AdminSource { Db, } -/// A resolved deployment-level principal, returned by [`authorize`] in nip98 -/// mode. +/// A resolved deployment-level principal, carried by [`AdminAccess::Staff`]. #[derive(Debug, Clone)] pub struct AdminPrincipal { /// 32-byte pubkey (binary). @@ -189,18 +199,16 @@ fn method_has_body(method: &str) -> bool { /// deserialize the same bytes. Never pass `None` for a body-bearing method in /// nip98 mode — the `payload` sha256 tag would be skipped. /// -/// Returns: -/// - `Ok(Some(principal))` — nip98 mode (role resolved from roster). -/// - `Ok(None)` — disabled mode; reads pass, mutations 403 via -/// [`require_mutation_principal`]. -/// - `Err(_)` — authentication or authorization failed. +/// `Ok(_)` is the view check: the caller may read. Writes and staffing then +/// call [`AdminAccess::act`] or [`AdminAccess::operator`]. `Err(_)` means +/// authentication or authorization failed. pub async fn authorize( state: &AppState, headers: &HeaderMap, path_and_query: &str, method: &str, raw_body: Option<&[u8]>, -) -> Result, ApiError> { +) -> Result { let config = state .config .admin @@ -209,8 +217,8 @@ pub async fn authorize( // Credential check first: an unauthenticated caller learns nothing about // which Host or Origin the deployment expects. - let (principal, nip98_event_id) = match &config.auth { - AdminAuth::Disabled => (None, None), + let (access, nip98_event_id) = match &config.auth { + AdminAuth::Disabled => (AdminAccess::NetworkTrusted, None), AdminAuth::Nip98 => { let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}"); let (pubkey_bytes, event_id) = @@ -220,7 +228,7 @@ pub async fn authorize( // must not be able to consume replay slots at request rate. Only a // request that clears authorization claims its event ID. let principal = resolve_admin_principal(state, pubkey_bytes).await?; - (Some(principal), Some(event_id)) + (AdminAccess::Staff(principal), Some(event_id)) } }; @@ -242,7 +250,46 @@ pub async fn authorize( claim_nip98_replay(state, &event_id).await?; } - Ok(principal) + Ok(access) +} + +/// [`authorize`] for a bodyless read, bound to the request's real method and +/// full target. Axum serves `HEAD` through `GET` handlers, so hardcoding +/// `"GET"` would reject a correctly signed `HEAD`. +pub async fn authorize_read( + state: &AppState, + headers: &HeaderMap, + method: &Method, + uri: &Uri, +) -> Result { + authorize(state, headers, request_target(uri), method.as_str(), None).await +} + +/// [`authorize`] for a mutation, bound to the request's real method and full +/// target. Hardcoding the method would reject every correctly signed request +/// to a handler mounted on a second method. `raw_body` follows [`authorize`]'s +/// contract: the buffered body bytes, or `None` only for a bodyless `DELETE`. +pub async fn authorize_write( + state: &AppState, + headers: &HeaderMap, + method: &Method, + uri: &Uri, + raw_body: Option<&[u8]>, +) -> Result { + authorize( + state, + headers, + request_target(uri), + method.as_str(), + raw_body, + ) + .await +} + +/// The full request target NIP-98 clients sign: path plus any query string. +fn request_target(uri: &Uri) -> &str { + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()) } /// Resolve a 32-byte pubkey to an `AdminPrincipal` using config + DB. @@ -333,25 +380,38 @@ pub async fn lookup_admin_principal( Ok(None) } -/// Require that this request resolved a principal (nip98 mode) and return it. -/// Mutation and staffing routes are unavailable in disabled mode. -/// -/// Returns the principal or a 403 if none was resolved. -pub fn require_mutation_principal( - principal: Option, -) -> Result { - principal - .ok_or_else(|| ApiError::forbidden_with_message("mutations require BUZZ_ADMIN_AUTH=nip98")) +/// What [`authorize`] established about the caller. +#[derive(Debug, Clone)] +pub enum AdminAccess { + /// `disabled` mode: the network vouches for the caller; there is no + /// identity. May view, never act or staff. + NetworkTrusted, + /// `nip98` mode: a signed, rostered staff member. + Staff(AdminPrincipal), } -/// Require that the principal holds Operator role. Used by staffing routes. -pub fn require_operator(principal: &AdminPrincipal) -> Result<(), ApiError> { - if principal.role == AdminRole::Operator { - Ok(()) - } else { - Err(ApiError::forbidden_with_message( - "staffing endpoints require operator role", - )) +impl AdminAccess { + /// The act check: a write needs a signed staff member. Role-specific + /// checks run on the returned principal. + pub fn act(self) -> Result { + match self { + Self::Staff(principal) => Ok(principal), + Self::NetworkTrusted => Err(ApiError::forbidden_with_message( + "this endpoint requires BUZZ_ADMIN_AUTH=nip98", + )), + } + } + + /// The operator check: roster routes need a signed Operator. + pub fn operator(self) -> Result { + let principal = self.act()?; + if principal.role == AdminRole::Operator { + Ok(principal) + } else { + Err(ApiError::forbidden_with_message( + "staffing endpoints require operator role", + )) + } } } diff --git a/crates/buzz-relay/src/api/admin/direct.rs b/crates/buzz-relay/src/api/admin/direct.rs index 9abafb73b0..b262c7827c 100644 --- a/crates/buzz-relay/src/api/admin/direct.rs +++ b/crates/buzz-relay/src/api/admin/direct.rs @@ -12,16 +12,14 @@ use std::sync::Arc; use axum::{ body::Bytes, extract::{Path, Query, State}, - http::{HeaderMap, StatusCode, Uri}, + http::{HeaderMap, Method, StatusCode, Uri}, Json, }; use buzz_db::relay_admin_actions::{AdminActionRecord, DirectActionInput, DirectClaim}; use serde::Deserialize; use uuid::Uuid; -use super::auth::{ - admin_role_str, authorize, lookup_admin_principal, require_mutation_principal, AdminRole, -}; +use super::auth::{admin_role_str, authorize_write, lookup_admin_principal, AdminRole}; use super::error::ApiError; use super::{compute_timeout_until, decode_hex_pubkey, CommunityQuery}; use crate::handlers::report_resolution::{drive_direct_action, ResolutionError}; @@ -44,35 +42,38 @@ enum Kind { pub(super) async fn ban( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(target): Path, Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, uri, headers, Kind::Ban, target, q, body).await + direct_action(state, method, uri, headers, Kind::Ban, target, q, body).await } pub(super) async fn timeout( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(target): Path, Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, uri, headers, Kind::Timeout, target, q, body).await + direct_action(state, method, uri, headers, Kind::Timeout, target, q, body).await } pub(super) async fn delete_event( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(target): Path, Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, uri, headers, Kind::Delete, target, q, body).await + direct_action(state, method, uri, headers, Kind::Delete, target, q, body).await } /// 409 `target_is_staff` when `target` is on the effective staff roster @@ -101,6 +102,7 @@ pub(super) fn enforcement_failed(action_id: Uuid, error: &str) -> ApiError { #[allow(clippy::too_many_arguments)] async fn direct_action( state: Arc, + method: Method, uri: Uri, headers: HeaderMap, kind: Kind, @@ -108,12 +110,9 @@ async fn direct_action( q: CommunityQuery, body_bytes: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - let path = uri - .path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()); - let principal = require_mutation_principal( - authorize(&state, &headers, path, "POST", Some(&body_bytes)).await?, - )?; + let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .act()?; let body: DirectBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; let target = decode_hex_pubkey(&target_hex)?; diff --git a/crates/buzz-relay/src/api/admin/error.rs b/crates/buzz-relay/src/api/admin/error.rs index ab3876c7c9..8507f86e2f 100644 --- a/crates/buzz-relay/src/api/admin/error.rs +++ b/crates/buzz-relay/src/api/admin/error.rs @@ -118,7 +118,8 @@ impl IntoResponse for ApiError { } impl From for ApiError { - fn from(_: buzz_db::DbError) -> Self { + fn from(err: buzz_db::DbError) -> Self { + tracing::error!(error = %err, "admin API database error"); Self::internal() } } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 2c3a356744..20935e9fed 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1,23 +1,26 @@ //! Private deployment moderation API. //! -//! Read routes are available in both auth modes (nip98, disabled). -//! Mutation and staffing routes require an authenticated `nip98` principal -//! (per-person, attributed to the resolved operator). +//! Every route is a view (moderation read), act (mutation) or operator +//! (`/operators`) route; see [`auth`] for the rule. In `nip98` mode every route +//! needs a signed, rostered staff member. In `disabled` mode every moderation +//! read is served to whoever can reach the relay, and act and operator routes +//! answer 403. mod auth; mod direct; mod error; +mod reads; use std::sync::Arc; use auth::{ - admin_role_str, admin_source_str, authorize, require_mutation_principal, require_operator, - AdminRole, AdminSource, + admin_role_str, admin_source_str, authorize_read, authorize_write, AdminAccess, AdminRole, + AdminSource, }; use axum::{ body::Bytes, extract::{Path, Query, State}, - http::{header, HeaderMap, HeaderValue, Uri}, + http::{header, HeaderMap, HeaderValue, Method, Uri}, middleware::{self, Next}, response::Response, routing::{delete, get, patch, put}, @@ -40,9 +43,8 @@ pub(crate) use auth::admin_api_origin; /// Build the deployment-admin routes. /// -/// Read routes are available in all auth modes. -/// Mutation routes (/reports/{id}/resolve, /feedback/{id}) and staffing routes -/// (/operators) require an authenticated `nip98` principal. +/// Moderation reads (GET) are served in both auth modes. Mutations and the +/// staffing routes (/operators) require an authenticated `nip98` principal. pub fn router(state: Arc) -> Router { Router::new() .route("/probe", get(probe)) @@ -61,6 +63,10 @@ pub fn router(state: Arc) -> Router { .route("/operators", get(list_operators)) .route("/operators/{pubkey}", put(upsert_operator)) .route("/operators/{pubkey}", delete(delete_operator)) + .route("/communities", get(reads::communities)) + .route("/members/search", get(reads::search_members)) + .route("/members/{pubkey}", get(reads::lookup_member)) + .route("/events/{id}", get(reads::event_preview)) .route("/members/restrictions", get(list_member_restrictions)) .route("/members/{pubkey}/ban", delete(unban_member)) .route("/members/{pubkey}/timeout", delete(untimeout_member)) @@ -116,12 +122,14 @@ struct ReportQuery { limit: Option, } -fn limit(value: Option) -> Result { - match value.unwrap_or(50) { - value @ 1..=200 => Ok(value), +/// A page size: `default` when absent, otherwise 1 to `max`, else 400 +/// `invalid_limit`. +fn limit(value: Option, default: i64, max: i64) -> Result { + match value.unwrap_or(default) { + value if (1..=max).contains(&value) => Ok(value), _ => Err(ApiError::bad_request( "invalid_limit", - "limit must be between 1 and 200", + &format!("limit must be between 1 and {max}"), )), } } @@ -158,26 +166,13 @@ struct ProbeResponse { async fn probe( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, ) -> Result, ApiError> { - let principal = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; - - let (auth_mode, role, source, can_act, can_staff) = match &state.config.admin { - Some(config) => match &config.auth { - crate::config::AdminAuth::Disabled => ("disabled", None, None, false, false), - crate::config::AdminAuth::Nip98 => { - // principal is Some in nip98 mode (authorize returns Ok(Some(_))) - let p = principal - .as_ref() - .expect("nip98 mode always resolves principal"); + let (auth_mode, role, source, can_act, can_staff) = + match authorize_read(&state, &headers, &method, &uri).await? { + AdminAccess::NetworkTrusted => ("disabled", None, None, false, false), + AdminAccess::Staff(p) => { let can_staff = p.role == AdminRole::Operator; ( "nip98", @@ -187,9 +182,7 @@ async fn probe( can_staff, ) } - }, - None => return Err(ApiError::not_found()), - }; + }; Ok(Json(ProbeResponse { status: "ok", @@ -204,18 +197,11 @@ async fn probe( async fn reports( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; validate( query.status.as_deref(), REPORT_STATUS_ALLOWLIST, @@ -246,7 +232,7 @@ async fn reports( query.after, query.before, None, - limit(query.limit)?, + limit(query.limit, 50, 200)?, ) .await?; Ok(Json(items)) @@ -255,18 +241,11 @@ async fn reports( async fn report_detail( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, Path(id): Path, ) -> Result, ApiError> { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; state .db .admin_get_report(id) @@ -294,17 +273,10 @@ struct FeedbackSummary { async fn feedback( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, ) -> Result>, ApiError> { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; let items = state .db .admin_list_feedback(100) @@ -330,18 +302,11 @@ async fn feedback( async fn feedback_detail( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, Path(id): Path, ) -> Result, ApiError> { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; state .db .admin_get_feedback(id) @@ -353,18 +318,11 @@ async fn feedback_detail( async fn feedback_attachment( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, Path((id, sha256)): Path<(Uuid, String)>, ) -> Result { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; if !is_sha256(&sha256) { return Err(ApiError::not_found()); } @@ -485,6 +443,7 @@ fn compute_timeout_until(secs: u64) -> Result, ApiError> { /// - delete/kick/ban/timeout: server-side enforcement state machine. async fn resolve_report( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(report_id): Path, @@ -495,17 +454,9 @@ async fn resolve_report( resolve_report_with_enforcement, ResolutionError, }; - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "POST", - Some(&body_bytes), - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; + let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .act()?; let body: ResolveReportBody = serde_json::from_slice(&body_bytes) .map_err(|_e| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -516,10 +467,11 @@ async fn resolve_report( return Err(ApiError::bad_request("invalid_action", "unknown action")); } - // Load report globally to derive target provenance. + // Load report globally to derive target provenance. Enforcement needs the + // real target author, so this read is unfiltered and never returned. let report_detail = state .db - .admin_get_report(report_id) + .admin_get_report_for_enforcement(report_id) .await? .ok_or_else(ApiError::not_found)?; @@ -713,6 +665,7 @@ struct ReopenReportBody { /// records a durable `reopen` audit row. `409` if the report is not terminal. async fn reopen_report( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(report_id): Path, @@ -720,17 +673,9 @@ async fn reopen_report( ) -> Result, ApiError> { use buzz_db::relay_admin_actions::ReopenResult; - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "POST", - Some(&body_bytes), - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; + let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .act()?; let body: ReopenReportBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -799,22 +744,15 @@ struct CancelReportBody { /// `activeAction: null`. async fn cancel_report( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(report_id): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "POST", - Some(&body_bytes), - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; + let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .act()?; let body: CancelReportBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -875,22 +813,15 @@ async fn cancel_report( /// Update product_feedback status. Requires nip98 auth. async fn update_feedback_status( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(id): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "PATCH", - Some(&body_bytes), - ) - .await?; - - let _principal = require_mutation_principal(principal_opt)?; + let _principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .act()?; #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -938,20 +869,12 @@ struct OperatorEntry { async fn list_operators( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, ) -> Result>, ApiError> { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; - require_operator(&principal)?; + authorize_read(&state, &headers, &method, &uri) + .await? + .operator()?; let config = state .config @@ -1020,23 +943,15 @@ struct UpsertOperatorBody { /// Requires nip98 auth + Operator role. async fn upsert_operator( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "PUT", - Some(&body_bytes), - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; - require_operator(&principal)?; + let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + .await? + .operator()?; // Canonicalize the path param once: validate it decodes to 32 bytes, then // lowercase it. Config-backed pubkeys are lowercased at parse, so the 409 @@ -1099,22 +1014,14 @@ async fn upsert_operator( /// Requires nip98 auth + Operator role. async fn delete_operator( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, ) -> Result, ApiError> { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "DELETE", - None, - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; - require_operator(&principal)?; + let principal = authorize_write(&state, &headers, &method, &uri, None) + .await? + .operator()?; // Canonicalize the path param once (validate + lowercase) so the 409 check // and the DB delete use the same form config-backed pubkeys are stored in; @@ -1244,7 +1151,7 @@ fn decode_cursor(token: &str) -> Result<(DateTime, Vec), ApiError> { } #[derive(Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct CommunityQuery { community_host: String, } @@ -1295,20 +1202,13 @@ struct RestrictionsQuery { async fn list_member_restrictions( State(state): State>, uri: Uri, + method: Method, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { - authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "GET", - None, - ) - .await?; + authorize_read(&state, &headers, &method, &uri).await?; - let page_limit = limit(Some(query.limit.unwrap_or(200)))?; + let page_limit = limit(query.limit, 200, 200)?; let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; let community = community_for_host(&state, &query.community_host).await?; @@ -1338,22 +1238,15 @@ async fn list_member_restrictions( /// Requires nip98 auth. async fn unban_member( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, Query(query): Query, ) -> Result { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "DELETE", - None, - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; + let principal = authorize_write(&state, &headers, &method, &uri, None) + .await? + .act()?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = community_for_host(&state, &query.community_host).await?; @@ -1386,22 +1279,15 @@ async fn unban_member( /// Requires nip98 auth. async fn untimeout_member( State(state): State>, + method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, Query(query): Query, ) -> Result { - let principal_opt = authorize( - &state, - &headers, - uri.path_and_query() - .map_or_else(|| uri.path(), |pq| pq.as_str()), - "DELETE", - None, - ) - .await?; - - let principal = require_mutation_principal(principal_opt)?; + let principal = authorize_write(&state, &headers, &method, &uri, None) + .await? + .act()?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = community_for_host(&state, &query.community_host).await?; @@ -1592,7 +1478,7 @@ mod postgres_tests { /// Rostered as a config operator in `test_state()` so `authorized()` can /// mint NIP-98 credentials that resolve to an Operator principal without a /// DB lookup. - fn test_operator_keys() -> nostr::Keys { + pub(super) fn test_operator_keys() -> nostr::Keys { nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid test secret key") } @@ -1606,7 +1492,7 @@ mod postgres_tests { nip98_state(vec![test_operator_keys().public_key().to_hex()]).await } - async fn disabled_mode_state() -> Arc { + pub(super) async fn disabled_mode_state() -> Arc { let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); @@ -1662,6 +1548,10 @@ mod postgres_tests { "/feedback".to_string(), format!("/feedback/{id}"), format!("/feedback/{id}/attachments/{HASH}"), + "/communities".to_string(), + "/members/search?communityHost=a.example&q=a".to_string(), + format!("/members/{HASH}?communityHost=a.example"), + format!("/events/{HASH}?communityHost=a.example"), ] } @@ -2236,7 +2126,9 @@ mod postgres_tests { /// Build an AppState that uses a real Postgres connection pool so HTTP /// routes that hit the DB can commit and read back results. - async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc { + pub(super) async fn nip98_state_with_real_pool( + pool: sqlx::PgPool, + ) -> Arc { let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); @@ -3031,7 +2923,7 @@ mod postgres_tests { /// Build a test AppState in nip98 mode with the given operator pubkeys /// (populated in relay_operator_pubkeys config) and an AlwaysFreshReplayGuard. - async fn nip98_state(pubkeys: Vec) -> Arc { + pub(super) async fn nip98_state(pubkeys: Vec) -> Arc { nip98_state_with_replay(pubkeys, Arc::new(AlwaysFreshReplayGuard)).await } @@ -3092,7 +2984,7 @@ mod postgres_tests { /// on `admin.example` (the test host). The path should be the handler-level /// path (e.g. `/reports`); this helper prefixes it with `ADMIN_API_PREFIX` /// to match the canonical URL the auth layer constructs in production. - fn make_nostr_auth(keys: &nostr::Keys, path: &str) -> String { + pub(super) fn make_nostr_auth(keys: &nostr::Keys, path: &str) -> String { use nostr::{EventBuilder, Kind, Tag}; let url = format!("https://admin.example{ADMIN_API_PREFIX}{path}"); let tags = vec![ @@ -3889,7 +3781,7 @@ mod postgres_tests { /// Build a NIP-98 `Authorization: Nostr` header from an explicit raw tag /// list, so a test can inject duplicate `u`/`method`/`payload` tags that the /// typed helpers can't express. Signs a real kind-27235 event. - fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec) -> String { + pub(super) fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec) -> String { use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use nostr::{EventBuilder, Kind}; @@ -4024,8 +3916,8 @@ mod postgres_tests { } /// POST /reports/{id}/resolve in disabled mode → 403. Disabled mode is - /// always read-only: `authorize()` resolves no principal, so - /// `require_mutation_principal` rejects every mutation with 403. + /// read-only: `authorize()` returns `NetworkTrusted`, so the act check + /// rejects every mutation with 403. #[tokio::test] async fn mutation_routes_in_disabled_mode_return_403() { let state = disabled_mode_state().await; @@ -4096,12 +3988,112 @@ mod postgres_tests { ); } - /// Moderator cannot access staffing endpoints. + /// Staffing is Operator-only for signed callers: a DB-rostered Moderator + /// gets 403 on every `/operators` route and writes neither the target's + /// roster row nor its audit log, while a config Operator succeeds on the + /// same requests. This is the only + /// test that tells `.operator()` apart from `.act()`; unsigned and + /// disabled-mode requests are refused by both. #[tokio::test] #[ignore = "requires Postgres — moderator DB lookup"] async fn moderator_cannot_access_staffing_endpoints() { - // This test needs DB to resolve moderator role. - // Covered by negative-matrix integration test suite. + let operator_keys = nostr::Keys::generate(); + let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); + let moderator_keys = nostr::Keys::generate(); + let target_keys = nostr::Keys::generate(); + let target_hex = target_keys.public_key().to_hex(); + let target_bytes = target_keys.public_key().to_bytes().to_vec(); + let grant_body = r#"{"role":"moderator"}"#.as_bytes(); + + let send = |keys: &nostr::Keys, method: &str, path: &str| { + let auth = match method { + "GET" => make_nostr_auth(keys, path), + "PUT" => make_nostr_auth_put(keys, path, grant_body), + "DELETE" => make_nostr_auth_delete(keys, path), + other => unreachable!("unexpected method {other}"), + }; + let body = if method == "PUT" { + Body::from(grant_body.to_vec()) + } else { + Body::empty() + }; + let request = Request::builder() + .method(method) + .uri(path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(body) + .expect("request"); + status_for(state.clone(), request) + }; + let target_rows = || async { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM relay_operators WHERE pubkey = $1") + .bind(&target_bytes) + .fetch_one(&pool) + .await + .expect("count roster rows") + }; + // The target's full roster row plus its audit-row count. The target is + // a fresh key, so scoping the audit count to it ignores concurrent tests. + let target_snapshot = || async { + sqlx::query_as::<_, (Option, i64)>( + "SELECT (SELECT r::text FROM relay_operators r WHERE r.pubkey = $1), \ + (SELECT COUNT(*) FROM relay_operator_audit WHERE target_pubkey = $1)", + ) + .bind(&target_bytes) + .fetch_one(&pool) + .await + .expect("snapshot target roster and audit") + }; + + // Roster the Moderator in the DB, and the target too so DELETE has a row. + for keys in [&moderator_keys, &target_keys] { + let path = format!("/operators/{}", keys.public_key().to_hex()); + let granted = send(&operator_keys, "PUT", &path).await; + assert_eq!(granted.status(), StatusCode::OK, "seed grant {path}"); + } + + let target_path = format!("/operators/{target_hex}"); + for (method, path) in [ + ("GET", "/operators"), + ("PUT", target_path.as_str()), + ("DELETE", target_path.as_str()), + ] { + let before = target_snapshot().await; + let denied = send(&moderator_keys, method, path).await; + assert_eq!( + denied.status(), + StatusCode::FORBIDDEN, + "Moderator {method} {path} must be refused" + ); + assert_eq!( + target_snapshot().await, + before, + "refused Moderator {method} {path} must not write the roster or audit log" + ); + } + + for (method, path) in [ + ("GET", "/operators"), + ("PUT", target_path.as_str()), + ("DELETE", target_path.as_str()), + ] { + let allowed = send(&operator_keys, method, path).await; + assert_eq!(allowed.status(), StatusCode::OK, "Operator {method} {path}"); + } + assert_eq!( + target_rows().await, + 0, + "Operator DELETE must remove the row" + ); + + let moderator_path = format!("/operators/{}", moderator_keys.public_key().to_hex()); + let cleanup = send(&operator_keys, "DELETE", &moderator_path).await; + assert_eq!(cleanup.status(), StatusCode::OK, "cleanup revoke"); } /// Config-backed pubkey upsert → 409 Conflict. @@ -7316,6 +7308,197 @@ mod postgres_tests { assert!(banned, "the stored author must be banned"); } + /// Seed an event report in the `admin.example` community whose target is a + /// hidden (shared-gated) kind authored by `author`. Returns the report id. + async fn seed_hidden_kind_event_report(pool: &sqlx::PgPool, author: &[u8]) -> Uuid { + let host_report = seed_admin_host_report(pool, "open").await; + let community_id: Uuid = + sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1") + .bind(host_report) + .fetch_one(pool) + .await + .expect("admin.example community"); + cleanup_admin_host_report(pool, host_report).await; + let (report_id, _channel_id, target_event_id) = + e2e_event_report_with_author(pool, community_id, author).await; + sqlx::query( + r#"UPDATE events SET kind = $3, tags = '[["shared","true"]]'::jsonb + WHERE community_id = $1 AND id = $2"#, + ) + .bind(community_id) + .bind(target_event_id.as_slice()) + .bind(buzz_core::kind::SHARED_GATED_KINDS[0] as i32) + .execute(pool) + .await + .expect("make target a hidden kind"); + report_id + } + + /// Ban through the real resolve route on a report whose target is a hidden + /// kind: the real author is banned, and report detail still hides it. + #[tokio::test] + #[ignore = "requires Postgres — resolve ban on a hidden-kind target bans the author, detail stays hidden"] + async fn resolve_ban_on_hidden_kind_target_bans_author_without_revealing_it() { + let keys = nostr::Keys::generate(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + let pool = e2e_pool().await; + let author = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let report_id = seed_hidden_kind_event_report(&pool, &author).await; + + let path = format!("/reports/{report_id}/resolve"); + let body = serde_json::json!({ "action": "ban", "requestId": Uuid::new_v4() }).to_string(); + let response = status_for( + state.clone(), + Request::builder() + .method("POST") + .uri(&path) + .header(header::HOST, "admin.example") + .header( + header::AUTHORIZATION, + make_nostr_auth_post(&keys, &path, body.as_bytes()), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "ban must be accepted"); + let resolved: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"), + ) + .expect("json"); + let mut keys_seen: Vec<&str> = resolved + .as_object() + .expect("object") + .keys() + .map(String::as_str) + .collect(); + keys_seen.sort_unstable(); + assert_eq!( + keys_seen, + ["activeAction", "status"], + "resolve returns only status/action" + ); + assert!( + !resolved.to_string().contains(&hex::encode(&author)), + "resolve response must not reveal the hidden target's author" + ); + + let banned: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM community_bans b JOIN moderation_reports r \ + ON r.community_id = b.community_id WHERE r.id = $1 AND b.pubkey = $2)", + ) + .bind(report_id) + .bind(&author) + .fetch_one(&pool) + .await + .expect("ban existence"); + assert!(banned, "the hidden target's author must be banned"); + + let detail_path = format!("/reports/{report_id}"); + let response = status_for( + state, + Request::builder() + .uri(&detail_path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, make_nostr_auth(&keys, &detail_path)) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let detail: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"), + ) + .expect("json"); + assert!(detail["message"].is_null(), "detail must hide the target"); + assert!(detail["targetAuthorPubkey"].is_null()); + } + + /// An accepted-but-unfinished ban on a hidden-kind target converges in the + /// recovery worker, which re-derives the author from the report. + #[tokio::test] + #[ignore = "requires Postgres — stranded ban on a hidden-kind target recovers"] + async fn stranded_ban_on_hidden_kind_target_recovers_via_worker() { + let pool = e2e_pool().await; + let author = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let report_id = seed_hidden_kind_event_report(&pool, &author).await; + let (community_id, channel_id): (Uuid, Option) = + sqlx::query_as("SELECT community_id, channel_id FROM moderation_reports WHERE id = $1") + .bind(report_id) + .fetch_one(&pool) + .await + .expect("report row"); + let actor = vec![0x61u8; 32]; + + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + buzz_core::CommunityId::from_uuid(community_id), + report_id, + Uuid::new_v4(), + &actor, + "operator", + "ban", + None, + None, + "resolve:ban", + "relay_operator", + Some(author.as_slice()), + None, + channel_id, + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + sqlx::query( + "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + let claim = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-stranded-ban-hidden", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch") + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + let state = state_from_pool(pool.clone()).await; + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("exists"); + assert_eq!(final_rec.state, "succeeded", "stranded ban must converge"); + let banned: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM community_bans WHERE community_id = $1 AND pubkey = $2)", + ) + .bind(community_id) + .bind(&author) + .fetch_one(&pool) + .await + .expect("ban existence"); + assert!(banned, "the hidden target's author must be banned"); + } + #[tokio::test] #[ignore = "requires Postgres — HTTP kick on a purged event report fails pre-claim without dirtying the report"] async fn http_kick_on_event_report_with_missing_event_rejects_pre_claim() { diff --git a/crates/buzz-relay/src/api/admin/reads.rs b/crates/buzz-relay/src/api/admin/reads.rs new file mode 100644 index 0000000000..6cb3e58635 --- /dev/null +++ b/crates/buzz-relay/src/api/admin/reads.rs @@ -0,0 +1,255 @@ +//! Read-only community reads for the Admin Console: the community directory, +//! member search and lookup, and the delete preview. +//! +//! Like every moderation read, these make the view check: signed staff in +//! `nip98` mode, any caller who can reach the relay in `disabled` mode. Each +//! community-scoped route binds `communityHost` through the tenant binder and +//! reads nothing outside that community. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, Method, StatusCode, Uri}, + Json, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use buzz_db::admin_moderation::{AdminCommunity, AdminEventPreview}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::auth::{authorize_read, lookup_admin_principal, AdminAccess}; +use super::error::ApiError; +use super::{community_for_host, decode_hex_pubkey, limit, CommunityQuery}; +use crate::state::AppState; + +type AppStateRef = State>; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct CommunitiesQuery { + q: Option, + cursor: Option, + limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CommunitiesPage { + items: Vec, + next_cursor: Option, +} + +/// The cursor is the last row's `lower(host)`, which is unique, so it alone +/// resumes the directory order. +fn encode_community_cursor(community: &AdminCommunity) -> String { + URL_SAFE_NO_PAD.encode(community.host.to_lowercase()) +} + +fn decode_community_cursor(token: &str) -> Result { + URL_SAFE_NO_PAD + .decode(token) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|host| !host.is_empty()) + .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor is invalid")) +} + +/// `GET /communities?q=&cursor=&limit=` — active communities whose host starts +/// with `q` (at most 255 characters, the host length), paged by `lower(host)`. +/// `limit` is 1–100, default 50. +pub(super) async fn communities( + State(state): AppStateRef, + method: Method, + uri: Uri, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + authorize_read(&state, &headers, &method, &uri).await?; + let limit = limit(query.limit, 50, 100)?; + let after = query + .cursor + .as_deref() + .map(decode_community_cursor) + .transpose()?; + let prefix = query.q.as_deref().unwrap_or("").trim(); + if prefix.chars().count() > 255 { + return Err(ApiError::bad_request( + "invalid_query", + "q must be at most 255 characters", + )); + } + let items = state + .db + .admin_list_communities(prefix, after.as_deref(), limit) + .await?; + let next_cursor = (items.len() as i64 == limit) + .then(|| items.last().map(encode_community_cursor)) + .flatten(); + Ok(Json(CommunitiesPage { items, next_cursor })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct MemberSearchQuery { + community_host: String, + q: String, + limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MemberSearchItem { + pubkey: String, + display_name: Option, + nip05: Option, + avatar_url: Option, +} + +#[derive(Serialize)] +pub(super) struct MemberSearchPage { + items: Vec, +} + +/// `GET /members/search?communityHost=&q=&limit=` — profile search inside one +/// community. Matches community profiles, so former members are included. +/// `limit` is 1–50, default 20. +pub(super) async fn search_members( + State(state): AppStateRef, + method: Method, + uri: Uri, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + authorize_read(&state, &headers, &method, &uri).await?; + let q = query.q.trim(); + if q.is_empty() || q.chars().count() > 100 { + return Err(ApiError::bad_request( + "invalid_query", + "q must be 1 to 100 characters", + )); + } + let limit = u32::try_from(limit(query.limit, 20, 50)?).map_err(|_| ApiError::internal())?; + let community = community_for_host(&state, &query.community_host).await?; + let items = state + .db + .search_users(community, q, limit) + .await? + .into_iter() + .map(|user| MemberSearchItem { + pubkey: hex::encode(user.pubkey), + display_name: user.display_name, + nip05: user.nip05_handle, + avatar_url: user.avatar_url, + }) + .collect(); + Ok(Json(MemberSearchPage { items })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MemberProfile { + display_name: Option, + nip05: Option, + avatar_url: Option, + about: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MemberLookup { + pubkey: String, + profile: Option, + role: Option, + banned: bool, + muted_until: Option>, + /// Whether the pubkey is deployment staff; `null` in `disabled` mode, + /// where no caller can act on the answer and the roster is not read. + is_staff: Option, +} + +/// Whether `pubkey` is deployment staff, using the direct-action staff guard's +/// roster lookup. A lookup failure is an error (500), never "not staff". +async fn is_staff(state: &AppState, pubkey: &[u8]) -> Result { + let pubkey: [u8; 32] = pubkey.try_into().map_err(|_| ApiError::internal())?; + Ok(lookup_admin_principal(state, pubkey).await?.is_some()) +} + +/// `GET /members/{pubkey}?communityHost=` — profile, community role, current +/// restriction and, for a signed caller, staff status for one pubkey in one +/// community. +pub(super) async fn lookup_member( + State(state): AppStateRef, + method: Method, + uri: Uri, + headers: HeaderMap, + Path(pubkey_hex): Path, + Query(query): Query, +) -> Result, ApiError> { + let access = authorize_read(&state, &headers, &method, &uri).await?; + let pubkey = decode_hex_pubkey(&pubkey_hex)?; + let pubkey_hex = hex::encode(&pubkey); + let community = community_for_host(&state, &query.community_host).await?; + let profile = state.db.get_user(community, &pubkey).await?; + let role = state.db.get_relay_member(community, &pubkey_hex).await?; + let restriction = state + .db + .moderation_restriction_state(community, &pubkey) + .await?; + let is_staff = match access { + AdminAccess::Staff(_) => Some(is_staff(&state, &pubkey).await?), + AdminAccess::NetworkTrusted => None, + }; + Ok(Json(MemberLookup { + profile: profile.map(|p| MemberProfile { + display_name: p.display_name, + nip05: p.nip05_handle, + avatar_url: p.avatar_url, + about: p.about, + }), + role: role.map(|member| member.role), + banned: restriction.banned, + muted_until: restriction.muted_until, + is_staff, + pubkey: pubkey_hex, + })) +} + +/// The one answer for an event the preview will not show: a malformed id, an +/// id stored only in another community, or an author-only or result-gated +/// event are all indistinguishable from an absent one. +fn event_not_found() -> ApiError { + ApiError { + status: StatusCode::NOT_FOUND, + code: "event_not_found", + message: "event was not found in this community".to_owned(), + } +} + +/// `GET /events/{id}?communityHost=` — one event inside one community, for the +/// delete preview. Anything else is [`event_not_found`]. +pub(super) async fn event_preview( + State(state): AppStateRef, + method: Method, + uri: Uri, + headers: HeaderMap, + Path(id_hex): Path, + Query(query): Query, +) -> Result, ApiError> { + authorize_read(&state, &headers, &method, &uri).await?; + let id = hex::decode(&id_hex) + .ok() + .filter(|id| id.len() == 32) + .ok_or_else(event_not_found)?; + let community = community_for_host(&state, &query.community_host).await?; + state + .db + .admin_get_event_preview(*community.as_uuid(), &id) + .await? + .map(Json) + .ok_or_else(event_not_found) +} + +#[cfg(test)] +#[path = "reads_tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-relay/src/api/admin/reads_tests.rs b/crates/buzz-relay/src/api/admin/reads_tests.rs new file mode 100644 index 0000000000..ef64dda1be --- /dev/null +++ b/crates/buzz-relay/src/api/admin/reads_tests.rs @@ -0,0 +1,968 @@ +//! Tests for the community reads, the HEAD binding of every admin read, and +//! the view/act/operator classification of every admin route. The unsigned, HEAD +//! and validation checks reject or answer before any database access; the rest +//! are `#[ignore]`d and run in the PostgreSQL lane. + +use std::sync::Arc; + +use axum::{ + body::Body, + http::{header, Request, StatusCode}, +}; +use buzz_core::CommunityId; +use serde_json::Value; +use tower::ServiceExt; +use uuid::Uuid; + +use super::super::auth::ADMIN_API_PREFIX; +use super::super::postgres_tests::{ + disabled_mode_state, make_nostr_auth, make_nostr_auth_raw_tags, nip98_state, + nip98_state_with_real_pool, test_operator_keys, +}; +use super::super::router; +use crate::state::AppState; +use crate::test_support::database_url; + +const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn community_read_routes(host: &str) -> Vec { + vec![ + "/communities".to_string(), + format!("/members/search?communityHost={host}&q=a"), + format!("/members/{PK}?communityHost={host}"), + format!("/events/{PK}?communityHost={host}"), + ] +} + +async fn send(state: &Arc, request: Request) -> (StatusCode, Value) { + let response = router(state.clone()) + .oneshot(request) + .await + .expect("response"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("body"); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} + +fn signed_get(keys: &nostr::Keys, uri: &str) -> Request { + Request::builder() + .uri(uri) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, make_nostr_auth(keys, uri)) + .body(Body::empty()) + .expect("request") +} + +/// A request to `sent` whose credential was signed for `signed_method` and +/// `signed_uri`, so tests can tamper with the method or the query. +fn signed_as( + keys: &nostr::Keys, + method: &str, + signed_method: &str, + signed_uri: &str, + sent: &str, +) -> Request { + let url = format!("https://admin.example{ADMIN_API_PREFIX}{signed_uri}"); + let tags = vec![ + nostr::Tag::parse(["u", &url]).unwrap(), + nostr::Tag::parse(["method", signed_method]).unwrap(), + ]; + Request::builder() + .method(method) + .uri(sent) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, make_nostr_auth_raw_tags(keys, tags)) + .body(Body::empty()) + .unwrap() +} + +async fn get(state: &Arc, keys: &nostr::Keys, uri: &str) -> (StatusCode, Value) { + send(state, signed_get(keys, uri)).await +} + +#[tokio::test] +async fn community_reads_reject_unsigned_requests() { + let state = nip98_state(vec![test_operator_keys().public_key().to_hex()]).await; + for uri in community_read_routes("a.example") { + let request = Request::builder() + .uri(&uri) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .unwrap(); + assert_eq!( + send(&state, request).await.0, + StatusCode::UNAUTHORIZED, + "{uri}" + ); + } +} + +/// The report, feedback, staffing and restriction GET routes, the status a +/// HEAD-signed HEAD gets past auth, and whether producing that status needs +/// the database. +fn moderation_reads() -> Vec<(String, StatusCode, bool)> { + let id = Uuid::new_v4(); + vec![ + ("/probe".to_owned(), StatusCode::OK, false), + ( + "/reports?status=bogus".to_owned(), + StatusCode::BAD_REQUEST, + false, + ), + (format!("/reports/{id}"), StatusCode::NOT_FOUND, true), + ("/feedback".to_owned(), StatusCode::OK, true), + (format!("/feedback/{id}"), StatusCode::NOT_FOUND, true), + ( + format!("/feedback/{id}/attachments/not-a-hash"), + StatusCode::NOT_FOUND, + false, + ), + ("/operators".to_owned(), StatusCode::OK, true), + ( + "/members/restrictions?communityHost=a.example&limit=0".to_owned(), + StatusCode::BAD_REQUEST, + false, + ), + ] +} + +/// Axum serves HEAD through GET handlers; the credential must be checked +/// against the real method, so a GET-signed HEAD is refused on every +/// one of these reads, and a HEAD-signed HEAD passes auth wherever the answer needs no +/// database (the rest are covered by the Postgres lane below). +#[tokio::test] +async fn moderation_reads_authorize_head_with_the_real_method() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + for (uri, want, needs_db) in moderation_reads() { + let head = |signed: &str| signed_as(&keys, "HEAD", signed, &uri, &uri); + assert_eq!( + send(&state, head("GET")).await.0, + StatusCode::UNAUTHORIZED, + "GET-signed HEAD {uri}" + ); + if !needs_db { + assert_eq!(send(&state, head("HEAD")).await.0, want, "HEAD {uri}"); + } + } +} + +/// The credential covers the full target: a GET-signed HEAD, or a query +/// changed after signing, is refused on every community read. +#[tokio::test] +async fn community_reads_reject_method_and_query_tampering() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + let mut routes = community_read_routes("a.example"); + routes[0] = "/communities?q=a.example".to_owned(); + for uri in routes { + let head = signed_as(&keys, "HEAD", "GET", &uri, &uri); + assert_eq!( + send(&state, head).await.0, + StatusCode::UNAUTHORIZED, + "{uri}" + ); + let tampered = uri.replace("a.example", "b.example"); + let request = signed_as(&keys, "GET", "GET", &uri, &tampered); + assert_eq!( + send(&state, request).await.0, + StatusCode::UNAUTHORIZED, + "{tampered}" + ); + } +} + +/// Out-of-range limits are refused before any database access. +#[tokio::test] +async fn community_reads_reject_out_of_range_limits() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + for uri in [ + "/communities?limit=0", + "/communities?limit=101", + "/members/search?communityHost=a.example&q=a&limit=0", + "/members/search?communityHost=a.example&q=a&limit=51", + ] { + let (status, body) = get(&state, &keys, uri).await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("invalid_limit")), + "{uri}" + ); + } +} + +#[tokio::test] +async fn member_search_rejects_empty_and_overlong_queries() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + for q in ["%20%20".to_owned(), "a".repeat(101)] { + let uri = format!("/members/search?communityHost=a.example&q={q}"); + let (status, body) = get(&state, &keys, &uri).await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("invalid_query")), + "{uri}" + ); + } +} + +/// `q` longer than a host can be is refused before any database access. +#[tokio::test] +async fn community_directory_rejects_a_query_longer_than_a_host() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + let uri = format!("/communities?q={}", "a".repeat(256)); + let (status, body) = get(&state, &keys, &uri).await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("invalid_query")) + ); +} + +/// A malformed event id gets the same answer as an absent event. +#[tokio::test] +async fn event_preview_answers_a_malformed_id_as_not_found() { + let keys = test_operator_keys(); + let state = nip98_state(vec![keys.public_key().to_hex()]).await; + for id in ["zz".repeat(32), "ab".repeat(31), "ab".repeat(33)] { + let uri = format!("/events/{id}?communityHost=a.example"); + let (status, body) = get(&state, &keys, &uri).await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::NOT_FOUND, Some("event_not_found")), + "{uri}" + ); + } +} + +async fn community(pool: &sqlx::PgPool, host: &str) -> CommunityId { + buzz_db::Db::from_pool(pool.clone()) + .ensure_configured_community(host) + .await + .expect("create community") + .id +} + +async fn seed_profile(pool: &sqlx::PgPool, c: CommunityId, pubkey: &[u8], name: &str) { + sqlx::query( + "INSERT INTO users (community_id, pubkey, display_name, about) VALUES ($1, $2, $3, $3)", + ) + .bind(c.as_uuid()) + .bind(pubkey) + .bind(name) + .execute(pool) + .await + .expect("seed profile"); +} + +/// A raw kind-1 event row: the same id may be stored in two communities with +/// different content, which is exactly what isolation must not leak. +async fn seed_event(pool: &sqlx::PgPool, c: CommunityId, id: &[u8], content: &str, deleted: bool) { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at) \ + VALUES ($1, $2, $2, now(), 1, '[]', $3, $2, CASE WHEN $4 THEN now() END)", + ) + .bind(c.as_uuid()) + .bind(id) + .bind(content) + .bind(deleted) + .execute(pool) + .await + .expect("seed event"); +} + +async fn fixture() -> (sqlx::PgPool, Arc) { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect"); + let state = nip98_state_with_real_pool(pool.clone()).await; + (pool, state) +} + +fn unique_host(label: &str) -> String { + format!("{label}-{}.example", Uuid::new_v4().simple()) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn community_reads_serve_operators_and_moderators_and_refuse_non_staff() { + let (pool, state) = fixture().await; + let host = unique_host("reads-auth"); + community(&pool, &host).await; + let moderator = nostr::Keys::generate(); + buzz_db::Db::from_pool(pool.clone()) + .upsert_relay_operator( + &moderator.public_key().to_bytes(), + "moderator", + &[1u8; 32], + true, + ) + .await + .expect("seed moderator"); + let expected = [ + StatusCode::OK, + StatusCode::OK, + StatusCode::OK, + StatusCode::NOT_FOUND, + ]; + for (uri, want) in community_read_routes(&host).iter().zip(expected) { + for keys in [test_operator_keys(), moderator.clone()] { + assert_eq!(get(&state, &keys, uri).await.0, want, "{uri}"); + let head = signed_as(&keys, "HEAD", "HEAD", uri, uri); + assert_eq!(send(&state, head).await.0, want, "HEAD {uri}"); + } + let stranger = nostr::Keys::generate(); + assert_eq!( + get(&state, &stranger, uri).await.0, + StatusCode::FORBIDDEN, + "{uri}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn community_reads_never_return_another_communitys_data() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let (host_a, host_b) = (unique_host("iso-a"), unique_host("iso-b")); + let (a, b) = ( + community(&pool, &host_a).await, + community(&pool, &host_b).await, + ); + + let shared = [0x31u8; 32]; + let foreign = [0x32u8; 32]; + let tag = Uuid::new_v4().simple().to_string(); + seed_profile(&pool, a, &shared, &format!("{tag} in a")).await; + seed_profile(&pool, b, &shared, &format!("{tag} in b")).await; + seed_profile(&pool, b, &foreign, &format!("{tag} only b")).await; + sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'admin')") + .bind(b.as_uuid()) + .bind(hex::encode(shared)) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) VALUES ($1, $2, true, $2)") + .bind(b.as_uuid()) + .bind(shared.as_slice()) + .execute(&pool) + .await + .unwrap(); + let (shared_event, foreign_event) = ([0x41u8; 32], [0x42u8; 32]); + seed_event(&pool, a, &shared_event, "content in a", false).await; + seed_event(&pool, b, &shared_event, "content in b", false).await; + seed_event(&pool, b, &foreign_event, "only in b", false).await; + + let (status, found) = get( + &state, + &keys, + &format!("/members/search?communityHost={host_a}&q={tag}"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{found}"); + let names: Vec<&str> = found["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["displayName"].as_str().unwrap()) + .collect(); + assert_eq!(names, [format!("{tag} in a")]); + + let (_, member) = get( + &state, + &keys, + &format!("/members/{}?communityHost={host_a}", hex::encode(shared)), + ) + .await; + assert_eq!(member["profile"]["displayName"], format!("{tag} in a")); + assert_eq!( + (member["role"].clone(), member["banned"].clone()), + (Value::Null, false.into()) + ); + let (_, member) = get( + &state, + &keys, + &format!("/members/{}?communityHost={host_b}", hex::encode(shared)), + ) + .await; + assert_eq!( + (member["role"].clone(), member["banned"].clone()), + ("admin".into(), true.into()), + "{member}" + ); + + let (status, stranger) = get( + &state, + &keys, + &format!("/members/{}?communityHost={host_a}", hex::encode(foreign)), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + (stranger["profile"].clone(), stranger["role"].clone()), + (Value::Null, Value::Null) + ); + + let (_, event) = get( + &state, + &keys, + &format!( + "/events/{}?communityHost={host_a}", + hex::encode(shared_event) + ), + ) + .await; + assert_eq!(event["content"], "content in a"); + let (status, missing) = get( + &state, + &keys, + &format!( + "/events/{}?communityHost={host_a}", + hex::encode(foreign_event) + ), + ) + .await; + assert_eq!( + (status, missing["error"]["code"].as_str()), + (StatusCode::NOT_FOUND, Some("event_not_found")) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn community_directory_pages_live_hosts_by_literal_prefix() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let p = format!("dir{}", Uuid::new_v4().simple()); + let live = [ + format!("{p}-a.example"), + format!("{p}-b.example"), + format!("{p}-c.example"), + format!("{p}%lit.example"), + format!("{p}_u.example"), + ]; + for host in &live { + sqlx::query("INSERT INTO communities (host) VALUES ($1)") + .bind(host) + .execute(&pool) + .await + .unwrap(); + } + for (host, archived, state_, deleted) in [ + (format!("{p}-arch.example"), true, "active", false), + (format!("{p}-del.example"), false, "quiescing", false), + (format!("{p}-gone.example"), false, "tombstone", true), + ] { + sqlx::query( + "INSERT INTO communities (host, archived_at, deletion_state, deleted_at) \ + VALUES ($1, CASE WHEN $2 THEN now() END, $3, CASE WHEN $4 THEN now() END)", + ) + .bind(host) + .bind(archived) + .bind(state_) + .bind(deleted) + .execute(&pool) + .await + .unwrap(); + } + + let hosts = |page: &Value| -> Vec { + page["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["host"].as_str().unwrap().to_owned()) + .collect() + }; + // Paging with limit 2 visits every live host once, then stops. + // The page cap turns a cursor that never ends into a failure, not a hang. + let mut seen = Vec::new(); + let mut next = Some(format!("/communities?q={}&limit=2", p.to_uppercase())); + for _ in 0..=live.len() { + let Some(uri) = next.take() else { break }; + let (status, page) = get(&state, &keys, &uri).await; + assert_eq!(status, StatusCode::OK, "{page}"); + seen.extend(hosts(&page)); + next = page["nextCursor"] + .as_str() + .map(|cursor| format!("/communities?q={p}&limit=2&cursor={cursor}")); + } + assert!(next.is_none(), "paging did not end: {next:?}"); + let mut want = live.to_vec(); + want.sort(); + seen.sort(); + assert_eq!(seen, want); + + // `%` and `_` match themselves, not any character. + let (_, pct) = get(&state, &keys, &format!("/communities?q={p}%25")).await; + assert_eq!(hosts(&pct), [format!("{p}%lit.example")]); + let (_, under) = get(&state, &keys, &format!("/communities?q={p}_")).await; + assert_eq!(hosts(&under), [format!("{p}_u.example")]); + + let (status, longest) = get( + &state, + &keys, + &format!("/communities?q={}", "a".repeat(255)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{longest}"); + + // Not base64, base64 of nothing, and base64 of invalid UTF-8. + for cursor in ["not-a-cursor!", "", "_w"] { + let (status, bad) = get(&state, &keys, &format!("/communities?cursor={cursor}")).await; + assert_eq!( + (status, bad["error"]["code"].as_str()), + (StatusCode::BAD_REQUEST, Some("invalid_cursor")), + "cursor={cursor:?}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn member_lookup_reports_staff_and_active_restrictions() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let host = unique_host("lookup"); + let c = community(&pool, &host).await; + let target = [0x51u8; 32]; + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, muted_until, actor_pubkey) \ + VALUES ($1, $2, true, now() + interval '1 hour', $2)", + ) + .bind(c.as_uuid()) + .bind(target.as_slice()) + .execute(&pool) + .await + .unwrap(); + let (_, member) = get( + &state, + &keys, + &format!("/members/{}?communityHost={host}", hex::encode(target)), + ) + .await; + assert_eq!( + (member["banned"].clone(), member["isStaff"].clone()), + (true.into(), false.into()) + ); + assert!(member["mutedUntil"].is_string(), "{member}"); + + let staff = keys.public_key().to_hex(); + let (_, member) = get( + &state, + &keys, + &format!("/members/{staff}?communityHost={host}"), + ) + .await; + assert_eq!(member["isStaff"], true); + assert_eq!(member["pubkey"], staff); +} + +/// A Moderator gets the staff answer too (the desktop preflights direct +/// actions with it); disabled mode has no signed caller and gets `null`. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn member_lookup_reports_staff_only_to_a_signed_caller() { + let (pool, state) = fixture().await; + let host = unique_host("lookup-staff"); + community(&pool, &host).await; + let moderator = nostr::Keys::generate(); + buzz_db::Db::from_pool(pool.clone()) + .upsert_relay_operator( + &moderator.public_key().to_bytes(), + "moderator", + &[1u8; 32], + true, + ) + .await + .expect("seed moderator"); + let operator = test_operator_keys().public_key().to_hex(); + let lookup = |pubkey: &str| format!("/members/{pubkey}?communityHost={host}"); + + for (pubkey, want) in [(operator.as_str(), true), (PK, false)] { + let (status, member) = get(&state, &moderator, &lookup(pubkey)).await; + assert_eq!(status, StatusCode::OK, "{member}"); + assert_eq!(member["isStaff"], want, "{pubkey}"); + } + + let open = disabled_mode_state().await; + let request = Request::builder() + .uri(lookup(&operator)) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .unwrap(); + let (status, member) = send(&open, request).await; + assert_eq!(status, StatusCode::OK, "{member}"); + assert!(member["isStaff"].is_null(), "{member}"); +} + +/// Only the staff-roster read fails: each connection shadows +/// `relay_operators` with an incompatible temp table, while the community, +/// profile, role and restriction reads still succeed. The route must answer +/// 500, never `isStaff: false`. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn member_lookup_fails_closed_when_the_staff_lookup_fails() { + let (pool, _) = fixture().await; + let host = unique_host("staff-fail"); + community(&pool, &host).await; + let broken = sqlx::postgres::PgPoolOptions::new() + .after_connect(|conn, _| { + Box::pin(async move { + sqlx::query("CREATE TEMP TABLE relay_operators (unrelated int)") + .execute(conn) + .await + .map(|_| ()) + }) + }) + .connect(&database_url()) + .await + .expect("connect"); + let state = nip98_state_with_real_pool(broken).await; + let (status, body) = get( + &state, + &test_operator_keys(), + &format!("/members/{PK}?communityHost={host}"), + ) + .await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::INTERNAL_SERVER_ERROR, Some("internal_error")), + "{body}" + ); +} + +/// The boundary limits return exactly that many matches. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn member_search_returns_up_to_the_limit() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let host = unique_host("search-limit"); + let c = community(&pool, &host).await; + let tag = Uuid::new_v4().simple().to_string(); + for i in 0..51u8 { + seed_profile(&pool, c, &[i; 32], &format!("{tag} {i}")).await; + } + for limit in [1usize, 50] { + let uri = format!("/members/search?communityHost={host}&q={tag}&limit={limit}"); + let (status, page) = get(&state, &keys, &uri).await; + assert_eq!(status, StatusCode::OK, "{page}"); + assert_eq!( + page["items"].as_array().unwrap().len(), + limit, + "limit={limit}" + ); + } +} + +/// The database-backed half of +/// `moderation_reads_authorize_head_with_the_real_method`. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn moderation_reads_serve_a_head_signed_head() { + let (_, state) = fixture().await; + let keys = test_operator_keys(); + for (uri, want, _) in moderation_reads().into_iter().filter(|r| r.2) { + let head = signed_as(&keys, "HEAD", "HEAD", &uri, &uri); + assert_eq!(send(&state, head).await.0, want, "HEAD {uri}"); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn event_preview_reports_the_stored_event_and_its_deletion() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let host = unique_host("preview"); + let c = community(&pool, &host).await; + let id = [0x61u8; 32]; + seed_event(&pool, c, &id, "deleted body", true).await; + let (status, event) = get( + &state, + &keys, + &format!("/events/{}?communityHost={host}", hex::encode(id)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{event}"); + assert_eq!( + ( + event["id"].as_str(), + event["authorPubkey"].as_str(), + event["kind"].as_i64() + ), + ( + Some(hex::encode(id).as_str()), + Some(hex::encode(id).as_str()), + Some(1) + ) + ); + assert!( + event["deletedAt"].is_string() && event["channelId"].is_null(), + "{event}" + ); +} + +/// Every author-only, result-gated, `#p`-gated and shared-gated event is +/// answered as absent, even to staff and even when shared; a message is not. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn event_preview_hides_every_private_kind() { + use buzz_core::kind::{ + AUTHOR_ONLY_KINDS, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + }; + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let host = unique_host("preview-hidden"); + let c = community(&pool, &host).await; + for (i, &kind) in AUTHOR_ONLY_KINDS + .iter() + .chain(RESULT_GATED_KINDS) + .chain(P_GATED_KINDS) + .chain(SHARED_GATED_KINDS) + .enumerate() + { + let id = [0x80 + i as u8; 32]; + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \ + VALUES ($1, $2, $2, now(), $3, '[[\"shared\",\"true\"]]', 'hidden', $2)", + ) + .bind(c.as_uuid()) + .bind(id) + .bind(kind as i32) + .execute(&pool) + .await + .expect("seed hidden event"); + let uri = format!("/events/{}?communityHost={host}", hex::encode(id)); + let (status, body) = get(&state, &keys, &uri).await; + assert_eq!( + (status, body["error"]["code"].as_str()), + (StatusCode::NOT_FOUND, Some("event_not_found")), + "kind {kind}: {body}" + ); + } + + let id = [0x7f_u8; 32]; + seed_event(&pool, c, &id, "visible", false).await; + let uri = format!("/events/{}?communityHost={host}", hex::encode(id)); + let (status, body) = get(&state, &keys, &uri).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["content"], "visible", "{body}"); +} + +/// Every query struct refuses an unknown field, on reads and on a write; the +/// refused write leaves the ban in place. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn admin_queries_reject_unknown_fields() { + let (pool, state) = fixture().await; + let keys = test_operator_keys(); + let host = unique_host("unknown-field"); + let c = community(&pool, &host).await; + let target = [0x91u8; 32]; + let db = buzz_db::Db::from_pool(pool.clone()); + db.ban_community_member(c, &target, &keys.public_key().to_bytes(), None, None) + .await + .expect("seed ban"); + + for uri in [ + "/communities?bogus=1".to_owned(), + format!("/members/search?communityHost={host}&q=a&bogus=1"), + format!("/members/{PK}?communityHost={host}&bogus=1"), + ] { + assert_eq!( + get(&state, &keys, &uri).await.0, + StatusCode::BAD_REQUEST, + "{uri}" + ); + } + + let uri = format!( + "/members/{}/ban?communityHost={host}&bogus=1", + hex::encode(target) + ); + let unban = signed_as(&keys, "DELETE", "DELETE", &uri, &uri); + assert_eq!( + send(&state, unban).await.0, + StatusCode::BAD_REQUEST, + "{uri}" + ); + let restriction = db + .moderation_restriction_state(c, &target) + .await + .expect("read ban"); + assert!( + restriction.banned, + "the refused unban must not lift the ban" + ); +} + +/// Disabled mode serves the community reads to an unsigned caller, and a +/// query for one community still returns only that community's rows. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn community_reads_serve_disabled_mode_within_the_selected_community() { + let (pool, _) = fixture().await; + let state = disabled_mode_state().await; + let (host_a, host_b) = (unique_host("open-a"), unique_host("open-b")); + let (a, b) = ( + community(&pool, &host_a).await, + community(&pool, &host_b).await, + ); + let member = [0x71u8; 32]; + let event = [0x72u8; 32]; + let tag = Uuid::new_v4().simple().to_string(); + seed_profile(&pool, a, &member, &format!("{tag} in a")).await; + seed_profile(&pool, b, &member, &format!("{tag} in b")).await; + seed_event(&pool, a, &event, "content in a", false).await; + seed_event(&pool, b, &event, "content in b", false).await; + + let state = &state; + let unsigned = |uri: String| async move { + let request = Request::builder() + .uri(&uri) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(state, request).await; + assert_eq!(status, StatusCode::OK, "{uri}: {body}"); + body + }; + + let directory = unsigned(format!("/communities?q={host_b}")).await; + assert_eq!(directory["items"][0]["host"], host_b.as_str()); + assert_eq!(directory["items"].as_array().unwrap().len(), 1); + + let found = unsigned(format!("/members/search?communityHost={host_b}&q={tag}")).await; + let names: Vec<&str> = found["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["displayName"].as_str().unwrap()) + .collect(); + assert_eq!(names, [format!("{tag} in b")]); + + let profile = unsigned(format!( + "/members/{}?communityHost={host_b}", + hex::encode(member) + )) + .await; + assert_eq!(profile["profile"]["displayName"], format!("{tag} in b")); + + let preview = unsigned(format!( + "/events/{}?communityHost={host_b}", + hex::encode(event) + )) + .await; + assert_eq!(preview["content"], "content in b"); +} + +/// Which check a route makes. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Check { + /// Any access: signed staff in nip98, any caller in disabled mode. + View, + /// A signed staff member; refused in disabled mode. + Act, + /// A signed Operator; refused in disabled mode. + Operator, +} + +/// Every route the admin API mounts, with its check. A new route must be +/// added here with a deliberate policy: `every_route_is_listed` fails while +/// the number of `.route(` registrations in `router()` differs from this list. +fn every_route() -> Vec<(&'static str, String, Check)> { + use Check::{Act, Operator, View}; + let id = Uuid::nil(); + let host = "communityHost=a.example"; + vec![ + ("GET", "/probe".into(), View), + ("GET", "/reports".into(), View), + ("GET", format!("/reports/{id}"), View), + ("POST", format!("/reports/{id}/resolve"), Act), + ("POST", format!("/reports/{id}/reopen"), Act), + ("POST", format!("/reports/{id}/cancel"), Act), + ("GET", "/feedback".into(), View), + ("GET", format!("/feedback/{id}"), View), + ("PATCH", format!("/feedback/{id}"), Act), + ("GET", format!("/feedback/{id}/attachments/{PK}"), View), + ("GET", "/operators".into(), Operator), + ("PUT", format!("/operators/{PK}"), Operator), + ("DELETE", format!("/operators/{PK}"), Operator), + ("GET", "/communities".into(), View), + ("GET", format!("/members/search?{host}&q=a"), View), + ("GET", format!("/members/{PK}?{host}"), View), + ("GET", format!("/events/{PK}?{host}"), View), + ("GET", format!("/members/restrictions?{host}"), View), + ("DELETE", format!("/members/{PK}/ban?{host}"), Act), + ("DELETE", format!("/members/{PK}/timeout?{host}"), Act), + ("POST", format!("/members/{PK}/ban?{host}"), Act), + ("POST", format!("/members/{PK}/timeout?{host}"), Act), + ("POST", format!("/events/{PK}/delete?{host}"), Act), + ] +} + +/// Counts registrations, not paths: it catches an added or removed `.route(` +/// call, not a changed path within one. +#[test] +fn every_route_is_listed() { + let source = include_str!("mod.rs"); + let start = source.find("pub fn router(").expect("router() in mod.rs"); + let body = &source[start..]; + let body = &body[..body.find("\n}\n").expect("end of router()")]; + assert_eq!(body.matches(".route(").count(), every_route().len()); +} + +fn unsigned_request(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::HOST, "admin.example") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap() +} + +/// nip98 demands a credential on every route; disabled mode serves every view +/// route and refuses every act and operator route. +/// +/// The route list is written by hand: a new route must be added here on +/// purpose. View rows only prove no credential is asked for (they accept 404 +/// or 500); `community_reads_serve_disabled_mode_within_the_selected_community` +/// is what proves the reads return data. Unsigned and disabled-mode requests +/// cannot tell `.operator()` from `.act()`; `moderator_cannot_access_staffing_endpoints` +/// covers that boundary. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn every_route_makes_its_check_in_both_auth_modes() { + let signed = nip98_state(vec![test_operator_keys().public_key().to_hex()]).await; + let open = disabled_mode_state().await; + for (method, uri, check) in every_route() { + let (status, _) = send(&signed, unsigned_request(method, &uri)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "nip98 {method} {uri}"); + + let (status, body) = send(&open, unsigned_request(method, &uri)).await; + if check == Check::View { + assert!( + status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN, + "disabled {method} {uri} ({check:?}) answered {status}: {body}" + ); + } else { + assert_eq!( + (status, body["error"]["message"].as_str()), + ( + StatusCode::FORBIDDEN, + Some("this endpoint requires BUZZ_ADMIN_AUTH=nip98") + ), + "disabled {method} {uri} ({check:?})" + ); + } + } +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 65f3199280..589dc25719 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -39,16 +39,17 @@ pub enum ConfigError { /// - `Moderator/Db` from the `relay_operators` table otherwise /// - `None` → 403 (no fall-through role, ever) /// -/// Disabled mode is always read-only. NIP-98 mode is read-write per resolved -/// principal. +/// Disabled mode serves every moderation read and refuses writes and staffing. +/// NIP-98 mode is read-write per resolved principal. #[derive(Debug, Clone)] pub enum AdminAuth { /// Authentication disabled. The operator has explicitly asserted /// that the admin API is protected at the network layer (reverse proxy, /// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth. /// Selected by `BUZZ_ADMIN_AUTH=disabled`. - /// Always read-only: `authorize()` resolves no principal for this mode, so - /// mutation and staffing routes always 403. + /// Every moderation read is served to anyone who can reach the relay; + /// `authorize()` returns `NetworkTrusted` with no identity, so write and + /// staffing routes always 403. Disabled, /// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr` /// header containing a signed kind-27235 event. The authenticated pubkey @@ -60,8 +61,7 @@ pub enum AdminAuth { } /// Deny-by-default deployment-admin configuration. Mutation and staffing routes -/// require a resolved principal (NIP-98 only); disabled mode is always -/// read-only. +/// require a resolved principal (NIP-98 only); disabled mode serves reads only. #[derive(Debug, Clone)] pub struct AdminConfig { /// Exact admin HTTP authority. @@ -1287,9 +1287,13 @@ impl Config { None | Some("") | Some("nip98") => AdminAuth::Nip98, Some("disabled") => { tracing::warn!( - "BUZZ_ADMIN_AUTH=disabled — the admin API is \ - unauthenticated; the operator has asserted that access is \ - controlled at the network layer (reverse proxy, VPN, firewall)" + "BUZZ_ADMIN_AUTH=disabled — the admin API serves every \ + moderation read without authentication to anyone who can \ + reach the relay: reports, feedback and attachments, \ + restrictions, the community directory, member profiles and \ + any stored message by ID, in every community. Writes and \ + staffing are refused. Keep the whole relay port private to \ + people allowed to see all of that" ); AdminAuth::Disabled } diff --git a/crates/buzz-relay/src/handlers/admin_action_worker.rs b/crates/buzz-relay/src/handlers/admin_action_worker.rs index ec1835ff9f..c792288048 100644 --- a/crates/buzz-relay/src/handlers/admin_action_worker.rs +++ b/crates/buzz-relay/src/handlers/admin_action_worker.rs @@ -148,7 +148,7 @@ pub(crate) async fn recover_one(state: &Arc, claim: StrandedActionClai // Non-kick actions: always re-derive from the report. // Pre-migration kick rows: re-derive so the convergence gate can use the // result as a legacy-context fallback (see report_resolution.rs). - let report = match state.db.admin_get_report(report_id).await { + let report = match state.db.admin_get_report_for_enforcement(report_id).await { Ok(Some(r)) => r, Ok(None) => { warn!(action_id = %action_id, "Action recovery: report not found"); diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 100ec893c3..624b5c818e 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -46,8 +46,13 @@ BUZZ_S3_ADDRESSING_STYLE=path # RELAY_OWNER_PUBKEY (implicit Operator fallback when operator list is empty), # and the relay_operators table (DB-managed Operator/Moderator roster). # Dashboard requires a NIP-07 browser extension (nos2x or Alby). -# BUZZ_ADMIN_AUTH=disabled — no auth. Use only behind a VPN or private ingress. -# Relay logs a WARN on boot. +# BUZZ_ADMIN_AUTH=disabled — read-only, no auth. Anyone who can reach the relay +# can read every community's reports, feedback and attachments, bans and +# timeouts, community directory, member profiles and any stored message by +# ID; writes and staffing are refused. Only people allowed to see all of that +# may reach it. The admin API is on the relay's main port and its hostname +# check uses the caller's Host header, so the published relay port itself must +# be private; a private admin hostname is not enough. Relay logs a WARN on boot. # Token authentication was removed: BUZZ_ADMIN_TOKEN is ignored with a startup # warning — remove it from the environment. # Any unrecognised BUZZ_ADMIN_AUTH value aborts startup. diff --git a/docs/admin/README.md b/docs/admin/README.md index fbf592ced6..c15ba13b7c 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -121,6 +121,7 @@ can be a break-glass root, silently discarding it would be a lockout. | Capability | Operator | Moderator | |---|---|---| | Read reports, feedback, attachment bytes | ✓ | ✓ | +| Read community directory, member profiles, and events | ✓ | ✓ | | Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ | | Update feedback status | ✓ | ✓ | | Manage operator roster (`GET/PUT/DELETE /operators`) | ✓ | ✗ | @@ -153,9 +154,8 @@ emptied freely because config still guarantees an operator. ### Disabled mode (`BUZZ_ADMIN_AUTH=disabled`) -Operators whose admin API is already protected at the network layer — for -example by a corporate VPN such as WARP+Okta — can disable request -authentication entirely: +Disabled mode turns the admin API into a read-only window with no request +authentication: ```text BUZZ_ADMIN_AUTH=disabled @@ -163,23 +163,50 @@ BUZZ_ADMIN_AUTH=disabled Only the exact value `disabled` is accepted. -In this mode the relay logs a `WARN` on every startup: +| | `nip98` (default) | `disabled` | +|---|---|---| +| Moderation reads | signed, any staff role | served to anyone who can reach the relay | +| Writes (resolve, ban, timeout, delete, lift, feedback status) | signed, role-checked | refused (`403`) | +| Staffing (`/operators`) | signed Operator | refused (`403`) | + +**What disabled mode exposes**, in every community the relay hosts, to anyone +who can send it an HTTP request: + +- reports, including reporter keys, private reporter notes, resolutions and the + full content of reported messages, deleted ones included +- product feedback and its attachment bytes +- bans and timeouts, with their private reasons and the acting moderator +- the directory of every community +- member search and member profiles, including role, ban and timeout state + (staff status is not revealed in disabled mode) +- any stored message by event ID, regardless of channel membership and + including deleted messages (encrypted content stays ciphertext) + +**Who should be able to reach it:** only people allowed to see all moderation +data in every community. A company-wide VPN is usually a much larger group. + +**The whole relay port must be private, not just the admin hostname.** The admin +API is served on the relay's main listener, and the admin hostname check +compares against the caller's own `Host` header, which any client can set. A +relay whose port is reachable from the internet exposes everything above even if +the admin hostname resolves only inside a VPN. If the relay must be public, use +`nip98`. + +Disabled mode records no reader identity. To act on anything, switch to `nip98`. + +The relay logs a `WARN` on every startup in this mode: ``` -BUZZ_ADMIN_AUTH=disabled — the admin API is unauthenticated; the operator has -asserted that access is controlled at the network layer +BUZZ_ADMIN_AUTH=disabled — the admin API serves every moderation read without +authentication to anyone who can reach the relay: reports, feedback and +attachments, restrictions, the community directory, member profiles and any +stored message by ID, in every community. Writes and staffing are refused. Keep +the whole relay port private to people allowed to see all of that ``` -The `Host`/`Origin` checks remain active as defense-in-depth. The dashboard -detects that no credential is needed on first load (probe returns `200`) and -renders directly. - -**This mode relies entirely on the operator's network controls.** If the admin -API is reachable by untrusted clients, the entire moderation and feedback dataset -is exposed. Use nip98 mode instead. - -When using a reverse proxy in this mode, document the requirement and consider a -proxy-injected shared secret or signed identity header for additional assurance. +The `Host`/`Origin` checks remain active, but they are not authentication. The +dashboard detects that no credential is needed on first load (probe returns +`200`) and renders read-only. ### Mode selection and error behaviour @@ -281,8 +308,12 @@ rolling. For local review, run `just admin-seed` before `just admin`. `just admin` defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a -credential. The seed command also uploads real image and diagnostic fixtures to -local MinIO. Feedback search and filters run over the bounded browser result +credential. Unless the mode is exactly `nip98`, it binds the relay to +`127.0.0.1` on the port from `BUZZ_BIND_ADDR` (default 3000), so +unauthenticated moderation reads stay local even when the value is padded or +misspelled; `BUZZ_ADMIN_AUTH=nip98` keeps `BUZZ_BIND_ADDR` as configured. The +seed command also uploads real image and diagnostic fixtures to local MinIO. +Feedback search and filters run over the bounded browser result set. The feedback **status** control (`new`/`reviewed`/`archived`) is server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned status, so every operator sees the same state; in `disabled` mode it renders as @@ -299,6 +330,50 @@ a read-only badge because the server rejects mutations. - `GET /api/admin/v1/feedback/:id` - `GET /api/admin/v1/feedback/:id/attachments/:sha256` +### Community read routes (Operator and Moderator in `nip98`; unauthenticated in `disabled` mode) + +The three member and event routes name the community by `communityHost` (the +relay's own host authority, host plus port when non-default), resolving it +through the same tenant binder that scopes live connections. A host that the +relay serves no live community for returns `400 unknown_community_host`. All +four reject unknown query parameters with `400` (axum's plain-text rejection, +not the JSON error envelope, before authentication). All four accept a signed +`HEAD` request (sign with method `HEAD`). + +- `GET /api/admin/v1/communities?q=&cursor=&limit=` + Lists live communities (not archived, not deleted). Response: + `{"items": [{"id", "host", "icon"|null}], "nextCursor": ""|null}`. + `q` is a case-insensitive literal host prefix (trimmed; `%` and `_` are + literal characters, not wildcards), max 255 chars else `400 + invalid_query`. Results are ordered by lowercased host. `limit` 1–100, + default 50, else `400 invalid_limit`. `cursor` is opaque: pass the + previous page's `nextCursor`; a malformed cursor returns `400 + invalid_cursor`. `nextCursor` is non-null when the page is full; a final + full page may be followed by an empty page. +- `GET /api/admin/v1/members/search?communityHost=&q=&limit=` + Searches the community's stored profiles; former members appear. Response: + `{"items": [{"pubkey", "displayName"|null, "nip05"|null, + "avatarUrl"|null}]}`. `q` is trimmed, 1–100 characters else `400 + invalid_query`. `limit` 1–50, default 20. +- `GET /api/admin/v1/members/{pubkey}?communityHost=` + Returns a single member record. Response: `{"pubkey", "profile": + {"displayName", "nip05", "avatarUrl", "about"}|null, "role": + "owner"|"admin"|"member"|null, "banned": bool, "mutedUntil": + |null, "isStaff": bool|null}`. `role` null means the pubkey + is not on the community roster. `isStaff` is whether the pubkey is + deployment staff (Operator or Moderator); it is `null` in `disabled` mode + (the roster is never revealed without a signed staff caller). A roster + lookup failure returns `500`. For an agent pubkey, `banned` and + `mutedUntil` reflect the effective restriction, which includes its + owner's. +- `GET /api/admin/v1/events/{id}?communityHost=` + Reads a single event scoped to that community, including soft-deleted + ones. Response: `{"id", "authorPubkey", "kind", "content", "createdAt", + "deletedAt"|null, "channelId"|null}`. Never returns author-only kinds + (event reminders, push leases, private managed agents) or result-gated + kinds (DM visibility, agent turn metrics). Absent, other-community, + excluded-kind, and malformed IDs all return `404 event_not_found`. + ### Action routes (Operator and Moderator) - `POST /api/admin/v1/reports/:id/resolve` @@ -340,6 +415,8 @@ authority (host, plus port when non-default), which the relay resolves to its tenant through the same binder that scopes live connections. The caller's local community ids are never accepted. A host the relay serves no community for returns `400` with error `unknown_community_host`, never an empty result. +Unknown query parameters are rejected with `400` (axum's plain-text +rejection, before authentication). - `GET /api/admin/v1/members/restrictions?communityHost=[&limit=<1-200>][&cursor=]` Lists active bans and timeouts, newest first. Response: