diff --git a/.env.example b/.env.example index 7ededb48cc..b6bced88b5 100644 --- a/.env.example +++ b/.env.example @@ -233,7 +233,69 @@ BUZZ_S3_ADDRESSING_STYLE=path RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug # Optional OpenTelemetry-only target filter. This is deliberately independent # from RUST_LOG so log verbosity changes cannot break trace parentage. -# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_datastore=info +# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_relay::access=off,buzz_datastore=info +# +# Access log: the relay writes JSON lines for security-relevant actions under +# the `buzz_relay::access` target, with an `event` field of `http`, `auth`, +# `req`, `count`, or `close`. `http` is one line per HTTP request, written +# by one layer when the response is ready (not proof the body was delivered): +# `route` is the matched route template (never the raw path), with the response +# `status` and `time_to_response_ms` on every line. It carries the login +# (`method`, `pubkey`, `pubkey_verified`) and `outcome` (`success`, `denied`, +# `failed`, `challenged`, or `undecided` when the route recorded no decision; a +# refusal is final). `/query` and `/count` add the filter summary and +# `result_count`; `/events` adds `accepted`, `kind`, `event_id` and a fixed +# `reject_reason` code; invite and admin attachment routes add their own fields +# (`invite_id`, `expires_at`, `max_uses`, `joined`, `feedback_id`, +# `attachment_sha256`). Invite codes, tokens and Blossom read hashes are never +# logged. An attachment read with admin auth disabled logs +# `method=network_trusted` and no key. A request dropped before its response +# logs `request=aborted` with no `status`. +# Every matched route is logged except `/` (non-upgrade), `/info`, +# `/.well-known/nostr.json`, `/health`, `/_liveness` and `/_readiness`. +# Requests matching no route log only at status 400 and above, as +# `route=""`, except that the SPA and static files the relay +# serves when enabled (web assets, invite landing pages, git web pages, the +# admin bundle) log nothing at any status, including 404. Successful WebSocket +# upgrades log no `http` line. +# `auth` is one line per WebSocket login, written when it succeeds or is +# refused; every root WebSocket `auth` line carries the TCP `peer_addr`. +# `req` / `count` is one line per WebSocket read, with the same filter summary +# (search text is never logged, only `search: true`). `close` is the +# connection's one line: duration, totals, the login's `pubkey`, `method` and +# `pubkey_verified`, and `reads_suppressed` / `auth_suppressed` for repeated +# refusals past the first 16. It is written however the connection ends, with +# `refused` (`not_admitted`, `connection_limit`) when the relay refused it +# before serving it. Refusals that arrive after `close` has been written log +# their own lines. +# Successful lines and git's credential-less first request +# (`outcome=challenged`) are INFO; refusals, error statuses and aborted +# requests are WARN. A WebSocket read is WARN only when it ends in `error`; +# refused and `aborted` reads stay INFO, since `aborted` means the client +# closed the connection mid-read, which is routine. Turn it down with +# `buzz_relay::access=warn` (keeps refusals, errors and aborted HTTP requests, +# and of WebSocket reads only `error` outcomes) or `buzz_relay::access=off` in +# RUST_LOG. +# It is off in the OTel export by default so lines are not shipped twice. +# Removed or renamed: `NIP-42 auth successful/failed`, `WebSocket connection +# established/closed` and the `event=connect` line (folded into `close`), `HTTP +# bridge request` (folded into the bridge's `http` line), `Event ingested via +# pipeline` (the WebSocket `Event ingested` line and the `/events` line carry +# its fields), `relay invite minted`, `relay member added via invite` and `admin +# feedback attachment read` (folded into the route's `http` line), the `addr` +# field, now `peer_addr`, and the JSON `span` field (`spans` already ends with +# the current span; a line logged under an explicit parent span loses that +# parent's fields). Git subprocess stderr moved from WARN to DEBUG because hook +# output can contain message text. + +# Request header holding the real client IP, logged as `client_ip` on `http`, +# `auth`, `close` and connection-limit lines next to `peer_addr` (optional, +# case-insensitive; unset or empty turns it off). A list value logs its +# rightmost entry; a value that isn't an IP address is not logged. Set it only +# when every request reaches the relay through a proxy that overwrites this +# header (for example `cf-connecting-ip` behind Cloudflare or `x-real-ip` +# behind nginx); otherwise clients can forge it. +# BUZZ_CLIENT_IP_HEADER=x-real-ip # OTLP tracing endpoint (optional — leave unset to disable) # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 5a4c347064..bf57fb424b 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -546,6 +546,18 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/api::git::settings::tests::external_infra::/)' \ --run-ignored ignored-only + - name: Admin attachment access-log regression + env: + # This test builds its state from Config::for_test(), which reads the + # relay's own DATABASE_URL and BUZZ_S3_* variables. + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + BUZZ_S3_ENDPOINT: http://localhost:9000 + BUZZ_S3_BUCKET: buzz-media + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/api::admin::postgres_tests::external_infra_minio_tests::/)' \ + --run-ignored ignored-only - name: Relay quota accounting and rejection regressions env: BUZZ_TEST_REDIS_URL: redis://localhost:6379 diff --git a/Cargo.lock b/Cargo.lock index 92c8f60307..f793532e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tracing-subscriber", "uuid", ] diff --git a/Justfile b/Justfile index c260b05b66..fa53e63273 100644 --- a/Justfile +++ b/Justfile @@ -554,7 +554,7 @@ test-unit: # subscription::tests covers the in-memory registry and its fan-out # indexes; it is infra-free. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^subscription::tests::/) + test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::bridge::search_slots::tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^subscription::tests::/) + test(/^api::admin::/) + test(/^access::tests::/) + test(/^api::git::transport::access_log_tests::/) + test(=api::bridge::postgres_tests::nip98_replay_check_logs_only_its_refusal) + test(=api::bridge::postgres_tests::warn_only_artifact_read_lines_keep_the_filter_summary) + test(=connection::tests::close_totals_count_frames_the_writer_flushed) + test(=connection::tests::startup_cancellation_writes_one_close_line) + test(=connection::tests::connection_limit_refusal_writes_one_close_line) + test(=connection::tests::dropped_authenticated_handler_writes_one_close_line) + test(=connection::tests::close_totals_count_a_batch_flushed_by_the_drain_after_cancellation) + test(=connection::tests::close_totals_skip_a_frame_the_socket_rejects) + test(=connection::tests::close_totals_skip_frames_stranded_by_a_failed_flush) + test(=connection::tests::read_before_login_logs_one_denial_from_the_handler) + test(=connection::tests::read_refused_by_a_full_handler_semaphore_logs_one_denial) + test(=connection::tests::read_refused_by_unavailable_admission_logs_one_denial) + test(=handlers::auth::tests::repeat_auth_after_a_terminal_logs_one_denial) + test(=nip_fi_upgrade::tests::deny_protected_logs_one_denied_auth_line) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::bridge::search_slots::tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 39e03f5392..7e795dbcc7 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -140,6 +140,44 @@ impl DbError { /// Convenience alias for `Result`. pub type Result = std::result::Result; +/// A fixed description of a driver error for log lines. Postgres primary +/// messages can repeat a bound value (`invalid input syntax for type uuid: +/// ""`), so only the SQLSTATE code is kept. +pub fn sqlx_error_kind(e: &sqlx::Error) -> String { + let kind = match e { + sqlx::Error::Database(db) => { + return format!("database:{}", db.code().as_deref().unwrap_or("unknown")) + } + sqlx::Error::RowNotFound => "row_not_found", + sqlx::Error::PoolTimedOut => "pool_timed_out", + sqlx::Error::PoolClosed => "pool_closed", + sqlx::Error::Io(_) => "io", + sqlx::Error::Tls(_) => "tls", + sqlx::Error::Protocol(_) => "protocol", + sqlx::Error::ColumnDecode { .. } | sqlx::Error::Decode(_) => "decode", + sqlx::Error::Encode(_) => "encode", + _ => "other", + }; + kind.to_owned() +} + +impl DbError { + /// A fixed description of this error for log lines: never the + /// message, which can repeat bound values. + pub fn kind(&self) -> String { + use DbError as E; + let kind = match self { + E::Sqlx(e) => return sqlx_error_kind(e), + E::Serde(_) => "serde", + E::InvalidData(_) => "invalid_data", + E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found", + E::AccessDenied(_) => "access_denied", + _ => "other", + }; + kind.to_owned() + } +} + #[cfg(test)] mod tests { use super::DbError; diff --git a/crates/buzz-relay/src/access.rs b/crates/buzz-relay/src/access.rs new file mode 100644 index 0000000000..47040600b8 --- /dev/null +++ b/crates/buzz-relay/src/access.rs @@ -0,0 +1,2008 @@ +//! Stdout access log for security investigations: who read what, and who +//! tried to authenticate. +//! +//! Every line goes to the [`TARGET`] tracing target (stdout, never the +//! `audit_log` table) and carries an `event` field to filter on. Search text, +//! event content, tokens, and assertions are never recorded: filters are +//! reduced to a [`FilterSummary`], and failures to fixed reason codes. +//! +//! `event=auth` lines cover WebSocket AUTH (root and audio): one line per +//! attempt; root lines carry the TCP `peer_addr`. `event=http` lines come from +//! [`http_access_log`]: one per HTTP request, with what the handler proved and +//! decided recorded through its [`AccessSlot`]. Every matched route is logged +//! except public documents and probes; a request that matched no route is +//! logged only at status 400 or above, as `route=""`. `route` is the +//! matched template, never the raw path. +//! +//! With `BUZZ_CLIENT_IP_HEADER` set, `http`, `auth` and `close` lines also +//! carry `client_ip`: the rightmost entry of that request header, only when it +//! parses as an IP address. It never replaces `peer_addr`. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use buzz_core::CommunityId; +use nostr::{Alphabet, Filter, PublicKey, SingleLetterTag}; +use tracing::Level; +use uuid::Uuid; + +/// The tracing target for every access line. +pub(crate) const TARGET: &str = "buzz_relay::access"; + +/// Most identifiers listed per field; the matching `*_count` field is exact. +const LIST_CAP: usize = 10; + +macro_rules! emit { + ($level:expr, $($fields:tt)+) => { + if $level == Level::WARN { + tracing::warn!(target: TARGET, $($fields)+) + } else { + tracing::info!(target: TARGET, $($fields)+) + } + }; +} + +/// What a REQ or COUNT asked for, merged across its filters. List fields hold +/// the first [`LIST_CAP`] distinct values; their `*_count` fields sum the +/// per-filter set sizes, so a value repeated in two filters counts twice. A +/// list field is `*` when any filter leaves that dimension open, because that +/// filter reads every value; `since`/`until`/`limit` likewise take the widest +/// bound and are absent when any filter leaves them open. No field understates +/// what the read covered. Only well-formed channel ids and pubkeys are listed, +/// so client-chosen tag text never reaches the log. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct FilterSummary { + filters: usize, + kinds: Option, + kind_count: usize, + channel_ids: Option, + channel_count: usize, + authors: Option, + author_count: usize, + p_tags: Option, + p_count: usize, + ids: usize, + since: Option, + until: Option, + limit: Option, + search: bool, +} + +/// The value logged for a list dimension that some filter leaves open. +const OPEN: &str = "*"; + +impl FilterSummary { + pub(crate) fn new(filters: &[Filter]) -> Self { + let h = SingleLetterTag::lowercase(Alphabet::H); + let p = SingleLetterTag::lowercase(Alphabet::P); + let (mut kinds, mut channels, mut authors, mut p_tags) = ( + Listed::default(), + Listed::default(), + Listed::default(), + Listed::default(), + ); + let mut ids = 0; + for filter in filters { + for k in filter.kinds.iter().flatten() { + kinds.offer(k.as_u16()); + } + for c in filter.generic_tags.get(&h).into_iter().flatten() { + channels.count(Uuid::parse_str(c).is_ok().then_some(c)); + } + for a in filter.authors.iter().flatten() { + authors.offer(*a); + } + for v in filter.generic_tags.get(&p).into_iter().flatten() { + p_tags.count(PublicKey::from_hex(v).is_ok().then_some(v)); + } + ids += filter.ids.as_ref().map_or(0, |s| s.len()); + } + let open = |bounded: &dyn Fn(&Filter) -> bool| filters.iter().any(|f| !bounded(f)); + let bound = |pick: fn(&Filter) -> Option, widest: fn(u64, u64) -> u64| { + let values: Option> = filters.iter().map(pick).collect(); + values?.into_iter().reduce(widest) + }; + let (kind_count, kinds) = kinds.finish(open(&|f| f.kinds.is_some()), u16::to_string); + let (channel_count, channel_ids) = channels + .finish(open(&|f| f.generic_tags.contains_key(&h)), |c| { + c.to_string() + }); + let (author_count, authors) = + authors.finish(open(&|f| f.authors.is_some()), PublicKey::to_hex); + let (p_count, p_tags) = p_tags.finish(open(&|f| f.generic_tags.contains_key(&p)), |v| { + v.to_string() + }); + Self { + filters: filters.len(), + kinds, + kind_count, + channel_ids, + channel_count, + authors, + author_count, + p_tags, + p_count, + ids, + since: bound(|f| f.since.map(|t| t.as_secs()), u64::min), + until: bound(|f| f.until.map(|t| t.as_secs()), u64::max), + limit: bound(|f| f.limit.map(|l| l as u64), u64::max).map(|l| l as usize), + search: filters.iter().any(|f| f.search.is_some()), + } + } +} + +/// The first [`LIST_CAP`] distinct values offered, in order, and how many +/// values were seen. Working state never exceeds the cap, however long the +/// filter lists are. +struct Listed { + listed: Vec, + seen: usize, +} + +impl Default for Listed { + fn default() -> Self { + Self { + listed: Vec::new(), + seen: 0, + } + } +} + +impl Listed { + fn offer(&mut self, value: T) { + self.count(Some(value)); + } + + /// Count one value, listing it when it is `Some` (well-formed). + fn count(&mut self, value: Option) { + self.seen += 1; + if let Some(value) = value { + if self.listed.len() < LIST_CAP && !self.listed.contains(&value) { + self.listed.push(value); + } + } + } + + /// The count, and the comma-joined list: [`OPEN`] when a filter leaves + /// the dimension open, `None` when nothing was listed. + fn finish(self, open: bool, show: impl Fn(&T) -> String) -> (usize, Option) { + let shown = if open { + Some(OPEN.to_owned()) + } else { + (!self.listed.is_empty()) + .then(|| self.listed.iter().map(show).collect::>().join(",")) + }; + (self.seen, shown) + } +} + +/// Lines a connection may write for client-repeatable refusals before the +/// rest are only counted. +const REFUSAL_LINES: u64 = 16; + +/// Per-connection bound on the access lines a client can trigger without +/// being served: reads refused or denied, and AUTH frames after the login was +/// decided. The first [`REFUSAL_LINES`] of each are logged; the rest are +/// counted and reported on the close line. Served reads are never capped. +#[derive(Debug, Default)] +pub(crate) struct RefusalBudget { + reads: AtomicU64, + auths: AtomicU64, +} + +impl RefusalBudget { + /// Whether this refused read may write its line. + pub(crate) fn read(&self) -> bool { + Self::charge(&self.reads) + } + + /// Whether this post-decision AUTH frame may write its line. + pub(crate) fn auth(&self) -> bool { + Self::charge(&self.auths) + } + + /// A charge logs while under budget, or once `close` has been written. + fn charge(seen: &AtomicU64) -> bool { + let prev = seen.fetch_add(1, Ordering::AcqRel); + !(REFUSAL_LINES..CLOSED).contains(&prev) + } + + /// Ends counting for `close` and returns how many were suppressed. + /// Swapping in [`CLOSED`] and every charge's `fetch_add` are + /// read-modify-writes on one atomic, so each charge lands either before + /// the swap (in `seen`) or after it (sees `CLOSED` and logs its own + /// line): none is both counted and logged, and none is lost. + fn close(seen: &AtomicU64) -> u64 { + seen.swap(CLOSED, Ordering::AcqRel) + .saturating_sub(REFUSAL_LINES) + } +} + +/// Counter value once `close` has been written. +const CLOSED: u64 = 1 << 63; + +/// How a read ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReadOutcome { + /// Dropped before a terminal frame: connection teardown or session expiry. + Aborted, + Served, + Denied(&'static str), + Error(&'static str), +} + +/// One access line per WebSocket REQ or COUNT, written when the handler +/// finishes, including when its future is dropped mid-read. +pub(crate) struct ReadLog { + event: &'static str, + conn_id: Uuid, + community: CommunityId, + pubkey: Option, + sub_id: String, + summary: FilterSummary, + outcome: ReadOutcome, + /// REQ: stored events handed to the connection's writer so far. + /// COUNT: the total returned. + results: u64, + /// Charged if the read ends denied; `None` once already charged. + budget: Option>, +} + +impl ReadLog { + pub(crate) fn req( + conn: &crate::connection::ConnectionState, + sub_id: &str, + filters: &[Filter], + ) -> Self { + Self::new("req", conn, sub_id, filters) + } + + pub(crate) fn count( + conn: &crate::connection::ConnectionState, + sub_id: &str, + filters: &[Filter], + ) -> Self { + Self::new("count", conn, sub_id, filters) + } + + /// A NIP-AR sync, summarized as the live filter it registers. Its line + /// is written once the initial set ends; live delivery is not counted. + pub(crate) fn sync( + conn: &crate::connection::ConnectionState, + sub_id: &str, + channels: &[Uuid], + ) -> Self { + let filter = crate::handlers::artifact_sync::live_filter(channels); + Self::new("sync", conn, sub_id, std::slice::from_ref(&filter)) + } + + /// Log a read refused before its handler ran; other frames log nothing. + pub(crate) fn refused( + msg: &crate::protocol::ClientMessage, + conn: &crate::connection::ConnectionState, + reason: &'static str, + ) { + use crate::protocol::ClientMessage as M; + match msg { + M::Req { + sub_id, filters, .. + } => Self::refused_read("req", conn, sub_id, filters, reason), + M::Count { sub_id, filters } => { + Self::refused_read("count", conn, sub_id, filters, reason) + } + M::ArtifactSync { sub_id, filter } => { + Self::refused_sync(conn, sub_id, filter.as_ref().ok(), reason) + } + // Admission refuses only reads and EVENTs. + _ => {} + } + } + + /// Log a `event` ("req", "count" or "sync") refused before its handler ran. + pub(crate) fn refused_read( + event: &'static str, + conn: &crate::connection::ConnectionState, + sub_id: &str, + filters: &[Filter], + reason: &'static str, + ) { + if conn.refusal_budget.read() { + let mut log = Self::new(event, conn, sub_id, filters); + log.budget = None; + log.deny(reason); + } + } + + /// Log a sync refused before its handler ran. An invalid filter is + /// summarized as no filter. + pub(crate) fn refused_sync( + conn: &crate::connection::ConnectionState, + sub_id: &str, + filter: Option<&buzz_core::artifact_sync::ArtifactSyncFilter>, + reason: &'static str, + ) { + let filters: Vec = filter + .map(|f| crate::handlers::artifact_sync::live_filter(&f.channels)) + .into_iter() + .collect(); + Self::refused_read("sync", conn, sub_id, &filters, reason); + } + + fn new( + event: &'static str, + conn: &crate::connection::ConnectionState, + sub_id: &str, + filters: &[Filter], + ) -> Self { + let pubkey = match conn.auth_state_snapshot() { + crate::connection::AuthState::Authenticated(ctx) => Some(ctx.pubkey.to_hex()), + _ => None, + }; + Self { + event, + conn_id: conn.conn_id, + community: conn.tenant.community(), + pubkey, + sub_id: sub_id.to_owned(), + // The summary is the costly part; skip it when the line is off. + // An `error` line is WARN, so it keeps the summary at `=warn`. + summary: if tracing::enabled!(target: TARGET, Level::WARN) { + FilterSummary::new(filters) + } else { + FilterSummary::default() + }, + outcome: ReadOutcome::Aborted, + results: 0, + budget: Some(Arc::clone(&conn.refusal_budget)), + } + } + + /// Refused before any stored event was read. + pub(crate) fn deny(&mut self, reason: &'static str) { + self.outcome = ReadOutcome::Denied(reason); + } + + /// Ended by a server-side failure. + pub(crate) fn error(&mut self, reason: &'static str) { + self.outcome = ReadOutcome::Error(reason); + } + + /// Ended by a failed database read. + pub(crate) fn db_failed(&mut self, e: &buzz_db::DbError) { + self.error(if e.is_statement_cancelled() { + "query_timed_out" + } else { + "database_error" + }); + } + + /// One stored event was handed to the writer. Counted as it happens, so + /// a read that later fails or is cancelled still logs its progress. + pub(crate) fn queued_one(&mut self) { + self.results += 1; + } + + /// Completed: every stored event was queued before EOSE. + pub(crate) fn served(&mut self) { + self.outcome = ReadOutcome::Served; + } + + /// A COUNT completed with `total`. + pub(crate) fn counted(&mut self, total: u64) { + self.outcome = ReadOutcome::Served; + self.results = total; + } +} + +impl Drop for ReadLog { + fn drop(&mut self) { + if let (ReadOutcome::Denied(_), Some(budget)) = (self.outcome, &self.budget) { + if !budget.read() { + return; + } + } + let (outcome, reason) = match self.outcome { + ReadOutcome::Aborted => ("aborted", None), + ReadOutcome::Served => ("served", None), + ReadOutcome::Denied(r) => ("denied", Some(r)), + ReadOutcome::Error(r) => ("error", Some(r)), + }; + let s = &self.summary; + let (events_queued, count) = match (self.event, self.outcome) { + ("count", ReadOutcome::Served) => (None, Some(self.results)), + ("count", _) => (None, None), + _ => (Some(self.results), None), + }; + // A client closing mid-read (`aborted`) or a refusal is routine; only + // a server-side failure is a WARN. + let level = match self.outcome { + ReadOutcome::Error(_) => Level::WARN, + _ => Level::INFO, + }; + emit!( + level, + event = self.event, + conn_id = %self.conn_id, + community = %self.community, + pubkey = self.pubkey.as_deref(), + sub_id = self.sub_id.as_str(), + filters = s.filters, + kinds = s.kinds.as_deref(), + kind_count = s.kind_count, + channel_ids = s.channel_ids.as_deref(), + channel_count = s.channel_count, + authors = s.authors.as_deref(), + author_count = s.author_count, + p_tags = s.p_tags.as_deref(), + p_count = s.p_count, + ids = s.ids, + since = s.since, + until = s.until, + limit = s.limit, + search = s.search, + outcome, + reason, + events_queued, + count, + "access" + ); + } +} + +/// Which credential an auth attempt presented. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthMethod { + /// WebSocket login (NIP-42). + Nip42, + /// HTTP signed request (NIP-98). + Nip98, + /// Enterprise identity assertion (NIP-FI). + NipFi, + /// Blossom upload authorization (kind 24242). + Blossom, + /// Dev-mode `X-Pubkey` header: a claimed key that nothing signed. + XPubkey, + /// Admitted by network position alone (`BUZZ_ADMIN_AUTH=disabled`): + /// no proof and no key. + NetworkTrusted, +} + +impl AuthMethod { + const fn as_str(self) -> &'static str { + match self { + Self::Nip42 => "nip42", + Self::Nip98 => "nip98", + Self::NipFi => "nip_fi", + Self::Blossom => "blossom", + Self::XPubkey => "x_pubkey", + Self::NetworkTrusted => "network_trusted", + } + } +} + +/// Why a proven identity was refused. One code per meaning on every +/// transport (WebSocket, HTTP, git, audio), so a single filter finds them all. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthReason { + NotRelayMember, + MembershipUnavailable, + Banned, + BanCheckUnavailable, + AllowlistDenied, + AllowlistUnavailable, + QuotaExceeded, + AdmissionUnavailable, + ConcurrencyLimit, + ReplayCheckUnavailable, + AuthorizationUnavailable, + VerifierUnavailable, + KeyMismatch, + DenySet, + SessionExpired, + RosterUnavailable, + OriginMismatch, + AdminHostMismatch, + NotStaff, + NotOperator, + NotOperatorListener, + NotModerator, + NipOaOwnerUnlinked, + OwnerMismatch, + ChannelMembershipDenied, + AlreadyAuthenticated, + AlreadyFailed, + Timeout, + Disconnect, + Shutdown, +} + +impl AuthReason { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::NotRelayMember => "not_relay_member", + Self::MembershipUnavailable => "membership_unavailable", + Self::Banned => "banned", + Self::BanCheckUnavailable => "ban_check_unavailable", + Self::AllowlistDenied => "allowlist_denied", + Self::AllowlistUnavailable => "allowlist_unavailable", + Self::QuotaExceeded => "quota_exceeded", + Self::AdmissionUnavailable => "admission_unavailable", + Self::ConcurrencyLimit => "concurrency_limit", + Self::ReplayCheckUnavailable => "replay_check_unavailable", + Self::AuthorizationUnavailable => "authorization_unavailable", + Self::VerifierUnavailable => "verifier_unavailable", + Self::KeyMismatch => "key_mismatch", + Self::DenySet => "deny_set", + Self::SessionExpired => "session_expired", + Self::RosterUnavailable => "roster_unavailable", + Self::OriginMismatch => "origin_mismatch", + Self::AdminHostMismatch => "admin_host_mismatch", + Self::NotStaff => "not_staff", + Self::NotOperator => "not_operator", + Self::NotOperatorListener => "not_operator_listener", + Self::NotModerator => "not_moderator", + Self::NipOaOwnerUnlinked => "nip_oa_owner_unlinked", + Self::OwnerMismatch => "owner_mismatch", + Self::ChannelMembershipDenied => "channel_membership_denied", + Self::AlreadyAuthenticated => "already_authenticated", + Self::AlreadyFailed => "already_failed", + Self::Timeout => "timeout", + Self::Disconnect => "disconnect", + Self::Shutdown => "shutdown", + } + } +} + +/// How an auth attempt ended. `Denied` means the identity was proven but not +/// admitted; `Failed` means the proof itself was rejected, with the code of +/// that proof type's single mapper. `Challenged` is a request that carried no +/// credentials and was answered with a challenge, the normal first step of a +/// git exchange. Reasons are fixed codes, never raw error text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthResult { + Success, + Denied(AuthReason), + Challenged, + Failed(&'static str), + /// Cut off before a decision (connection cancelled or session expired). + Aborted(&'static str), +} + +impl AuthResult { + /// The line's level, `outcome`, and `reason`. + fn parts(self) -> (Level, &'static str, Option<&'static str>) { + match self { + Self::Success => (Level::INFO, "success", None), + Self::Denied(r) => (Level::WARN, "denied", Some(r.as_str())), + Self::Challenged => (Level::INFO, "challenged", None), + Self::Failed(r) => (Level::WARN, "failed", Some(r)), + Self::Aborted(r) => (Level::WARN, "aborted", Some(r)), + } + } + + /// The access result for a WebSocket auth terminal. Only a bad proof is + /// `Failed`; every policy refusal, including a fail-closed lookup error, + /// is `Denied` with the metric's stable label as its reason. + pub(crate) fn from_outcome(outcome: crate::metrics::AuthOutcome) -> Self { + use crate::metrics::AuthOutcome as O; + Self::Denied(match outcome { + O::Success => return Self::Success, + O::Invalid => return Self::Failed(outcome.as_str()), + O::Banned => AuthReason::Banned, + O::BanCheckError => AuthReason::BanCheckUnavailable, + O::AllowlistCheckError => AuthReason::AllowlistUnavailable, + O::AllowlistDenied => AuthReason::AllowlistDenied, + O::RelayMembershipCheckError => AuthReason::MembershipUnavailable, + O::NotRelayMember => AuthReason::NotRelayMember, + O::PairingMismatch => AuthReason::KeyMismatch, + O::Timeout => AuthReason::Timeout, + O::Disconnect => AuthReason::Disconnect, + O::Shutdown => AuthReason::Shutdown, + }) + } +} + +/// One WebSocket auth attempt (NIP-42 or audio) whose exits are spread +/// across a long handler: it logs exactly once, at [`AuthLine::finish`], or +/// on drop as `aborted` if the handler returned (cancellation, teardown) +/// without deciding. HTTP requests use [`AccessSlot`] instead. +pub(crate) struct AuthLine<'a> { + attempt: AuthAttempt<'a>, + abort_reason: &'static str, + done: bool, +} + +impl<'a> AuthLine<'a> { + pub(crate) fn new(attempt: AuthAttempt<'a>) -> Self { + Self { + attempt, + abort_reason: "connection_closed", + done: false, + } + } + + /// Record that the signature proved `pubkey`. + pub(crate) fn proven(&mut self, pubkey: PublicKey) { + self.attempt.pubkey = Some(pubkey); + self.attempt.pubkey_verified = true; + } + + /// The `aborted` reason logged if the line is dropped undecided. + pub(crate) fn abort_reason(&mut self, reason: &'static str) { + self.abort_reason = reason; + } + + /// Log the final decision; later calls are no-ops. + pub(crate) fn finish(&mut self, result: AuthResult) { + if !std::mem::replace(&mut self.done, true) { + self.attempt.log(result); + } + } +} + +impl Drop for AuthLine<'_> { + fn drop(&mut self) { + self.finish(AuthResult::Aborted(self.abort_reason)); + } +} + +/// The client IP in `header`, when configured: the rightmost entry of the +/// field's comma-separated list, across repeated fields (the one the +/// operator's proxy appended), and only if it parses as an IP address. +pub(crate) fn client_ip( + headers: &axum::http::HeaderMap, + header: Option<&axum::http::HeaderName>, +) -> Option { + let value = headers.get_all(header?).iter().next_back()?.to_str().ok()?; + value.rsplit(',').next()?.trim().parse().ok() +} + +/// A fixed code for a rejected proof. `Nip98Invalid` and `Internal` carry +/// free text that can echo request data, so they collapse to one code each. +pub(crate) fn auth_error_code(e: &buzz_auth::AuthError) -> &'static str { + use buzz_auth::AuthError as E; + match e { + E::InvalidSignature => "invalid_signature", + E::ChallengeMismatch => "challenge_mismatch", + E::RelayUrlMismatch => "relay_url_mismatch", + E::EventExpired => "event_expired", + E::Nip98Invalid(_) => "nip98_invalid", + E::Nip98Replay => "nip98_replay", + E::PubkeyMismatch => "pubkey_mismatch", + E::InsufficientScope { .. } => "insufficient_scope", + E::ChannelAccessDenied => "channel_access_denied", + E::Internal(_) => "internal", + } +} + +/// One auth attempt. `pubkey_verified` is false when the key is only what +/// the client claimed (a failed signature, or a key never checked). +pub(crate) struct AuthAttempt<'a> { + method: AuthMethod, + community: Option, + conn_id: Option, + route: Option<&'a str>, + peer_addr: Option, + client_ip: Option, + pubkey: Option, + pubkey_verified: bool, +} + +impl AuthAttempt<'_> { + /// A WebSocket login on `conn` by `pubkey`. + pub(crate) fn nip42( + conn: &crate::connection::ConnectionState, + pubkey: PublicKey, + pubkey_verified: bool, + ) -> Self { + Self { + method: AuthMethod::Nip42, + community: Some(conn.tenant.community()), + conn_id: Some(conn.conn_id), + route: None, + peer_addr: Some(conn.remote_addr), + client_ip: conn.community_control.client_ip(), + pubkey: Some(pubkey), + pubkey_verified, + } + } + + /// An HTTP signed-request attempt on `route` (a URL path, never a query). + pub(crate) fn http<'r>( + method: AuthMethod, + route: &'r str, + pubkey: Option, + pubkey_verified: bool, + ) -> AuthAttempt<'r> { + AuthAttempt { + method, + community: None, + conn_id: None, + route: Some(route), + peer_addr: None, + client_ip: None, + pubkey, + pubkey_verified, + } + } + + /// This attempt, from a request whose configured header gave `client_ip`. + pub(crate) fn with_client_ip(self, client_ip: Option) -> Self { + Self { client_ip, ..self } + } + + /// This attempt, bound to `community`. + pub(crate) fn in_community(self, community: CommunityId) -> Self { + Self { + community: Some(community), + ..self + } + } + + /// Finish the WebSocket login with `outcome`, logging it only if this + /// call won the connection's single auth terminal. + pub(crate) fn reject( + &self, + conn: &crate::connection::ConnectionState, + outcome: crate::metrics::AuthOutcome, + ) -> bool { + let won = conn.reject_auth(outcome); + if won { + self.log(AuthResult::from_outcome(outcome)); + } + won + } + + /// Log an AUTH frame that arrived after the connection's login was + /// decided, within the connection's [`RefusalBudget`]. A frame over + /// budget is only counted, but still settles its frame's line. + pub(crate) fn log_after_decision( + &self, + conn: &crate::connection::ConnectionState, + result: AuthResult, + ) { + if conn.refusal_budget.auth() { + self.log(result); + } else { + crate::connection::note_auth_line_written(); + } + } + + pub(crate) fn log(&self, result: AuthResult) { + crate::connection::note_auth_line_written(); + let (level, outcome, reason) = result.parts(); + let pubkey = self.pubkey.as_ref().map(PublicKey::to_hex); + emit!( + level, + event = "auth", + method = self.method.as_str(), + conn_id = self.conn_id.map(tracing::field::display), + community = self.community.map(tracing::field::display), + route = self.route, + peer_addr = self.peer_addr.map(tracing::field::display), + client_ip = self.client_ip.map(tracing::field::display), + pubkey = pubkey.as_deref(), + pubkey_verified = pubkey.is_some().then_some(self.pubkey_verified), + outcome, + reason, + "access" + ); + } +} + +/// Frames the connection's writer accepted: stored events and live pushes +/// alike. A frame counts once its flush succeeds, so a frame the socket +/// rejects, or one stranded by a failed flush, is never counted. This is not +/// client delivery confirmation. +#[derive(Debug, Default)] +pub(crate) struct QueuedTotals { + events: AtomicU64, + bytes: AtomicU64, +} + +impl QueuedTotals { + pub(crate) fn events(&self) -> u64 { + self.events.load(Ordering::Relaxed) + } + + pub(crate) fn bytes(&self) -> u64 { + self.bytes.load(Ordering::Relaxed) + } +} + +/// `(events, bytes)` a frame contributes to [`QueuedTotals`]. +fn frame_size(frame: &axum::extract::ws::Message) -> (u64, u64) { + use axum::extract::ws::Message; + match frame { + Message::Text(text) => ( + u64::from(text.as_str().starts_with("[\"EVENT\"")), + text.len() as u64, + ), + Message::Binary(bytes) => (0, bytes.len() as u64), + _ => (0, 0), + } +} + +/// A WebSocket writer that adds each frame to [`QueuedTotals`] once the +/// inner sink accepts it and a later flush succeeds. +pub(crate) struct CountingSink { + inner: S, + totals: std::sync::Arc, + pending: (u64, u64), +} + +impl CountingSink { + pub(crate) fn new(inner: S, totals: std::sync::Arc) -> Self { + Self { + inner, + totals, + pending: (0, 0), + } + } + + fn settle(&mut self, flushed: &Result<(), E>) { + let (events, bytes) = std::mem::take(&mut self.pending); + if flushed.is_ok() { + self.totals.events.fetch_add(events, Ordering::Relaxed); + self.totals.bytes.fetch_add(bytes, Ordering::Relaxed); + } + } +} + +impl futures_util::Sink for CountingSink +where + S: futures_util::Sink + Unpin, +{ + type Error = S::Error; + + fn poll_ready( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_ready(cx) + } + + fn start_send( + mut self: std::pin::Pin<&mut Self>, + item: axum::extract::ws::Message, + ) -> Result<(), Self::Error> { + let (events, bytes) = frame_size(&item); + std::pin::Pin::new(&mut self.inner).start_send(item)?; + self.pending.0 += events; + self.pending.1 += bytes; + Ok(()) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let flushed = futures_util::ready!(std::pin::Pin::new(&mut self.inner).poll_flush(cx)); + self.settle(&flushed); + std::task::Poll::Ready(flushed) + } + + fn poll_close( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let closed = futures_util::ready!(std::pin::Pin::new(&mut self.inner).poll_close(cx)); + self.settle(&closed); + std::task::Poll::Ready(closed) + } +} + +/// The connection's one line, written once when the last holder drops it: +/// after a normal close, a refusal during startup, or the handler being +/// dropped. `pubkey` is the identity it logged in as, if any, with its +/// `method` and `pubkey_verified`; the login also wrote its own `auth` line. +pub(crate) struct CloseLine { + conn_id: Uuid, + community: CommunityId, + peer_addr: SocketAddr, + client_ip: Option, + opened: std::time::Instant, + pubkey: std::sync::Mutex>, + refused: std::sync::OnceLock<&'static str>, + pub(crate) queued: std::sync::Arc, + pub(crate) refusals: std::sync::Arc, +} + +impl CloseLine { + /// The line for a connection upgraded now. + pub(crate) fn open( + conn_id: Uuid, + community: CommunityId, + peer_addr: SocketAddr, + client_ip: Option, + ) -> std::sync::Arc { + std::sync::Arc::new(Self { + conn_id, + community, + peer_addr, + client_ip, + opened: std::time::Instant::now(), + pubkey: std::sync::Mutex::new(None), + refused: std::sync::OnceLock::new(), + queued: Default::default(), + refusals: Default::default(), + }) + } + + pub(crate) fn conn_id(&self) -> Uuid { + self.conn_id + } + + /// Record the identity the connection logged in as. + pub(crate) fn logged_in(&self, pubkey: PublicKey) { + *self.pubkey.lock().unwrap_or_else(|e| e.into_inner()) = Some(pubkey); + } + + /// Record why the relay refused the connection before serving it. + pub(crate) fn refused(&self, reason: &'static str) { + let _ = self.refused.set(reason); + } +} + +impl Drop for CloseLine { + fn drop(&mut self) { + let pubkey = self + .pubkey + .get_mut() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(PublicKey::to_hex); + emit!( + Level::INFO, + event = "close", + conn_id = %self.conn_id, + community = %self.community, + peer_addr = %self.peer_addr, + client_ip = self.client_ip.map(tracing::field::display), + refused = self.refused.get().copied(), + pubkey = pubkey.as_deref(), + method = pubkey.is_some().then_some(AuthMethod::Nip42.as_str()), + pubkey_verified = pubkey.is_some().then_some(true), + duration_ms = u64::try_from(self.opened.elapsed().as_millis()).unwrap_or(u64::MAX), + events_queued = self.queued.events(), + bytes_queued = self.queued.bytes(), + reads_suppressed = RefusalBudget::close(&self.refusals.reads), + auth_suppressed = RefusalBudget::close(&self.refusals.auths), + "access" + ); + } +} + +/// The `event` value of every HTTP request line. +pub(crate) const HTTP_EVENT: &str = "http"; + +/// The `route` of a logged request that matched no route. +const UNMATCHED_ROUTE: &str = ""; + +/// Matched routes that never get a line: public documents and probes. +/// `/` is listed only for requests that are not WebSocket upgrades. +const EXCLUDED_ROUTES: [&str; 5] = [ + "/info", + "/.well-known/nostr.json", + "/health", + "/_liveness", + "/_readiness", +]; + +/// What one HTTP request proved and what was decided about it, written by +/// handlers through [`AccessSlot`] and logged once by [`http_access_log`]. +#[derive(Default)] +struct AccessNote { + method: Option, + community: Option, + pubkey: Option, + pubkey_verified: bool, + decision: Option, + summary: Option, + detail: Option, +} + +/// What a route did, beyond its auth decision. Each identifier lives only in +/// the variant of the route that logs it; invite codes, tokens and Blossom +/// hashes have no field. +#[derive(Debug)] +pub(crate) enum Detail { + /// An HTTP bridge read or write. + Bridge(BridgeDetail), + /// A minted invite: its id, expiry, and use limit (absent: unlimited). + InviteMinted { + invite_id: Uuid, + expires_at: i64, + max_uses: Option, + }, + /// An invite claim: whether it added the member (`false`: already one). + InviteClaimed { joined: bool }, + /// An admin feedback attachment read. + Attachment { + feedback_id: Uuid, + attachment_sha256: String, + }, +} + +/// What a bridge request read or wrote. +#[derive(Debug, Default)] +pub(crate) struct BridgeDetail { + pub(crate) result_count: Option, + pub(crate) accepted: Option, + pub(crate) kind: Option, + /// The id of the event a write stored or rejected. + pub(crate) event_id: Option, + /// A body that failed to parse: its error category and position. + pub(crate) category: Option<&'static str>, + pub(crate) line: Option, + pub(crate) column: Option, + /// An ingest rejection's fixed code, never its message. + pub(crate) reject_reason: Option<&'static str>, +} + +/// The access note of one HTTP request. [`http_access_log`] puts it in the +/// request's extensions; handlers take it as an extractor (a detached note +/// when the layer is absent, as in handler unit tests) and pass `&AccessSlot` +/// to their helpers. A refusal is final: a later admission leaves it, and +/// a later refusal replaces an admission. +#[derive(Clone, Default)] +pub struct AccessSlot(Arc>); + +impl AccessSlot { + fn note(&self) -> std::sync::MutexGuard<'_, AccessNote> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// The credential presented and the key it claims. A proven key is + /// never replaced by a claimed one. + pub(crate) fn attempt(&self, method: AuthMethod, pubkey: Option, verified: bool) { + let mut note = self.note(); + note.method = Some(method); + if verified || !note.pubkey_verified { + note.pubkey = pubkey.or(note.pubkey); + note.pubkey_verified = verified; + } + } + + /// The credential presented, keeping any key already recorded. + pub(crate) fn method(&self, method: AuthMethod) { + self.note().method = Some(method); + } + + pub(crate) fn in_community(&self, community: CommunityId) { + self.note().community = Some(community); + } + + /// The note of the request `parts` belongs to. + pub(crate) fn of(parts: &axum::http::request::Parts) -> Self { + parts.extensions.get::().cloned().unwrap_or_default() + } + + /// Record the decision. A refusal is final; a later refusal replaces an + /// earlier admission, never the reverse. + pub(crate) fn finish(&self, result: AuthResult) { + let mut note = self.note(); + if matches!(note.decision, None | Some(AuthResult::Success)) { + note.decision = Some(result); + } + } + + /// Pass `checked` through, finishing with `refusal`'s result when it is + /// an error. + pub(crate) fn gate( + &self, + checked: Result, + refusal: impl FnOnce(&E) -> AuthResult, + ) -> Result { + if let Err(e) = &checked { + self.finish(refusal(e)); + } + checked + } + + /// Record the filters the request reads. Built at WARN, not INFO, + /// because a denied, failed or aborted request logs its line at WARN. + pub(crate) fn read(&self, filters: &[Filter]) { + if tracing::enabled!(target: TARGET, Level::WARN) { + self.note().summary = Some(FilterSummary::new(filters)); + } + } + + pub(crate) fn detail(&self, detail: Detail) { + self.note().detail = Some(detail); + } + + /// Write this note's line as the layer would for `route` answered with + /// `status`. + #[cfg(test)] + pub(crate) fn emit(&self, route: &str, status: u16) { + drop(HttpLine { + slot: self.clone(), + route: Some(route.to_owned()), + http_method: axum::http::Method::POST, + peer_addr: None, + client_ip: None, + started: std::time::Instant::now(), + status: Some(axum::http::StatusCode::from_u16(status).unwrap()), + }); + } + + /// Write this note's line as the layer would for a `route` request + /// dropped before its response was ready. + #[cfg(test)] + pub(crate) fn abandon(&self, route: &str) { + drop(HttpLine { + slot: self.clone(), + route: Some(route.to_owned()), + http_method: axum::http::Method::GET, + peer_addr: None, + client_ip: None, + started: std::time::Instant::now(), + status: None, + }); + } +} + +impl axum::extract::FromRequestParts for AccessSlot { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _: &S, + ) -> Result { + Ok(Self::of(parts)) + } +} + +/// Middleware: one access line per HTTP request, written when the response +/// is ready, or as `request=aborted` if the request is dropped first. Public +/// documents, probes, SPA pages and static files are skipped; a request +/// that matched no route is logged only at status 400 or above. +pub(crate) async fn http_access_log( + axum::extract::State(state): axum::extract::State>, + mut req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let route = match req.extensions().get::() { + Some(m) if excluded_route(m.as_str(), &req) => return next.run(req).await, + Some(m) => Some(m.as_str().to_owned()), + None if crate::router::is_document_request(&state, &req) => return next.run(req).await, + None => None, + }; + let slot = AccessSlot::default(); + req.extensions_mut().insert(slot.clone()); + let mut line = HttpLine { + slot, + route, + http_method: req.method().clone(), + peer_addr: req + .extensions() + .get::>() + .map(|c| c.0), + client_ip: client_ip(req.headers(), state.config.client_ip_header.as_ref()), + started: std::time::Instant::now(), + status: None, + }; + let response = next.run(req).await; + line.status = Some(response.status()); + response +} + +/// Whether a matched route is never logged. +fn excluded_route(route: &str, req: &axum::extract::Request) -> bool { + let upgrade = req.headers().contains_key(axum::http::header::UPGRADE) + || req.method() == axum::http::Method::CONNECT; + EXCLUDED_ROUTES.contains(&route) || (route == "/" && !upgrade) +} + +/// A request's pending line, written on drop. +struct HttpLine { + slot: AccessSlot, + /// The matched template; `None` for a request that matched no route. + route: Option, + http_method: axum::http::Method, + peer_addr: Option, + client_ip: Option, + started: std::time::Instant, + /// `None` until the response is ready. + status: Option, +} + +impl Drop for HttpLine { + fn drop(&mut self) { + let status = self.status.map(|s| s.as_u16()); + let skip = match (&self.route, status) { + // A successful upgrade: the connection's own lines cover it. + (_, Some(101)) => true, + (None, Some(s)) => s < 400, + (None, None) => true, + (Some(_), _) => false, + }; + if skip { + return; + } + let note = self.slot.note(); + let aborted = status.is_none(); + let (level, outcome, reason) = note.decision.map_or((Level::INFO, None, None), |d| { + let (level, outcome, reason) = d.parts(); + (level, Some(outcome), reason) + }); + // A challenge is git's normal first step, so its 401 stays INFO. + let challenged = matches!(note.decision, Some(AuthResult::Challenged)); + let level = if aborted || (!challenged && status.is_some_and(|s| s >= 400)) { + Level::WARN + } else { + level + }; + let pubkey = note.pubkey.as_ref().map(PublicKey::to_hex); + let s = note.summary.as_ref(); + let bridge = match ¬e.detail { + Some(Detail::Bridge(b)) => Some(b), + _ => None, + }; + let (invite_id, expires_at, max_uses) = match ¬e.detail { + Some(Detail::InviteMinted { + invite_id, + expires_at, + max_uses, + }) => (Some(invite_id), Some(*expires_at), *max_uses), + _ => (None, None, None), + }; + let (feedback_id, attachment_sha256) = match ¬e.detail { + Some(Detail::Attachment { + feedback_id, + attachment_sha256, + }) => (Some(feedback_id), Some(attachment_sha256.as_str())), + _ => (None, None), + }; + let joined = match ¬e.detail { + Some(Detail::InviteClaimed { joined }) => Some(*joined), + _ => None, + }; + emit!( + level, + event = HTTP_EVENT, + route = self.route.as_deref().unwrap_or(UNMATCHED_ROUTE), + http_method = %self.http_method, + peer_addr = self.peer_addr.map(tracing::field::display), + client_ip = self.client_ip.map(tracing::field::display), + method = note.method.map(AuthMethod::as_str), + community = note.community.map(tracing::field::display), + pubkey = pubkey.as_deref(), + pubkey_verified = pubkey.is_some().then_some(note.pubkey_verified), + outcome = outcome.unwrap_or("undecided"), + reason, + request = aborted.then_some("aborted"), + status, + time_to_response_ms = + u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX), + result_count = bridge.and_then(|b| b.result_count), + accepted = bridge.and_then(|b| b.accepted), + kind = bridge.and_then(|b| b.kind), + event_id = bridge.and_then(|b| b.event_id.as_deref()), + category = bridge.and_then(|b| b.category), + line = bridge.and_then(|b| b.line), + column = bridge.and_then(|b| b.column), + reject_reason = bridge.and_then(|b| b.reject_reason), + invite_id = invite_id.map(tracing::field::display), + expires_at, + max_uses, + joined, + feedback_id = feedback_id.map(tracing::field::display), + attachment_sha256, + filters = s.map(|s| s.filters), + kinds = s.and_then(|s| s.kinds.as_deref()), + kind_count = s.map(|s| s.kind_count), + channel_ids = s.and_then(|s| s.channel_ids.as_deref()), + channel_count = s.map(|s| s.channel_count), + authors = s.and_then(|s| s.authors.as_deref()), + author_count = s.map(|s| s.author_count), + p_tags = s.and_then(|s| s.p_tags.as_deref()), + p_count = s.map(|s| s.p_count), + ids = s.map(|s| s.ids), + since = s.and_then(|s| s.since), + until = s.and_then(|s| s.until), + limit = s.and_then(|s| s.limit), + search = s.map(|s| s.search), + "access" + ); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::collections::BTreeSet; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::prelude::*; + + #[derive(Clone, Default)] + pub(crate) struct Captured(Arc>>); + + impl std::io::Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Captured { + /// Everything written so far, on every target. + pub(crate) fn raw(&self) -> String { + String::from_utf8(self.0.lock().unwrap().clone()).unwrap() + } + + /// Access lines written so far, parsed from the production JSON shape. + pub(crate) fn lines(&self) -> Vec { + String::from_utf8(self.0.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .filter(|v: &serde_json::Value| v["target"] == TARGET) + .collect() + } + } + + fn json_subscriber(out: &Captured) -> impl tracing::Subscriber + Send + Sync { + let writer = out.clone(); + tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .json() + .flatten_event(true) + .with_writer(move || writer.clone()), + ) + } + + /// Like [`capture`], with the access target filtered to WARN. + pub(crate) fn capture_warn(f: impl FnOnce() -> R) -> (R, Captured) { + let _lock = crate::test_support::tracing_dispatch_lock(); + let out = Captured::default(); + let subscriber = json_subscriber(&out) + .with(tracing_subscriber::filter::Targets::new().with_target(TARGET, Level::WARN)); + let r = tracing::subscriber::with_default(subscriber, f); + (r, out) + } + + /// Run `f` with JSON logs (flattened, as in production) captured. + pub(crate) fn capture(f: impl FnOnce() -> R) -> (R, Captured) { + let _lock = crate::test_support::tracing_dispatch_lock(); + let out = Captured::default(); + let r = tracing::subscriber::with_default(json_subscriber(&out), f); + (r, out) + } + + /// Capture JSON logs on this thread until the guard drops, for async + /// tests on a current-thread runtime that `.await` the code under test + /// directly (spawned tasks on other threads are not captured). + pub(crate) fn capture_scoped() -> (tracing::subscriber::DefaultGuard, Captured) { + let out = Captured::default(); + (tracing::subscriber::set_default(json_subscriber(&out)), out) + } + + /// A replay guard whose check never finishes, so a request parks in its + /// replay wait; `arrived` fires once a request reaches it. + #[derive(Default)] + pub(crate) struct StallingReplayGuard { + pub(crate) arrived: tokio::sync::Notify, + } + + impl buzz_auth::Nip98ReplayGuard for StallingReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + self.arrived.notify_one(); + Box::pin(std::future::pending()) + } + } + + /// The one line of a request dropped mid-flight: aborted at WARN, with + /// the proven caller it had recorded and no status. + pub(crate) fn assert_one_aborted_line(out: &Captured, pubkey: &PublicKey) -> serde_json::Value { + let lines = out.lines(); + assert_eq!(lines.len(), 1, "exactly one access line: {lines:?}"); + let line = lines[0].clone(); + assert_eq!(line["event"], HTTP_EVENT, "{line}"); + assert_eq!(line["request"], "aborted", "{line}"); + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["pubkey"], pubkey.to_hex(), "{line}"); + assert_eq!(line["pubkey_verified"], true, "{line}"); + assert!(line.get("status").is_none(), "{line}"); + line + } + + fn filter(json: serde_json::Value) -> Filter { + serde_json::from_value(json).unwrap() + } + + const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn pk() -> String { + nostr::Keys::generate().public_key().to_hex() + } + + /// A request dropped before its response is ready keeps its login and + /// says the request aborted, at WARN, with no status. + #[test] + fn admitted_request_never_completed_logs_aborted() { + let key = nostr::Keys::generate().public_key(); + let (_, out) = capture(|| { + let slot = AccessSlot::default(); + slot.attempt(AuthMethod::Nip98, Some(key), true); + slot.finish(AuthResult::Success); + slot.abandon("/query"); + }); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["outcome"], "success", "{line}"); + assert_eq!(line["pubkey"], key.to_hex(), "{line}"); + assert_eq!(line["request"], "aborted", "{line}"); + assert_eq!(line["level"], "WARN", "{line}"); + assert!(line.get("status").is_none(), "{line}"); + } + + /// The completed counterpart carries its status and no `request` field. + #[test] + fn admitted_request_completed_logs_no_abort() { + let (_, out) = capture(|| { + let slot = AccessSlot::default(); + slot.attempt(AuthMethod::Nip98, None, false); + slot.finish(AuthResult::Success); + slot.emit("/query", 200); + }); + let line = &out.lines()[0]; + assert!(line.get("request").is_none(), "{line}"); + assert_eq!(line["status"], 200, "{line}"); + assert_eq!(line["level"], "INFO", "{line}"); + } + + #[test] + fn summary_merges_filters_to_the_widest_bounds() { + let s = FilterSummary::new(&[ + filter(serde_json::json!({"kinds": [9, 7], "since": 100, "until": 200, "limit": 10})), + filter(serde_json::json!({"kinds": [7, 5], "since": 50, "until": 300, "limit": 50})), + ]); + assert_eq!(s.filters, 2); + assert_eq!((s.kinds.as_deref(), s.kind_count), (Some("7,9,5"), 4)); + assert_eq!((s.since, s.until, s.limit), (Some(50), Some(300), Some(50))); + assert!(!s.search); + } + + #[test] + fn summary_omits_a_bound_any_filter_leaves_open() { + let s = FilterSummary::new(&[ + filter(serde_json::json!({"since": 100, "limit": 10})), + filter(serde_json::json!({"until": 300})), + ]); + assert_eq!((s.since, s.until, s.limit), (None, None, None)); + } + + #[test] + fn summary_caps_lists_and_counts_every_value() { + let channels: Vec = (0..14).map(|_| Uuid::new_v4().to_string()).collect(); + let ps: Vec = (0..12).map(|_| pk()).collect(); + let s = FilterSummary::new(&[filter(serde_json::json!({ + "#h": channels, "#p": ps, "authors": [PK_A], "ids": [PK_A] + }))]); + assert_eq!(s.channel_count, 14); + assert_eq!(s.channel_ids.unwrap().split(',').count(), LIST_CAP); + assert_eq!(s.p_count, 12); + assert_eq!(s.p_tags.unwrap().split(',').count(), LIST_CAP); + assert_eq!( + (s.authors.as_deref(), s.author_count, s.ids), + (Some(PK_A), 1, 1) + ); + } + + #[test] + fn summary_of_long_lists_lists_ten_distinct_and_sums_counts() { + let authors: Vec = (0..5_000).map(|_| pk()).collect(); + let shared = authors[0].clone(); + let s = FilterSummary::new(&[ + filter(serde_json::json!({"authors": authors})), + filter(serde_json::json!({"authors": [shared]})), + ]); + // Cross-filter duplicates count twice. + assert_eq!(s.author_count, 5_001); + let listed: Vec<&str> = s.authors.as_deref().unwrap().split(',').collect(); + assert_eq!(listed.len(), LIST_CAP); + assert_eq!(listed.iter().collect::>().len(), LIST_CAP); + } + + #[test] + fn summary_caps_kinds_like_the_identifier_lists() { + let kinds: Vec = (0..=u16::MAX).collect(); + let s = FilterSummary::new(&[filter(serde_json::json!({"kinds": kinds}))]); + assert_eq!(s.kinds.unwrap().split(',').count(), LIST_CAP); + assert_eq!(s.kind_count, 65_536); + } + + #[test] + fn summary_marks_a_dimension_any_filter_leaves_open() { + let s = FilterSummary::new(&[ + filter( + serde_json::json!({"kinds": [9], "authors": [PK_A], "#h": [Uuid::nil().to_string()]}), + ), + filter(serde_json::json!({"#p": [PK_A]})), + ]); + assert_eq!(s.kinds.as_deref(), Some("*")); + assert_eq!(s.authors.as_deref(), Some("*")); + assert_eq!(s.channel_ids.as_deref(), Some("*")); + assert_eq!(s.p_tags.as_deref(), Some("*")); + assert_eq!( + (s.kind_count, s.author_count, s.channel_count, s.p_count), + (1, 1, 1, 1) + ); + } + + /// A read's level follows its outcome: only a server-side error is WARN. + #[test] + fn read_lines_warn_only_on_error() { + let read = |outcome| ReadLog { + event: "req", + conn_id: Uuid::nil(), + community: CommunityId::from_uuid(Uuid::nil()), + pubkey: None, + sub_id: "s".into(), + summary: FilterSummary::default(), + outcome, + results: 0, + budget: None, + }; + let (_, out) = capture(|| { + drop(read(ReadOutcome::Error("database_error"))); + drop(read(ReadOutcome::Aborted)); + drop(read(ReadOutcome::Denied("auth_required"))); + }); + let levels: Vec<_> = out + .lines() + .iter() + .map(|l| format!("{}={}", l["outcome"], l["level"])) + .collect(); + assert_eq!( + levels, + [ + r#""error"="WARN""#, + r#""aborted"="INFO""#, + r#""denied"="INFO""# + ] + ); + } + + /// Under `buzz_relay::access=warn` an `error` read still carries its + /// filter summary, and never the search text. + #[test] + fn warn_only_error_lines_keep_the_filter_summary() { + let (conn, _rx) = + crate::connection::tests::test_conn_with_auth(crate::connection::AuthState::Failed); + let filters = [filter(serde_json::json!({ + "kinds": [9], "#h": [Uuid::nil().to_string()], "since": 10, + "search": "secret words", + }))]; + let (_, out) = capture_warn(|| { + ReadLog::req(&conn, "s", &filters).error("database_error"); + ReadLog::count(&conn, "c", &filters).error("database_error"); + }); + assert!(!out.raw().contains("secret"), "{}", out.raw()); + let lines = out.lines(); + assert_eq!(lines.len(), 2, "{lines:?}"); + for line in &lines { + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["filters"], 1, "{line}"); + assert_eq!(line["kinds"], "9", "{line}"); + assert_eq!(line["channel_count"], 1, "{line}"); + assert_eq!(line["since"], 10, "{line}"); + assert_eq!(line["search"], true, "{line}"); + } + } + + /// HTTP lines that go to WARN keep the read's summary under a + /// WARN-only filter, without the search text. + #[test] + fn warn_only_http_read_lines_keep_the_filter_summary() { + let filters = [filter(serde_json::json!({ + "kinds": [9], "#h": [Uuid::nil().to_string()], "since": 10, + "search": "secret words", + }))]; + let (_, out) = capture_warn(|| { + let failed = AccessSlot::default(); + failed.read(&filters); + failed.emit("/query", 500); + let aborted = AccessSlot::default(); + aborted.read(&filters); + aborted.abandon("/query"); + }); + assert!(!out.raw().contains("secret"), "{}", out.raw()); + let lines = out.lines(); + assert_eq!(lines.len(), 2, "{lines:?}"); + for line in &lines { + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["filters"], 1, "{line}"); + assert_eq!(line["kinds"], "9", "{line}"); + assert_eq!(line["channel_count"], 1, "{line}"); + assert_eq!(line["since"], 10, "{line}"); + assert_eq!(line["search"], true, "{line}"); + } + } + + #[test] + fn refusal_budget_logs_sixteen_then_counts_the_rest() { + let budget = RefusalBudget::default(); + let logged = (0..20).filter(|_| budget.read()).count(); + assert_eq!(logged, 16); + assert_eq!(RefusalBudget::close(&budget.reads), 4); + assert_eq!(RefusalBudget::close(&budget.auths), 0); + } + + /// Refusals charged after `close` was written each log their own line, + /// so every refusal is either counted on `close` or logged, never both. + #[test] + fn refusals_after_close_log_their_own_lines() { + let (late, out) = capture(|| { + let close = CloseLine::open( + Uuid::nil(), + CommunityId::from_uuid(Uuid::nil()), + "127.0.0.1:1".parse().unwrap(), + None, + ); + let budget = Arc::clone(&close.refusals); + for _ in 0..(REFUSAL_LINES + 4) { + budget.read(); + budget.auth(); + } + drop(close); + let reads = (0..3).filter(|_| budget.read()).count(); + let auths = (0..3).filter(|_| budget.auth()).count(); + (reads, auths) + }); + assert_eq!(late, (3, 3), "late refusals must each log"); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["reads_suppressed"], 4, "{}", lines[0]); + assert_eq!(lines[0]["auth_suppressed"], 4, "{}", lines[0]); + } + + #[test] + fn summary_never_lists_malformed_tag_text() { + let s = FilterSummary::new(&[filter(serde_json::json!({ + "#h": ["secret plans for tuesday"], "#p": ["not a pubkey"] + }))]); + assert_eq!((s.channel_ids, s.channel_count), (None, 1)); + assert_eq!((s.p_tags, s.p_count), (None, 1)); + } + + #[test] + fn req_line_records_search_use_but_never_the_search_text() { + let needle = "zebra-crossing-7f3a"; + let filters = [filter(serde_json::json!({"kinds": [9], "search": needle}))]; + let ((), out) = capture(|| { + let mut log = ReadLog { + event: "req", + conn_id: Uuid::nil(), + community: CommunityId::from_uuid(Uuid::nil()), + pubkey: Some(PK_A.to_owned()), + sub_id: "sub".to_owned(), + summary: FilterSummary::new(&filters), + outcome: ReadOutcome::Aborted, + results: 0, + budget: None, + }; + for _ in 0..3 { + log.queued_one(); + } + log.served(); + }); + let raw = String::from_utf8(out.0.lock().unwrap().clone()).unwrap(); + assert!(!raw.contains(needle), "search text leaked: {raw}"); + let lines = out.lines(); + assert_eq!(lines.len(), 1); + let line = &lines[0]; + assert_eq!(line["event"], "req"); + assert_eq!(line["search"], true); + assert_eq!(line["outcome"], "served"); + assert_eq!(line["events_queued"], 3); + assert_eq!(line["pubkey"], PK_A); + assert_eq!(line["kinds"], "9"); + assert!(line.get("since").is_none() && line.get("reason").is_none()); + assert!(line.get("count").is_none()); + } + + #[test] + fn dropped_read_logs_as_aborted() { + let ((), out) = capture(|| { + let _log = ReadLog { + event: "count", + conn_id: Uuid::nil(), + community: CommunityId::from_uuid(Uuid::nil()), + pubkey: None, + sub_id: "c".to_owned(), + summary: FilterSummary::default(), + outcome: ReadOutcome::Aborted, + results: 0, + budget: None, + }; + }); + let line = &out.lines()[0]; + assert_eq!( + (&line["event"], &line["outcome"]), + (&"count".into(), &"aborted".into()) + ); + assert!( + line.get("count").is_none(), + "an uncounted COUNT has no count" + ); + assert!(line.get("pubkey").is_none()); + } + + #[test] + fn auth_failure_is_warn_and_marks_the_claimed_key_unverified() { + let key = nostr::Keys::generate().public_key(); + let ((), out) = capture(|| { + AuthAttempt { + method: AuthMethod::Nip98, + community: None, + conn_id: None, + route: Some("/query"), + peer_addr: None, + client_ip: None, + pubkey: Some(key), + pubkey_verified: false, + } + .log(AuthResult::Failed("invalid_signature")); + }); + let line = &out.lines()[0]; + assert_eq!(line["level"], "WARN"); + assert_eq!(line["event"], "auth"); + assert_eq!(line["method"], "nip98"); + assert_eq!(line["outcome"], "failed"); + assert_eq!(line["reason"], "invalid_signature"); + assert_eq!(line["pubkey"], key.to_hex()); + assert_eq!(line["pubkey_verified"], false); + assert_eq!(line["route"], "/query"); + } + + #[test] + fn auth_success_is_info_without_a_reason() { + let ((), out) = capture(|| { + AuthAttempt { + method: AuthMethod::Nip42, + community: Some(CommunityId::from_uuid(Uuid::nil())), + conn_id: Some(Uuid::nil()), + route: None, + peer_addr: None, + client_ip: None, + pubkey: Some(nostr::Keys::generate().public_key()), + pubkey_verified: true, + } + .log(AuthResult::Success); + }); + let line = &out.lines()[0]; + assert_eq!( + (&line["level"], &line["outcome"]), + (&"INFO".into(), &"success".into()) + ); + assert_eq!(line["pubkey_verified"], true); + assert!(line.get("reason").is_none() && line.get("route").is_none()); + } + + #[test] + fn auth_line_logs_once_and_aborts_when_dropped_undecided() { + let attempt = || AuthAttempt::http(AuthMethod::Nip42, "audio", None, false); + let ((), out) = capture(|| { + let mut line = AuthLine::new(attempt()); + line.finish(AuthResult::Denied(crate::access::AuthReason::DenySet)); + line.finish(AuthResult::Success); + drop(AuthLine::new(attempt())); + }); + let outcomes: Vec<_> = out.lines().iter().map(|l| l["outcome"].clone()).collect(); + assert_eq!(outcomes, ["denied", "aborted"]); + } + + /// Lines the layer writes for `req` through a router with one protected + /// template, the excluded documents, and a fallback answering `fallback`. + async fn layer_lines( + state: Arc, + fallback: axum::http::StatusCode, + req: axum::http::Request, + ) -> Vec { + use axum::routing::get; + use tower::ServiceExt as _; + let ok = || async { "ok" }; + let router = axum::Router::new() + .route("/x/{id}", get(ok)) + .route("/", get(ok)) + .route("/info", get(ok)) + .route("/health", get(ok)) + .route("/.well-known/nostr.json", get(ok)) + .fallback(move || async move { fallback }) + .layer(axum::middleware::from_fn_with_state(state, http_access_log)); + let (guard, out) = capture_scoped(); + router.oneshot(req).await.expect("router oneshot"); + drop(guard); + out.lines() + } + + fn get_req(path: &str) -> axum::http::Request { + axum::http::Request::get(path) + .header("host", "relay.test") + .body(axum::body::Body::empty()) + .unwrap() + } + + /// A matched route no handler annotated still gets a line: its template, + /// no identity, and an undecided outcome. + #[tokio::test(flavor = "current_thread")] + async fn unannotated_matched_route_logs_an_empty_note() { + let state = crate::state::tests::test_state().await; + let lines = layer_lines( + state, + axum::http::StatusCode::NOT_FOUND, + get_req("/x/secret-id"), + ) + .await; + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["event"], HTTP_EVENT, "{line}"); + assert_eq!(line["route"], "/x/{id}", "{line}"); + assert_eq!(line["outcome"], "undecided", "{line}"); + assert_eq!(line["status"], 200, "{line}"); + assert!(line.get("pubkey").is_none(), "{line}"); + assert!(line["time_to_response_ms"].is_u64(), "{line}"); + assert!(!line.to_string().contains("secret-id"), "{line}"); + } + + /// Public documents and probes never get a line. + #[tokio::test(flavor = "current_thread")] + async fn excluded_routes_log_nothing() { + let state = crate::state::tests::test_state().await; + for path in ["/", "/info", "/health", "/.well-known/nostr.json"] { + let lines = layer_lines( + state.clone(), + axum::http::StatusCode::NOT_FOUND, + get_req(path), + ) + .await; + assert!(lines.is_empty(), "{path}: {lines:?}"); + } + } + + /// An unmatched request is logged at 400 and above as ``, + /// never with its path, and skipped below 400. + #[tokio::test(flavor = "current_thread")] + async fn unmatched_request_logs_only_an_error_without_its_path() { + let state = crate::state::tests::test_state().await; + let lines = layer_lines( + state.clone(), + axum::http::StatusCode::NOT_FOUND, + get_req("/invite/INVITECODE123/extra?token=TOKEN456"), + ) + .await; + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["route"], UNMATCHED_ROUTE, "{}", lines[0]); + assert_eq!(lines[0]["status"], 404, "{}", lines[0]); + let raw = lines[0].to_string(); + assert!( + !raw.contains("INVITECODE123") && !raw.contains("TOKEN456"), + "{raw}" + ); + + let lines = layer_lines(state, axum::http::StatusCode::OK, get_req("/anything")).await; + assert!(lines.is_empty(), "{lines:?}"); + } + + /// With a web bundle configured, SPA documents are skipped even when + /// the fallback answers an error, ahead of the unmatched rule. + #[tokio::test(flavor = "current_thread")] + async fn spa_document_logs_nothing_at_any_status() { + let mut state = (*crate::state::tests::test_state().await).clone(); + let mut config = (*state.config).clone(); + config.web_dir = Some(std::env::temp_dir()); + state.config = Arc::new(config); + let state = Arc::new(state); + for status in [ + axum::http::StatusCode::OK, + axum::http::StatusCode::NOT_FOUND, + ] { + for path in ["/assets/app.js", "/invite/INVITECODE123"] { + let lines = layer_lines(state.clone(), status, get_req(path)).await; + assert!(lines.is_empty(), "{path} {status}: {lines:?}"); + } + } + } + + /// The configured header's IP rides the `http` line; an unset setting, a + /// missing header or a non-IP value logs none, and a list or repeated + /// fields log the rightmost entry, with no fallback past an invalid one. + #[tokio::test(flavor = "current_thread")] + async fn http_line_logs_the_configured_client_ip() { + let base = crate::state::tests::test_state().await; + async fn ip( + base: &Arc, + setting: Option<&str>, + values: &[&str], + ) -> Option { + let mut state = (**base).clone(); + Arc::make_mut(&mut state.config).client_ip_header = setting.map(|s| s.parse().unwrap()); + let mut req = get_req("/x/1"); + for value in values { + req.headers_mut() + .append("x-real-ip", value.parse().unwrap()); + } + let lines = layer_lines(Arc::new(state), axum::http::StatusCode::NOT_FOUND, req).await; + assert_eq!(lines.len(), 1, "{lines:?}"); + lines[0].get("client_ip").cloned() + } + let on = Some("X-Real-IP"); + assert_eq!( + ip(&base, on, &["203.0.113.7"]).await, + Some("203.0.113.7".into()) + ); + assert_eq!(ip(&base, None, &["203.0.113.7"]).await, None); + assert_eq!(ip(&base, on, &[]).await, None); + assert_eq!(ip(&base, on, &["not-an-ip"]).await, None); + assert_eq!( + ip(&base, on, &["198.51.100.1, 10.0.0.2"]).await, + Some("10.0.0.2".into()) + ); + assert_eq!( + ip(&base, on, &["198.51.100.1", "10.0.0.2"]).await, + Some("10.0.0.2".into()) + ); + assert_eq!(ip(&base, on, &["198.51.100.1", "not-an-ip"]).await, None); + } + + /// A request dropped before its response is ready is logged once as + /// `request=aborted` by the layer itself. + #[tokio::test(flavor = "current_thread")] + async fn layer_logs_a_dropped_request_as_aborted() { + use axum::routing::get; + use tower::ServiceExt as _; + let state = crate::state::tests::test_state().await; + let router = axum::Router::new() + .route("/x/{id}", get(std::future::pending::<&'static str>)) + .layer(axum::middleware::from_fn_with_state(state, http_access_log)); + let (guard, out) = capture_scoped(); + let call = router.oneshot(get_req("/x/1")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), call) + .await + .is_err() + ); + drop(guard); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["request"], "aborted", "{}", lines[0]); + assert_eq!(lines[0]["route"], "/x/{id}", "{}", lines[0]); + assert!(lines[0].get("status").is_none(), "{}", lines[0]); + } + + /// A refusal is final: a later admission cannot replace it, while a + /// later refusal replaces an admission. + #[test] + fn refusal_is_final_and_overrides_an_admission() { + let slot = AccessSlot::default(); + slot.finish(AuthResult::Denied(AuthReason::Banned)); + slot.finish(AuthResult::Success); + assert_eq!( + slot.note().decision, + Some(AuthResult::Denied(AuthReason::Banned)) + ); + + let slot = AccessSlot::default(); + slot.finish(AuthResult::Success); + slot.finish(AuthResult::Failed("nip98_replay")); + assert_eq!( + slot.note().decision, + Some(AuthResult::Failed("nip98_replay")) + ); + } +} diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs index b0ae99fc7b..64d56a00ef 100644 --- a/crates/buzz-relay/src/api/admin/auth.rs +++ b/crates/buzz-relay/src/api/admin/auth.rs @@ -14,7 +14,7 @@ //! Every request carries `Authorization: Nostr `. After //! verifying the signature, timestamp, `u` tag, method tag, and (for //! body-bearing mutations) the `payload` sha256 tag, the authenticated pubkey -//! is resolved to an [`AdminPrincipal`] via [`resolve_admin_principal`]. +//! is resolved to an [`AdminPrincipal`] via [`lookup_admin_principal`]. //! //! ## Principal resolution — union with fallback B //! @@ -38,6 +38,7 @@ //! [`AdminAccess::operator`] refuse with 403; an anonymous caller is never given //! a principal. +use crate::access::{auth_error_code, AccessSlot, AuthMethod, AuthResult}; use axum::http::{header, HeaderMap, Method, Uri}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; @@ -204,6 +205,7 @@ fn method_has_body(method: &str) -> bool { /// authentication or authorization failed. pub async fn authorize( state: &AppState, + slot: &AccessSlot, headers: &HeaderMap, path_and_query: &str, method: &str, @@ -215,42 +217,112 @@ pub async fn authorize( .as_ref() .ok_or_else(ApiError::not_found)?; - // Credential check first: an unauthenticated caller learns nothing about - // which Host or Origin the deployment expects. - let (access, nip98_event_id) = match &config.auth { - AdminAuth::Disabled => (AdminAccess::NetworkTrusted, None), + let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}"); + authorize_at(state, slot, headers, config, &full_path, method, raw_body).await +} + +/// The admin decision, recorded on `slot`. With auth disabled the request +/// is network-trusted: it carries no key, and only the Host and Origin +/// checks can refuse it. +async fn authorize_at( + state: &AppState, + slot: &AccessSlot, + headers: &HeaderMap, + config: &AdminConfig, + full_path: &str, + method: &str, + raw_body: Option<&[u8]>, +) -> Result { + let decided = match config.auth { AdminAuth::Nip98 => { - let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}"); - let (pubkey_bytes, event_id) = - authorize_nip98(config, headers, &full_path, method, raw_body).await?; - // Resolve the roster grant BEFORE claiming the replay ID: an - // unrostered-but-validly-signing key (e.g. any WARP-admitted laptop) - // 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?; - (AdminAccess::Staff(principal), Some(event_id)) + slot.method(AuthMethod::Nip98); + authorize_staff(state, slot, headers, config, full_path, method, raw_body).await + } + AdminAuth::Disabled => { + slot.method(AuthMethod::NetworkTrusted); + check_admin_origin(state, headers, config).map(|()| AdminAccess::NetworkTrusted) } }; + slot.finish(match &decided { + Ok(_) => AuthResult::Success, + Err((_, result)) => *result, + }); + decided.map_err(|(e, _)| e) +} +/// An admin auth refusal and its access-log result. +type AdminRefusal = (ApiError, AuthResult); + +/// The NIP-98 admin decision. Records the key on `slot` once the signature +/// verifies; the caller records the decision. +async fn authorize_staff( + state: &AppState, + slot: &AccessSlot, + headers: &HeaderMap, + config: &AdminConfig, + full_path: &str, + method: &str, + raw_body: Option<&[u8]>, +) -> Result { + // Credential check first: an unauthenticated caller learns nothing about + // which Host or Origin the deployment expects. + let (pubkey_bytes, event_id) = authorize_nip98(config, headers, full_path, method, raw_body) + .await + .map_err(|(e, code)| (e, AuthResult::Failed(code)))?; + if let Ok(pubkey) = nostr::PublicKey::from_slice(&pubkey_bytes) { + slot.attempt(AuthMethod::Nip98, Some(pubkey), true); + } + // Resolve the roster grant BEFORE claiming the replay ID: an + // unrostered-but-validly-signing key (e.g. any WARP-admitted laptop) + // must not be able to consume replay slots at request rate. Only a + // request that clears authorization claims its event ID. + let principal = match lookup_admin_principal(state, pubkey_bytes).await { + Ok(Some(principal)) => principal, + Ok(None) => { + return Err(( + ApiError::forbidden(), + AuthResult::Denied(crate::access::AuthReason::NotStaff), + )) + } + Err(e) => { + return Err(( + e, + AuthResult::Denied(crate::access::AuthReason::RosterUnavailable), + )) + } + }; + check_admin_origin(state, headers, config)?; + + // Claim the NIP-98 replay ID only after Host and Origin validation succeed, + // so a request rejected by either check does not burn the event ID — the + // caller can retry with the corrected header without a new signature. + claim_nip98_replay(state, &event_id).await?; + Ok(AdminAccess::Staff(principal)) +} + +/// The admin Host and Origin checks. +fn check_admin_origin( + state: &AppState, + headers: &HeaderMap, + config: &AdminConfig, +) -> Result<(), AdminRefusal> { if !is_admin_host(state, headers) { - return Err(ApiError::forbidden()); + return Err(( + ApiError::forbidden(), + AuthResult::Denied(crate::access::AuthReason::AdminHostMismatch), + )); } if headers.get(header::ORIGIN).is_some_and(|origin| { origin .to_str() .map_or(true, |origin| !origin_matches_host(origin, &config.host)) }) { - return Err(ApiError::forbidden()); + return Err(( + ApiError::forbidden(), + AuthResult::Denied(crate::access::AuthReason::OriginMismatch), + )); } - - // Claim the NIP-98 replay ID only after Host and Origin validation succeed, - // so a request rejected by either check does not burn the event ID — the - // caller can retry with the corrected header without a new signature. - if let Some(event_id) = nip98_event_id { - claim_nip98_replay(state, &event_id).await?; - } - - Ok(access) + Ok(()) } /// [`authorize`] for a bodyless read, bound to the request's real method and @@ -258,11 +330,20 @@ pub async fn authorize( /// `"GET"` would reject a correctly signed `HEAD`. pub async fn authorize_read( state: &AppState, + slot: &AccessSlot, headers: &HeaderMap, method: &Method, uri: &Uri, ) -> Result { - authorize(state, headers, request_target(uri), method.as_str(), None).await + authorize( + state, + slot, + headers, + request_target(uri), + method.as_str(), + None, + ) + .await } /// [`authorize`] for a mutation, bound to the request's real method and full @@ -271,6 +352,7 @@ pub async fn authorize_read( /// contract: the buffered body bytes, or `None` only for a bodyless `DELETE`. pub async fn authorize_write( state: &AppState, + slot: &AccessSlot, headers: &HeaderMap, method: &Method, uri: &Uri, @@ -278,6 +360,7 @@ pub async fn authorize_write( ) -> Result { authorize( state, + slot, headers, request_target(uri), method.as_str(), @@ -292,25 +375,6 @@ fn request_target(uri: &Uri) -> &str { .map_or_else(|| uri.path(), |pq| pq.as_str()) } -/// Resolve a 32-byte pubkey to an `AdminPrincipal` using config + DB. -/// -/// Resolution order (config outranks DB): -/// 1. Operator/Config if pubkey ∈ RELAY_OPERATOR_PUBKEYS -/// 2. Operator/OwnerFallback if pubkey == RELAY_OWNER_PUBKEY AND RELAY_OPERATOR_PUBKEYS is empty -/// 3. role from relay_operators DB row -/// 4. None → 403 -/// -/// A DB moderator row for a config-backed Operator is ignored (never demotes -/// the config grant). -pub async fn resolve_admin_principal( - state: &AppState, - pubkey: [u8; 32], -) -> Result { - lookup_admin_principal(state, pubkey) - .await? - .ok_or_else(ApiError::forbidden) -} - /// True when `pubkey` holds the operator role (not moderator), with the same /// precedence as [`lookup_admin_principal`]. A roster lookup failure is an /// `Err`, never "not an operator". @@ -323,8 +387,18 @@ pub(crate) async fn is_relay_operator( .is_some_and(|p| p.role == AdminRole::Operator)) } -/// Effective staff grant for `pubkey`, same precedence as -/// [`resolve_admin_principal`]. `Ok(None)` means "not staff"; a roster lookup +/// Resolve a 32-byte pubkey to an `AdminPrincipal` using config + DB. +/// +/// Resolution order (config outranks DB): +/// 1. Operator/Config if pubkey ∈ RELAY_OPERATOR_PUBKEYS +/// 2. Operator/OwnerFallback if pubkey == RELAY_OWNER_PUBKEY AND RELAY_OPERATOR_PUBKEYS is empty +/// 3. role from relay_operators DB row +/// 4. None → not staff (403) +/// +/// A DB moderator row for a config-backed Operator is ignored (never demotes +/// the config grant). +/// +/// `Ok(None)` means "not staff"; a roster lookup /// failure is an `Err`, so callers fail closed instead of treating it as /// "not staff". pub async fn lookup_admin_principal( @@ -446,13 +520,13 @@ async fn authorize_nip98( path: &str, method: &str, raw_body: Option<&[u8]>, -) -> Result<([u8; 32], nostr::EventId), ApiError> { - let unauth = ApiError::unauthorized; +) -> Result<([u8; 32], nostr::EventId), (ApiError, &'static str)> { + let unauth = || (ApiError::unauthorized(), "nip98_invalid"); // 1. Extract exactly one Authorization: Nostr header. let mut values = headers.get_all(header::AUTHORIZATION).iter(); let (Some(value), None) = (values.next(), values.next()) else { - return Err(unauth()); + return Err((ApiError::unauthorized(), "nip98_missing")); }; let auth_str = value .to_str() @@ -490,8 +564,8 @@ async fn authorize_nip98( // 5. Verify signature, timestamp, u-tag, method-tag, and payload hash. // For GET/HEAD (no body), body is None so payload tag is optional. // For mutations, body bytes are provided so the payload hash is verified. - let pubkey = - buzz_auth::verify_nip98_event(&event_json, &url, method, raw_body).map_err(|_| unauth())?; + let pubkey = buzz_auth::verify_nip98_event(&event_json, &url, method, raw_body) + .map_err(|e| (ApiError::unauthorized(), auth_error_code(&e)))?; Ok(( pubkey.to_bytes(), @@ -501,9 +575,12 @@ async fn authorize_nip98( /// Atomically claim a verified NIP-98 event ID against the deployment-scoped /// replay guard. Called only after [`authorize_nip98`] verified the event and -/// [`resolve_admin_principal`] confirmed a roster grant, so an unrostered +/// [`lookup_admin_principal`] confirmed a roster grant, so an unrostered /// signer never consumes a slot. Redis failure fails closed. -async fn claim_nip98_replay(state: &AppState, event_id: &nostr::EventId) -> Result<(), ApiError> { +async fn claim_nip98_replay( + state: &AppState, + event_id: &nostr::EventId, +) -> Result<(), AdminRefusal> { let unauth = ApiError::unauthorized; match state .nip98_replay @@ -515,14 +592,17 @@ async fn claim_nip98_replay(state: &AppState, event_id: &nostr::EventId) -> Resu .await { Ok(true) => Ok(()), - Ok(false) => Err(unauth()), + Ok(false) => Err((unauth(), AuthResult::Failed("nip98_replay"))), Err(err) => { tracing::warn!( scope = ADMIN_REPLAY_SCOPE, error = %err, "admin NIP-98 replay guard failed; rejecting request fail-closed" ); - Err(unauth()) + Err(( + unauth(), + AuthResult::Denied(crate::access::AuthReason::ReplayCheckUnavailable), + )) } } } @@ -554,8 +634,8 @@ fn origin_matches_host(origin: &str, host: &str) -> bool { #[cfg(test)] mod tests { use super::{ - admin_api_origin, canonical_url, host_matches, method_has_body, nostr_credential, - origin_matches_host, + admin_api_origin, authorize, canonical_url, header, host_matches, method_has_body, + nostr_credential, origin_matches_host, ADMIN_API_PREFIX, BASE64, }; #[test] @@ -784,4 +864,137 @@ mod tests { let _ = m; // acknowledged } } + + // A rostered admin request dropped while its replay check is in flight + // still writes one `aborted` line. The real NIP-98, roster and Host + // checks pass first; the replay guard never answers. + #[tokio::test(flavor = "current_thread")] + async fn admin_request_dropped_during_replay_logs_one_aborted_line() { + use base64::Engine as _; + let keys = nostr::Keys::generate(); + let mut state = (*crate::state::tests::test_state().await).clone(); + let mut config = (*state.config).clone(); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example.com".into(), + auth: crate::config::AdminAuth::Nip98, + web_dir: None, + }); + config.relay_operator_pubkeys = vec![keys.public_key().to_hex()]; + state.config = std::sync::Arc::new(config); + let replay = std::sync::Arc::new(crate::access::tests::StallingReplayGuard::default()); + state.nip98_replay = replay.clone(); + let url = format!("https://admin.example.com{ADMIN_API_PREFIX}/users"); + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags([ + nostr::Tag::parse(["u", url.as_str()]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert(header::HOST, "admin.example.com".parse().unwrap()); + let credential = BASE64.encode(serde_json::to_string(&event).unwrap()); + headers.insert( + header::AUTHORIZATION, + format!("Nostr {credential}").parse().unwrap(), + ); + + let slot = crate::access::AccessSlot::default(); + let (guard, out) = crate::access::tests::capture_scoped(); + tokio::select! { + _ = authorize(&state, &slot, &headers, "/users", "GET", None) => { + panic!("the replay check must stall"); + } + () = replay.arrived.notified() => {} + } + slot.abandon(&format!("{ADMIN_API_PREFIX}/users")); + drop(guard); + crate::access::tests::assert_one_aborted_line(&out, &keys.public_key()); + } + + // A refused admin request logs exactly one access line with a fixed code, + // and the query string never reaches it. + #[tokio::test(flavor = "current_thread")] + async fn admin_refusal_logs_one_auth_line() { + let mut state = (*crate::state::tests::test_state().await).clone(); + let mut config = (*state.config).clone(); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example.com".into(), + auth: crate::config::AdminAuth::Nip98, + web_dir: None, + }); + state.config = std::sync::Arc::new(config); + let slot = crate::access::AccessSlot::default(); + let (guard, out) = crate::access::tests::capture_scoped(); + let refused = crate::api::admin::auth::authorize( + &state, + &slot, + &axum::http::HeaderMap::new(), + "/users?q=secret", + "GET", + None, + ) + .await; + slot.emit("/api/admin/users", 401); + drop(guard); + assert!(refused.is_err()); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "failed"); + assert_eq!(lines[0]["reason"], "nip98_missing"); + assert!(!out.raw().contains("secret")); + } + + // With admin auth disabled, the network-trusted admission and the + // Host/Origin refusal each record their outcome on the request's line. + #[tokio::test(flavor = "current_thread")] + async fn disabled_admin_records_admission_and_refusal() { + let mut state = (*crate::state::tests::test_state().await).clone(); + let mut config = (*state.config).clone(); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example.com".into(), + auth: crate::config::AdminAuth::Disabled, + web_dir: None, + }); + state.config = std::sync::Arc::new(config); + let cases = [ + ("admin.example.com", None, "success", None), + ( + "tenant.example.com", + None, + "denied", + Some("admin_host_mismatch"), + ), + ( + "admin.example.com", + Some("https://evil.example"), + "denied", + Some("origin_mismatch"), + ), + ]; + for (host, origin, outcome, reason) in cases { + let mut headers = axum::http::HeaderMap::new(); + headers.insert(header::HOST, host.parse().unwrap()); + if let Some(origin) = origin { + headers.insert(header::ORIGIN, origin.parse().unwrap()); + } + let slot = crate::access::AccessSlot::default(); + let (guard, out) = crate::access::tests::capture_scoped(); + let decided = authorize(&state, &slot, &headers, "/users", "GET", None).await; + slot.emit("/api/admin/users", if decided.is_ok() { 200 } else { 403 }); + drop(guard); + assert_eq!(decided.is_ok(), outcome == "success", "{host} {origin:?}"); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["method"], "network_trusted", "{line}"); + assert_eq!(line["outcome"], outcome, "{line}"); + assert_eq!( + line.get("reason").and_then(|r| r.as_str()), + reason, + "{line}" + ); + assert!(line.get("pubkey").is_none(), "{line}"); + } + } } diff --git a/crates/buzz-relay/src/api/admin/direct.rs b/crates/buzz-relay/src/api/admin/direct.rs index b262c7827c..22d0560c87 100644 --- a/crates/buzz-relay/src/api/admin/direct.rs +++ b/crates/buzz-relay/src/api/admin/direct.rs @@ -40,8 +40,10 @@ enum Kind { Delete, } +#[allow(clippy::too_many_arguments)] // one Axum extractor per argument pub(super) async fn ban( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -49,11 +51,24 @@ pub(super) async fn ban( Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, method, uri, headers, Kind::Ban, target, q, body).await + direct_action( + state, + slot, + method, + uri, + headers, + Kind::Ban, + target, + q, + body, + ) + .await } +#[allow(clippy::too_many_arguments)] // one Axum extractor per argument pub(super) async fn timeout( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -61,11 +76,24 @@ pub(super) async fn timeout( Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, method, uri, headers, Kind::Timeout, target, q, body).await + direct_action( + state, + slot, + method, + uri, + headers, + Kind::Timeout, + target, + q, + body, + ) + .await } +#[allow(clippy::too_many_arguments)] // one Axum extractor per argument pub(super) async fn delete_event( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -73,7 +101,18 @@ pub(super) async fn delete_event( Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - direct_action(state, method, uri, headers, Kind::Delete, target, q, body).await + direct_action( + state, + slot, + method, + uri, + headers, + Kind::Delete, + target, + q, + body, + ) + .await } /// 409 `target_is_staff` when `target` is on the effective staff roster @@ -102,6 +141,7 @@ pub(super) fn enforcement_failed(action_id: Uuid, error: &str) -> ApiError { #[allow(clippy::too_many_arguments)] async fn direct_action( state: Arc, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -110,7 +150,7 @@ async fn direct_action( q: CommunityQuery, body_bytes: Bytes, ) -> Result<(StatusCode, Json), ApiError> { - let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .act()?; let body: DirectBody = serde_json::from_slice(&body_bytes) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 84a59e917b..323118903b 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -166,12 +166,13 @@ struct ProbeResponse { async fn probe( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, ) -> Result, ApiError> { let (auth_mode, role, source, can_act, can_staff) = - match authorize_read(&state, &headers, &method, &uri).await? { + match authorize_read(&state, &slot, &headers, &method, &uri).await? { AdminAccess::NetworkTrusted => ("disabled", None, None, false, false), AdminAccess::Staff(p) => { let can_staff = p.role == AdminRole::Operator; @@ -197,12 +198,13 @@ async fn probe( async fn reports( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; validate( query.status.as_deref(), REPORT_STATUS_ALLOWLIST, @@ -241,12 +243,13 @@ async fn reports( async fn report_detail( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, Path(id): Path, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; state .db .admin_get_report(id) @@ -273,11 +276,12 @@ struct FeedbackSummary { async fn feedback( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, ) -> Result>, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; let items = state .db .admin_list_feedback(100) @@ -302,12 +306,13 @@ async fn feedback( async fn feedback_detail( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, Path(id): Path, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; state .db .admin_get_feedback(id) @@ -318,13 +323,33 @@ async fn feedback_detail( async fn feedback_attachment( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, Path((id, sha256)): Path<(Uuid, String)>, ) -> Result { - authorize_read(&state, &headers, &method, &uri).await?; - if !is_sha256(&sha256) { + authorize_read(&state, &slot, &headers, &method, &uri).await?; + // The request's access line carries the attachment read. + let served = serve_feedback_attachment_checked(&state, &headers, id, &sha256).await; + if let Ok((_, community_id)) = &served { + slot.in_community(*community_id); + slot.detail(crate::access::Detail::Attachment { + feedback_id: id, + attachment_sha256: sha256, + }); + } + served.map(|(response, _)| response) +} + +/// The attachment `sha256` of feedback `id`, and the community it was read from. +async fn serve_feedback_attachment_checked( + state: &crate::state::AppState, + headers: &HeaderMap, + id: Uuid, + sha256: &str, +) -> Result<(Response, buzz_core::tenant::CommunityId), ApiError> { + if !is_sha256(sha256) { return Err(ApiError::not_found()); } @@ -343,7 +368,7 @@ async fn feedback_attachment( return Err(ApiError::not_found()); }; - if !feedback_references_hash(&feedback.tags, community_host, &sha256) { + if !feedback_references_hash(&feedback.tags, community_host, sha256) { return Err(ApiError::not_found()); } @@ -363,19 +388,13 @@ async fn feedback_attachment( return Err(ApiError::not_found()); } - let response = crate::api::media::serve_feedback_attachment(&state, &tenant, &sha256, &headers) + let response = crate::api::media::serve_feedback_attachment(state, &tenant, sha256, headers) .await .map_err(|error| match error { buzz_media::MediaError::NotFound => ApiError::not_found(), _ => ApiError::internal(), })?; - tracing::info!( - feedback_id = %feedback.id, - community_id = %community_id, - attachment_sha256 = %sha256, - "admin feedback attachment read" - ); - Ok(response) + Ok((response, tenant.community())) } // ── Phase 2: Report resolution ──────────────────────────────────────────────── @@ -444,6 +463,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>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -455,7 +475,7 @@ async fn resolve_report( resolve_report_with_enforcement, ResolutionError, }; - let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .act()?; @@ -666,6 +686,7 @@ struct ReopenReportBody { /// records a durable `reopen` audit row. `409` if the report is not terminal. async fn reopen_report( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, @@ -674,7 +695,7 @@ async fn reopen_report( ) -> Result, ApiError> { use buzz_db::relay_admin_actions::ReopenResult; - let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .act()?; @@ -745,13 +766,14 @@ struct CancelReportBody { /// `activeAction: null`. async fn cancel_report( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(report_id): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .act()?; @@ -814,13 +836,14 @@ async fn cancel_report( /// Update product_feedback status. Requires nip98 auth. async fn update_feedback_status( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(id): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let _principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let _principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .act()?; @@ -869,11 +892,12 @@ struct OperatorEntry { /// Requires nip98 auth + Operator role. async fn list_operators( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, ) -> Result>, ApiError> { - authorize_read(&state, &headers, &method, &uri) + authorize_read(&state, &slot, &headers, &method, &uri) .await? .operator()?; @@ -944,13 +968,14 @@ struct UpsertOperatorBody { /// Requires nip98 auth + Operator role. async fn upsert_operator( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, body_bytes: Bytes, ) -> Result, ApiError> { - let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes)) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, Some(&body_bytes)) .await? .operator()?; @@ -1015,12 +1040,13 @@ async fn upsert_operator( /// Requires nip98 auth + Operator role. async fn delete_operator( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, ) -> Result, ApiError> { - let principal = authorize_write(&state, &headers, &method, &uri, None) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, None) .await? .operator()?; @@ -1202,12 +1228,13 @@ struct RestrictionsQuery { /// malformed. Returns 401 without a valid admin credential. async fn list_member_restrictions( State(state): State>, + slot: crate::access::AccessSlot, uri: Uri, method: Method, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; let page_limit = limit(query.limit, 200, 200)?; let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; @@ -1239,13 +1266,14 @@ async fn list_member_restrictions( /// Requires nip98 auth. async fn unban_member( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, Query(query): Query, ) -> Result { - let principal = authorize_write(&state, &headers, &method, &uri, None) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, None) .await? .act()?; @@ -1280,13 +1308,14 @@ async fn unban_member( /// Requires nip98 auth. async fn untimeout_member( State(state): State>, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(pubkey_hex): Path, Query(query): Query, ) -> Result { - let principal = authorize_write(&state, &headers, &method, &uri, None) + let principal = authorize_write(&state, &slot, &headers, &method, &uri, None) .await? .act()?; @@ -1583,6 +1612,21 @@ mod postgres_tests { router(state).oneshot(request).await.expect("response") } + /// Sends an admin-relative request to its mounted path on the production + /// router, whose access layer writes the request's line. + async fn production_status_for( + state: Arc, + mut request: Request, + ) -> axum::response::Response { + *request.uri_mut() = format!("{ADMIN_API_PREFIX}{}", request.uri()) + .parse() + .expect("mounted uri"); + crate::router::build_router(state) + .oneshot(request) + .await + .expect("response") + } + #[tokio::test] async fn every_route_rejects_a_missing_credential_before_database_access() { let state = test_state().await; @@ -1719,8 +1763,10 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres — DB lookup returns 500 without a database"] async fn feedback_attachment_rejects_unknown_feedback() { - let response = status_for( - test_state().await, + let state = test_state().await; + let (_guard, out) = crate::access::tests::capture_scoped(); + let response = production_status_for( + state, status_request(authorized(&format!( "/feedback/{}/attachments/{HASH}", Uuid::nil() @@ -1728,6 +1774,106 @@ mod postgres_tests { ) .await; assert_eq!(response.status(), StatusCode::NOT_FOUND); + // The read's one access line: the login and the read's status. + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "success", "{}", lines[0]); + assert_eq!(lines[0]["status"], 404, "{}", lines[0]); + } + + /// A served attachment writes one access line carrying the feedback id, + /// attachment hash, community and final status, in both admin auth + /// modes; a network-trusted read carries no key. + mod external_infra_minio_tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres and MinIO"] + async fn served_attachment_writes_one_line_in_both_admin_modes() { + let signed = nip98_state(vec![test_operator_keys().public_key().to_hex()]).await; + let open = disabled_mode_state().await; + for (mode, state) in [("nip98", signed), ("network_trusted", open)] { + let host = format!("attach-{}.test", Uuid::new_v4().simple()); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let tenant = crate::tenant::bind_community(&state.db, &host) + .await + .expect("bind community"); + let bytes = format!("attachment bytes {}", Uuid::new_v4()).into_bytes(); + let sha256 = hex::encode(::digest(&bytes)); + state + .media_storage + .put(&format!("{sha256}.txt"), &bytes, "text/plain") + .await + .expect("put blob"); + let meta = buzz_media::BlobMeta { + dim: String::new(), + blurhash: String::new(), + thumb_url: String::new(), + ext: "txt".into(), + mime_type: "text/plain".into(), + size: bytes.len() as u64, + uploaded_at: 0, + duration_secs: None, + }; + state + .media_storage + .put_sidecar(&tenant, &sha256, &meta) + .await + .expect("put sidecar"); + let feedback_id = Uuid::new_v4(); + let tags = serde_json::json!([[ + "imeta", + format!("url /media/{sha256}.txt"), + format!("x {sha256}") + ]]); + sqlx::query( + "INSERT INTO product_feedback (id, community_id, event_id, submitter_pubkey, \ + category, body, tags, status, event_created_at, received_at) \ + VALUES ($1, $2, $3, $4, 'bug', 'see attachment', $5, 'new', now(), now())", + ) + .bind(feedback_id) + .bind(tenant.community().as_uuid()) + .bind([feedback_id.as_bytes().as_slice(), feedback_id.as_bytes()].concat()) + .bind(vec![9_u8; 32]) + .bind(&tags) + .execute(state.db.pool()) + .await + .expect("insert feedback"); + + let uri = format!("/feedback/{feedback_id}/attachments/{sha256}"); + let request = if mode == "nip98" { + authorized(&uri) + } else { + Request::builder() + .uri(&uri) + .header(header::HOST, "admin.example") + }; + let (guard, out) = crate::access::tests::capture_scoped(); + let response = production_status_for(state, status_request(request)).await; + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + drop(guard); + assert_eq!(status, StatusCode::OK, "{mode}"); + assert_eq!(body.as_ref(), bytes.as_slice(), "{mode}"); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{mode}: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["method"], mode, "{line}"); + assert_eq!(line["status"], 200, "{line}"); + assert_eq!(line["feedback_id"], feedback_id.to_string(), "{line}"); + assert_eq!(line["attachment_sha256"], sha256.as_str(), "{line}"); + assert_eq!(line["community"], tenant.community().to_string(), "{line}"); + if mode == "network_trusted" { + assert!(line.get("pubkey").is_none(), "{line}"); + } + } + } } #[tokio::test] @@ -2935,7 +3081,7 @@ mod postgres_tests { 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(); - // Populate relay_operator_pubkeys so resolve_admin_principal can grant + // Populate relay_operator_pubkeys so lookup_admin_principal can grant // Operator/Config to the test pubkeys without a DB lookup. config.relay_operator_pubkeys = pubkeys; // Ensure relay_operator_api_origin is set (required when pubkeys is non-empty). diff --git a/crates/buzz-relay/src/api/admin/reads.rs b/crates/buzz-relay/src/api/admin/reads.rs index 6cb3e58635..8e88cadbf0 100644 --- a/crates/buzz-relay/src/api/admin/reads.rs +++ b/crates/buzz-relay/src/api/admin/reads.rs @@ -60,12 +60,13 @@ fn decode_community_cursor(token: &str) -> Result { /// `limit` is 1–100, default 50. pub(super) async fn communities( State(state): AppStateRef, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; let limit = limit(query.limit, 50, 100)?; let after = query .cursor @@ -116,12 +117,13 @@ pub(super) struct MemberSearchPage { /// `limit` is 1–50, default 20. pub(super) async fn search_members( State(state): AppStateRef, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; let q = query.q.trim(); if q.is_empty() || q.chars().count() > 100 { return Err(ApiError::bad_request( @@ -180,13 +182,14 @@ async fn is_staff(state: &AppState, pubkey: &[u8]) -> Result { /// community. pub(super) async fn lookup_member( State(state): AppStateRef, + slot: crate::access::AccessSlot, 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 access = authorize_read(&state, &slot, &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?; @@ -230,13 +233,14 @@ fn event_not_found() -> ApiError { /// delete preview. Anything else is [`event_not_found`]. pub(super) async fn event_preview( State(state): AppStateRef, + slot: crate::access::AccessSlot, method: Method, uri: Uri, headers: HeaderMap, Path(id_hex): Path, Query(query): Query, ) -> Result, ApiError> { - authorize_read(&state, &headers, &method, &uri).await?; + authorize_read(&state, &slot, &headers, &method, &uri).await?; let id = hex::decode(&id_hex) .ok() .filter(|id| id.len() == 32) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 1d75835ff1..759e09d195 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -19,11 +19,11 @@ use serde_json::Value; use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; use buzz_core::TenantContext; +use crate::access::{AccessSlot, AuthResult}; use crate::handlers::ingest::{IngestAuth, IngestError}; use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; -use super::relay_members::MembershipRefusal; use super::{ api_error, db_read_error, internal_error, not_found, parse_query_or_400, search_read_error, }; @@ -43,6 +43,7 @@ mod workflow_webhook_postgres_tests; #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub(crate) async fn authenticate_member_post( state: &Arc, + slot: &AccessSlot, headers: &HeaderMap, path: &str, body: &[u8], @@ -63,7 +64,9 @@ pub(crate) async fn authenticate_member_post( let admission = admit_nip_fi_http_on_state( state, headers, + slot, make_nip98_closure_for_admission( + slot.clone(), headers.clone(), "POST", expected_url, @@ -73,30 +76,168 @@ pub(crate) async fn authenticate_member_post( ), ) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - - enforce_http_admission(state, &tenant, &pubkey) + let mut caller = ProvenCaller::new(admission, slot, &tenant); + caller + .rate_limit_and_replay(state, &tenant) .await - .map_err(|e| e.into_response())?; - check_nip98_replay(state, &tenant, event_id_bytes) - .await - .map_err(|e| e.into_response())?; - super::relay_members::enforce_relay_membership( + .map_err(IntoResponse::into_response)?; + let membership = super::relay_members::enforce_relay_membership( state, &tenant, - &pubkey.to_bytes(), + &caller.pubkey.to_bytes(), super::relay_members::extract_auth_tag_header(headers), - signed_created_at, - token_owner, + caller.signed_at, + caller.token_owner, ) - .await - .map_err(|e| e.into_response())?; + .await; + caller + .admit_member(membership) + .map_err(IntoResponse::into_response)?; + let pubkey = caller.pubkey; Ok((tenant, pubkey)) } +/// A caller whose NIP-98 proof (and enterprise assertion, when enforced) +/// passed, before the shared rate limit, replay guard and relay membership +/// decide whether it is admitted, recorded on the request's access note. +pub(crate) struct ProvenCaller { + pub(crate) pubkey: nostr::PublicKey, + event_id_bytes: [u8; 32], + pub(crate) signed_at: Option, + /// The owner named by a verified agent token (Enforce only). + pub(crate) token_owner: Option, + slot: AccessSlot, +} + +impl ProvenCaller { + /// The caller from a NIP-98 admission whose proof closure returned the + /// proof's event id and its signed `created_at`. + pub(crate) fn new( + admission: crate::nip_fi_http::NipFiAdmission<([u8; 32], Option)>, + slot: &AccessSlot, + tenant: &TenantContext, + ) -> Self { + slot.in_community(tenant.community()); + let (pubkey, token_owner) = (*admission.proven_pubkey(), admission.token_owner()); + let (event_id_bytes, signed_at) = admission.into_extra(); + Self { + pubkey, + event_id_bytes, + signed_at, + token_owner, + slot: slot.clone(), + } + } + + /// Record the filters the request reads. + pub(crate) fn read(&self, filters: &[nostr::Filter]) { + self.slot.read(filters); + } + + /// Record what an artifact read covered. Only the standard dimensions + /// of each raw filter are summarized; every other predicate (artifact + /// or client-defined tags) only narrows the read, so it is dropped + /// before parsing and cannot cancel the summary. A filter whose standard + /// dimensions do not parse leaves the line without a summary. An + /// incremental read is summarized by the query it wraps. Skipped below + /// WARN, the level a failed or aborted request logs at. + fn read_artifact(&self, raw: &[Value]) { + const SUMMARIZED: [&str; 9] = [ + "kinds", "authors", "ids", "#h", "#p", "since", "until", "limit", "search", + ]; + if !tracing::enabled!(target: crate::access::TARGET, tracing::Level::WARN) { + return; + } + let standard = |v: &Value| -> Value { + let v = match v.get("artifact").and_then(Value::as_str) { + Some("incremental") => v.get("query").unwrap_or(v), + _ => v, + }; + let mut kept = serde_json::Map::new(); + if let Some(obj) = v.as_object() { + for key in SUMMARIZED { + if let Some(x) = obj.get(key) { + kept.insert(key.to_owned(), x.clone()); + } + } + } + Value::Object(kept) + }; + if let Ok(filters) = raw + .iter() + .map(|v| serde_json::from_value::(standard(v))) + .collect::, _>>() + { + self.slot.read(&filters); + } + } + + /// Record what the request did. + pub(crate) fn complete(&self, detail: crate::access::BridgeDetail) { + self.slot.detail(crate::access::Detail::Bridge(detail)); + } + + /// The shared API rate limit, then the replay guard. A refusal is logged. + pub(crate) async fn rate_limit_and_replay( + &mut self, + state: &AppState, + tenant: &TenantContext, + ) -> Result<(), (StatusCode, Json)> { + self.slot.gate( + enforce_http_admission(state, tenant, &self.pubkey).await, + admission_refusal, + )?; + self.replay(state, tenant).await + } + + /// The replay guard alone, for routes outside the shared rate limit. + pub(crate) async fn replay( + &mut self, + state: &AppState, + tenant: &TenantContext, + ) -> Result<(), (StatusCode, Json)> { + self.slot + .gate( + check_nip98_replay(state, tenant, self.event_id_bytes).await, + ReplayRejection::auth_result, + ) + .map_err(Into::into) + } + + /// The result of an intermediate admission step: logs only a refusal. + pub(crate) fn check( + &mut self, + step: Result, + refusal: impl FnOnce(&E) -> AuthResult, + ) -> Result { + self.slot.gate(step, refusal) + } + + /// The result of the route's last admission step (relay membership and + /// ban): logs the request's final auth decision either way. + pub(crate) fn admit( + &mut self, + last_step: Result, + refusal: impl FnOnce(&E) -> AuthResult, + ) -> Result { + let admitted = self.slot.gate(last_step, refusal)?; + self.slot.finish(AuthResult::Success); + Ok(admitted) + } + + /// [`Self::admit`] for the relay membership and ban check. + pub(crate) fn admit_member( + &mut self, + membership: Result, + ) -> Result { + self.admit( + membership, + super::relay_members::MembershipRefusal::auth_result, + ) + } +} + pub(crate) async fn enforce_http_admission( state: &AppState, tenant: &TenantContext, @@ -163,16 +304,18 @@ type BridgeAuthResult = Result)>; /// (explicitly-named exempt paths), or the `pub(crate)` form below for /// git-settings and other crate-local specialized handlers. pub(crate) fn verify_bridge_auth( + slot: &AccessSlot, headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, require_auth_token: bool, ) -> BridgeAuthResult { - verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) + verify_bridge_auth_with_options(slot, headers, method, url, body, require_auth_token, false) } pub(crate) fn verify_bridge_auth_with_options( + slot: &AccessSlot, headers: &HeaderMap, method: &str, url: &str, @@ -180,6 +323,63 @@ pub(crate) fn verify_bridge_auth_with_options( require_auth_token: bool, require_payload: bool, ) -> BridgeAuthResult { + check_bridge_proof( + headers, + method, + url, + body, + require_auth_token, + require_payload, + ) + .map_err(|rejected| { + slot.attempt(crate::access::AuthMethod::Nip98, rejected.claimed, false); + slot.finish(crate::access::AuthResult::Failed(rejected.reason)); + api_error(StatusCode::UNAUTHORIZED, &rejected.message) + }) +} + +/// [`verify_bridge_auth_with_options`] without the access line, for shadow +/// observation re-checking a proof the request's real admission also checks. +pub(crate) fn bridge_strict_proof( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_payload: bool, +) -> Result<(), ()> { + check_bridge_proof(headers, method, url, body, true, require_payload) + .map(drop) + .map_err(drop) +} + +/// Why a bridge proof was rejected: a fixed `reason` code for the access log, +/// the pubkey the proof claimed (unverified), and the client-facing message. +struct BridgeRejection { + reason: &'static str, + claimed: Option, + message: String, +} + +fn rejected( + reason: &'static str, + claimed: Option, + message: impl Into, +) -> BridgeRejection { + BridgeRejection { + reason, + claimed, + message: message.into(), + } +} + +fn check_bridge_proof( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, + require_payload: bool, +) -> Result { // Try NIP-98 first (Authorization: Nostr ) // // Cardinality is enforced at the NIP-FI admission boundary @@ -195,14 +395,15 @@ pub(crate) fn verify_bridge_auth_with_options( use base64::engine::general_purpose::STANDARD as BASE64; let bytes = BASE64 .decode(auth_str) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid base64 in Nostr auth"))?; + .map_err(|_| rejected("invalid_encoding", None, "invalid base64 in Nostr auth"))?; String::from_utf8(bytes) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid UTF-8 in Nostr auth"))? + .map_err(|_| rejected("invalid_encoding", None, "invalid UTF-8 in Nostr auth"))? }; let event: nostr::Event = serde_json::from_str(&event_json) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid NIP-98 event JSON"))?; + .map_err(|_| rejected("invalid_event", None, "invalid NIP-98 event JSON"))?; let event_id_bytes = event.id.to_bytes(); + let claimed = Some(event.pubkey); if require_payload && !event @@ -210,14 +411,21 @@ pub(crate) fn verify_bridge_auth_with_options( .iter() .any(|tag| tag.kind() == nostr::TagKind::Payload) { - return Err(api_error( - StatusCode::UNAUTHORIZED, + return Err(rejected( + "missing_payload", + claimed, "NIP-98: missing payload tag", )); } - let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) - .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; + let pubkey = + buzz_auth::verify_nip98_event(&event_json, url, method, body).map_err(|e| { + rejected( + crate::access::auth_error_code(&e), + claimed, + format!("NIP-98: {e}"), + ) + })?; return Ok(VerifiedBridgeAuth { pubkey, @@ -230,7 +438,7 @@ pub(crate) fn verify_bridge_auth_with_options( if !require_auth_token { if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) { let pubkey = nostr::PublicKey::from_hex(hex_val) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; + .map_err(|_| rejected("invalid_x_pubkey", None, "invalid X-Pubkey hex"))?; // Zero event ID — no replay detection needed for dev mode return Ok(VerifiedBridgeAuth { pubkey, @@ -240,7 +448,7 @@ pub(crate) fn verify_bridge_auth_with_options( } } - Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) + Err(rejected("missing_auth", None, "missing Nostr auth")) } // ── NIP-FI Authority boundary ───────────────────────────────────────────────── @@ -277,6 +485,7 @@ pub(crate) fn verify_bridge_auth_with_options( #[allow(clippy::result_large_err)] #[allow(clippy::type_complexity)] // The return type IS the admission closure contract; a type alias cannot name impl Trait pub(crate) fn make_nip98_closure_for_admission( + slot: AccessSlot, headers: HeaderMap, method: &'static str, url: String, @@ -287,6 +496,7 @@ pub(crate) fn make_nip98_closure_for_admission( { move || { verify_bridge_auth_with_options( + &slot, &headers, method, &url, @@ -305,13 +515,14 @@ pub(crate) fn make_nip98_closure_for_admission( /// structurally outside the NIP-FI state machine. This function makes the /// exemption nameable and greppable. [FI-TRACE-AUTHORITY-EXEMPT] pub(crate) fn verify_nip98_exempt_invite_claim( + slot: &AccessSlot, headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, ) -> BridgeAuthResult { verify_bridge_auth_with_options( - headers, method, url, body, + slot, headers, method, url, body, true, // invite-claim always requires NIP-98; no X-Pubkey dev fallback true, // POST bodies must be covered by a payload tag ) @@ -322,12 +533,14 @@ pub(crate) fn verify_nip98_exempt_invite_claim( /// Operator endpoints use a separate auth origin and are structurally outside /// the per-tenant NIP-FI state machine. [FI-TRACE-AUTHORITY-EXEMPT] pub(crate) fn verify_nip98_exempt_operator( + slot: &AccessSlot, headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, ) -> BridgeAuthResult { verify_bridge_auth_with_options( + slot, headers, method, url, @@ -337,7 +550,9 @@ pub(crate) fn verify_nip98_exempt_operator( ) } -/// Check NIP-98 replay and record the event ID atomically. +/// Check NIP-98 replay and record the event ID atomically. Logs nothing: +/// replay is never the last admission step on its own, so the caller records +/// the request's final decision on its [`AccessSlot`](crate::access::AccessSlot). /// /// The correctness boundary is the shared, community-scoped Redis seen-set on /// `AppState`, not process-local memory. Any Redis/guard error fails closed: @@ -347,15 +562,58 @@ pub(crate) async fn check_nip98_replay( state: &AppState, tenant: &TenantContext, event_id_bytes: [u8; 32], -) -> Result<(), (StatusCode, Json)> { +) -> Result<(), ReplayRejection> { check_nip98_replay_with_guard(state.nip98_replay.as_ref(), tenant, event_id_bytes).await } +/// Why the replay check refused a proof. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ReplayRejection { + Replayed, + Unavailable, +} + +impl ReplayRejection { + /// The access-log result for this refusal. + pub(crate) fn auth_result(&self) -> crate::access::AuthResult { + match self { + Self::Replayed => crate::access::AuthResult::Failed("nip98_replay"), + Self::Unavailable => { + crate::access::AuthResult::Denied(crate::access::AuthReason::ReplayCheckUnavailable) + } + } + } +} + +impl From for (StatusCode, Json) { + fn from(rejection: ReplayRejection) -> Self { + let message = match rejection { + ReplayRejection::Replayed => "NIP-98: replay detected", + ReplayRejection::Unavailable => "NIP-98: replay check unavailable", + }; + api_error(StatusCode::UNAUTHORIZED, message) + } +} + +impl IntoResponse for ReplayRejection { + fn into_response(self) -> Response { + <(StatusCode, Json)>::from(self).into_response() + } +} + +/// The access-log result for a refusal from [`enforce_http_admission`]. +pub(crate) fn admission_refusal(e: &(StatusCode, Json)) -> crate::access::AuthResult { + crate::access::AuthResult::Denied(match e.0 { + StatusCode::TOO_MANY_REQUESTS => crate::access::AuthReason::QuotaExceeded, + _ => crate::access::AuthReason::AdmissionUnavailable, + }) +} + async fn check_nip98_replay_with_guard( replay_guard: &dyn Nip98ReplayGuard, tenant: &TenantContext, event_id_bytes: [u8; 32], -) -> Result<(), (StatusCode, Json)> { +) -> Result<(), ReplayRejection> { // Skip replay detection for dev-mode X-Pubkey auth (zero hash). if event_id_bytes == [0u8; 32] { return Ok(()); @@ -367,20 +625,14 @@ async fn check_nip98_replay_with_guard( .await { Ok(true) => Ok(()), - Ok(false) => Err(api_error( - StatusCode::UNAUTHORIZED, - "NIP-98: replay detected", - )), + Ok(false) => Err(ReplayRejection::Replayed), Err(e) => { tracing::warn!( community = %tenant.community(), error = %e, "NIP-98 replay guard failed; rejecting request fail-closed" ); - Err(api_error( - StatusCode::UNAUTHORIZED, - "NIP-98: replay check unavailable", - )) + Err(ReplayRejection::Unavailable) } } } @@ -956,32 +1208,32 @@ fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid:: } } -/// Hard cap on the `reason` field logged for a rejected `/events` request. -/// -/// The reject message can embed event-controlled content (e.g. a submitted -/// channel's `visibility`/`channel_type` tag values, or a raw tag pubkey) — -/// attacker-controlled text that must never reach Datadog unbounded. -const REJECT_REASON_MAX_BYTES: usize = 256; - -/// Truncate `s` to at most `max_bytes`, cutting at the nearest UTF-8 character -/// boundary so a multi-byte codepoint straddling the cutoff is never split. -/// Bounds attacker-controlled text before it enters a structured log line — -/// the line's size must stay bounded regardless of the triggering input size. -fn truncate_reason(s: &str, max_bytes: usize) -> &str { - if s.len() <= max_bytes { - return s; - } - let mut end = max_bytes; - while !s.is_char_boundary(end) { - end -= 1; - } - &s[..end] +/// The fixed code logged for a rejected `/events` write: the NIP-01 prefix +/// that leads the rejection message (`invalid`, `blocked`, ...), never the +/// text after it, which can quote event-controlled tag values. +fn reject_code(msg: &str) -> &'static str { + const PREFIXES: [&str; 8] = [ + "invalid", + "blocked", + "restricted", + "duplicate", + "pow", + "rate-limited", + "auth-required", + "error", + ]; + let prefix = msg.split([':', ' ']).next().unwrap_or_default(); + PREFIXES + .into_iter() + .find(|p| *p == prefix) + .unwrap_or("rejected") } /// Submit a signed Nostr event via HTTP bridge (NIP-98 auth). #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn submit_event( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { @@ -1014,11 +1266,12 @@ pub async fn submit_event( // assertion verify → pair → deny-map in fixed order. The proven pubkey is // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { - verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) + bridge_strict_proof(&headers, "POST", &url, Some(&body), true) }) .await; - let admission = admit_nip_fi_http_on_state(&state, &headers, || { + let admission = admit_nip_fi_http_on_state(&state, &headers, &slot, || { verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -1030,81 +1283,13 @@ pub async fn submit_event( .map_err(|e| e.into_response()) }) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - let pubkey_hex = pubkey.to_hex(); + let mut caller = ProvenCaller::new(admission, &slot, &tenant); // Everything after auth — admission, replay, membership, parse, ingest — - // runs inside the helper. The thin wrapper here owns the single terminal - // attribution line so it fires for every outcome, including admission/ - // replay/membership failures that previously returned before any log fired. - let outcome = submit_event_authed( - &state, - &tenant, - &headers, - &body, - pubkey, - event_id_bytes, - signed_created_at, - token_owner, - ) - .await; - - match &outcome { - SubmitOutcome::Ok { accepted, kind, .. } => { - tracing::info!( - pubkey = %pubkey_hex, - route = "/events", - status = 200u16, - accepted, - kind, - "HTTP bridge request" - ); - } - SubmitOutcome::ParseFail { - category, - line, - column, - .. - } => { - tracing::warn!( - pubkey = %pubkey_hex, - route = "/events", - status = 400u16, - accepted = false, - category, - line, - column, - "HTTP bridge request" - ); - } - SubmitOutcome::Rejected { - kind, - reason, - response, - } => { - tracing::warn!( - pubkey = %pubkey_hex, - route = "/events", - status = response.0.as_u16(), - accepted = false, - kind, - reason = %reason, - "HTTP bridge request" - ); - } - SubmitOutcome::Err { status, .. } => { - tracing::warn!( - pubkey = %pubkey_hex, - route = "/events", - status = status.as_u16(), - accepted = false, - "HTTP bridge request" - ); - } - } - + // runs inside the helper; the caller's access line is the request's one + // line, covering every outcome including a refused login. + let outcome = submit_event_authed(&state, &tenant, &headers, &body, &mut caller).await; + caller.complete(outcome.fields()); Ok(outcome.into_response().into_response()) } @@ -1117,6 +1302,7 @@ enum SubmitOutcome { Ok { accepted: bool, kind: u32, + event_id: String, response: Json, }, /// JSON parse failure before ingest — log category/line/column, not msg. @@ -1132,23 +1318,61 @@ enum SubmitOutcome { /// The logged `status` reflects the actual response status carried in `response`. Rejected { kind: u32, - reason: String, + reason: &'static str, response: (StatusCode, Json), }, /// Any other error (admission, replay, membership, auth, internal) — /// only the HTTP status is logged; the response body is returned as-is. - Err { - status: StatusCode, - response: MembershipRefusal, - }, + Err { response: Response }, } impl SubmitOutcome { - fn into_response(self) -> Result, MembershipRefusal> { + /// What the request's access line records about this outcome. + fn fields(&self) -> crate::access::BridgeDetail { + use crate::access::BridgeDetail; + match self { + SubmitOutcome::Ok { + accepted, + kind, + event_id, + .. + } => BridgeDetail { + accepted: Some(*accepted), + kind: Some(*kind), + event_id: Some(event_id.clone()), + ..BridgeDetail::default() + }, + SubmitOutcome::ParseFail { + category, + line, + column, + .. + } => BridgeDetail { + accepted: Some(false), + category: Some(category), + line: Some(*line), + column: Some(*column), + ..BridgeDetail::default() + }, + SubmitOutcome::Rejected { kind, reason, .. } => BridgeDetail { + accepted: Some(false), + kind: Some(*kind), + reject_reason: Some(reason), + ..BridgeDetail::default() + }, + SubmitOutcome::Err { .. } => BridgeDetail { + accepted: Some(false), + ..BridgeDetail::default() + }, + } + } + + #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers + fn into_response(self) -> Result, Response> { match self { SubmitOutcome::Ok { response, .. } => Ok(response), - SubmitOutcome::ParseFail { response, .. } => Err(response.into()), - SubmitOutcome::Rejected { response, .. } => Err(response.into()), + SubmitOutcome::ParseFail { response, .. } => Err(response.into_response()), + SubmitOutcome::Rejected { response, .. } => Err(response.into_response()), SubmitOutcome::Err { response, .. } => Err(response), } } @@ -1164,25 +1388,17 @@ async fn submit_event_authed( tenant: &TenantContext, headers: &HeaderMap, body: &[u8], - pubkey: nostr::PublicKey, - event_id_bytes: [u8; 32], - signed_auth_created_at: Option, - token_owner: Option, + caller: &mut ProvenCaller, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. - if let Err(e) = enforce_http_admission(state, tenant, &pubkey).await { + if let Err(e) = caller.rate_limit_and_replay(state, tenant).await { return SubmitOutcome::Err { - status: e.0, - response: e.into(), - }; - } - if let Err(e) = check_nip98_replay(state, tenant, event_id_bytes).await { - return SubmitOutcome::Err { - status: e.0, - response: e.into(), + response: e.into_response(), }; } + let (pubkey, signed_auth_created_at, token_owner) = + (caller.pubkey, caller.signed_at, caller.token_owner); let pubkey_bytes = pubkey.to_bytes().to_vec(); let event: nostr::Event = match serde_json::from_slice(body) { @@ -1237,7 +1453,7 @@ async fn submit_event_authed( ) .await }; - let nip_oa_owner = match membership { + let nip_oa_owner = match caller.admit_member(membership) { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { super::relay_members::extract_nip_oa_owner( @@ -1251,8 +1467,7 @@ async fn submit_event_authed( }), Err(e) => { return SubmitOutcome::Err { - status: e.status(), - response: e, + response: e.into_response(), }; } }; @@ -1286,17 +1501,16 @@ async fn submit_event_authed( let result = match result { Err(IngestError::Ban { refusal, .. }) if token_owner.is_some() => { crate::handlers::ingest::reject_with_transport("http", "auth"); - let refusal = MembershipRefusal::NipFi(match refusal { + let class = match refusal { crate::handlers::ingest::BanRefusal::Banned => { buzz_auth::DenialClass::AuthorizationDenied } crate::handlers::ingest::BanRefusal::Unavailable => { buzz_auth::DenialClass::AuthorizationUnavailable } - }); + }; return SubmitOutcome::Err { - status: refusal.status(), - response: refusal, + response: crate::nip_fi_core::http_denial(class), }; } other => other.map_err(IngestError::into_legacy), @@ -1305,22 +1519,21 @@ async fn submit_event_authed( match result { Ok(result) => { let response = Json(serde_json::json!({ - "event_id": result.event_id, + "event_id": &result.event_id, "accepted": result.accepted, "message": result.message, })); SubmitOutcome::Ok { accepted: result.accepted, kind: kind_u32, + event_id: result.event_id, response, } } Err(IngestError::Rejected(msg)) => { - // `msg` can embed event-controlled content (e.g. a channel - // create's raw `visibility`/`channel_type` tag values, or a raw - // tag pubkey) — truncate before logging, but return the full msg - // in the HTTP response body (unchanged from prior behaviour). - let reason = truncate_reason(&msg, REJECT_REASON_MAX_BYTES).to_owned(); + // `msg` can quote event-controlled tag values: the log gets only + // its fixed prefix code, the response body the full message. + let reason = reject_code(&msg); crate::handlers::ingest::reject_with_transport("http", "invalid"); SubmitOutcome::Rejected { kind: kind_u32, @@ -1333,7 +1546,7 @@ async fn submit_event_authed( // CLI's reconciliation branch (which gates on `status == 409`) is // reachable against the live relay. The message body is unchanged; // the desktop TypeScript layer matches on message text, not status. - let reason = truncate_reason(&msg, REJECT_REASON_MAX_BYTES).to_owned(); + let reason = "canvas_conflict"; crate::handlers::ingest::reject_with_transport("http", "invalid"); SubmitOutcome::Rejected { kind: kind_u32, @@ -1345,8 +1558,7 @@ async fn submit_event_authed( crate::handlers::ingest::reject_with_transport("http", "auth"); let e = api_error(StatusCode::FORBIDDEN, &msg); SubmitOutcome::Err { - status: e.0, - response: e.into(), + response: e.into_response(), } } // `into_legacy` never returns `Ban`; it would fail closed here. @@ -1354,16 +1566,14 @@ async fn submit_event_authed( crate::handlers::ingest::reject_with_transport("http", "error"); let e = internal_error("ban refusal reached the legacy mapping"); SubmitOutcome::Err { - status: e.0, - response: e.into(), + response: e.into_response(), } } Err(IngestError::Internal(msg)) => { crate::handlers::ingest::reject_with_transport("http", "error"); let e = internal_error(&msg); SubmitOutcome::Err { - status: e.0, - response: e.into(), + response: e.into_response(), } } } @@ -1375,6 +1585,7 @@ async fn submit_event_authed( #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn query_events( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { @@ -1405,11 +1616,12 @@ pub async fn query_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { - verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) + bridge_strict_proof(&headers, "POST", &url, Some(&body), true) }) .await; - let admission = admit_nip_fi_http_on_state(&state, &headers, || { + let admission = admit_nip_fi_http_on_state(&state, &headers, &slot, || { verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -1421,47 +1633,17 @@ pub async fn query_events( .map_err(|e| e.into_response()) }) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - let pubkey_hex = pubkey.to_hex(); + let mut caller = ProvenCaller::new(admission, &slot, &tenant); // Admission, replay, membership, and filter execution all run inside the - // helper. The single terminal attribution line fires here from the Result - // so every outcome — including admission/replay/membership failures that - // previously returned before any log — is attributed. - let result = query_events_authed( - &state, - &tenant, - &headers, - &body, - pubkey, - event_id_bytes, - signed_created_at, - token_owner, - ) - .await; - match &result { - Ok(Json(Value::Array(events))) => { - tracing::info!( - pubkey = %pubkey_hex, - route = "/query", - status = 200u16, - result_count = events.len(), - "HTTP bridge request" - ); - } - Ok(_) => { - tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request"); - } - Err(refusal) => { - tracing::warn!( - pubkey = %pubkey_hex, - route = "/query", - status = refusal.status().as_u16(), - "HTTP bridge request" - ); - } + // helper; the caller's access line is the request's one line, covering + // every outcome including a refused login. + let result = query_events_authed(&state, &tenant, &headers, &body, &mut caller).await; + if let Ok(Json(Value::Array(events))) = &result { + caller.complete(crate::access::BridgeDetail { + result_count: Some(events.len() as u64), + ..Default::default() + }); } Ok(result.into_response()) } @@ -1470,32 +1652,38 @@ pub async fn query_events( /// Handles admission, replay, membership, and all filter paths so the thin /// wrapper above can emit exactly one terminal attribution line from the Result. #[allow(clippy::too_many_arguments)] +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn query_events_authed( state: &Arc, tenant: &TenantContext, headers: &HeaderMap, body: &[u8], - pubkey: nostr::PublicKey, - event_id_bytes: [u8; 32], - signed_auth_created_at: Option, - token_owner: Option, -) -> Result, MembershipRefusal> { - enforce_http_admission(state, tenant, &pubkey).await?; - check_nip98_replay(state, tenant, event_id_bytes).await?; + caller: &mut ProvenCaller, +) -> Result, Response> { + caller + .rate_limit_and_replay(state, tenant) + .await + .map_err(IntoResponse::into_response)?; + let pubkey = caller.pubkey; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = super::relay_members::extract_auth_tag_header(headers); - super::relay_members::enforce_relay_membership( + let membership = super::relay_members::enforce_relay_membership( state, tenant, &pubkey_bytes, auth_tag, - signed_auth_created_at, - token_owner, + caller.signed_at, + caller.token_owner, ) - .await?; + .await; + caller + .admit_member(membership) + .map_err(IntoResponse::into_response)?; - Ok(query_member_events(state, tenant, body, pubkey).await?) + query_member_events(state, tenant, body, caller) + .await + .map_err(IntoResponse::into_response) } /// The rest of [`query_events_authed`], once the caller is admitted as a relay member. @@ -1503,8 +1691,9 @@ async fn query_member_events( state: &Arc, tenant: &TenantContext, body: &[u8], - pubkey: nostr::PublicKey, + caller: &ProvenCaller, ) -> Result, (StatusCode, Json)> { + let pubkey = caller.pubkey; let pubkey_bytes = pubkey.to_bytes().to_vec(); // Two-pass parse: preserve raw JSON for custom extension fields (before_id, // depth_limit, feed_types) that nostr::Filter silently drops. @@ -1512,6 +1701,7 @@ async fn query_member_events( .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; if let Some(result) = super::artifact::query(state, tenant, &pubkey, &raw_filters, false).await { + caller.read_artifact(&raw_filters); return result; } let thread_windows = thread_window::parse(&raw_filters)?; @@ -1520,6 +1710,7 @@ async fn query_member_events( .map(|v| serde_json::from_value(v.clone())) .collect::>() .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + caller.read(&filters); crate::handlers::req::extract_channel_ids_from_filters_limited(&filters) .map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?; @@ -2074,6 +2265,7 @@ pub(crate) async fn repair_requested_channel_access( #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn count_events( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { @@ -2103,11 +2295,12 @@ pub async fn count_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { - verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) + bridge_strict_proof(&headers, "POST", &url, Some(&body), true) }) .await; - let admission = admit_nip_fi_http_on_state(&state, &headers, || { + let admission = admit_nip_fi_http_on_state(&state, &headers, &slot, || { verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -2119,45 +2312,17 @@ pub async fn count_events( .map_err(|e| e.into_response()) }) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - let pubkey_hex = pubkey.to_hex(); + let mut caller = ProvenCaller::new(admission, &slot, &tenant); // Admission, replay, membership, and count execution all run inside the - // helper. The single terminal attribution line fires here from the Result - // so every outcome — including admission/replay/membership failures that - // previously returned before any log — is attributed. - let result = count_events_authed( - &state, - &tenant, - &headers, - &body, - pubkey, - event_id_bytes, - signed_created_at, - token_owner, - ) - .await; - match &result { - Ok(Json(value)) => { - let count = value.get("count").and_then(Value::as_u64); - tracing::info!( - pubkey = %pubkey_hex, - route = "/count", - status = 200u16, - result_count = count, - "HTTP bridge request" - ); - } - Err(refusal) => { - tracing::warn!( - pubkey = %pubkey_hex, - route = "/count", - status = refusal.status().as_u16(), - "HTTP bridge request" - ); - } + // helper; the caller's access line is the request's one line, covering + // every outcome including a refused login. + let result = count_events_authed(&state, &tenant, &headers, &body, &mut caller).await; + if let Ok(Json(value)) = &result { + caller.complete(crate::access::BridgeDetail { + result_count: value.get("count").and_then(Value::as_u64), + ..Default::default() + }); } Ok(result.into_response()) } @@ -2166,32 +2331,38 @@ pub async fn count_events( /// Handles admission, replay, membership, and count execution so the thin /// wrapper above can emit exactly one terminal attribution line from the Result. #[allow(clippy::too_many_arguments)] +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn count_events_authed( state: &Arc, tenant: &TenantContext, headers: &HeaderMap, body: &[u8], - pubkey: nostr::PublicKey, - event_id_bytes: [u8; 32], - signed_auth_created_at: Option, - token_owner: Option, -) -> Result, MembershipRefusal> { - enforce_http_admission(state, tenant, &pubkey).await?; - check_nip98_replay(state, tenant, event_id_bytes).await?; + caller: &mut ProvenCaller, +) -> Result, Response> { + caller + .rate_limit_and_replay(state, tenant) + .await + .map_err(IntoResponse::into_response)?; + let pubkey = caller.pubkey; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = super::relay_members::extract_auth_tag_header(headers); - super::relay_members::enforce_relay_membership( + let membership = super::relay_members::enforce_relay_membership( state, tenant, &pubkey_bytes, auth_tag, - signed_auth_created_at, - token_owner, + caller.signed_at, + caller.token_owner, ) - .await?; + .await; + caller + .admit_member(membership) + .map_err(IntoResponse::into_response)?; - Ok(count_member_events(state, tenant, body, pubkey).await?) + count_member_events(state, tenant, body, caller) + .await + .map_err(IntoResponse::into_response) } /// The rest of [`count_events_authed`], once the caller is admitted as a relay member. @@ -2199,16 +2370,19 @@ async fn count_member_events( state: &Arc, tenant: &TenantContext, body: &[u8], - pubkey: nostr::PublicKey, + caller: &ProvenCaller, ) -> Result, (StatusCode, Json)> { + let pubkey = caller.pubkey; let pubkey_bytes = pubkey.to_bytes().to_vec(); let raw: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; if let Some(result) = super::artifact::query(state, tenant, &pubkey, &raw, true).await { + caller.read_artifact(&raw); return result; } let filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + caller.read(&filters); crate::handlers::req::extract_channel_ids_from_filters_limited(&filters) .map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?; @@ -2941,6 +3115,7 @@ async fn synthesize_presence( #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_moderation_read( state: &Arc, + slot: &AccessSlot, headers: &HeaderMap, path: &str, raw_query: Option<&str>, @@ -2967,43 +3142,48 @@ async fn authorize_moderation_read( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(state, headers, "bridge", || { - verify_bridge_auth(headers, "GET", &url, None, true).map(drop) + bridge_strict_proof(headers, "GET", &url, None, false) }) .await; - let admission = admit_nip_fi_http_on_state(state, headers, || { + let admission = admit_nip_fi_http_on_state(state, headers, slot, || { verify_bridge_auth( + slot, headers, "GET", &url, None, state.config.require_auth_token || nip_fi_active, ) - .map(|auth| auth.proof(auth.event_id_bytes)) + .map(|auth| auth.proof((auth.event_id_bytes, None))) .map_err(|e| e.into_response()) }) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let event_id_bytes = admission.into_extra(); - - check_nip98_replay(state, &tenant, event_id_bytes) + let mut caller = ProvenCaller::new(admission, slot, &tenant); + caller + .replay(state, &tenant) .await - .map_err(|e| e.into_response())?; - let pubkey_bytes = pubkey.to_bytes().to_vec(); + .map_err(IntoResponse::into_response)?; + let pubkey_bytes = caller.pubkey.to_bytes().to_vec(); // Membership and community ban, same step as the other NIP-98 routes. - super::relay_members::enforce_relay_membership( + let membership = super::relay_members::enforce_relay_membership( state, &tenant, &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), None, - token_owner, + caller.token_owner, ) - .await - .map_err(|e| e.into_response())?; + .await; + caller + .check( + membership, + super::relay_members::MembershipRefusal::auth_result, + ) + .map_err(IntoResponse::into_response)?; - crate::handlers::moderation_authz::authorize_moderation_action( + // Moderator authority is this route's last admission step. + let moderator = crate::handlers::moderation_authz::authorize_moderation_action( &tenant, state, &pubkey_bytes, @@ -3011,14 +3191,18 @@ async fn authorize_moderation_read( crate::handlers::moderation_authz::ModerationTarget::None, crate::handlers::moderation_authz::ModerationAction::ViewQueue, ) - .await - .map_err(|_| { - api_error( - StatusCode::FORBIDDEN, - "restricted: moderator access required", - ) - .into_response() - })?; + .await; + caller + .admit(moderator, |_| { + AuthResult::Denied(crate::access::AuthReason::NotModerator) + }) + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + .into_response() + })?; Ok(tenant) } @@ -3043,11 +3227,13 @@ fn clamp_limit(requested: Option) -> i64 { /// `GET /moderation/reports` — the moderation queue (NIP-98 + mod-authz). pub async fn moderation_reports( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, ) -> Response { let tenant = match authorize_moderation_read( &state, + &slot, &headers, "/moderation/reports", raw_query.as_deref(), @@ -3083,11 +3269,13 @@ pub async fn moderation_reports( /// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). pub async fn moderation_audit( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, ) -> Response { let tenant = match authorize_moderation_read( &state, + &slot, &headers, "/moderation/audit", raw_query.as_deref(), @@ -3118,10 +3306,13 @@ pub async fn moderation_audit( /// `GET /moderation/restricted` — currently banned/timed-out members. pub async fn moderation_restricted( State(state): State>, + slot: AccessSlot, headers: HeaderMap, ) -> Response { let tenant = - match authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await { + match authorize_moderation_read(&state, &slot, &headers, "/moderation/restricted", None) + .await + { Ok(t) => t, Err(r) => return r, }; @@ -3215,6 +3406,37 @@ pub(crate) mod postgres_tests { ) } + /// An artifact read's summary survives a WARN-only filter on a failed + /// request, without the search text or the artifact-only predicates. + #[test] + fn warn_only_artifact_read_lines_keep_the_filter_summary() { + let raw = [serde_json::json!({ + "kinds": [9], "#h": [uuid::Uuid::nil().to_string()], "since": 10, + "search": "secret words", "#x": ["artifact"], + })]; + let (_, out) = crate::access::tests::capture_warn(|| { + let caller = ProvenCaller { + pubkey: Keys::generate().public_key(), + event_id_bytes: fresh_nip98_event_id_bytes(), + signed_at: None, + token_owner: None, + slot: AccessSlot::default(), + }; + caller.read_artifact(&raw); + caller.slot.emit("/query", 500); + }); + assert!(!out.raw().contains("secret"), "{}", out.raw()); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["filters"], 1, "{line}"); + assert_eq!(line["kinds"], "9", "{line}"); + assert_eq!(line["channel_count"], 1, "{line}"); + assert_eq!(line["since"], 10, "{line}"); + assert_eq!(line["search"], true, "{line}"); + } + fn fresh_nip98_event_id_bytes() -> [u8; 32] { EventBuilder::new(Kind::HttpAuth, "") .sign_with_keys(&Keys::generate()) @@ -3463,10 +3685,10 @@ pub(crate) mod postgres_tests { .await .expect("first pod should claim fresh NIP-98 event id"); - let (status, _) = check_nip98_replay_with_guard(&pod_b, &tenant_a, event_id_bytes) + let rejection = check_nip98_replay_with_guard(&pod_b, &tenant_a, event_id_bytes) .await .expect_err("second pod must reject same-community replay"); - assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(rejection, ReplayRejection::Replayed); check_nip98_replay_with_guard(&pod_b, &tenant_b, event_id_bytes) .await @@ -3488,10 +3710,78 @@ pub(crate) mod postgres_tests { .await .expect("first claim on a fresh event id must succeed"); - let (status, _) = check_nip98_replay_with_guard(&pod, &tenant, event_id_bytes) + let rejection = check_nip98_replay_with_guard(&pod, &tenant, event_id_bytes) .await .expect_err("same-pod replay of the same id+community must reject"); - assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(rejection, ReplayRejection::Replayed); + } + + /// Replay guard returning a fixed answer, for access-line tests. + struct FixedReplayGuard(Option); + + impl Nip98ReplayGuard for FixedReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + let answer = self.0; + Box::pin(async move { + answer.ok_or_else(|| buzz_auth::AuthError::Internal("simulated outage".into())) + }) + } + } + + /// Drive one request's note through the replay step the way + /// [`ProvenCaller`] does, then write its line as the layer would. + async fn replay_auth_lines(answer: Option) -> Vec { + let (_guard, out) = crate::access::tests::capture_scoped(); + let tenant = fresh_tenant("relay-a.example"); + let slot = AccessSlot::default(); + slot.attempt( + crate::access::AuthMethod::Nip98, + Some(Keys::generate().public_key()), + true, + ); + slot.in_community(tenant.community()); + let checked = slot.gate( + check_nip98_replay_with_guard( + &FixedReplayGuard(answer), + &tenant, + fresh_nip98_event_id_bytes(), + ) + .await, + ReplayRejection::auth_result, + ); + assert!(out.lines().is_empty(), "only the layer writes the line"); + slot.emit("/query", if checked.is_ok() { 200 } else { 401 }); + out.lines() + } + + /// Replay never decides success on its own: a refusal is final, and a + /// pass leaves the decision to the later admission steps (here none + /// run, so the line stays `undecided`). + #[tokio::test(flavor = "current_thread")] + async fn nip98_replay_check_logs_only_its_refusal() { + for (answer, outcome, reason) in [ + (Some(true), "undecided", None), + (Some(false), "failed", Some("nip98_replay")), + (None, "denied", Some("replay_check_unavailable")), + ] { + let lines = replay_auth_lines(answer).await; + assert_eq!(lines.len(), 1, "{answer:?}: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["event"], "http"); + assert_eq!(line["method"], "nip98"); + assert_eq!(line["outcome"], outcome); + assert_eq!(line["reason"].as_str(), reason); + assert_eq!(line["route"], "/query"); + assert!(line["community"].is_string()); + assert!(!line.to_string().contains("simulated outage")); + } } mod external_infra_redis_tests { @@ -3545,9 +3835,11 @@ pub(crate) mod postgres_tests { let tenant = fresh_tenant("relay-a.example"); let event_id_bytes = fresh_nip98_event_id_bytes(); - let (status, body) = check_nip98_replay_with_guard(&guard, &tenant, event_id_bytes) + let rejection = check_nip98_replay_with_guard(&guard, &tenant, event_id_bytes) .await .expect_err("guard error MUST fail closed, never admit"); + assert_eq!(rejection, ReplayRejection::Unavailable); + let (status, body) = <(StatusCode, Json)>::from(rejection); assert_eq!( status, StatusCode::UNAUTHORIZED, @@ -3619,11 +3911,18 @@ pub(crate) mod postgres_tests { let tenant_b = fresh_tenant("host-b.example"); let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events"); - let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) - .expect_err( - "cross-host NIP-98 event MUST be rejected — row 44: `u` URL host \ + let (status, body) = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "POST", + &expected_url, + Some(b""), + true, + ) + .expect_err( + "cross-host NIP-98 event MUST be rejected — row 44: `u` URL host \ must match req.community", - ); + ); assert_eq!( status, StatusCode::UNAUTHORIZED, @@ -3648,6 +3947,7 @@ pub(crate) mod postgres_tests { let headers = nip98_auth_headers(&event_json); let (status, body) = verify_bridge_auth_with_options( + &crate::access::AccessSlot::default(), &headers, "POST", signed_url, @@ -3689,8 +3989,15 @@ pub(crate) mod postgres_tests { pubkey, signed_created_at, .. - } = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) - .expect("matching-host NIP-98 event must verify"); + } = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "POST", + &expected_url, + Some(b""), + true, + ) + .expect("matching-host NIP-98 event must verify"); assert_eq!( pubkey, keys.public_key(), @@ -3741,9 +4048,15 @@ pub(crate) mod postgres_tests { Some("limit=20&status=open"), ); - let VerifiedBridgeAuth { pubkey, .. } = - verify_bridge_auth(&headers, "GET", &expected_url, None, true) - .expect("query-bearing moderation read must verify against the same query"); + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "GET", + &expected_url, + None, + true, + ) + .expect("query-bearing moderation read must verify against the same query"); assert_eq!(pubkey, keys.public_key()); } @@ -3767,8 +4080,15 @@ pub(crate) mod postgres_tests { None, ); - let (status, body) = verify_bridge_auth(&headers, "GET", &bare_url, None, true) - .expect_err("query-signed event MUST NOT match a bare-path expected URL"); + let (status, body) = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "GET", + &bare_url, + None, + true, + ) + .expect_err("query-signed event MUST NOT match a bare-path expected URL"); assert_eq!(status, StatusCode::UNAUTHORIZED); let msg = body .get("error") @@ -3798,9 +4118,15 @@ pub(crate) mod postgres_tests { Some("limit=20"), ); - let VerifiedBridgeAuth { pubkey, .. } = - verify_bridge_auth(&headers, "GET", &expected_url, None, true) - .expect("audit query-bearing read must verify"); + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "GET", + &expected_url, + None, + true, + ) + .expect("audit query-bearing read must verify"); assert_eq!(pubkey, keys.public_key()); } @@ -3823,9 +4149,15 @@ pub(crate) mod postgres_tests { ); assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); - let VerifiedBridgeAuth { pubkey, .. } = - verify_bridge_auth(&headers, "GET", &expected_url, None, true) - .expect("query-less restricted read must verify against the bare path"); + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth( + &crate::access::AccessSlot::default(), + &headers, + "GET", + &expected_url, + None, + true, + ) + .expect("query-less restricted read must verify against the bare path"); assert_eq!(pubkey, keys.public_key()); } @@ -4505,46 +4837,16 @@ pub(crate) mod postgres_tests { ); } - // ────────────────────────────────────────────────────────────────────────── - // truncate_reason regression tests - // - // Required by T1: prove a near-limit malformed input cannot produce a - // near-limit log payload. No infrastructure needed — pure unit tests. - // ────────────────────────────────────────────────────────────────────────── - - /// Near-limit ASCII input (1 MiB) must be capped at exactly `max_bytes`. #[test] - fn truncate_reason_large_ascii_input_is_bounded() { - let big = "a".repeat(1024 * 1024); // 1 MiB - let result = truncate_reason(&big, 256); - assert_eq!(result.len(), 256); - assert!(result.is_ascii()); - } - - /// A multi-byte codepoint that straddles the byte boundary must not be - /// split: the output must end at the last complete codepoint before the - /// cap, keeping the slice valid UTF-8. - #[test] - fn truncate_reason_multibyte_codepoint_at_boundary_is_not_split() { - // Build a string of 255 ASCII bytes followed by a 3-byte codepoint (€, U+20AC). - // Naïve cutoff at byte 256 would land inside the 3-byte sequence. - let mut s = "a".repeat(255); - s.push('€'); // 3 bytes: 0xE2 0x82 0xAC — spans bytes 255..258 - assert_eq!(s.len(), 258); - - let result = truncate_reason(&s, 256); - // Must end before the multi-byte codepoint. - assert_eq!(result.len(), 255); - assert_eq!(result, "a".repeat(255)); - assert!(std::str::from_utf8(result.as_bytes()).is_ok()); - } - - /// Short input well under the cap must be returned unchanged. - #[test] - fn truncate_reason_short_input_returned_unchanged() { - let s = "invalid: kind 24620 rejected"; - let result = truncate_reason(s, 256); - assert_eq!(result, s); + fn reject_code_keeps_only_a_known_prefix() { + assert_eq!(reject_code("invalid: visibility SECRET"), "invalid"); + assert_eq!(reject_code("invalid visibility: SECRET"), "invalid"); + assert_eq!( + reject_code("restricted: community writes are fenced"), + "restricted" + ); + assert_eq!(reject_code("SECRET: invalid"), "rejected"); + assert_eq!(reject_code("no prefix SECRET"), "rejected"); } // ────────────────────────────────────────────────────────────────────────── @@ -5156,9 +5458,8 @@ pub(crate) mod postgres_tests { // ────────────────────────────────────────────────────────────────────────── // Log-capture helpers and attribution-invariant tests // - // These tests assert that exactly ONE "HTTP bridge request" log line - // appears per authed request, pinning the W1 single-terminal-log invariant - // and the E1 no-double-log rule. + // These tests assert that exactly ONE access line, carrying both the + // login and the request's outcome, appears per authed request. // // Infrastructure: same `#[ignore = "requires Postgres"]` + current_thread // runtime discipline as the HTTP-counter tests above. @@ -5219,21 +5520,28 @@ pub(crate) mod postgres_tests { (status, captured) } - /// Count lines that contain the terminal attribution marker. + /// The access lines in `log`; each must carry the login and a status. fn count_attribution_lines(log: &str) -> usize { - log.lines() - .filter(|l| l.contains("HTTP bridge request")) - .count() + let lines: Vec<_> = log + .lines() + .filter(|l| l.contains(crate::access::TARGET)) + .collect(); + for line in &lines { + assert!( + line.contains("event=\"http\"") && line.contains("status="), + "the request's one line carries its login and status: {line}" + ); + } + lines.len() } /// T3a — exactly-once invariant, pre-parse 400 arm (invalid JSON). /// /// An authenticated client that submits a non-JSON body must produce - /// exactly ONE "HTTP bridge request" log line. This pins W1 (early exits - /// are attributed) and E1 (no double line even for the parse-fail arm). + /// exactly ONE access line, carrying its login and its 400 status. /// - /// Discriminating: if the attribution log is removed from the ParseFail - /// arm in submit_event, this test fails. + /// Discriminating: if `submit_event` stops completing the caller's line + /// with the outcome, or logs the login separately, this test fails. #[test] #[ignore = "requires Postgres"] fn submit_event_invalid_json_emits_exactly_one_attribution_line() { @@ -5271,6 +5579,7 @@ pub(crate) mod postgres_tests { n, 1, "expected exactly 1 attribution line for invalid-JSON arm, got {n};\nlog:\n{log}" ); + assert!(log.contains("status=400"), "log:\n{log}"); assert!( log.contains(&pubkey_hex[..16]), "attribution line must carry the pubkey;\nlog:\n{log}" @@ -5280,8 +5589,8 @@ pub(crate) mod postgres_tests { /// T3b — exactly-once invariant, post-parse IngestError::Rejected arm (relay-only kind). /// /// An authenticated client that submits a relay-only-kind event (kind 13534) - /// must produce exactly ONE "HTTP bridge request" log line — not two - /// (the old code emitted both an info! attribution and a warn! reason line). + /// must produce exactly ONE access line, carrying its login and its 400 + /// status, not a separate login line and request line. /// /// Discriminating: if two log lines are emitted (old double-log bug), this /// test fails. @@ -5331,6 +5640,7 @@ pub(crate) mod postgres_tests { n, 1, "expected exactly 1 attribution line for IngestError::Rejected arm, got {n};\nlog:\n{log}" ); + assert!(log.contains("status=400"), "log:\n{log}"); assert!( log.contains(&pubkey_hex[..16]), "attribution line must carry the pubkey;\nlog:\n{log}" @@ -5700,6 +6010,525 @@ pub(crate) mod postgres_tests { (status, resp_headers, resp_body) } + // ── HTTP auth access lines: one line, at the final decision ───────────── + + const QUERY_BODY: &[u8] = br#"[{"kinds":[1],"limit":1}]"#; + + /// `state` with a community at a fresh host, and that host. + async fn with_community(state: &crate::state::AppState) -> String { + let host = format!("auth-line-{}.local", uuid::Uuid::new_v4().simple()); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + host + } + + /// POST a NIP-98-signed `/query` through the full router; returns the + /// status and the request's access auth lines. + async fn signed_query_auth_lines( + state: Arc, + host: &str, + keys: &Keys, + ) -> (axum::http::StatusCode, Vec) { + let headers = + make_nip98_headers(keys, &format!("https://{host}/query"), "POST", QUERY_BODY); + let (_guard, out) = crate::access::tests::capture_scoped(); + let status = oneshot_request(state, "POST", "/query", host, headers, QUERY_BODY).await; + let lines = out + .lines() + .into_iter() + .filter(|line| line["event"] == "http") + .collect(); + (status, lines) + } + + fn assert_one_auth_line( + lines: &[serde_json::Value], + outcome: &str, + reason: Option<&str>, + ) -> serde_json::Value { + assert_eq!(lines.len(), 1, "exactly one access line: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["outcome"], outcome, "{line}"); + match reason { + Some(reason) => assert_eq!(line["reason"], reason, "{line}"), + None => assert!(line.get("reason").is_none(), "{line}"), + } + assert_eq!(line["route"], "/query"); + line.clone() + } + + /// Positive control: an admitted `/query` logs one success, after the + /// membership and ban checks. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn admitted_query_logs_one_success_line() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let keys = Keys::generate(); + let (status, lines) = signed_query_auth_lines(state, &host, &keys).await; + assert_eq!(status, axum::http::StatusCode::OK); + let line = assert_one_auth_line(&lines, "success", None); + assert_eq!(line["method"], "nip98"); + assert_eq!(line["pubkey"], keys.public_key().to_hex()); + assert_eq!(line["pubkey_verified"], true); + } + + /// The same signed `/query` sent twice: the second is refused by the + /// replay check inside `ProvenCaller`, and that refusal is its one line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn replayed_query_logs_one_failed_line() { + /// Admits each event id once, like the Redis guard. + struct SeenOnce(std::sync::Mutex>); + impl Nip98ReplayGuard for SeenOnce { + fn try_mark_in_scope<'a>( + &'a self, + scope: &'a str, + event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + let fresh = self + .0 + .lock() + .expect("seen set") + .insert(format!("{scope}:{event_id}")); + Box::pin(async move { Ok(fresh) }) + } + } + let mut state = (*nip_fi_off_test_state().await.expect("local Postgres")).clone(); + state.nip98_replay = Arc::new(SeenOnce(Default::default())); + let state = Arc::new(state); + let host = with_community(&state).await; + let keys = Keys::generate(); + let headers = + make_nip98_headers(&keys, &format!("https://{host}/query"), "POST", QUERY_BODY); + let (_guard, out) = crate::access::tests::capture_scoped(); + for expected in [ + axum::http::StatusCode::OK, + axum::http::StatusCode::UNAUTHORIZED, + ] { + let status = oneshot_request( + Arc::clone(&state), + "POST", + "/query", + &host, + headers.clone(), + QUERY_BODY, + ) + .await; + assert_eq!(status, expected); + } + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|line| line["event"] == "http") + .collect(); + assert_eq!(lines.len(), 2, "one line per request: {lines:?}"); + assert_eq!(lines[0]["outcome"], "success", "{lines:?}"); + assert_one_auth_line(&lines[1..], "failed", Some("nip98_replay")); + assert_eq!( + lines[1]["status"], 401, + "a refused login keeps its status: {lines:?}" + ); + } + + /// A served `/query` writes one access line: the login, the route's + /// status and result count, and the filter summary, and no separate + /// bridge line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn query_writes_one_line_with_login_outcome_and_filters() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let keys = Keys::generate(); + let headers = + make_nip98_headers(&keys, &format!("https://{host}/query"), "POST", QUERY_BODY); + let (_guard, out) = crate::access::tests::capture_scoped(); + let status = oneshot_request(state, "POST", "/query", &host, headers, QUERY_BODY).await; + assert_eq!(status, axum::http::StatusCode::OK); + assert!(!out.raw().contains("HTTP bridge request"), "{}", out.raw()); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "one line per request: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["level"], "INFO", "{line}"); + assert_eq!(line["event"], "http", "{line}"); + assert_eq!(line["outcome"], "success", "{line}"); + assert_eq!(line["method"], "nip98", "{line}"); + assert_eq!(line["pubkey"], keys.public_key().to_hex(), "{line}"); + assert_eq!(line["pubkey_verified"], true, "{line}"); + assert_eq!(line["route"], "/query", "{line}"); + assert_eq!(line["status"], 200, "{line}"); + assert_eq!(line["result_count"], 0, "{line}"); + assert_eq!( + (&line["kinds"], &line["limit"]), + (&"1".into(), &1.into()), + "{line}" + ); + assert_eq!(line["authors"], "*", "an open dimension is `*`: {line}"); + } + + /// POST `body` to `route` signed by `keys` for `signed_url`, and return + /// the status and every access line the request wrote. + async fn post_access_lines( + state: Arc, + host: &str, + route: &str, + signed_url: &str, + keys: &Keys, + body: &[u8], + ) -> (axum::http::StatusCode, Vec, String) { + let headers = make_nip98_headers(keys, signed_url, "POST", body); + let (_guard, out) = crate::access::tests::capture_scoped(); + let status = oneshot_request(state, "POST", route, host, headers, body).await; + (status, out.lines(), out.raw()) + } + + /// A refused `/events` write logs a fixed code; the event's own tag + /// values, which the rejection message quotes, appear nowhere. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn rejected_write_logs_a_fixed_code_never_tag_text() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let keys = Keys::generate(); + for (tag, marker) in [ + ("visibility", "LEAKMARKERVISIBILITY7f3a"), + ("channel_type", "LEAKMARKERCHANNELTYPE9c1d"), + ] { + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9007), "") + .tags([ + nostr::Tag::parse(["name", "marker-test"]).unwrap(), + nostr::Tag::parse([tag, marker]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let body = serde_json::to_vec(&event).unwrap(); + let url = format!("https://{host}/events"); + let (status, lines, raw) = + post_access_lines(Arc::clone(&state), &host, "/events", &url, &keys, &body).await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{raw}"); + assert!(!raw.contains(marker), "tag text leaked into logs: {raw}"); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["reject_reason"], "invalid", "{}", lines[0]); + assert_eq!(lines[0]["accepted"], false, "{}", lines[0]); + } + } + + /// A served artifact `/query` and `/count` record which kinds, channel + /// and limit they read, like any other read. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn artifact_reads_log_their_filter_summary() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let keys = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + let body = serde_json::to_vec(&serde_json::json!([{ + "artifact": "current", + "kinds": [45010], + "#h": [channel], + "limit": 1 + }])) + .unwrap(); + for route in ["/query", "/count"] { + let url = format!("https://{host}{route}"); + let (status, lines, _) = + post_access_lines(Arc::clone(&state), &host, route, &url, &keys, &body).await; + assert_eq!(status, axum::http::StatusCode::OK, "{lines:?}"); + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["route"], route, "{line}"); + assert_eq!(line["kinds"], "45010", "{line}"); + assert_eq!(line["channel_ids"], channel.as_str(), "{line}"); + assert_eq!(line["limit"], 1, "{line}"); + assert_eq!(line["filters"], 1, "{line}"); + } + } + + /// A client-defined tag the plain filter parser rejects (`#1`) narrows + /// an artifact read but must not cancel its summary, and its value is + /// never logged. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn artifact_reads_with_custom_tags_keep_their_summary() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let keys = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + let marker = "CUSTOMTAGMARKER4b2e"; + let body = serde_json::to_vec(&serde_json::json!([{ + "artifact": "current", + "kinds": [45010], + "#h": [channel], + "#1": [marker], + "limit": 1 + }])) + .unwrap(); + for route in ["/query", "/count"] { + let url = format!("https://{host}{route}"); + let (status, lines, raw) = + post_access_lines(Arc::clone(&state), &host, route, &url, &keys, &body).await; + assert_eq!(status, axum::http::StatusCode::OK, "{raw}"); + assert_eq!(lines.len(), 1, "{lines:?}"); + let line = &lines[0]; + assert_eq!(line["kinds"], "45010", "{line}"); + assert_eq!(line["channel_ids"], channel.as_str(), "{line}"); + assert_eq!(line["limit"], 1, "{line}"); + assert!(!raw.contains(marker), "custom tag value logged: {raw}"); + } + } + + /// Assert `lines` is one refusal for `route` carrying `status`, the + /// status the client actually received. + fn assert_one_refusal( + lines: &[serde_json::Value], + route: &str, + status: axum::http::StatusCode, + ) { + assert_eq!(lines.len(), 1, "one line per refused request: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["route"], route, "{line}"); + assert_eq!(line["status"], status.as_u16(), "{line}"); + assert_eq!(line["level"], "WARN", "{line}"); + assert_ne!(line["outcome"], "success", "{line}"); + } + + /// Off mode: a proof signed for another URL is refused 401, and its one + /// line says so. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn off_mode_bad_proof_logs_route_and_status() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let wrong = format!("https://{host}/elsewhere"); + let (status, lines, _) = post_access_lines( + state, + &host, + "/query", + &wrong, + &Keys::generate(), + QUERY_BODY, + ) + .await; + assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED); + assert_one_refusal(&lines, "/query", status); + } + + /// Enforce mode: a missing assertion (refused by the router guard) and a + /// bad proof beside a valid assertion (refused by the handler's + /// admission, which remaps the proof's 401) each log the status sent. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn enforce_mode_refusals_log_route_and_final_status() { + let keys = Keys::generate(); + + let state = nip_fi_enforce_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let url = format!("https://{host}/query"); + let (status, lines, _) = + post_access_lines(state, &host, "/query", &url, &keys, QUERY_BODY).await; + assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED); + assert_one_refusal(&lines, "/query", status); + assert_eq!(lines[0]["reason"], "assertion_missing", "{}", lines[0]); + + let state = nip_fi_enforce_test_state_with_verifier() + .await + .expect("local Postgres"); + let host = with_community(&state).await; + let headers = same_key_nip98_and_assertion_headers( + &keys, + &format!("https://{host}/elsewhere"), + "POST", + QUERY_BODY, + ); + let (_guard, out) = crate::access::tests::capture_scoped(); + let status = oneshot_request(state, "POST", "/query", &host, headers, QUERY_BODY).await; + assert!(status.is_client_error(), "{status}"); + let lines = out.lines(); + assert_one_refusal(&lines, "/query", status); + assert_eq!(lines[0]["method"], "nip98", "{}", lines[0]); + } + + /// DenyProtected: the unconditional 503 logs its route and status. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn deny_protected_refusal_logs_route_and_status() { + let state = nip_fi_deny_protected_test_state() + .await + .expect("local Postgres"); + let host = with_community(&state).await; + let url = format!("https://{host}/query"); + let (status, lines, _) = + post_access_lines(state, &host, "/query", &url, &Keys::generate(), QUERY_BODY).await; + assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_one_refusal(&lines, "/query", status); + } + + /// A valid proof from a banned identity is refused by the ban check + /// after replay: one `denied` line, never a `success`. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn banned_query_logs_one_denied_line() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let tenant = crate::tenant::bind_community(&state.db, &host) + .await + .expect("bind community"); + let keys = Keys::generate(); + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, true, $2)", + ) + .bind(tenant.community().as_uuid()) + .bind(keys.public_key().to_bytes().as_slice()) + .execute(state.db.pool()) + .await + .expect("ban"); + let (status, lines) = signed_query_auth_lines(state, &host, &keys).await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); + assert_one_auth_line(&lines, "denied", Some("banned")); + } + + /// On a members-only relay a valid proof from a nonmember is refused by + /// the membership check: one `denied` line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn nonmember_query_logs_one_denied_line() { + let mut state = (*nip_fi_off_test_state().await.expect("local Postgres")).clone(); + Arc::make_mut(&mut state.config).require_relay_membership = true; + let host = with_community(&state).await; + let (status, lines) = + signed_query_auth_lines(Arc::new(state), &host, &Keys::generate()).await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); + assert_one_auth_line(&lines, "denied", Some("not_relay_member")); + } + + /// The shared API rate limit refuses before replay: one `denied` line + /// for an exhausted quota and for an unreachable limiter. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres and Redis"] + async fn rate_limited_query_logs_one_denied_line() { + let base = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&base).await; + + let mut quota = (*base).clone(); + let mut auth_config = quota.config.auth.clone(); + auth_config.rate_limits.human_api_calls_per_min = 0; + quota.auth = Arc::new(buzz_auth::AuthService::new(auth_config)); + let (status, lines) = + signed_query_auth_lines(Arc::new(quota), &host, &Keys::generate()).await; + assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS); + assert_one_auth_line(&lines, "denied", Some("quota_exceeded")); + + let mut unavailable = (*base).clone(); + let dead = deadpool_redis::Config::from_url("redis://127.0.0.1:1") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + unavailable.admission_rate_limiter = + Arc::new(buzz_pubsub::rate_limiter::RedisRateLimiter::new(dead)); + let (status, lines) = + signed_query_auth_lines(Arc::new(unavailable), &host, &Keys::generate()).await; + assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_one_auth_line(&lines, "denied", Some("admission_unavailable")); + } + + /// The outer assertion guard refuses before any handler runs, so it logs + /// the refusal itself: a missing assertion in Enforce and every request + /// in DenyProtected. In Shadow it refuses nothing and logs nothing, so the + /// handler's own line is the request's only one. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn assertion_guard_logs_its_refusals_and_shadow_logs_nothing() { + let enforce = nip_fi_enforce_test_state().await.expect("local Postgres"); + let host = with_community(&enforce).await; + let keys = Keys::generate(); + + let (status, lines) = signed_query_auth_lines(enforce.clone(), &host, &keys).await; + assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED); + let line = assert_one_auth_line(&lines, "failed", Some("assertion_missing")); + assert_eq!(line["method"], "nip_fi"); + + let mut deny = (*enforce).clone(); + Arc::make_mut(&mut deny.config).nip_fi.mode = buzz_auth::NipFiMode::DenyProtected; + let (status, lines) = signed_query_auth_lines(Arc::new(deny), &host, &keys).await; + assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_one_auth_line(&lines, "denied", Some("authorization_unavailable")); + + let mut shadow = (*enforce).clone(); + Arc::make_mut(&mut shadow.config).nip_fi.mode = buzz_auth::NipFiMode::Shadow; + let (status, lines) = signed_query_auth_lines(Arc::new(shadow), &host, &keys).await; + assert_eq!(status, axum::http::StatusCode::OK); + let line = assert_one_auth_line(&lines, "success", None); + assert_eq!(line["method"], "nip98"); + } + + /// A failed workflow-deletion write reaches the HTTP ERROR log as a fixed + /// kind: a database error quoting the event's content never appears. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn failed_workflow_deletion_logs_no_database_error_text() { + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let marker = format!("wfdel-marker-{}", uuid::Uuid::new_v4().simple()); + // A row trigger that fails with the inserted content as its message, + // for marker events only. + for sql in [ + "CREATE OR REPLACE FUNCTION access_log_test_fail() RETURNS trigger \ + LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION '%', NEW.content; END$$", + "DROP TRIGGER IF EXISTS access_log_test_fail ON events", + "CREATE TRIGGER access_log_test_fail BEFORE INSERT ON events FOR EACH ROW \ + WHEN (NEW.content LIKE 'wfdel-marker-%') EXECUTE FUNCTION access_log_test_fail()", + ] { + sqlx::query(sql) + .execute(state.db.pool()) + .await + .expect("install trigger"); + } + + let keys = Keys::generate(); + let coordinate = format!( + "30620:{}:{}", + keys.public_key().to_hex(), + uuid::Uuid::new_v4() + ); + let event = nostr::EventBuilder::new(nostr::Kind::EventDeletion, &marker) + .tag(nostr::Tag::parse(["a", &coordinate]).unwrap()) + .sign_with_keys(&keys) + .unwrap(); + let body = nostr::JsonUtil::as_json(&event).into_bytes(); + let headers = make_nip98_headers(&keys, &format!("https://{host}/events"), "POST", &body); + let (guard, out) = crate::access::tests::capture_scoped(); + // Catch a panic in the request so the shared trigger is removed first. + let status = futures_util::FutureExt::catch_unwind(std::panic::AssertUnwindSafe( + oneshot_request(Arc::clone(&state), "POST", "/events", &host, headers, &body), + )) + .await; + drop(guard); + sqlx::query("DROP TRIGGER IF EXISTS access_log_test_fail ON events") + .execute(state.db.pool()) + .await + .expect("remove trigger"); + let status = status.unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + + assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); + let raw = out.raw(); + let error = raw + .lines() + .find(|line| line.contains("\"level\":\"ERROR\"")) + .unwrap_or_else(|| panic!("an ERROR line: {raw}")); + assert!(error.contains("workflow deletion write failed"), "{error}"); + assert!(!raw.contains(&marker), "marker leaked: {raw}"); + } + // ── F4: bridge POST /events — enforce mode, no assertion → 401 ────────── // // Exercises the OUTER assertion guard in `router.rs` (not the per-handler @@ -9249,10 +10078,22 @@ pub(crate) mod postgres_tests { Arc, tokio::sync::mpsc::Receiver, ) { - let (send_tx, send_rx) = tokio::sync::mpsc::channel(64); + self.ws_conn_with_capacity(64) + } + + /// A connection whose writer queue holds `capacity` frames. + fn ws_conn_with_capacity( + &self, + capacity: usize, + ) -> ( + Arc, + tokio::sync::mpsc::Receiver, + ) { + let (send_tx, send_rx) = tokio::sync::mpsc::channel(capacity); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(4); let cancel = tokio_util::sync::CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: TenantContext::resolved(self.community, self.host.clone()), remote_addr: "127.0.0.1:1234".parse().expect("addr"), @@ -9487,6 +10328,50 @@ pub(crate) mod postgres_tests { fx.drop().await; } + /// A history read that queues one event and then fails to enqueue the + /// next logs `aborted` with the event it already queued, not zero. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn aborted_history_read_logs_events_already_queued() { + let fx = cancel_fixture().await; + let channel: uuid::Uuid = fx.channel.parse().expect("channel uuid"); + let author = Keys::generate(); + for text in ["second", "third"] { + let event = EventBuilder::new(Kind::Custom(9), text) + .tag(Tag::parse(["h", &fx.channel]).expect("h")) + .sign_with_keys(&author) + .expect("sign"); + for pool in [&fx.writer, &fx.replica] { + buzz_db::Db::from_pool(pool.clone()) + .insert_event(fx.community, &event, Some(channel)) + .await + .expect("insert event"); + } + } + // The queue holds one frame and nobody drains it: the first EVENT is + // queued and the second enqueue fails, ending the read. + let (conn, _rx) = fx.ws_conn_with_capacity(1); + let filter: nostr::Filter = + serde_json::from_value(serde_json::json!({"kinds": [9], "#h": [&fx.channel]})) + .expect("filter"); + let (guard, out) = crate::access::tests::capture_scoped(); + crate::handlers::req::handle_req( + "p".into(), + vec![filter], + vec![None], + conn, + fx.state.clone(), + ) + .await; + drop(guard); + let lines = out.lines(); + let reads: Vec<_> = lines.iter().filter(|l| l["event"] == "req").collect(); + assert_eq!(reads.len(), 1, "one access line per REQ: {lines:?}"); + assert_eq!(reads[0]["outcome"], "aborted"); + assert_eq!(reads[0]["events_queued"], 1); + fx.drop().await; + } + /// A search REQ reusing a live subscription's ID retires it (NIP-01 /// replacement) before its hydrate is cancelled, so the timeout CLOSED /// leaves nothing registered under the ID. @@ -9612,11 +10497,13 @@ pub(crate) mod postgres_tests { let channel: uuid::Uuid = fx.channel.parse().expect("channel uuid"); let topic = buzz_pubsub::EventTopic::Channel(channel); let tenant = TenantContext::resolved(fx.community, fx.host.clone()); - let fails_and_retires = |sub: &'static str, reason: &'static str| { + let fails_and_retires = |sub: &'static str, reason: &'static str, logged: &'static str| { let state = fx.state.clone(); let (conn, mut rx) = fx.ws_conn(); let tenant = tenant.clone(); + let channel = fx.channel.clone(); async move { + let (_guard, out) = crate::access::tests::capture_scoped(); handle_req( sub.into(), vec![req()], @@ -9625,6 +10512,17 @@ pub(crate) mod postgres_tests { state.clone(), ) .await; + // The read line is a WARN error that keeps its filter summary. + let lines = out.lines(); + let line = lines + .iter() + .find(|l| l["event"] == "req") + .unwrap_or_else(|| panic!("no req line: {lines:?}")); + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["outcome"], "error", "{line}"); + assert_eq!(line["reason"], logged, "{line}"); + assert_eq!(line["kinds"], "9", "{line}"); + assert_eq!(line["channel_ids"], channel, "{line}"); assert_eq!( frames(&mut rx), vec![format!(r#"["CLOSED","{sub}","{reason}"]"#)], @@ -9646,7 +10544,12 @@ pub(crate) mod postgres_tests { .execute(&mut *locker) .await .expect("lock replica events"); - fails_and_retires("t", crate::handlers::req::QUERY_TIMED_OUT_CLOSED).await; + fails_and_retires( + "t", + crate::handlers::req::QUERY_TIMED_OUT_CLOSED, + "query_timed_out", + ) + .await; locker.rollback().await.expect("unlock"); // `events` gone on both pools: the history read fails with 42P01 on the @@ -9665,7 +10568,12 @@ pub(crate) mod postgres_tests { } }; rename("events", "events_gone").await; - fails_and_retires("h", crate::handlers::req::DATABASE_ERROR_CLOSED).await; + fails_and_retires( + "h", + crate::handlers::req::DATABASE_ERROR_CLOSED, + "database_error", + ) + .await; // The DB recovers and the client reconnects with the same REQ. rename("events_gone", "events").await; @@ -9854,10 +10762,13 @@ pub(crate) mod postgres_tests { &tenant, &headers, serde_json::to_vec(&event).expect("event json").as_slice(), - agent.public_key(), - fresh_nip98_event_id_bytes(), - Some(nostr::Timestamp::now().as_secs()), - None, + &mut ProvenCaller { + pubkey: agent.public_key(), + event_id_bytes: fresh_nip98_event_id_bytes(), + signed_at: Some(nostr::Timestamp::now().as_secs()), + token_owner: None, + slot: AccessSlot::default(), + }, ) .await; assert!( diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index 920221d856..98707bd960 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -85,6 +85,7 @@ pub(super) struct Principal { pub(super) async fn authorize( state: &Arc, + slot: &crate::access::AccessSlot, headers: &HeaderMap, uri: &Uri, method: &'static str, @@ -103,7 +104,9 @@ pub(super) async fn authorize( let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( state, headers, + slot, bridge::make_nip98_closure_for_admission( + slot.clone(), headers.clone(), method, url, @@ -121,30 +124,44 @@ pub(super) async fn authorize( bridge_error((response.status(), Json(Value::Null))) } })?; - let actor = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id, signed_at) = admission.into_extra(); - bridge::enforce_http_admission(state, &tenant, &actor) - .await - .map_err(bridge_error)?; - bridge::check_nip98_replay(state, &tenant, event_id) + let mut caller = bridge::ProvenCaller::new(admission, slot, &tenant); + caller + .rate_limit_and_replay(state, &tenant) .await .map_err(bridge_error)?; let principal = Principal { tenant, - actor, - signed_at, - token_owner, + actor: caller.pubkey, + signed_at: caller.signed_at, + token_owner: caller.token_owner, }; - recheck(state, headers, &principal).await?; + caller + .admit( + admission_checks(state, headers, &principal).await, + |(_, refusal)| *refusal, + ) + .map_err(|(error, _)| error)?; Ok(principal) } +/// Re-run the per-request admission checks for an authorized principal. pub(super) async fn recheck( state: &AppState, headers: &HeaderMap, principal: &Principal, ) -> Result<(), Error> { + admission_checks(state, headers, principal) + .await + .map_err(|(error, _)| error) +} + +/// Relay membership, then community ban. A refusal carries its access-log +/// result alongside the response error. +async fn admission_checks( + state: &AppState, + headers: &HeaderMap, + principal: &Principal, +) -> Result<(), (Error, crate::access::AuthResult)> { relay_members::enforce_relay_membership( state, &principal.tenant, @@ -154,16 +171,23 @@ pub(super) async fn recheck( principal.token_owner, ) .await - .map_err(|refusal| match refusal { - relay_members::MembershipRefusal::Api(refusal) => bridge_error(refusal), - // Preserve the shared NIP-FI wire contract, as admission does. - refusal => Error::Admission(Box::new(refusal.into_response())), + .map_err(|refusal| { + let result = refusal.auth_result(); + let error = match refusal { + relay_members::MembershipRefusal::Api(_, refusal) => bridge_error(refusal), + // Preserve the shared NIP-FI wire contract, as admission does. + refusal => Error::Admission(Box::new(refusal.into_response())), + }; + (error, result) })?; // A verified agent token gets only NIP-FI's fixed denial here too, so a // ban or outage between the two reads never shows which step refused it. - let refuse = |class, legacy: Error| match principal.token_owner { - Some(_) => Error::Admission(Box::new(crate::nip_fi_core::http_denial(class))), - None => legacy, + let refuse = |class, legacy: Error, reason| { + let error = match principal.token_owner { + Some(_) => Error::Admission(Box::new(crate::nip_fi_core::http_denial(class))), + None => legacy, + }; + (error, crate::access::AuthResult::Denied(reason)) }; #[cfg(test)] crate::nip_fi_test_hooks::before_http_ban_recheck(principal.tenant.community()).await; @@ -171,11 +195,18 @@ pub(super) async fn recheck( .db .moderation_restriction_state(principal.tenant.community(), &principal.actor.to_bytes()) .await - .map_err(|_| refuse(DenialClass::AuthorizationUnavailable, Error::unavailable()))?; + .map_err(|_| { + refuse( + DenialClass::AuthorizationUnavailable, + Error::unavailable(), + crate::access::AuthReason::BanCheckUnavailable, + ) + })?; if restrictions.banned { return Err(refuse( DenialClass::AuthorizationDenied, Error::new(StatusCode::FORBIDDEN, "forbidden"), + crate::access::AuthReason::Banned, )); } // Timeouts block posting conversation content, not personal reading. diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index f3dbf09565..c6c24d79b4 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -35,12 +35,13 @@ pub(super) struct SidebarQuery { pub(super) async fn sidebar( State(state): State>, + slot: crate::access::AccessSlot, headers: HeaderMap, OriginalUri(uri): OriginalUri, query: Result, axum::extract::rejection::QueryRejection>, ) -> Result { tokio::time::timeout(Duration::from_secs(8), async { - let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; + let principal = auth::authorize(&state, &slot, &headers, &uri, "GET", None).await?; let Query(query) = query.map_err(|_| Error::invalid())?; if query .limit @@ -139,6 +140,7 @@ struct Batch { pub(super) async fn write( State(state): State>, + slot: crate::access::AccessSlot, headers: HeaderMap, OriginalUri(uri): OriginalUri, body: Bytes, @@ -146,7 +148,7 @@ pub(super) async fn write( if body.len() > 64 * 1024 { return Err(Error::invalid()); } - let principal = auth::authorize(&state, &headers, &uri, "POST", Some(&body)).await?; + let principal = auth::authorize(&state, &slot, &headers, &uri, "POST", Some(&body)).await?; let batch: Batch = serde_json::from_slice(&body).map_err(|_| Error::invalid())?; if batch.intents.is_empty() || batch.intents.len() > MAX_INTENTS { return Err(Error::invalid()); diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 8c235e90d8..1c15804004 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -438,11 +438,17 @@ async fn accessory_write_revocation_is_terminal_before_persistence() { .parse() .unwrap(), ); - let principal = - super::auth::authorize(&state, &headers, &path.parse().unwrap(), "POST", Some(body)) - .await - .ok() - .expect("admitted before revoke"); + let principal = super::auth::authorize( + &state, + &crate::access::AccessSlot::default(), + &headers, + &path.parse().unwrap(), + "POST", + Some(body), + ) + .await + .ok() + .expect("admitted before revoke"); state .db .remove_relay_member( diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c1d087dfce..0dc247b2ee 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -217,15 +217,19 @@ fn successful_search_payload(upstream: &Value) -> Result>, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Response { - search_inner(state, headers, body).await.into_response() + search_inner(state, slot, headers, body) + .await + .into_response() } #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn search_inner( state: Arc, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, Response> { @@ -233,7 +237,7 @@ async fn search_inner( // check (including provider availability) so the denial contract wins over // config or request-validation errors. [FI-TRACE-HTTP-INGRESS] let (tenant, pubkey) = - bridge::authenticate_member_post(&state, &headers, SEARCH_PATH, &body).await?; + bridge::authenticate_member_post(&state, &slot, &headers, SEARCH_PATH, &body).await?; let Some(config) = state.config.klipy.as_ref() else { return Err( @@ -294,21 +298,25 @@ async fn search_inner( /// Report a selected GIF to KLIPY so the provider can update Recents. pub async fn share( State(state): State>, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Response { - share_inner(state, headers, body).await.into_response() + share_inner(state, slot, headers, body) + .await + .into_response() } #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn share_inner( state: Arc, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { // Admission first: NIP-98 + NIP-FI must fire before provider availability // check. [FI-TRACE-HTTP-INGRESS] - bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; + bridge::authenticate_member_post(&state, &slot, &headers, SHARE_PATH, &body).await?; let Some(config) = state.config.klipy.as_ref() else { return Err( diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index f11e11026b..69e6f06514 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -166,6 +166,7 @@ struct SettingsAuth { async fn authenticate( state: &Arc, + slot: &crate::access::AccessSlot, headers: &HeaderMap, path: &str, body: Option<&[u8]>, @@ -188,7 +189,9 @@ async fn authenticate( let admission = admit_nip_fi_http_on_state( state, headers, + slot, bridge::make_nip98_closure_for_admission( + slot.clone(), headers.clone(), method, url, @@ -198,18 +201,16 @@ async fn authenticate( ), ) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); + let mut caller = bridge::ProvenCaller::new(admission, slot, &tenant); + let (pubkey, signed_created_at, token_owner) = + (caller.pubkey, caller.signed_at, caller.token_owner); - bridge::enforce_http_admission(state, &tenant, &pubkey) - .await - .map_err(IntoResponse::into_response)?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes) + caller + .rate_limit_and_replay(state, &tenant) .await .map_err(IntoResponse::into_response)?; let tag = relay_members::extract_auth_tag_header(headers); - relay_members::enforce_relay_membership( + let membership = relay_members::enforce_relay_membership( state, &tenant, pubkey.as_bytes(), @@ -217,11 +218,13 @@ async fn authenticate( signed_created_at, token_owner, ) - .await - .map_err(IntoResponse::into_response)?; + .await; + caller + .check(membership, relay_members::MembershipRefusal::auth_result) + .map_err(IntoResponse::into_response)?; #[cfg(test)] crate::nip_fi_test_hooks::before_http_ban_recheck(tenant.community()).await; - deny_banned_git_principal( + let ban = deny_banned_git_principal( &state.db, tenant.community(), &pubkey, @@ -229,17 +232,24 @@ async fn authenticate( signed_created_at, token_owner, ) - .await - .map_err(|legacy| match token_owner { - // A verified agent token gets only NIP-FI's fixed denial, as smart - // Git transport does, so the response never shows which step refused. - Some(_) => crate::nip_fi_core::http_denial(if legacy.status().is_server_error() { - buzz_auth::DenialClass::AuthorizationUnavailable - } else { - buzz_auth::DenialClass::AuthorizationDenied - }), - None => legacy, - })?; + .await; + caller + .admit(ban, |refused| { + crate::access::AuthResult::Denied(match refused.status() { + StatusCode::FORBIDDEN => crate::access::AuthReason::Banned, + _ => crate::access::AuthReason::BanCheckUnavailable, + }) + }) + .map_err(|legacy| match token_owner { + // A verified agent token gets only NIP-FI's fixed denial, as smart + // Git transport does, so the response never shows which step refused. + Some(_) => crate::nip_fi_core::http_denial(if legacy.status().is_server_error() { + buzz_auth::DenialClass::AuthorizationUnavailable + } else { + buzz_auth::DenialClass::AuthorizationDenied + }), + None => legacy, + })?; // Admission ignores kind= restrictions by design (NIP-AA). Repository // management must not turn a message-only credential into write authority. // This HTTP operation has no event kind: only kind-unrestricted credentials @@ -331,12 +341,13 @@ async fn authorize_management( async fn get_default_branch( State(state): State>, + slot: crate::access::AccessSlot, Path((owner, repo)): Path<(String, String)>, headers: HeaderMap, ) -> Result, Response> { let path = format!("/git/{owner}/{repo}/default-branch"); let repo_name = validate_repo_id(&owner, &repo)?; - let auth = authenticate(&state, &headers, &path, None).await?; + let auth = authenticate(&state, &slot, &headers, &path, None).await?; authorize_git_read( &state.db, auth.tenant.community(), @@ -352,13 +363,14 @@ async fn get_default_branch( async fn set_default_branch( State(state): State>, + slot: crate::access::AccessSlot, Path((owner, repo)): Path<(String, String)>, headers: HeaderMap, body: Bytes, ) -> Result, Response> { let path = format!("/git/{owner}/{repo}/default-branch"); let repo_name = validate_repo_id(&owner, &repo)?; - let auth = authenticate(&state, &headers, &path, Some(&body)).await?; + let auth = authenticate(&state, &slot, &headers, &path, Some(&body)).await?; let announcement = authorize_git_read( &state.db, auth.tenant.community(), diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 0df9101bc0..2de7040189 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -11,6 +11,7 @@ // Response is the natural error type for axum handlers. #![allow(clippy::result_large_err)] +use crate::access::{auth_error_code, AccessSlot, AuthMethod, AuthResult}; use std::future::Future; use std::path::Path; use std::sync::Arc; @@ -105,8 +106,10 @@ impl axum::extract::FromRequestParts> for GitAuth { // must be verified against the tenant-bound host, not a process-global // domain. let mode = state.config.nip_fi.mode; + let slot = AccessSlot::of(parts); if !mode.restricts() { if let Err(rejection) = parse_git_auth_header(&parts.headers, method) { + git_header_rejected(&slot, &parts.headers); // Off returns `rejection` as is; shadow also records the // enforce verdict for the failed proof. crate::nip_fi_http::observe_failed_proof(state, &parts.headers).await; @@ -155,53 +158,19 @@ impl axum::extract::FromRequestParts> for GitAuth { // Token is time-bounded (±60s) and URL-locked — acceptable trade-off. let headers_clone = parts.headers.clone(); let method_str = method.to_owned(); + let proof_slot = slot.clone(); let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( state, &parts.headers, - move || -> Result, Response> { - // In Off mode `parse_git_auth_header` already ran above and - // succeeded, so re-parsing here is purely for the return value. - // In Enforce mode it runs for the first time inside this closure - // (on the auth-rejection path the NIP-FI layer maps the error). - let (event_json, method_for_verify) = - parse_git_auth_header_full(&headers_clone, &method_str)?; - - // SECURITY: method intentionally not verified for git routes. The tautological - // check (event.method == event.method) is deliberate — see comment block above. - // Git's credential protocol signs once with GET and reuses for POST. The URL tag - // provides the real security boundary (±60s timestamp + URL lock + HTTPS). - let pubkey = buzz_auth::nip98::verify_nip98_event( - &event_json, - &expected_url, - &method_for_verify, - None, - ) - .map_err(|e| { - warn!(error = %e, "git NIP-98 auth failed"); - (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() - })?; - - // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. - // Git's credential protocol reuses one signed token across multiple requests - // in a session (info_refs GET -> upload-pack/receive-pack POST). Rejecting - // replayed event IDs would break normal clone/push operations. - // The +-60s timestamp window + URL scoping + HTTPS transport provide sufficient - // replay protection for v1. Per-request signing requires protocol changes. - - let event: nostr::Event = serde_json::from_str(&event_json).map_err(|_| { - (StatusCode::UNAUTHORIZED, "invalid auth event").into_response() - })?; - let signed_auth_created_at = event.created_at.as_secs(); - - Ok(crate::nip_fi_http::Nip98Proof::new( - pubkey, - (event, signed_auth_created_at), - )) - }, + &slot, + move || verify_git_proof(&headers_clone, &method_str, &expected_url, &proof_slot), ) .await?; let pubkey = *admission.proven_pubkey(); + // Git reuses one signed token per session, so there is no replay + // step: the membership and ban gates below make the final decision. + slot.in_community(tenant.community()); let token_owner = admission.token_owner(); let (event, signed_auth_created_at) = admission.into_extra(); @@ -244,6 +213,9 @@ impl axum::extract::FromRequestParts> for GitAuth { { Ok(crate::api::relay_members::MembershipDecision::Denied) => { warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::NotRelayMember, + )); return Err(denied( (StatusCode::FORBIDDEN, "restricted: not a relay member").into_response(), )); @@ -251,6 +223,9 @@ impl axum::extract::FromRequestParts> for GitAuth { Ok(_) => {} Err(e) => { warn!(pubkey = %pubkey.to_hex(), error = %e, "git: relay membership lookup failed"); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::MembershipUnavailable, + )); return Err(unavailable( ( StatusCode::SERVICE_UNAVAILABLE, @@ -272,8 +247,12 @@ impl axum::extract::FromRequestParts> for GitAuth { .await .map_err(|denial| { if denial.status() == StatusCode::SERVICE_UNAVAILABLE { + slot.finish(AuthResult::Denied( + crate::access::AuthReason::BanCheckUnavailable, + )); unavailable(denial) } else { + slot.finish(AuthResult::Denied(crate::access::AuthReason::Banned)); denied(denial) } })?; @@ -285,22 +264,27 @@ impl axum::extract::FromRequestParts> for GitAuth { match link_agent_owner(state, &tenant, &pubkey, &owner).await { OwnerLink::Linked => {} OwnerLink::Conflict => { + slot.finish(AuthResult::Denied(crate::access::AuthReason::OwnerMismatch)); return Err(crate::nip_fi_core::http_denial( buzz_auth::DenialClass::AuthorizationDenied, - )) + )); } OwnerLink::Unavailable => { + slot.finish(AuthResult::Denied( + crate::access::AuthReason::NipOaOwnerUnlinked, + )); return Err(unavailable( ( StatusCode::SERVICE_UNAVAILABLE, crate::handlers::auth::OWNER_LINK_ERROR, ) .into_response(), - )) + )); } } } + slot.finish(AuthResult::Success); Ok(GitAuth { pubkey, tenant }) } } @@ -396,6 +380,74 @@ fn enforce_git_ban_cascade( } } +/// Record a rejected git proof. +fn git_failed(slot: &AccessSlot, reason: &'static str) { + slot.method(AuthMethod::Nip98); + slot.finish(AuthResult::Failed(reason)); +} + +/// Verify a git request's NIP-98 proof (the admission closure). Each failure +/// records a fixed code, never the error text. +#[allow(clippy::result_large_err)] +fn verify_git_proof( + headers: &axum::http::HeaderMap, + method: &str, + expected_url: &str, + slot: &AccessSlot, +) -> Result, Response> { + // In Off mode `parse_git_auth_header` already ran above and + // succeeded, so re-parsing here is purely for the return value. + // In Enforce mode it runs for the first time inside this closure + // (on the auth-rejection path the NIP-FI layer maps the error). + let (event_json, method_for_verify) = parse_git_auth_header_full(headers, method) + .inspect_err(|_| git_header_rejected(slot, headers))?; + + // SECURITY: method intentionally not verified for git routes. The tautological + // check (event.method == event.method) is deliberate — see comment block above. + // Git's credential protocol signs once with GET and reuses for POST. The URL tag + // provides the real security boundary (±60s timestamp + URL lock + HTTPS). + let pubkey = + buzz_auth::nip98::verify_nip98_event(&event_json, expected_url, &method_for_verify, None) + .map_err(|e| { + // Fixed code only: a URL-mismatch error echoes the + // client's whole signed `u` tag. + git_failed(slot, auth_error_code(&e)); + (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() + })?; + + // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. + // Git's credential protocol reuses one signed token across multiple requests + // in a session (info_refs GET -> upload-pack/receive-pack POST). Rejecting + // replayed event IDs would break normal clone/push operations. + // The +-60s timestamp window + URL scoping + HTTPS transport provide sufficient + // replay protection for v1. Per-request signing requires protocol changes. + + let event: nostr::Event = serde_json::from_str(&event_json).map_err(|_| { + git_failed(slot, "nip98_invalid"); + (StatusCode::UNAUTHORIZED, "invalid auth event").into_response() + })?; + let signed_auth_created_at = event.created_at.as_secs(); + + Ok(crate::nip_fi_http::Nip98Proof::new( + pubkey, + (event, signed_auth_created_at), + )) +} + +/// The access-log code for a git `Authorization` header that failed to parse. +/// Log a request whose `Authorization` header was refused. A missing header +/// is git's credential-less first request, answered with the 401 challenge +/// it asks for, so it is `challenged`; a header that is present but bad is a +/// failed proof. +fn git_header_rejected(slot: &AccessSlot, headers: &axum::http::HeaderMap) { + if headers.contains_key(header::AUTHORIZATION) { + git_failed(slot, "nip98_invalid"); + } else { + slot.method(AuthMethod::Nip98); + slot.finish(AuthResult::Challenged); + } +} + /// Parse and syntax-validate the `Authorization: Nostr ` header for /// Git HTTP requests. Returns `Ok(())` on success (the caller only needs to /// know whether the syntax is valid); on failure returns a `Response` that @@ -1514,8 +1566,10 @@ async fn run_git_at( } if !status.success() { + // Hook output lands in stderr, so it stays out of WARN. let stderr = read_log_prefix(stderr_tmp.path(), 64 * 1024).await; - warn!(stderr = %stderr, service = %service, "git subprocess exited with error"); + warn!(service = %service, ?status, "git subprocess exited with error"); + tracing::debug!(stderr = %stderr, service = %service, "git subprocess stderr"); // Still return output — git protocol errors are communicated in-band. // A non-zero exit feeds `PackOutput.ok` below, but it is NOT the signal // for a pre-receive hook decline: `git receive-pack --stateless-rpc` @@ -4258,6 +4312,10 @@ mod off_mode_precedence_tests { .body(axum::body::Body::empty()) .expect("build request"); let resp = git_router(Arc::clone(&state)) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&state), + crate::access::http_access_log, + )) .oneshot(req) .await .expect("router oneshot"); @@ -4289,6 +4347,172 @@ mod off_mode_precedence_tests { } } + /// Through the production router: git's credential-less first request + /// in Enforce mode is one INFO `challenged` line, a malformed proof + /// beside a valid assertion is one WARN `failed` line carrying the + /// remapped 403, and the same proof in Off mode carries its legacy 401. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn git_access_lines_carry_level_outcome_and_status_sent() { + struct AdmitAnyAssertion; + impl buzz_auth::VerifyAssertion for AdmitAnyAssertion { + fn verify_assertion( + &self, + _token: &str, + _community: &buzz_auth::CommunityBinding, + ) -> Result + { + Ok(buzz_auth::VerifiedAssertion::new_for_test( + nostr::Keys::generate().public_key(), + )) + } + } + /// Mode, request headers, then the expected status, outcome and level. + type Case = ( + buzz_auth::NipFiMode, + &'static [(&'static str, &'static str)], + u16, + &'static str, + &'static str, + ); + let cases: [Case; 3] = [ + ( + buzz_auth::NipFiMode::Enforce, + &[], + 401, + "challenged", + "INFO", + ), + ( + buzz_auth::NipFiMode::Enforce, + &[ + ("authorization", "Nostr !!!bad!!!"), + (buzz_auth::CLIENT_ATTACHED_HEADER, "Bearer any.valid.token"), + ], + 403, + "failed", + "WARN", + ), + ( + buzz_auth::NipFiMode::Off, + &[("authorization", "Nostr !!!bad!!!")], + 401, + "failed", + "WARN", + ), + ]; + for (mode, headers, status, outcome, level) in cases { + let mut state = off_mode_state().await.expect("local Postgres"); + let host = format!("git-access-{}.test", uuid::Uuid::new_v4().simple()); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + { + let s = Arc::get_mut(&mut state).expect("fixture state is uniquely owned"); + Arc::make_mut(&mut s.config).nip_fi.mode = mode; + Arc::make_mut(&mut s.config).nip_fi.communities = + crate::nip_fi_core::test_support::any_host( + "https://git-access.relay.invalid", + ); + s.nip_fi_verifier = Some(Arc::new(AdmitAnyAssertion)); + } + let mut req = axum::http::Request::builder() + .method("GET") + .uri(GIT_PATH) + .header("host", &host); + for (name, value) in headers { + req = req.header(*name, *value); + } + let req = req.body(axum::body::Body::empty()).expect("build request"); + let (guard, out) = crate::access::tests::capture_scoped(); + let resp = crate::router::build_router(state) + .oneshot(req) + .await + .expect("router oneshot"); + drop(guard); + assert_eq!(resp.status().as_u16(), status, "{mode:?} {headers:?}"); + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "http") + .collect(); + assert_eq!(lines.len(), 1, "{mode:?} {headers:?}: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["outcome"], outcome, "{line}"); + assert_eq!(line["level"], level, "{line}"); + assert_eq!(line["status"], status, "{line}"); + } + } + + /// A Git request dropped while its relay-membership lookup is in + /// flight still writes one `aborted` line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn git_request_dropped_during_membership_logs_one_aborted_line() { + use base64::Engine as _; + let state = off_mode_state().await.expect("local Postgres"); + let host = format!("git-drop-{}.test", uuid::Uuid::new_v4().simple()); + let tenant = { + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + crate::tenant::bind_community(&state.db, &host) + .await + .expect("bind community") + }; + let keys = nostr::Keys::generate(); + let scheme = if state.config.relay_url.starts_with("wss://") { + "https" + } else { + "http" + }; + let signed_url = format!("{scheme}://{host}/git/{OWNER_HEX}/myrepo"); + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags([ + nostr::Tag::parse(["u", signed_url.as_str()]).expect("u tag"), + nostr::Tag::parse(["method", "GET"]).expect("method tag"), + ]) + .sign_with_keys(&keys) + .expect("sign NIP-98 event"); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_string(&event).expect("serialize")) + ); + // The hook parks the request right before the membership lookup. + let (arrived, _release) = + crate::nip_fi_test_hooks::git_membership_hook::arm(tenant.community()); + let (guard, out) = crate::access::tests::capture_scoped(); + tokio::select! { + _ = production_git_request(state, &host, &auth) => { + panic!("the membership wait must stall"); + } + arrived = arrived => arrived.expect("request reached the membership wait"), + } + drop(guard); + crate::access::tests::assert_one_aborted_line(&out, &keys.public_key()); + } + + /// Sends the request through the production router, whose access + /// layer owns the line written when the request is dropped. + async fn production_git_request(state: Arc, host: &str, auth: &str) { + let req = axum::http::Request::builder() + .method("GET") + .uri(GIT_PATH) + .header("host", host) + .header("authorization", auth) + .body(axum::body::Body::empty()) + .expect("build request"); + let _ = crate::router::build_router(state) + .oneshot(req) + .await + .expect("router oneshot"); + } + async fn git_request( state: Arc, host: &str, @@ -5532,3 +5756,49 @@ mod off_mode_precedence_tests { } } } + +#[cfg(test)] +mod access_log_tests { + use super::*; + + // A URL-mismatch failure logs one fixed-code access line; the client's + // signed `u` tag (and anything smuggled into it) never reaches the logs. + #[test] + fn git_url_mismatch_logs_a_fixed_code_without_the_signed_url() { + const MARKER: &str = "sentinel-7f3c9a"; + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tag( + nostr::Tag::parse(["u", &format!("https://evil.example/x?token={MARKER}")]) + .unwrap(), + ) + .tag(nostr::Tag::parse(["method", "GET"]).unwrap()) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + let token = + base64::engine::general_purpose::STANDARD.encode(nostr::JsonUtil::as_json(&event)); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + format!("Nostr {token}").parse().unwrap(), + ); + + let (refused, out) = crate::access::tests::capture(|| { + let slot = AccessSlot::default(); + let refused = verify_git_proof( + &headers, + "GET", + "https://relay.example/git/o/r.git/info/refs", + &slot, + ) + .is_err(); + slot.emit("/git/{owner}/{repo}/info/refs", 401); + refused + }); + assert!(refused); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "failed"); + assert_eq!(lines[0]["reason"], "nip98_invalid"); + assert!(!out.raw().contains(MARKER), "marker leaked: {}", out.raw()); + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 3710f5747e..46cf65c4f1 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -24,6 +24,7 @@ use axum::{ use serde::Deserialize; use serde_json::Value; +use crate::access::AccessSlot; use crate::handlers::side_effects::{ auto_join_default_channels, fan_out_nip43_membership_list, publish_nip43_member_added, }; @@ -250,17 +251,19 @@ fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { /// tenant host. pub async fn mint_invite( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> axum::response::Response { // NIP-FI gate wraps the entire handler so the denial is emitted as exact // text/plain bytes. [FI-TRACE-AUTHORITY-UNIFORM] - mint_invite_checked(state, headers, body).await + mint_invite_checked(state, slot, headers, body).await } #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn mint_invite_checked( state: Arc, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> axum::response::Response { @@ -282,7 +285,9 @@ async fn mint_invite_checked( let admission = match admit_nip_fi_http_on_state( &state, &headers, + &slot, bridge::make_nip98_closure_for_admission( + slot.clone(), headers.clone(), "POST", url, @@ -296,33 +301,32 @@ async fn mint_invite_checked( Ok(a) => a, Err(resp) => return resp, }; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); + let mut caller = bridge::ProvenCaller::new(admission, &slot, &tenant); + let pubkey = caller.pubkey; // Replay detection runs after NIP-98+assertion admission (both proofs verified). - if let Err(e) = bridge::check_nip98_replay(&state, &tenant, event_id_bytes).await { + if let Err(e) = caller.replay(&state, &tenant).await { return e.into_response(); } // Membership and community ban, same step as the other NIP-98 routes. - if let Err(e) = super::relay_members::enforce_relay_membership( + let membership = super::relay_members::enforce_relay_membership( &state, &tenant, pubkey.as_bytes(), super::relay_members::extract_auth_tag_header(&headers), - signed_created_at, - token_owner, + caller.signed_at, + caller.token_owner, ) - .await - { + .await; + if let Err(e) = caller.admit_member(membership) { return e.into_response(); } if let Some(owner) = super::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), super::relay_members::extract_auth_tag_header(&headers), - signed_created_at, + caller.signed_at, ) { match super::relay_members::link_agent_owner(&state, &tenant, &pubkey, &owner).await { super::relay_members::OwnerLink::Linked => {} @@ -339,8 +343,12 @@ async fn mint_invite_checked( } } + // The request's access line carries the minted invite. match mint_invite_inner(&state, body, tenant, pubkey).await { - Ok(json) => json.into_response(), + Ok((json, minted)) => { + slot.detail(minted); + json.into_response() + } Err(e) => e.into_response(), } } @@ -350,7 +358,7 @@ async fn mint_invite_inner( body: axum::body::Bytes, tenant: buzz_core::TenantContext, pubkey: nostr::PublicKey, -) -> Result, (StatusCode, Json)> { +) -> Result<(Json, crate::access::Detail), (StatusCode, Json)> { // Authz mirrors kind:9030 (add member): owner or admin only. let sender_hex = pubkey.to_hex(); let member = state @@ -394,25 +402,25 @@ async fn mint_invite_inner( "http" }; - tracing::info!( - community = %tenant.community(), - minted_by = %sender_hex, - invite_id = %invite.invite_id, - expires_at = %invite.expires_at, - max_uses = ?invite.max_uses, - "relay invite minted" - ); + let minted = crate::access::Detail::InviteMinted { + invite_id: invite.invite_id, + expires_at: invite.expires_at.timestamp(), + max_uses: invite.max_uses, + }; // expires_at as unix seconds for the response contract. let expires_at_unix = invite.expires_at.timestamp() as u64; - Ok(Json(serde_json::json!({ - "code": invite.code, - "expires_at": expires_at_unix, - "max_uses": invite.max_uses, - "uses_remaining": invite.uses_remaining, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), - }))) + Ok(( + Json(serde_json::json!({ + "code": invite.code, + "expires_at": expires_at_unix, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), + })), + minted, + )) } /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the @@ -423,20 +431,37 @@ async fn mint_invite_inner( /// code is never fallen back to v1 verification. pub async fn claim_invite( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { let (tenant, pubkey, verified_owner) = - authenticate(&state, &headers, "/api/invites/claim", &body).await?; + authenticate(&state, &slot, &headers, "/api/invites/claim", &body).await?; + // The request's access line carries whether the claim joined. + let claimed = claim_invite_authed(&state, tenant, pubkey, verified_owner, &body).await; + if let Ok(Json(json)) = &claimed { + slot.detail(crate::access::Detail::InviteClaimed { + joined: json["status"] == "joined", + }); + } + claimed +} - if claim_rate_limited(&state, tenant.community(), &pubkey) { +async fn claim_invite_authed( + state: &Arc, + tenant: buzz_core::TenantContext, + pubkey: nostr::PublicKey, + verified_owner: Option, + body: &[u8], +) -> Result, (StatusCode, Json)> { + if claim_rate_limited(state, tenant.community(), &pubkey) { return Err(api_error( StatusCode::TOO_MANY_REQUESTS, "too many invite claim attempts, slow down", )); } - let request: ClaimInviteRequest = serde_json::from_slice(&body) + let request: ClaimInviteRequest = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; let claimer_hex = pubkey.to_hex(); @@ -490,21 +515,14 @@ pub async fn claim_invite( write.outcome.outcome, buzz_db::relay_invite::ClaimOutcome::Joined { .. } ); - finish_committed_claim(&tenant, &state, &claimer_hex, write.snapshot, joined).await; + finish_committed_claim(&tenant, state, &claimer_hex, write.snapshot, joined).await; return match write.outcome.outcome { - buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { - tracing::info!( - community = %tenant.community(), - member = %claimer_hex, - "relay member added via v2 invite" - ); - Ok(Json(serde_json::json!({ - "status": "joined", - "community_id": tenant.community().to_string(), - "host": tenant.host(), - "role": "member", - }))) - } + buzz_db::relay_invite::ClaimOutcome::Joined { .. } => Ok(Json(serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))), buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { Ok(Json(serde_json::json!({ "status": "already_member", @@ -580,15 +598,7 @@ pub async fn claim_invite( state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); } let was_inserted = write.outcome.inserted; - finish_committed_claim(&tenant, &state, &claimer_hex, write.snapshot, was_inserted).await; - - if was_inserted { - tracing::info!( - community = %tenant.community(), - member = %claimer_hex, - "relay member added via invite" - ); - } + finish_committed_claim(&tenant, state, &claimer_hex, write.snapshot, was_inserted).await; Ok(Json(serde_json::json!({ "status": if was_inserted { "joined" } else { "already_member" }, @@ -987,7 +997,7 @@ mod postgres_tests { .await .expect("lookup") .expect("community exists"); - state + let _ = state .db .add_relay_member( community.id, @@ -1739,6 +1749,74 @@ mod postgres_tests { } } + /// A mint and a claim each write one access line carrying what they + /// did, and the invite code reaches no log. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn mint_and_claim_write_one_line_each_without_the_code() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let (owner, joiner) = (Keys::generate(), Keys::generate()); + let state = invite_test_state(&host).await.expect("local Postgres"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let _ = state + .db + .add_relay_member( + community.id, + &owner.public_key().to_hex(), + "owner", + None, + &nostr::Keys::generate(), + ) + .await + .expect("seed owner"); + + let (_guard, out) = crate::access::tests::capture_scoped(); + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 3 }), + ) + .await; + let mint = out.lines(); + assert_eq!(mint.len(), 1, "{mint:?}"); + assert_eq!(mint[0]["route"], "/api/invites", "{}", mint[0]); + assert_eq!(mint[0]["status"], 200, "{}", mint[0]); + assert_eq!(mint[0]["max_uses"], 3, "{}", mint[0]); + assert!(mint[0]["invite_id"].is_string(), "{}", mint[0]); + assert!(mint[0]["expires_at"].is_i64(), "{}", mint[0]); + + let response = post_json( + state, + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code }).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let lines = out.lines(); + assert_eq!(lines.len(), 2, "one line per request: {lines:?}"); + assert_eq!(lines[1]["route"], "/api/invites/claim", "{}", lines[1]); + assert_eq!(lines[1]["joined"], true, "{}", lines[1]); + assert_eq!( + lines[1]["pubkey"], + joiner.public_key().to_hex(), + "{}", + lines[1] + ); + assert!( + !out.raw().contains(&code), + "invite code logged: {}", + out.raw() + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { diff --git a/crates/buzz-relay/src/api/invites/claim_auth.rs b/crates/buzz-relay/src/api/invites/claim_auth.rs index 756b2ba848..9b3bb8f917 100644 --- a/crates/buzz-relay/src/api/invites/claim_auth.rs +++ b/crates/buzz-relay/src/api/invites/claim_auth.rs @@ -9,8 +9,9 @@ use std::sync::Arc; /// Verify tenant-scoped NIP-98 and optional NIP-OA provenance without user writes. pub(super) async fn authenticate( state: &Arc, + slot: &crate::access::AccessSlot, headers: &HeaderMap, - path: &str, + path: &'static str, body: &[u8], ) -> Result< ( @@ -37,8 +38,16 @@ pub(super) async fn authenticate( pubkey, event_id_bytes, signed_created_at, - } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + } = bridge::verify_nip98_exempt_invite_claim(slot, headers, "POST", &url, Some(body))?; + // Claiming an invite is how a non-member joins: replay is the last + // admission step, so it decides the request. + slot.attempt(crate::access::AuthMethod::Nip98, Some(pubkey), true); + slot.in_community(tenant.community()); + slot.gate( + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await, + bridge::ReplayRejection::auth_result, + )?; + slot.finish(crate::access::AuthResult::Success); let owner = super::super::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), super::super::relay_members::extract_auth_tag_header(headers), diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index e86f37a497..a720325e5f 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -26,6 +26,7 @@ use buzz_media::{ BlobDescriptor, BlossomDenialKind, MediaError, UploadAttribution, UploadNetworkInfo, }; +use crate::access::{AccessSlot, AuthMethod, AuthResult}; use crate::state::AppState; /// Lightweight pre-auth upload context: tenant + route mode only. @@ -317,6 +318,7 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { #[allow(clippy::result_large_err)] // Response is the natural error type for axum closures pub(crate) async fn upload_blob( State(state): State>, + slot: AccessSlot, ctx: UploadContext, headers: HeaderMap, body: axum::body::Body, @@ -341,12 +343,18 @@ pub(crate) async fn upload_blob( }) .await; let headers_clone = headers.clone(); - let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { - let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; + let proof_slot = slot.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &headers, &slot, move || { + let reject = |e: MediaError, claimed: Option| { + proof_slot.attempt(AuthMethod::Blossom, claimed, false); + proof_slot.finish(AuthResult::Failed(blossom_proof_code(&e))); + e.into_response() + }; + let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| reject(e, None))?; // Pre-body check: freshness, cardinality, and server tag. The x-tag // hash binding is checked after body completion. buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), strictness) - .map_err(|e| e.into_response())?; + .map_err(|e| reject(e, Some(auth_event.pubkey)))?; let pubkey = auth_event.pubkey; Ok(Nip98Proof::new(pubkey, auth_event)) }) @@ -355,6 +363,10 @@ pub(crate) async fn upload_blob( Ok(a) => a, Err(resp) => return resp, }; + // A refusal at any admission step below, or success once the + // concurrency permit is held. + admission.record(&slot, AuthMethod::Blossom); + slot.in_community(ctx.tenant.community()); let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); @@ -364,27 +376,26 @@ pub(crate) async fn upload_blob( let claimed_hash = match headers.get("x-sha-256").and_then(|v| v.to_str().ok()) { Some(h) => h.to_owned(), None => { - return media_denial(MediaError::MissingTag("x-sha-256"), strictness).into_response() + slot.finish(AuthResult::Failed("missing_hash")); + return media_denial(MediaError::MissingTag("x-sha-256"), strictness).into_response(); } }; - if claimed_hash.len() != 64 - || !claimed_hash + let hash_bound = claimed_hash.len() == 64 + && claimed_hash .chars() .all(|c| matches!(c, '0'..='9' | 'a'..='f')) - { - return media_denial(MediaError::HashMismatch, strictness).into_response(); - } - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); - if !has_matching_x { + && auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); + if !hash_bound { + slot.finish(AuthResult::Failed("hash_mismatch")); return media_denial(MediaError::HashMismatch, strictness).into_response(); } // Post-admission: relay membership gate (NIP-43). let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); - if let Err(e) = crate::api::relay_members::enforce_relay_membership( + let membership = crate::api::relay_members::enforce_relay_membership( &state, &ctx.tenant, auth_event.pubkey.as_bytes(), @@ -392,17 +403,19 @@ pub(crate) async fn upload_blob( Some(auth_event.created_at.as_secs()), token_owner, ) - .await - .map(|_| ()) - .map_err(|e| membership_denial(e, strictness)) - { - return e.into_response(); + .await; + if let Err(e) = slot.gate( + membership, + crate::api::relay_members::MembershipRefusal::auth_result, + ) { + return membership_denial(e, strictness).into_response(); } // Post-admission: rate limit and concurrency permit. if upload_rate_limited(&state, ctx.tenant.community(), &auth_event.pubkey) { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") .increment(1); + slot.finish(AuthResult::Denied(crate::access::AuthReason::QuotaExceeded)); return MediaError::UploadRateLimitExceeded.into_response(); } let upload_permit = match acquire_upload_permit( @@ -415,8 +428,14 @@ pub(crate) async fn upload_blob( .increment(1); }) { Ok(p) => p, - Err(e) => return e.into_response(), + Err(e) => { + slot.finish(AuthResult::Denied( + crate::access::AuthReason::ConcurrencyLimit, + )); + return e.into_response(); + } }; + slot.finish(AuthResult::Success); let auth = AuthenticatedUpload { auth_event, @@ -696,9 +715,10 @@ async fn enforce_blossom_read_membership( token_owner: Option, headers: &HeaderMap, strictness: BlossomStrictness, + slot: &AccessSlot, ) -> Result<(), MediaDenial> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); - crate::api::relay_members::enforce_relay_membership( + let membership = crate::api::relay_members::enforce_relay_membership( state, tenant, auth_event.pubkey.as_bytes(), @@ -706,9 +726,14 @@ async fn enforce_blossom_read_membership( Some(auth_event.created_at.as_secs()), token_owner, ) - .await - .map(|_| ()) - .map_err(|e| membership_denial(e, strictness)) + .await; + slot.gate( + membership, + crate::api::relay_members::MembershipRefusal::auth_result, + ) + .map_err(|e| membership_denial(e, strictness))?; + slot.finish(AuthResult::Success); + Ok(()) } fn blob_cache_control() -> &'static str { @@ -797,6 +822,7 @@ const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub(crate) async fn get_blob( State(state): State>, + slot: AccessSlot, Path(sha256_ext): Path, req_headers: HeaderMap, ) -> Result { @@ -827,15 +853,21 @@ pub(crate) async fn get_blob( .map(drop) }) .await; - let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) - .map_err(|e| e.into_response()) + let proof_slot = slot.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &req_headers, &slot, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness).map_err(|e| { + proof_slot.method(AuthMethod::Blossom); + proof_slot.finish(AuthResult::Failed(blossom_proof_code(&e))); + e.into_response() + }) }) .await { Ok(a) => a, Err(resp) => return Ok(resp), }; + admission.record(&slot, AuthMethod::Blossom); + slot.in_community(tenant.community()); let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); // Post-admission: membership gate. @@ -846,6 +878,7 @@ pub(crate) async fn get_blob( token_owner, &req_headers, strictness, + &slot, ) .await?; serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers) @@ -1111,6 +1144,7 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub(crate) async fn head_blob( State(state): State>, + slot: AccessSlot, headers: HeaderMap, Path(sha256_ext): Path, ) -> Result { @@ -1133,15 +1167,21 @@ pub(crate) async fn head_blob( .map(drop) }) .await; - let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) - .map_err(|e| e.into_response()) + let proof_slot = slot.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &headers, &slot, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness).map_err(|e| { + proof_slot.method(AuthMethod::Blossom); + proof_slot.finish(AuthResult::Failed(blossom_proof_code(&e))); + e.into_response() + }) }) .await { Ok(a) => a, Err(resp) => return Ok(resp), }; + admission.record(&slot, AuthMethod::Blossom); + slot.in_community(tenant.community()); let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); enforce_blossom_read_membership( @@ -1151,6 +1191,7 @@ pub(crate) async fn head_blob( token_owner, &headers, strictness, + &slot, ) .await?; let cache_control = blob_cache_control(); @@ -1230,6 +1271,20 @@ async fn resolve_s3_key( } } +/// A fixed access-log code for a rejected Blossom proof. +fn blossom_proof_code(e: &MediaError) -> &'static str { + match e { + MediaError::MissingAuth => "missing_auth", + MediaError::InvalidAuthScheme + | MediaError::InvalidBase64 + | MediaError::InvalidAuthEvent => "malformed_auth", + MediaError::InvalidSignature => "invalid_signature", + MediaError::TokenExpired | MediaError::TimestampOutOfWindow => "event_expired", + MediaError::ServerMismatch => "server_mismatch", + _ => "blossom_invalid", + } +} + /// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header. /// /// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat). @@ -2446,6 +2501,170 @@ mod tests { } } + /// Run an upload with access lines captured; return the response and + /// the `event=http` lines. + fn upload_auth_lines( + rt: &tokio::runtime::Runtime, + state: &Arc, + host: &str, + authorization: &str, + assertion: Option<&str>, + ) -> (StatusCode, Vec) { + let ((status, _, _), out) = crate::access::tests::capture(|| { + upload_request(rt, state, "/upload", host, &[authorization], assertion) + }); + let lines = out + .lines() + .into_iter() + .filter(|l| l["event"] == "http") + .collect(); + (status, lines) + } + + fn assert_one_upload_line( + lines: &[serde_json::Value], + method: &str, + outcome: &str, + reason: Option<&str>, + ) { + assert_eq!(lines.len(), 1, "exactly one access line: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["method"], method, "{line}"); + assert_eq!(line["outcome"], outcome, "{line}"); + assert_eq!(line["route"], "/upload", "{line}"); + match reason { + Some(reason) => assert_eq!(line["reason"], reason, "{line}"), + None => assert!(line.get("reason").is_none(), "{line}"), + } + } + + /// A banned caller's valid signed upload is refused at the membership + /// step: one `denied` line and no `success`. + #[test] + #[ignore = "requires Postgres"] + fn banned_upload_logs_one_denied_line() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let tenant = rt + .block_on(crate::tenant::bind_community(&state.db, &host)) + .expect("bind community"); + let keys = Keys::generate(); + rt.block_on( + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, true, $2)", + ) + .bind(tenant.community().as_uuid()) + .bind(keys.public_key().to_bytes().as_slice()) + .execute(state.db.pool()), + ) + .expect("ban"); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let (status, lines) = upload_auth_lines(&rt, &state, &host, &proof, None); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_one_upload_line(&lines, "blossom", "denied", Some("banned")); + } + + /// A malformed proof alongside a valid enterprise assertion: the proof + /// closure writes one `failed` line and the enterprise helper adds none. + /// Off mode, where the helper is bypassed, also writes exactly one. + #[test] + #[ignore = "requires Postgres"] + fn bad_upload_proof_logs_one_failed_line() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + let (status, lines) = upload_auth_lines( + &rt, + &state, + &host, + "Nostr !!!not-base64!!!", + Some(&assertion), + ); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_one_upload_line(&lines, "blossom", "failed", Some("malformed_auth")); + assert_eq!(lines[0]["status"], 403, "the remapped status: {}", lines[0]); + + let (rt, state, host) = media_fixture(media_off_test_state()); + let (status, lines) = + upload_auth_lines(&rt, &state, &host, "Nostr !!!not-base64!!!", None); + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_one_upload_line(&lines, "blossom", "failed", Some("malformed_auth")); + assert_eq!(lines[0]["status"], 401, "the legacy status: {}", lines[0]); + } + + /// A malformed read proof alongside a valid enterprise assertion: GET + /// and HEAD each write one WARN `failed` line carrying the 403 sent. + #[test] + #[ignore = "requires Postgres"] + fn bad_read_proof_logs_the_status_sent() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let path = format!("/media/{}.jpg", "d".repeat(64)); + for method in ["GET", "HEAD"] { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!bad!!!".parse().expect("valid header bytes"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + signed_assertion(&Keys::generate().public_key().to_hex()) + ) + .parse() + .expect("valid header"), + ); + let ((status, _, _), out) = crate::access::tests::capture(|| { + rt.block_on(media_oneshot( + Arc::clone(&state), + method, + &path, + &host, + headers, + b"", + )) + }); + assert_eq!(status, StatusCode::FORBIDDEN, "{method}"); + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "http") + .collect(); + assert_eq!(lines.len(), 1, "{method}: {lines:?}"); + let line = &lines[0]; + assert_eq!(line["outcome"], "failed", "{method}: {line}"); + assert_eq!(line["level"], "WARN", "{method}: {line}"); + assert_eq!(line["status"], 403, "{method}: {line}"); + assert_eq!(line["route"], "/media/{sha256_ext}", "{method}: {line}"); + // The hash never reaches any field of the line. + assert!( + !out.raw().contains(&"d".repeat(64)), + "{method}: {}", + out.raw() + ); + } + } + + /// An admitted upload logs one `success` once the concurrency permit + /// is held, in both modes; the later content rejection adds nothing. + #[test] + #[ignore = "requires Postgres"] + fn admitted_upload_logs_one_success_line() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let keys = Keys::generate(); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let (status, lines) = upload_auth_lines(&rt, &state, &host, &proof, None); + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_one_upload_line(&lines, "blossom", "success", None); + + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let (status, lines) = upload_auth_lines(&rt, &state, &host, &proof, Some(&assertion)); + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_one_upload_line(&lines, "nip_fi", "success", None); + } + /// Off mode skips the cardinality gate and `HeaderMap::get` takes the /// first Authorization value. A single valid proof and a /// valid-first/malformed-second pair both reach the exact 415; applying @@ -3092,6 +3311,7 @@ mod tests { .expect("valid header"), ); + let (guard, out) = crate::access::tests::capture_scoped(); let (status, resp_headers, body) = rt.block_on(media_oneshot( Arc::clone(&state), "GET", @@ -3100,6 +3320,17 @@ mod tests { headers, b"", )); + drop(guard); + // The rejected read proof writes exactly one access line. + let auth: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "http") + .collect(); + assert_eq!(auth.len(), 1, "{auth:?}"); + assert_eq!(auth[0]["method"], "blossom"); + assert_eq!(auth[0]["outcome"], "failed"); + assert_eq!(auth[0]["route"], "/media/{sha256_ext}"); assert_eq!( status, diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9059d03ae..6b431f1ac0 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -41,7 +41,7 @@ pub(crate) fn db_read_error( context: &str, e: &buzz_db::DbError, ) -> (StatusCode, Json) { - read_error(context, e.is_statement_cancelled(), e) + read_error(context, e.is_statement_cancelled(), e.kind()) } /// [`db_read_error`] for a search: a search cancelled by the search pool's @@ -50,19 +50,20 @@ pub(crate) fn search_read_error( context: &str, e: &buzz_search::SearchError, ) -> (StatusCode, Json) { - read_error(context, e.is_statement_cancelled(), e) + read_error(context, e.is_statement_cancelled(), e.kind()) } +/// Only the error kind is logged: DB error text can repeat a bound value. fn read_error( context: &str, statement_cancelled: bool, - e: &dyn std::fmt::Display, + kind: String, ) -> (StatusCode, Json) { if statement_cancelled { - tracing::warn!("{context}: {e}"); + tracing::warn!(error_kind = %kind, "{context}"); return api_error(StatusCode::SERVICE_UNAVAILABLE, QUERY_TIMED_OUT); } - internal_error(&format!("{context}: {e}")) + internal_error(&format!("{context}: {kind}")) } #[allow(dead_code)] @@ -105,6 +106,7 @@ pub mod relay_members { use buzz_core::{tenant::CommunityId, TenantContext}; use tracing::{debug, info}; + use crate::access::AuthReason; use crate::state::AppState; /// Transport-neutral outcome of a relay-membership check. @@ -120,39 +122,43 @@ pub mod relay_members { Denied, } - /// An HTTP relay-membership refusal. + /// An HTTP relay-membership refusal, with its access-log reason. #[derive(Debug)] - pub enum MembershipRefusal { + pub(crate) enum MembershipRefusal { /// The relay's own JSON refusal. - Api((StatusCode, Json)), + Api(AuthReason, (StatusCode, Json)), /// NIP-FI's fixed denial. A request carrying a verified agent token /// gets only this, so the response never shows which step refused it /// or whether the agent already has a stored owner. - NipFi(DenialClass), + NipFi(AuthReason, DenialClass), } + /// A refusal decided inside [`admit_member`], before the agent-token + /// rule picks its response shape. + type Refused = (AuthReason, (StatusCode, Json)); + impl MembershipRefusal { + /// The access-log result for this refusal. + pub(crate) fn auth_result(&self) -> crate::access::AuthResult { + let (Self::Api(reason, _) | Self::NipFi(reason, _)) = self; + crate::access::AuthResult::Denied(*reason) + } + /// The response status. pub fn status(&self) -> StatusCode { match self { - Self::Api((status, _)) => *status, - Self::NipFi(class) => StatusCode::from_u16(class.http_status()) + Self::Api(_, (status, _)) => *status, + Self::NipFi(_, class) => StatusCode::from_u16(class.http_status()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), } } } - impl From<(StatusCode, Json)> for MembershipRefusal { - fn from(refusal: (StatusCode, Json)) -> Self { - Self::Api(refusal) - } - } - impl IntoResponse for MembershipRefusal { fn into_response(self) -> Response { match self { - Self::Api(refusal) => refusal.into_response(), - Self::NipFi(class) => crate::nip_fi_core::http_denial(class), + Self::Api(_, refusal) => refusal.into_response(), + Self::NipFi(_, class) => crate::nip_fi_core::http_denial(class), } } } @@ -317,7 +323,7 @@ pub mod relay_members { /// /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when /// no NIP-OA tag is present/applicable (open relay without auth tag). - pub async fn enforce_relay_membership( + pub(crate) async fn enforce_relay_membership( state: &AppState, tenant: &TenantContext, pubkey_bytes: &[u8], @@ -341,7 +347,7 @@ pub mod relay_members { /// reads through the 30-second ephemeral-path restriction cache, the same /// staleness bound the pulse's own write-restriction check already has. /// Every other HTTP request reads ban state fresh. - pub async fn enforce_relay_membership_ephemeral( + pub(crate) async fn enforce_relay_membership_ephemeral( state: &AppState, tenant: &TenantContext, pubkey_bytes: &[u8], @@ -381,12 +387,12 @@ pub mod relay_members { cached_ban, ) .await - .map_err(|refusal| match token_owner { - None => MembershipRefusal::Api(refusal), + .map_err(|(reason, refusal)| match token_owner { + None => MembershipRefusal::Api(reason, refusal), Some(_) if refusal.0.is_server_error() => { - MembershipRefusal::NipFi(DenialClass::AuthorizationUnavailable) + MembershipRefusal::NipFi(reason, DenialClass::AuthorizationUnavailable) } - Some(_) => MembershipRefusal::NipFi(DenialClass::AuthorizationDenied), + Some(_) => MembershipRefusal::NipFi(reason, DenialClass::AuthorizationDenied), }) } @@ -398,7 +404,7 @@ pub mod relay_members { signed_auth_created_at: Option, token_owner: Option, cached_ban: bool, - ) -> Result, (StatusCode, Json)> { + ) -> Result, Refused> { let community = tenant.community(); let owner = match check_relay_membership( state, @@ -414,16 +420,19 @@ pub mod relay_members { Ok(MembershipDecision::ViaOwner(owner)) => Some(owner), Ok(MembershipDecision::Denied) => { return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "relay_membership_required", - "message": "You must be a relay member to access this relay" - })), + AuthReason::NotRelayMember, + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + ), )) } Err(e) => { tracing::error!("relay membership check errored: {e}"); - return Err(super::internal_error(&e)); + return Err((AuthReason::MembershipUnavailable, super::internal_error(&e))); } }; deny_banned( @@ -438,17 +447,26 @@ pub mod relay_members { .await?; if let Some(token_owner) = token_owner { let Ok(agent) = nostr::PublicKey::from_slice(pubkey_bytes) else { - return Err(super::internal_error("invalid pubkey for owner link")); + return Err(( + AuthReason::MembershipUnavailable, + super::internal_error("invalid pubkey for owner link"), + )); }; match link_agent_owner(state, tenant, &agent, &token_owner).await { OwnerLink::Linked => {} OwnerLink::Conflict => { - return Err(super::api_error(StatusCode::FORBIDDEN, OWNER_MISMATCH)) + return Err(( + AuthReason::OwnerMismatch, + super::api_error(StatusCode::FORBIDDEN, OWNER_MISMATCH), + )) } OwnerLink::Unavailable => { - return Err(super::api_error( - StatusCode::SERVICE_UNAVAILABLE, - crate::handlers::auth::OWNER_LINK_ERROR, + return Err(( + AuthReason::NipOaOwnerUnlinked, + super::api_error( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + ), )) } } @@ -467,9 +485,12 @@ pub mod relay_members { signed_auth_created_at: Option, token_owner: Option, cached: bool, - ) -> Result<(), (StatusCode, Json)> { + ) -> Result<(), Refused> { let Ok(pubkey) = nostr::PublicKey::from_slice(pubkey_bytes) else { - return Err(super::internal_error("invalid pubkey for ban check")); + return Err(( + AuthReason::MembershipUnavailable, + super::internal_error("invalid pubkey for ban check"), + )); }; match crate::handlers::auth::community_ban_outcome( state, @@ -483,13 +504,19 @@ pub mod relay_members { .await { crate::handlers::auth::BanOutcome::Clear => Ok(()), - crate::handlers::auth::BanOutcome::Banned => Err(super::api_error( - StatusCode::FORBIDDEN, - "blocked: you are banned from this community", + crate::handlers::auth::BanOutcome::Banned => Err(( + AuthReason::Banned, + super::api_error( + StatusCode::FORBIDDEN, + "blocked: you are banned from this community", + ), )), - crate::handlers::auth::BanOutcome::DbError => Err(super::api_error( - StatusCode::SERVICE_UNAVAILABLE, - "error: internal error checking restriction state", + crate::handlers::auth::BanOutcome::DbError => Err(( + AuthReason::BanCheckUnavailable, + super::api_error( + StatusCode::SERVICE_UNAVAILABLE, + "error: internal error checking restriction state", + ), )), } } diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 4cdaa42304..94d599f425 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2534,9 +2534,12 @@ mod route_integration_tests { .parse() .expect("header value"), ); - crate::nip_fi_http::admit_nip_fi_http_on_state(state, &headers, || { - Ok(crate::nip_fi_http::Nip98Proof::new(*key, ())) - }) + crate::nip_fi_http::admit_nip_fi_http_on_state( + state, + &headers, + &crate::access::AccessSlot::default(), + || Ok(crate::nip_fi_http::Nip98Proof::new(*key, ())), + ) .await .map(|_| ()) } @@ -2668,12 +2671,14 @@ mod route_integration_tests { "control: the route recorded the deny entry" ); - let admitted = - crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &HeaderMap::new(), || { - Ok(crate::nip_fi_http::Nip98Proof::new(key, 7u8)) - }) - .await - .expect("Off mode admits on NIP-98 success regardless of deny entries"); + let admitted = crate::nip_fi_http::admit_nip_fi_http_on_state( + &state, + &HeaderMap::new(), + &crate::access::AccessSlot::default(), + || Ok(crate::nip_fi_http::Nip98Proof::new(key, 7u8)), + ) + .await + .expect("Off mode admits on NIP-98 success regardless of deny entries"); assert_eq!(admitted.proven_pubkey(), &key); assert!(admitted.assertion().is_none()); assert_eq!(*admitted.extra(), 7); @@ -2688,6 +2693,7 @@ mod route_integration_tests { let resp = crate::nip_fi_http::admit_nip_fi_http_on_state::<(), _>( &state, &HeaderMap::new(), + &crate::access::AccessSlot::default(), || Err(legacy()), ) .await diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 0663012f0d..2bda337cfb 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -4,6 +4,7 @@ //! request signing and replay protection, but they do not run through event //! ingest, relay membership, channel scoping, storage, or fan-out. +use crate::access::{AccessSlot, AuthMethod, AuthResult}; use std::sync::Arc; use axum::{ @@ -194,6 +195,7 @@ fn verify_registration_proof( /// or an inbound proxy `Host` header. async fn authorize_operator_request( state: &Arc, + slot: &AccessSlot, headers: &HeaderMap, method: &str, path: &str, @@ -214,8 +216,9 @@ async fn authorize_operator_request( pubkey, event_id_bytes, .. - } = bridge::verify_nip98_exempt_operator(headers, method, &url, body)?; - check_operator_replay(state, event_id_bytes).await?; + } = bridge::verify_nip98_exempt_operator(slot, headers, method, &url, body)?; + slot.attempt(AuthMethod::Nip98, Some(pubkey), true); + check_operator_replay(state, event_id_bytes, slot).await?; let pubkey_hex = pubkey.to_hex(); if !state @@ -224,12 +227,13 @@ async fn authorize_operator_request( .iter() .any(|pk| pk == &pubkey_hex) { + slot.finish(AuthResult::Denied(crate::access::AuthReason::NotOperator)); return Err(api_error( StatusCode::FORBIDDEN, "actor not authorized: not a relay operator", )); } - + slot.finish(AuthResult::Success); Ok(pubkey) } @@ -237,6 +241,7 @@ async fn authorize_operator_request( /// identity and a NIP-98 request signature. async fn authorize_operator_listener_request( state: &Arc, + slot: &AccessSlot, headers: &HeaderMap, method: &str, path: &str, @@ -252,30 +257,42 @@ async fn authorize_operator_listener_request( pubkey, event_id_bytes, .. - } = bridge::verify_nip98_exempt_operator(headers, method, &url, Some(body))?; - check_operator_replay(state, event_id_bytes).await?; + } = bridge::verify_nip98_exempt_operator(slot, headers, method, &url, Some(body))?; + slot.attempt(AuthMethod::Nip98, Some(pubkey), true); + check_operator_replay(state, event_id_bytes, slot).await?; if !state .config .operator_listener_delivery_urls .contains_key(&pubkey.to_hex()) { + slot.finish(AuthResult::Denied( + crate::access::AuthReason::NotOperatorListener, + )); return Err(api_error( StatusCode::FORBIDDEN, "actor not authorized: not a configured operator listener", )); } + slot.finish(AuthResult::Success); Ok(pubkey) } /// Register target pubkeys for the authenticated operator listener. pub async fn register_listener_pubkeys( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let listener = - authorize_operator_listener_request(&state, &headers, "POST", LISTENER_PUBKEYS_PATH, &body) - .await?; + let listener = authorize_operator_listener_request( + &state, + &slot, + &headers, + "POST", + LISTENER_PUBKEYS_PATH, + &body, + ) + .await?; let origin = state .config .relay_operator_api_origin @@ -298,11 +315,13 @@ pub async fn register_listener_pubkeys( /// Remove target pubkeys for the authenticated operator listener. pub async fn remove_listener_pubkeys( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { let listener = authorize_operator_listener_request( &state, + &slot, &headers, "DELETE", LISTENER_PUBKEYS_PATH, @@ -321,6 +340,7 @@ pub async fn remove_listener_pubkeys( async fn check_operator_replay( state: &AppState, event_id_bytes: [u8; 32], + slot: &AccessSlot, ) -> Result<(), (StatusCode, Json)> { let event_id = nostr::EventId::from_byte_array(event_id_bytes); match state @@ -333,11 +353,17 @@ async fn check_operator_replay( .await { Ok(true) => Ok(()), - Ok(false) => Err(api_error( - StatusCode::UNAUTHORIZED, - "NIP-98: replay detected", - )), + Ok(false) => { + slot.finish(AuthResult::Failed("nip98_replay")); + Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: replay detected", + )) + } Err(error) => { + slot.finish(AuthResult::Denied( + crate::access::AuthReason::ReplayCheckUnavailable, + )); tracing::warn!( scope = OPERATOR_REPLAY_SCOPE, error = %error, @@ -365,11 +391,13 @@ async fn check_operator_replay( /// authority for this deployment-root control-plane surface. pub async fn provision_community( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { let pubkey = authorize_operator_request( &state, + &slot, &headers, "POST", "/operator/communities", @@ -435,11 +463,12 @@ pub struct DeleteCommunityRequest { /// Idempotently archive a community owned by the asserted end-user identity. pub async fn archive_community( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { const PATH: &str = "/operator/communities/archive"; - authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)).await?; let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { api_error( StatusCode::BAD_REQUEST, @@ -500,11 +529,12 @@ pub async fn archive_community( /// Idempotently restore an archived community owned by the asserted end-user identity. pub async fn unarchive_community( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { const PATH: &str = "/operator/communities/unarchive"; - authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)).await?; let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { api_error( StatusCode::BAD_REQUEST, @@ -586,12 +616,14 @@ pub async fn unarchive_community( /// that upstream ceremony, not verified as cryptographic owner consent. pub async fn delete_community( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { const PATH: &str = "/operator/communities/delete"; let operator = - authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)) + .await?; let request: DeleteCommunityRequest = serde_json::from_slice(&body).map_err(|e| { coded_api_error( StatusCode::BAD_REQUEST, @@ -698,12 +730,14 @@ pub async fn delete_community( /// The operator is trusted to select the identity; this route does not bind it to a user session. pub async fn list_joined_communities( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, + &slot, &headers, "GET", "/operator/communities/joined", @@ -733,12 +767,14 @@ pub async fn list_joined_communities( /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, + &slot, &headers, "GET", "/operator/communities", @@ -788,11 +824,13 @@ pub async fn list_owned_communities( /// NIP-43 membership list is best-effort, matching the provision path. pub async fn transfer_community( State(state): State>, + slot: AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { let _pubkey = authorize_operator_request( &state, + &slot, &headers, "POST", "/operator/communities/transfer", @@ -921,12 +959,14 @@ pub async fn transfer_community( /// normalized authority used by create. pub async fn community_availability( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, + &slot, &headers, "GET", "/operator/communities/availability", @@ -980,12 +1020,14 @@ pub struct CommunityNipFiBindingQuery { /// - 404 means no community row has this host. pub async fn community_nip_fi_binding( State(state): State>, + slot: AccessSlot, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, + &slot, &headers, "GET", "/operator/communities/nip-fi-binding", @@ -1444,6 +1486,35 @@ mod postgres_tests { } } + /// An operator request dropped while its replay check is in flight + /// still writes one `aborted` line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn operator_request_dropped_during_replay_logs_one_aborted_line() { + let operator = Keys::generate(); + let state = operator_test_state(std::slice::from_ref(&operator)) + .await + .expect("local Postgres"); + let mut state = (*state).clone(); + let replay = Arc::new(crate::access::tests::StallingReplayGuard::default()); + state.nip98_replay = replay.clone(); + // The query string is required, or extraction refuses before auth. + let path = format!( + "/operator/communities?owner_pubkey={}", + operator.public_key().to_hex() + ); + let (guard, out) = crate::access::tests::capture_scoped(); + tokio::select! { + _ = signed_operator_request(Arc::new(state), &operator, "GET", &path, None) => { + panic!("the replay check must stall"); + } + () = replay.arrived.notified() => {} + } + drop(guard); + let line = crate::access::tests::assert_one_aborted_line(&out, &operator.public_key()); + assert_eq!(line["route"], "/operator/communities"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn non_allowlisted_operator_key_gets_403() { diff --git a/crates/buzz-relay/src/api/profiles.rs b/crates/buzz-relay/src/api/profiles.rs index e099d896b3..7d3822a4f1 100644 --- a/crates/buzz-relay/src/api/profiles.rs +++ b/crates/buzz-relay/src/api/profiles.rs @@ -76,22 +76,26 @@ fn parse_request(body: &[u8]) -> Result)> { /// member. Responds with one page of signed kind:0 events, best match first. pub async fn search( State(state): State>, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Response { - search_inner(state, headers, body).await.into_response() + search_inner(state, slot, headers, body) + .await + .into_response() } #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn search_inner( state: Arc, + slot: crate::access::AccessSlot, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, Response> { // Admission first so the denial contract wins over request validation. // [FI-TRACE-HTTP-INGRESS] let (tenant, pubkey) = - bridge::authenticate_member_post(&state, &headers, SEARCH_PATH, &body).await?; + bridge::authenticate_member_post(&state, &slot, &headers, SEARCH_PATH, &body).await?; let (query, limit, page) = parse_request(&body).map_err(IntoResponse::into_response)?; let hits = state diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 957a238a80..5bc0964a54 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -44,6 +44,7 @@ fn request_path(path: &str, raw_query: Option<&str>) -> String { #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_workflow_read( state: &Arc, + slot: &crate::access::AccessSlot, headers: &HeaderMap, path: &str, raw_query: Option<&str>, @@ -71,7 +72,9 @@ async fn authorize_workflow_read( let admission = admit_nip_fi_http_on_state( state, headers, + slot, bridge::make_nip98_closure_for_admission( + slot.clone(), headers.clone(), "GET", url, @@ -81,29 +84,26 @@ async fn authorize_workflow_read( ), ) .await?; - let pubkey = *admission.proven_pubkey(); - let token_owner = admission.token_owner(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - - bridge::enforce_http_admission(state, &tenant, &pubkey) - .await - .map_err(|e| e.into_response())?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes) + let mut caller = bridge::ProvenCaller::new(admission, slot, &tenant); + caller + .rate_limit_and_replay(state, &tenant) .await .map_err(|e| e.into_response())?; - let pubkey_bytes = pubkey.to_bytes().to_vec(); + let pubkey_bytes = caller.pubkey.to_bytes().to_vec(); let auth_tag = super::relay_members::extract_auth_tag_header(headers); - super::relay_members::enforce_relay_membership( + let membership = super::relay_members::enforce_relay_membership( state, &tenant, &pubkey_bytes, auth_tag, - signed_created_at, - token_owner, + caller.signed_at, + caller.token_owner, ) - .await - .map_err(|e| e.into_response())?; + .await; + caller + .admit_member(membership) + .map_err(|e| e.into_response())?; // A workflow the caller cannot read answers exactly like a missing one, so // the response never reveals which workflow IDs exist. @@ -149,11 +149,12 @@ async fn authorize_workflow_read( /// `GET /workflows/{workflow_id}/runs` — one authorized, keyset-paginated page. pub async fn workflow_runs( State(state): State>, + slot: crate::access::AccessSlot, Path(workflow_id): Path, headers: HeaderMap, RawQuery(raw_query): RawQuery, ) -> Response { - workflow_runs_inner(state, workflow_id, headers, raw_query) + workflow_runs_inner(state, slot, workflow_id, headers, raw_query) .await .into_response() } @@ -161,6 +162,7 @@ pub async fn workflow_runs( #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn workflow_runs_inner( state: Arc, + slot: crate::access::AccessSlot, workflow_id: Uuid, headers: HeaderMap, raw_query: Option, @@ -170,8 +172,15 @@ async fn workflow_runs_inner( // over request-validation errors. [FI-TRACE-HTTP-INGRESS] // Raw query is preserved here; parsing happens after admission. let path = format!("/workflows/{workflow_id}/runs"); - let tenant = - authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; + let tenant = authorize_workflow_read( + &state, + &slot, + &headers, + &path, + raw_query.as_deref(), + workflow_id, + ) + .await?; // Parse query string after admission so malformed params (e.g. ?limit=abc) // cannot 400 before the NIP-FI gate fires. A parse failure after @@ -228,10 +237,11 @@ async fn workflow_runs_inner( /// `GET /workflows/{workflow_id}/runs/{run_id}/approvals` — approvals for a run. pub async fn run_approvals( State(state): State>, + slot: crate::access::AccessSlot, Path((workflow_id, run_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, ) -> Response { - run_approvals_inner(state, workflow_id, run_id, headers) + run_approvals_inner(state, slot, workflow_id, run_id, headers) .await .into_response() } @@ -239,12 +249,13 @@ pub async fn run_approvals( #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn run_approvals_inner( state: Arc, + slot: crate::access::AccessSlot, workflow_id: Uuid, run_id: Uuid, headers: HeaderMap, ) -> Result, Response> { let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals"); - let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?; + let tenant = authorize_workflow_read(&state, &slot, &headers, &path, None, workflow_id).await?; let run = state .db diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 2cbb6b5935..7b26ae9b04 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -11,6 +11,7 @@ //! └─ cleanup: remove peer, broadcast left, emit lifecycle events //! ``` +use crate::access::{auth_error_code, AuthAttempt, AuthLine, AuthMethod, AuthResult}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -84,10 +85,16 @@ const COMMIT_CONFIRM_SEND_TIMEOUT: Duration = Duration::from_secs(5); /// [FI-TRACE-COMMIT-CONFIRM-TIMEOUT] const CLEAN_CLOSE_SEND_TIMEOUT: Duration = Duration::from_secs(2); +/// The request path of a channel's huddle audio socket, for access lines. +fn audio_route(channel_id: Uuid) -> String { + format!("/huddle/{channel_id}/audio") +} + /// WebSocket upgrade handler for `/huddle/:channel_id/audio`. pub async fn ws_audio_handler( State(state): State>, Path(channel_id): Path, + slot: crate::access::AccessSlot, headers: HeaderMap, req: axum::extract::Request, ) -> impl IntoResponse { @@ -101,6 +108,7 @@ pub async fn ws_audio_handler( let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); match check_nip_fi_at_upgrade( + &slot, crate::nip_fi_session::NipFiWsRoute::Audio, &headers, communities, @@ -150,10 +158,15 @@ pub async fn ws_audio_handler( Err(e) => return e.into_response(), }; + let client_ip = crate::access::client_ip(&headers, state.config.client_ip_header.as_ref()); let permit = match acquire_audio_connection_permit(&state.conn_semaphore) { Some(permit) => permit, None => { - warn!(channel_id = %channel_id, "Connection limit reached, rejecting audio WebSocket"); + warn!( + channel_id = %channel_id, + client_ip = client_ip.map(tracing::field::display), + "Connection limit reached, rejecting audio WebSocket" + ); return ( StatusCode::SERVICE_UNAVAILABLE, "relay: connection limit reached", @@ -189,6 +202,7 @@ pub async fn ws_audio_handler( shadow, shadow_line, connection_time, + client_ip, ) }) } @@ -239,6 +253,7 @@ pub(crate) async fn handle_audio_connection( nip_fi_shadow: Option>, shadow_line: Option, connection_time: chrono::DateTime, + client_ip: Option, ) { let cancel = CancellationToken::new(); @@ -265,7 +280,7 @@ pub(crate) async fn handle_audio_connection( // the inner handler drains it via ws_send. let (pre_terminal_ctrl_tx, pre_terminal_ctrl_rx) = tokio::sync::mpsc::channel::(1); - let control = CommunityConnectionControl::new(cancel); + let control = CommunityConnectionControl::new(cancel).with_client_ip(client_ip); control.attach_nip_fi_shadow(nip_fi_shadow); // Its first verified AUTH writes it; else the read loop's end does. let shadow_line = shadow_line.map(Arc::new); @@ -693,12 +708,36 @@ async fn run_active_audio_connection( crate::nip_fi_test_hooks::before_auth_verify(tenant.community()).await; // Every exit before the join commit leaves the shadow session unadmitted. let shadow_attempt = crate::nip_fi_shadow_session::AuthAttempt(control.nip_fi_shadow()); + // The audio login's single access line: decided at the final admission + // check below; any earlier return without a decision logs `aborted`. + let route = audio_route(channel_id); + let mut auth_line = AuthLine::new( + AuthAttempt::http( + AuthMethod::Nip42, + &route, + Some(auth_msg.event.pubkey), + false, + ) + .in_community(tenant.community()) + .with_client_ip(control.client_ip()), + ); + // Why a login cut off before its decision ended, read when the cut-off + // is seen: the session deadline passed, or the connection was cancelled + // for any other reason (the gate reports both as `SessionExpired`). + let abort_cause = || { + if audio_session_deadline.is_some_and(|deadline| chrono::Utc::now() >= deadline) { + "session_expired" + } else { + "connection_closed" + } + }; let auth_ctx = tokio::select! { biased; _ = cancel.cancelled() => { // Expiry fired while waiting for verify_auth_event. Drain the // terminal channel so the denial frame reaches the client. + auth_line.abort_reason(abort_cause()); crate::connection::send_exit_frames_bounded( &mut ws_send, terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), @@ -710,7 +749,8 @@ async fn run_active_audio_connection( match result { Ok(ctx) => ctx, Err(e) => { - warn!(channel_id = %channel_id, "audio auth failed: {e}"); + warn!(channel_id = %channel_id, reason = auth_error_code(&e), "audio auth failed"); + auth_line.finish(AuthResult::Failed(auth_error_code(&e))); shadow_attempt.refused(); // Under NIP-FI the failure is classified exactly as on the // root route; Off-mode keeps the legacy bespoke frame. @@ -741,6 +781,7 @@ async fn run_active_audio_connection( }; let pubkey = auth_ctx.pubkey; + auth_line.proven(pubkey); let pubkey_hex = pubkey.to_hex(); let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; @@ -779,6 +820,7 @@ async fn run_active_audio_connection( .await == crate::nip_fi_session::PairingOutcome::Denied { + auth_line.finish(AuthResult::Denied(crate::access::AuthReason::KeyMismatch)); return; } @@ -793,6 +835,9 @@ async fn run_active_audio_connection( pubkey = %pubkey_hex, "NIP-FI session deadline already expired at pairing — rejecting audio admission" ); + auth_line.finish(AuthResult::Denied( + crate::access::AuthReason::SessionExpired, + )); deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } @@ -819,6 +864,7 @@ async fn run_active_audio_connection( pubkey = %pubkey_hex, "NIP-FI deny-set hit at audio post-registration check — denying" ); + auth_line.finish(AuthResult::Denied(crate::access::AuthReason::DenySet)); deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } @@ -903,6 +949,12 @@ async fn run_active_audio_connection( } }; if let Some(class) = relay_refusal { + auth_line.finish(AuthResult::Denied(match class { + buzz_auth::DenialClass::AuthorizationUnavailable => { + crate::access::AuthReason::MembershipUnavailable + } + _ => crate::access::AuthReason::NotRelayMember, + })); shadow_attempt.refused(); #[cfg(test)] crate::nip_fi_test_hooks::before_audio_refusal_frames(tenant.community()).await; @@ -918,7 +970,8 @@ async fn run_active_audio_connection( .await; return; } - check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send, + cleanup: auth_line.abort_reason(abort_cause())); // ── Step 3: membership check / auto-add ─────────────────────────────────── let membership_admission = match check_membership_for_admission( @@ -932,6 +985,9 @@ async fn run_active_audio_connection( { Ok(admission) => admission, Err(refusal) => { + auth_line.finish(AuthResult::Denied( + crate::access::AuthReason::ChannelMembershipDenied, + )); shadow_attempt.refused(); warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {refusal}"); exit_authorization_refusal( @@ -954,7 +1010,8 @@ async fn run_active_audio_connection( parent_channel_id, .. } => *parent_channel_id, }; - check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send, + cleanup: auth_line.abort_reason(abort_cause())); // Record a delegated agent's owner link before admission, as root AUTH // does: revoking the owner finds the agent's sockets through it, so an @@ -967,6 +1024,7 @@ async fn run_active_audio_connection( let _owner_permit = match audio_gate.acquire_effect().await { Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { + auth_line.abort_reason(abort_cause()); cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { let _ = t.await; @@ -983,6 +1041,9 @@ async fn run_active_audio_connection( }; if let Some(denial) = crate::handlers::auth::owner_link_denial(link, token_owner.is_some()) { + auth_line.finish(AuthResult::Denied( + crate::access::AuthReason::NipOaOwnerUnlinked, + )); shadow_attempt.refused(); warn!(channel_id = %channel_id, pubkey = %pubkey_hex, owner = %owner.to_hex(), ?link, "audio: agent owner could not be linked, denying"); @@ -1028,6 +1089,7 @@ async fn run_active_audio_connection( } }; if let Some(denial) = denial { + auth_line.finish(AuthResult::from_outcome(denial.outcome)); shadow_attempt.refused(); let (class, message) = (denial.class, denial.reason); warn!(channel_id = %channel_id, pubkey = %pubkey_hex, reason = message, "audio: denied at final admission check"); @@ -1042,7 +1104,11 @@ async fn run_active_audio_connection( .await; return; } - check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send, + cleanup: auth_line.abort_reason(abort_cause())); + + // Every auth and admission check passed; what follows is room joining. + auth_line.finish(AuthResult::Success); // Huddle cross-pod routing (mesh) OR single-pod guardrail. // @@ -5213,6 +5279,171 @@ mod tests { let _ = server.await; } + // ── Access line: why an audio login cut off before its decision ended ────── + + /// Where [`audio_login_abort_reason`] parks the login. + #[derive(Clone, Copy)] + enum AbortStage { + /// Before `verify_auth_event`. + Verify, + /// After the deny-set check, before the relay-membership policy read. + PolicyRead, + } + + /// Run a real audio connection whose NIP-FI session ends at `deadline` + /// (no assertion, so no deadline, when `None`), park its login at `stage`, then end the connection: by an external + /// cancel when `external_cancel`, otherwise by waiting for the deadline + /// (the production expiry task cancels). Returns the login's `aborted` + /// reason, asserting it wrote exactly one auth line. + async fn audio_login_abort_reason( + community_uuid: uuid::Uuid, + deadline: Option>, + stage: AbortStage, + external_cancel: bool, + ) -> String { + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let key = nostr::Keys::generate(); + let assertion = deadline.map(|deadline| { + buzz_auth::VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]) + }); + let state = audio_test_state().await; + let community = buzz_core::tenant::CommunityId::from_uuid(community_uuid); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + let conn_cancel = CancellationToken::new(); + let cancel = conn_cancel.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let (state, tenant, assertion) = + (Arc::clone(&state), tenant.clone(), assertion.clone()); + let control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state, + tenant, + uuid::Uuid::new_v4(), + control, + assertion, + chrono::Utc::now(), + None, + ) + .await + }) + } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (guard, out) = crate::access::tests::capture_scoped(); + let (arrived, release) = match stage { + AbortStage::Verify => crate::nip_fi_test_hooks::audio_auth_verify_hook::arm(community), + AbortStage::PolicyRead => { + crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community) + } + }; + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect"); + let challenge = match client.next().await.expect("challenge").expect("frame") { + tokio_tungstenite::tungstenite::Message::Text(t) => { + serde_json::from_str::(&t).expect("json")["challenge"] + .as_str() + .expect("challenge") + .to_owned() + } + other => panic!("expected a challenge, got {other:?}"), + }; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", "ws://test.local"]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text(auth.into())) + .await + .expect("send auth"); + tokio::time::timeout(std::time::Duration::from_secs(5), arrived) + .await + .expect("login reaches the hook") + .expect("hook channel"); + + if external_cancel { + cancel.cancel(); + } else { + tokio::time::timeout(std::time::Duration::from_secs(5), cancel.cancelled()) + .await + .expect("the expiry task cancels at the deadline"); + } + release.notify_one(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), async { + while client.next().await.is_some() {} + }) + .await; + server.abort(); + let _ = server.await; + drop(guard); + + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "auth") + .collect(); + assert_eq!(lines.len(), 1, "exactly one auth line: {lines:?}"); + assert_eq!(lines[0]["outcome"], "aborted", "{}", lines[0]); + lines[0]["reason"].as_str().expect("reason").to_owned() + } + + #[tokio::test] + async fn audio_login_cancelled_with_live_session_logs_connection_closed() { + let community = uuid::Uuid::from_u128(0x0000_0000_0A81_0000_0000_0000_0000_0001); + let live = chrono::Utc::now() + chrono::Duration::hours(1); + let reason = + audio_login_abort_reason(community, Some(live), AbortStage::Verify, true).await; + assert_eq!(reason, "connection_closed"); + } + + #[tokio::test] + async fn audio_login_past_its_deadline_logs_session_expired() { + let community = uuid::Uuid::from_u128(0x0000_0000_0A81_0000_0000_0000_0000_0002); + let soon = chrono::Utc::now() + chrono::Duration::milliseconds(500); + let reason = + audio_login_abort_reason(community, Some(soon), AbortStage::Verify, false).await; + assert_eq!(reason, "session_expired"); + } + + #[tokio::test] + async fn audio_login_expiring_during_the_policy_read_logs_session_expired() { + let community = uuid::Uuid::from_u128(0x0000_0000_0A81_0000_0000_0000_0000_0003); + let soon = chrono::Utc::now() + chrono::Duration::milliseconds(500); + let reason = + audio_login_abort_reason(community, Some(soon), AbortStage::PolicyRead, false).await; + assert_eq!(reason, "session_expired"); + } + + #[tokio::test] + async fn audio_login_cancelled_during_the_policy_read_logs_connection_closed() { + let community = uuid::Uuid::from_u128(0x0000_0000_0A81_0000_0000_0000_0000_0004); + let live = chrono::Utc::now() + chrono::Duration::hours(1); + let reason = + audio_login_abort_reason(community, Some(live), AbortStage::PolicyRead, true).await; + assert_eq!(reason, "connection_closed"); + } + + #[tokio::test] + async fn audio_login_cancelled_without_a_deadline_logs_connection_closed() { + let community = uuid::Uuid::from_u128(0x0000_0000_0A81_0000_0000_0000_0000_0005); + let reason = audio_login_abort_reason(community, None, AbortStage::Verify, true).await; + assert_eq!(reason, "connection_closed"); + } + // ── W6 (B1 audio mid-admission): cancellation before room.add_peer ───────── // // With the expiry task armed before admission (above the first persisting @@ -6280,6 +6511,7 @@ mod tests { None, None, conn_time, + None, ) .await }) @@ -10891,6 +11123,7 @@ mod tests { None, line, Utc::now(), + None, ) .await; ended.notify_one(); @@ -11172,6 +11405,7 @@ mod tests { None, None, Utc::now(), + None, ) }) } @@ -16185,6 +16419,7 @@ mod tests { None, None, chrono::Utc::now(), + None, ) }) } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index cbed06b641..1f5b1bd6af 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -32,7 +32,7 @@ pub enum ConfigError { /// # Role resolution (nip98 mode only) /// /// In `nip98` mode the authenticated pubkey is resolved to an -/// `AdminPrincipal` at request time via [`crate::api::admin::auth::resolve_admin_principal`]: +/// `AdminPrincipal` at request time via [`crate::api::admin::auth::lookup_admin_principal`]: /// - `Operator/Config` if pubkey ∈ `RELAY_OPERATOR_PUBKEYS` /// - `Operator/OwnerFallback` if pubkey == `RELAY_OWNER_PUBKEY` **and** /// `RELAY_OPERATOR_PUBKEYS` is empty (evaluated from config, never runtime rows) @@ -182,6 +182,9 @@ pub struct Config { pub max_frame_bytes: usize, /// Number of consecutive buffer-full events tolerated before cancelling a slow client. pub slow_client_grace_limit: u8, + /// `BUZZ_CLIENT_IP_HEADER`: the request header whose IP access lines log + /// as `client_ip`. `None` logs only the TCP `peer_addr`. + pub client_ip_header: Option, /// Authentication provider configuration. pub auth: buzz_auth::AuthConfig, /// Whether REST API requests must present a valid token. Independent of @@ -442,6 +445,19 @@ fn invite_v1_cutoff_from_env() -> Result, ConfigError> { } } +/// An empty value turns the setting off; header names match case-insensitively. +fn parse_client_ip_header(raw: &str) -> Result, ConfigError> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + axum::http::HeaderName::from_bytes(raw.to_ascii_lowercase().as_bytes()) + .map(Some) + .map_err(|_| { + ConfigError::InvalidValue(format!("BUZZ_CLIENT_IP_HEADER is not a header name: {raw}")) + }) +} + fn positive_u64_from_env(name: &str, default: u64) -> Result { match std::env::var(name) { Ok(raw) => raw @@ -788,6 +804,9 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(15); + let client_ip_header = + parse_client_ip_header(&std::env::var("BUZZ_CLIENT_IP_HEADER").unwrap_or_default())?; + let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -1443,6 +1462,7 @@ impl Config { send_buffer_size, max_frame_bytes, slow_client_grace_limit, + client_ip_header, auth, require_auth_token, buzz_v1_enabled, @@ -2943,6 +2963,17 @@ mod tests { ); } + #[test] + fn client_ip_header_is_off_when_empty_and_case_insensitive() { + assert_eq!(parse_client_ip_header("").unwrap(), None); + assert_eq!(parse_client_ip_header(" ").unwrap(), None); + assert_eq!( + parse_client_ip_header(" CF-Connecting-IP ").unwrap(), + Some(axum::http::HeaderName::from_static("cf-connecting-ip")) + ); + assert!(parse_client_ip_header("not a header").is_err()); + } + #[test] fn invalid_bind_addr_returns_error() { assert!(matches!( diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index f2f49eedc0..4d36c0b6c5 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1,5 +1,6 @@ //! WebSocket connection lifecycle: semaphore → challenge → recv/send/heartbeat loops → cleanup. +use std::cell::Cell; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicU8, Ordering}; @@ -11,7 +12,7 @@ use futures_util::{Sink, SinkExt, StreamExt}; use tokio::sync::{mpsc, watch, Mutex}; use tokio_util::sync::CancellationToken; use tracing::Instrument as _; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, trace, warn}; use uuid::Uuid; use buzz_auth::{generate_challenge, AuthContext}; @@ -151,6 +152,9 @@ pub struct ConnectionState { /// peer cleanup) cannot start until all pre-expiry effects finish their /// bounded commits. [FI-TRACE-LEASE-BOUND, one-gate-per-connection] pub(crate) nip_fi_gate: std::sync::Arc, + + /// Bounds the access lines this client can trigger without being served. + pub(crate) refusal_budget: Arc, } impl ConnectionState { @@ -288,20 +292,27 @@ impl ConnectionState { /// aborts and panics fall back to `disconnect` and cancel child tasks. struct AuthLifecycleGuard { conn: Arc, + close: Arc, finished: bool, } impl AuthLifecycleGuard { - fn new(conn: Arc) -> Self { + fn new(conn: Arc, close: Arc) -> Self { Self { conn, + close, finished: false, } } + /// Terminalize the login, recording its identity on the close line. fn finish(&mut self, outcome: AuthOutcome) -> Option { self.finished = true; - self.conn.finish_auth_on_close(outcome) + let authenticated = self.conn.finish_auth_on_close(outcome); + if let Some(ctx) = &authenticated { + self.close.logged_in(ctx.pubkey); + } + authenticated } } @@ -309,7 +320,7 @@ impl Drop for AuthLifecycleGuard { fn drop(&mut self) { if !self.finished { self.conn.cancel.cancel(); - self.conn.finish_auth_on_close(AuthOutcome::Disconnect); + self.finish(AuthOutcome::Disconnect); } } } @@ -360,6 +371,7 @@ pub(crate) async fn handle_connection( socket: WebSocket, state: Arc, addr: SocketAddr, + client_ip: Option, tenant: TenantContext, nip_fi_assertion: Option, nip_fi_shadow: Option>, @@ -368,12 +380,23 @@ pub(crate) async fn handle_connection( ) { // Held through bootstrap, the active handler and any refusal drain, so a // socket counts against `BUZZ_MAX_CONNECTIONS` for as long as it is open. + let conn_id = Uuid::new_v4(); + // Owned from the upgrade on, so every exit (inactive community, the + // connection limit, the active handler, or this future being dropped) + // writes the connection's one line. + let close = crate::access::CloseLine::open(conn_id, tenant.community(), addr, client_ip); let Ok(_permit) = state.conn_semaphore.clone().try_acquire_owned() else { - warn!("Connection limit reached, rejecting {addr}"); + warn!( + peer_addr = %addr, + client_ip = client_ip.map(tracing::field::display), + "Connection limit reached, rejecting" + ); + close.refused("connection_limit"); return; }; - let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); + let close_for_run = Arc::clone(&close); + let close_for_drain = Arc::clone(&close); // Fix 3: Arm the NIP-FI gate and expiry task BEFORE the `is_community_active` // bootstrap await so the session deadline is enforced even when the DB check @@ -393,7 +416,7 @@ pub(crate) async fn handle_connection( crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()) }; let (pre_terminal_ctrl_tx, pre_terminal_ctrl_rx) = mpsc::channel::(1); - let control = CommunityConnectionControl::new(cancel); + let control = CommunityConnectionControl::new(cancel).with_client_ip(client_ip); control.attach_nip_fi_shadow(nip_fi_shadow); // Its first verified AUTH writes it; else the read loop's end does. let shadow_line = shadow_line.map(Arc::new); @@ -457,7 +480,7 @@ pub(crate) async fn handle_connection( run_state, addr, tenant, - conn_id, + close_for_run, control, nip_fi_assertion, connection_time, @@ -471,6 +494,8 @@ pub(crate) async fn handle_connection( .await }, move || async move { + // Bootstrap ended the connection before serving it. + close_for_drain.refused("not_admitted"); // Deliver a queued denial frame (if any) and the reason's Close // even when the active-connection path is never entered. let socket = socket_for_drain.lock().await.take(); @@ -498,7 +523,7 @@ async fn handle_active_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, - conn_id: Uuid, + close: Arc, control: CommunityConnectionControl, nip_fi_assertion: Option, connection_time: chrono::DateTime, @@ -507,6 +532,7 @@ async fn handle_active_connection( // produced by any caller but retained for future test extensibility. pre_built: Option, ) { + let conn_id = close.conn_id(); let cancel = control.cancellation_token(); let disconnect_reason = control.disconnect_reason(); // connection_time is threaded in from the HTTP handler (captured immediately @@ -586,6 +612,7 @@ async fn handle_active_connection( }); let conn = Arc::new(ConnectionState { + refusal_budget: Arc::clone(&close.refusals), conn_id, tenant, remote_addr: addr, @@ -606,7 +633,6 @@ async fn handle_active_connection( community_control: control.clone(), }); - info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); metrics::counter!( "buzz_ws_connections_total", "community" => conn.tenant.host().to_owned() @@ -627,7 +653,7 @@ async fn handle_active_connection( // don't leak. Decremented in the cleanup path below. metrics::gauge!("buzz_ws_connections_active").increment(1.0); crate::metrics::record_auth_attempt_started(); - let mut auth_lifecycle = AuthLifecycleGuard::new(Arc::clone(&conn)); + let mut auth_lifecycle = AuthLifecycleGuard::new(Arc::clone(&conn), Arc::clone(&close)); // Register after challenge succeeds — avoids leaked entries on early disconnect. state.conn_manager.register( @@ -647,6 +673,7 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); + let ws_send = crate::access::CountingSink::new(ws_send, Arc::clone(&close.queued)); let send_task = tokio::spawn(send_loop( ws_send, rx, @@ -777,7 +804,6 @@ async fn handle_active_connection( }; tokio::join!(cleanup, await_client_close(ws_recv)); metrics::gauge!("buzz_ws_connections_active").decrement(1.0); - info!(conn_id = %conn_id, addr = %addr, "WebSocket connection closed"); } /// Send WebSocket messages in priority order: control frames before data frames. @@ -786,15 +812,18 @@ async fn handle_active_connection( /// giving them priority over data frames. If the underlying socket writer /// is stalled, control frames queue in the small ctrl_rx buffer; callers /// treat a full control channel as terminal (Bug 7 fix). -async fn send_loop( - ws_send: futures_util::stream::SplitSink, +async fn send_loop( + ws_send: S, data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, terminal_ctrl_rx: mpsc::Receiver, restart_rx: mpsc::Receiver, cancel: CancellationToken, disconnect_reason: watch::Receiver>, -) { +) where + S: Sink + Unpin, + S::Error: std::error::Error + 'static, +{ send_loop_inner( ws_send, data_rx, @@ -1328,21 +1357,17 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } }; - if !enforce_ws_admission(&msg, &conn, &state).await { + if let Err(reason) = enforce_ws_admission(&msg, &conn, &state).await { + crate::access::ReadLog::refused(&msg, &conn, reason); return; } match msg { ClientMessage::Auth(event) => { // AUTH remains inline so only one frame can race the connection's - // pending lifecycle, but cancellation can preempt dependency waits. - let span = tracing::info_span!("ws.auth", conn_id = %conn.conn_id); - tokio::select! { - biased; - _ = conn.cancel.cancelled() => {} - _ = handlers::auth::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)) - .instrument(span) => {} - } + // pending lifecycle. Boxed: the handler's future is deep enough + // to overflow the layout depth limit when nested here. + Box::pin(handle_auth_frame(event, &conn, &state)).await; } ClientMessage::Event(event) => { let conn = Arc::clone(&conn); @@ -1393,6 +1418,13 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar &state, ) .await; + crate::access::ReadLog::refused_read( + "req", + &conn, + &sub_id, + &filters, + "too_many_concurrent_requests", + ); return; } }; @@ -1419,6 +1451,12 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar &state, ) .await; + crate::access::ReadLog::refused_sync( + &conn, + &sub_id, + filter.as_ref().ok(), + "too_many_concurrent_requests", + ); return; } }; @@ -1441,6 +1479,13 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); + crate::access::ReadLog::refused_read( + "count", + &conn, + &sub_id, + &filters, + "too_many_concurrent_requests", + ); return; } }; @@ -1459,6 +1504,60 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } } +/// One AUTH frame: cancellation can preempt the handler's dependency waits, +/// and a frame that ends without logging a decision logs `aborted`. +async fn handle_auth_frame( + event: nostr::Event, + conn: &Arc, + state: &Arc, +) { + let span = tracing::info_span!("ws.auth", conn_id = %conn.conn_id); + let claimed = event.pubkey; + AUTH_LINE_WRITTEN + .scope(Cell::new(false), async { + tokio::select! { + biased; + _ = conn.cancel.cancelled() => {} + _ = handlers::auth::handle_auth(event, Arc::clone(conn), Arc::clone(state)) + .instrument(span) => {} + } + if !AUTH_LINE_WRITTEN.with(Cell::get) { + log_aborted_auth(conn, claimed); + } + }) + .await; +} + +tokio::task_local! { + /// Whether the AUTH frame being handled has logged its access line. The + /// connection's auth state cannot answer this: the cancellation watcher + /// and the auth timeout end a pending attempt without logging it. + static AUTH_LINE_WRITTEN: Cell; +} + +/// Record that the current AUTH frame logged its line (no-op elsewhere). +pub(crate) fn note_auth_line_written() { + let _ = AUTH_LINE_WRITTEN.try_with(|written| written.set(true)); +} + +/// Log an AUTH frame that ended without a decision of its own: cut off by +/// cancellation, or finished after the watcher or timeout ended its attempt. +fn log_aborted_auth(conn: &ConnectionState, claimed: nostr::PublicKey) { + let reason = if conn.nip_fi_gate.deadline_passed() { + "session_expired" + } else { + "connection_closed" + }; + // Cut off after `authenticate()` succeeded: the close line will show + // this key as authenticated, so this line must not call it unverified. + let verified = matches!( + conn.auth_state_snapshot(), + AuthState::Authenticated(ctx) if ctx.pubkey == claimed + ); + crate::access::AuthAttempt::nip42(conn, claimed, verified) + .log(crate::access::AuthResult::Aborted(reason)); +} + #[cfg(test)] pub(crate) mod test_seam { use super::*; @@ -1535,6 +1634,7 @@ pub(crate) mod tests { let (terminal_ctrl_tx, terminal_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let conn = ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), @@ -1833,6 +1933,177 @@ pub(crate) mod tests { ); } + /// The close lines `out` holds, once at least one is written. + async fn close_lines(out: &crate::access::tests::Captured) -> Vec { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "close") + .collect(); + if !lines.is_empty() { + // Let a duplicate, if any, land before counting. + tokio::task::yield_now().await; + break out + .lines() + .into_iter() + .filter(|l| l["event"] == "close") + .collect(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("a close line") + } + + /// Serve `upgrade` on a local WebSocket, connect once, read until the + /// server ends the socket, and return the data frames the client received + /// and the connection's close lines. + async fn close_lines_of( + upgrade: F, + ) -> ( + Vec, + Vec, + ) + where + F: Fn(axum::extract::ws::WebSocket) -> Fut + Clone + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + use axum::{routing::get, Router}; + use futures_util::StreamExt as _; + let (_guard, out) = crate::access::tests::capture_scoped(); + let app = Router::new().route( + "/", + get(move |ws: axum::extract::ws::WebSocketUpgrade| { + let upgrade = upgrade.clone(); + async move { ws.on_upgrade(upgrade) } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect"); + let mut frames = Vec::new(); + while let Ok(Some(Ok(frame))) = + tokio::time::timeout(std::time::Duration::from_secs(3), client.next()).await + { + if frame.is_close() { + break; + } + if frame.is_text() || frame.is_binary() { + frames.push(frame); + } + } + let lines = close_lines(&out).await; + server.abort(); + (frames, lines) + } + + /// A connection cancelled during the community-active startup check + /// still writes its one close line, marked not admitted. + #[tokio::test] + async fn startup_cancellation_writes_one_close_line() { + let key = nostr::Keys::generate(); + let deadline = chrono::Utc::now() - chrono::Duration::seconds(2); + let assertion = + buzz_auth::VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + let state = crate::state::tests::test_state().await; + let (_, lines) = close_lines_of(move |socket| { + let (state, assertion) = (Arc::clone(&state), assertion.clone()); + async move { + handle_connection( + socket, + state, + "127.0.0.1:9999".parse().unwrap(), + None, + TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ), + Some(assertion), + None, + None, + chrono::Utc::now(), + ) + .await + } + }) + .await; + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["refused"], "not_admitted", "{}", lines[0]); + assert!(lines[0].get("pubkey").is_none(), "{}", lines[0]); + } + + /// A connection refused at the connection limit writes one close line + /// saying so. + #[tokio::test] + async fn connection_limit_refusal_writes_one_close_line() { + let mut state = (*crate::state::tests::test_state().await).clone(); + state.conn_semaphore = Arc::new(tokio::sync::Semaphore::new(0)); + let state = Arc::new(state); + let (frames, lines) = close_lines_of(move |socket| { + let state = Arc::clone(&state); + async move { + handle_connection( + socket, + state, + "127.0.0.1:9999".parse().unwrap(), + None, + TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ), + None, + None, + None, + chrono::Utc::now(), + ) + .await + } + }) + .await; + assert!(frames.is_empty(), "refused socket was served: {frames:?}"); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["refused"], "connection_limit", "{}", lines[0]); + } + + /// Dropping an authenticated handler mid-connection writes its one + /// close line, with the identity it logged in as. + #[tokio::test(flavor = "current_thread")] + async fn dropped_authenticated_handler_writes_one_close_line() { + let (_guard, out) = crate::access::tests::capture_scoped(); + let (conn, _rx) = test_conn_with_auth(pending_state()); + let ctx = auth_context(); + let pubkey = ctx.pubkey; + assert!(conn.authenticate(ctx)); + let started = Arc::new(Notify::new()); + let task = { + let started = Arc::clone(&started); + tokio::spawn(async move { + let close = crate::access::CloseLine::open( + conn.conn_id, + conn.tenant.community(), + conn.remote_addr, + None, + ); + let _guard = AuthLifecycleGuard::new(conn, close); + started.notify_one(); + std::future::pending::<()>().await; + }) + }; + started.notified().await; + task.abort(); + let _ = task.await; + let lines = close_lines(&out).await; + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["pubkey"], pubkey.to_hex(), "{}", lines[0]); + assert!(lines[0].get("refused").is_none(), "{}", lines[0]); + } + /// Aborting the production lifecycle owner must synchronously terminalize /// pending accounting and release an authenticated gauge exactly once. #[tokio::test(flavor = "current_thread")] @@ -1849,7 +2120,13 @@ pub(crate) mod tests { let conn = Arc::clone(&authenticated); let started = Arc::clone(&authenticated_started); tokio::spawn(async move { - let _guard = AuthLifecycleGuard::new(conn); + let close = crate::access::CloseLine::open( + conn.conn_id, + conn.tenant.community(), + conn.remote_addr, + None, + ); + let _guard = AuthLifecycleGuard::new(conn, close); started.notify_one(); std::future::pending::<()>().await; }) @@ -1865,7 +2142,13 @@ pub(crate) mod tests { let conn = Arc::clone(&pending); let started = Arc::clone(&pending_started); tokio::spawn(async move { - let _guard = AuthLifecycleGuard::new(conn); + let close = crate::access::CloseLine::open( + conn.conn_id, + conn.tenant.community(), + conn.remote_addr, + None, + ); + let _guard = AuthLifecycleGuard::new(conn, close); started.notify_one(); std::future::pending::<()>().await; }) @@ -1955,12 +2238,18 @@ pub(crate) mod tests { ws.on_upgrade(move |socket| async move { let cancel = CancellationToken::new(); let control = CommunityConnectionControl::new(cancel); + let close = crate::access::CloseLine::open( + Uuid::new_v4(), + tenant.community(), + "127.0.0.1:1234".parse().expect("client address"), + None, + ); handle_active_connection( socket, state, "127.0.0.1:1234".parse().expect("client address"), tenant, - Uuid::new_v4(), + close, control, None, chrono::Utc::now(), @@ -2406,6 +2695,8 @@ pub(crate) mod tests { messages: Vec, flush_count: usize, fail_after_flushes: Option, + reject_sends: bool, + pending_flushes: usize, } #[derive(Debug, Clone)] @@ -2439,11 +2730,14 @@ pub(crate) mod tests { } fn start_send(self: std::pin::Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { - self.state - .lock() - .expect("mock sink poisoned") - .messages - .push(item); + let mut state = self.state.lock().expect("mock sink poisoned"); + if state.reject_sends { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "mock send rejected", + )); + } + state.messages.push(item); Ok(()) } @@ -2453,6 +2747,10 @@ pub(crate) mod tests { ) -> std::task::Poll> { let mut state = self.state.lock().expect("mock sink poisoned"); state.flush_count += 1; + if state.pending_flushes > 0 { + state.pending_flushes -= 1; + return std::task::Poll::Pending; + } if state .fail_after_flushes .is_some_and(|limit| state.flush_count >= limit) @@ -2508,6 +2806,862 @@ pub(crate) mod tests { } } + /// Run the production writer over a counting `sink` for `frames`, then + /// return the close-line totals it recorded. + async fn queued_totals( + sink: MockSink, + state: &Arc>, + frames: &[&str], + ) -> Arc { + let (data_tx, data_rx) = mpsc::channel(frames.len().max(1)); + for frame in frames { + data_tx + .send(WsMessage::Text((*frame).into())) + .await + .expect("queue frame"); + } + let totals = Arc::new(crate::access::QueuedTotals::default()); + let cancel = CancellationToken::new(); + let writer = tokio::spawn(send_loop_inner( + crate::access::CountingSink::new(sink, Arc::clone(&totals)), + data_rx, + mpsc::channel(1).1, + mpsc::channel(1).1, + mpsc::channel(1).1, + cancel.clone(), + ordinary_disconnect_reason(), + )); + while state.lock().expect("mock sink poisoned").flush_count == 0 + && !state.lock().expect("mock sink poisoned").reject_sends + { + tokio::task::yield_now().await; + } + tokio::task::yield_now().await; + cancel.cancel(); + writer.await.expect("writer exits"); + totals + } + + const EVENT_FRAME: &str = r#"["EVENT","s",{}]"#; + + #[tokio::test] + async fn close_totals_count_frames_the_writer_flushed() { + let (sink, state) = MockSink::new(None); + let totals = + queued_totals(sink, &state, &[EVENT_FRAME, EVENT_FRAME, r#"["EOSE","s"]"#]).await; + assert_eq!(totals.events(), 2); + assert_eq!( + totals.bytes(), + (EVENT_FRAME.len() * 2 + r#"["EOSE","s"]"#.len()) as u64 + ); + } + + #[tokio::test] + async fn close_totals_skip_a_frame_the_socket_rejects() { + let (sink, state) = MockSink::new(None); + state.lock().expect("mock sink poisoned").reject_sends = true; + let totals = queued_totals(sink, &state, &[EVENT_FRAME]).await; + assert_eq!((totals.events(), totals.bytes()), (0, 0)); + } + + #[tokio::test] + async fn close_totals_skip_frames_stranded_by_a_failed_flush() { + let (sink, state) = MockSink::new(Some(1)); + let totals = queued_totals(sink, &state, &[EVENT_FRAME, EVENT_FRAME]).await; + assert_eq!(state.lock().expect("mock sink poisoned").messages.len(), 2); + assert_eq!((totals.events(), totals.bytes()), (0, 0)); + } + + /// Cancellation lands while the batch's flush is still pending; the + /// terminal drain then flushes it, so the batch counts once. + #[tokio::test] + async fn close_totals_count_a_batch_flushed_by_the_drain_after_cancellation() { + let (sink, state) = MockSink::new(None); + state.lock().expect("mock sink poisoned").pending_flushes = 1; + let totals = queued_totals(sink, &state, &[EVENT_FRAME, EVENT_FRAME]).await; + assert!(state.lock().expect("mock sink poisoned").flush_count >= 2); + assert_eq!(totals.events(), 2); + assert!(totals.bytes() >= (EVENT_FRAME.len() * 2) as u64); + } + + /// Feed `frame` through the production frame dispatcher and return the + /// read access lines it produced once every spawned handler finished. + async fn read_lines_for( + frame: &str, + auth: AuthState, + state: Arc, + ) -> Vec { + let (guard, out) = crate::access::tests::capture_scoped(); + let (conn, _rx) = test_conn_with_auth(auth); + handle_text_message(frame.to_owned(), conn, state).await; + let reads = |out: &crate::access::tests::Captured| -> Vec { + out.lines() + .into_iter() + .filter(|l| ["req", "count", "sync"].contains(&l["event"].as_str().unwrap_or(""))) + .collect() + }; + // A dispatched handler runs as a spawned task on this thread. + for _ in 0..1_000 { + if !reads(&out).is_empty() { + break; + } + tokio::task::yield_now().await; + } + // Let any duplicate line surface before counting. + for _ in 0..10 { + tokio::task::yield_now().await; + } + drop(guard); + reads(&out) + } + + /// A NIP-AR sync REQ for one channel. + const SYNC_FRAME: &str = + r##"["REQ","y",{"artifact":"current","#h":["4f4b2a3e-6c1d-4f0e-9a51-2d7c8e9b0a11"]}]"##; + + fn assert_one_denial(lines: &[serde_json::Value], event: &str, reason: &str) { + assert_eq!(lines.len(), 1, "one access line per read: {lines:?}"); + assert_eq!(lines[0]["event"], event); + assert_eq!(lines[0]["outcome"], "denied"); + assert_eq!(lines[0]["reason"], reason); + } + + #[tokio::test] + async fn read_refused_by_unavailable_admission_logs_one_denial() { + // The test state has no Redis, so shared admission fails closed. + let state = crate::state::tests::test_state().await; + for (frame, event) in [ + (r#"["REQ","s",{"kinds":[9]}]"#, "req"), + (r#"["COUNT","c",{"kinds":[9]}]"#, "count"), + (SYNC_FRAME, "sync"), + ] { + let lines = read_lines_for(frame, authenticated_state(), Arc::clone(&state)).await; + assert_one_denial(&lines, event, "admission_unavailable"); + } + } + + #[tokio::test] + async fn read_refused_by_a_full_handler_semaphore_logs_one_denial() { + let mut state = (*crate::state::tests::test_state().await).clone(); + state.handler_semaphore = Arc::new(tokio::sync::Semaphore::new(0)); + let state = Arc::new(state); + for (frame, event) in [ + (r#"["REQ","s",{"kinds":[9]}]"#, "req"), + (r#"["COUNT","c",{"kinds":[9]}]"#, "count"), + (SYNC_FRAME, "sync"), + ] { + let lines = read_lines_for(frame, AuthState::Failed, Arc::clone(&state)).await; + assert_one_denial(&lines, event, "too_many_concurrent_requests"); + } + } + + #[tokio::test] + async fn read_before_login_logs_one_denial_from_the_handler() { + let state = crate::state::tests::test_state().await; + for (frame, event) in [ + (r#"["REQ","s",{"kinds":[9]}]"#, "req"), + (r#"["COUNT","c",{"kinds":[9]}]"#, "count"), + (SYNC_FRAME, "sync"), + ] { + let lines = read_lines_for(frame, AuthState::Failed, Arc::clone(&state)).await; + assert_one_denial(&lines, event, "auth_required"); + } + } + + /// Root AUTH cut off mid-check, through the real connection lifecycle. + mod postgres_tests { + use super::*; + + /// Each decided AUTH frame, sent through the production dispatcher, + /// writes its decision's line exactly once, never also an `aborted`: + /// a success one INFO, a failure one WARN. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn decided_auth_frames_log_exactly_one_line_each() { + let mut state = (*crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres")) + .clone(); + Arc::make_mut(&mut state.config).require_relay_membership = false; + let state = Arc::new(state); + let keys = Keys::generate(); + let other = Keys::generate(); + for (good, outcome) in [(true, "success"), (false, "failed")] { + let (conn, _rx) = test_conn_with_auth(pending_state()); + let relay_url: RelayUrl = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &conn.tenant, + ) + .parse() + .expect("relay url"); + let event = EventBuilder::auth("test-challenge", relay_url.clone()) + .sign_with_keys(&keys) + .expect("sign auth"); + let mut frame = serde_json::json!(["AUTH", event]); + if !good { + // A well-formed signature over a different event. + let foreign = EventBuilder::auth("other", relay_url) + .sign_with_keys(&other) + .expect("sign foreign"); + frame[1]["sig"] = serde_json::json!(foreign.sig.to_string()); + } + let (guard, out) = crate::access::tests::capture_scoped(); + handle_text_message(frame.to_string(), Arc::clone(&conn), Arc::clone(&state)).await; + drop(guard); + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "auth") + .collect(); + let outcomes: Vec<_> = lines.iter().map(|l| l["outcome"].clone()).collect(); + assert_eq!(outcomes, [outcome], "{lines:?}"); + let level = if good { "INFO" } else { "WARN" }; + assert_eq!(lines[0]["level"], level, "{lines:?}"); + assert_eq!(lines[0]["peer_addr"], conn.remote_addr.to_string()); + } + } + + /// The TCP peer of every [`ws_login_lines`] connection. + const PEER: &str = "127.0.0.1:9999"; + + /// A connection that logs in, reads, and closes writes its success + /// `auth` line when the login commits, its REQ line, and one close + /// line still carrying the login, and no `connect` line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn logged_in_connection_writes_its_login_read_and_one_close_line() { + let (keys, lines) = ws_login_lines(true, None).await; + let events: Vec<_> = lines.iter().map(|l| l["event"].clone()).collect(); + assert_eq!(events, ["auth", "req", "close"], "{lines:?}"); + let auth = &lines[0]; + assert_eq!(auth["level"], "INFO", "{auth}"); + assert_eq!(auth["outcome"], "success", "{auth}"); + assert_eq!(auth["method"], "nip42", "{auth}"); + assert_eq!(auth["pubkey"], keys.public_key().to_hex(), "{auth}"); + assert_eq!(auth["pubkey_verified"], true, "{auth}"); + assert_eq!(auth["peer_addr"], PEER, "{auth}"); + assert_eq!(auth["conn_id"], lines[2]["conn_id"], "{lines:?}"); + assert_eq!(auth["community"], lines[2]["community"], "{lines:?}"); + assert!(auth.get("reason").is_none(), "{auth}"); + let close = &lines[2]; + assert_eq!(close["pubkey"], keys.public_key().to_hex(), "{close}"); + assert_eq!(close["method"], "nip42", "{close}"); + assert_eq!(close["pubkey_verified"], true, "{close}"); + assert_eq!(close["peer_addr"], PEER, "{close}"); + assert_eq!(lines[1]["outcome"], "served", "{lines:?}"); + } + + /// A refused login writes one WARN `auth` line, no success line, and a close line with no identity. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn refused_login_writes_one_warn_auth_line_and_no_success() { + let (keys, lines) = ws_login_lines(false, None).await; + let events: Vec<_> = lines.iter().map(|l| l["event"].clone()).collect(); + assert_eq!(events, ["auth", "close"], "{lines:?}"); + let auth = &lines[0]; + assert_eq!(auth["level"], "WARN", "{auth}"); + assert_eq!(auth["outcome"], "failed", "{auth}"); + assert_eq!(auth["pubkey"], keys.public_key().to_hex(), "{auth}"); + assert_eq!(auth["pubkey_verified"], false, "{auth}"); + assert_eq!(auth["peer_addr"], PEER, "{auth}"); + assert!(lines[1].get("pubkey").is_none(), "{lines:?}"); + } + + /// The `client_ip` the login's `auth` line and its close line log for + /// `setting` and the header value sent, with `peer_addr` unchanged. + async fn ws_client_ip( + setting: Option<(&'static str, &'static [&'static str])>, + ) -> Option { + let (_, lines) = ws_login_lines(true, setting).await; + let (auth, close) = (&lines[0], &lines[2]); + assert_eq!( + (&auth["event"], &close["event"]), + (&"auth".into(), &"close".into()) + ); + assert_eq!(auth["peer_addr"], PEER, "{auth}"); + assert_eq!(close["peer_addr"], PEER, "{close}"); + assert_eq!(auth.get("client_ip"), close.get("client_ip"), "{lines:?}"); + auth.get("client_ip") + .map(|ip| ip.as_str().unwrap().to_owned()) + } + + /// The configured header's IP rides the login and close lines; an + /// unset setting, a missing header or a non-IP value logs none, and + /// a list or repeated fields log the rightmost entry, with no + /// fallback past an invalid one. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn login_and_close_lines_log_the_configured_client_ip() { + const H: &str = "X-Real-IP"; + assert_eq!( + ws_client_ip(Some((H, &["203.0.113.7"]))).await.as_deref(), + Some("203.0.113.7") + ); + assert_eq!(ws_client_ip(None).await, None); + assert_eq!(ws_client_ip(Some((H, &[]))).await, None); + assert_eq!(ws_client_ip(Some((H, &["not-an-ip"]))).await, None); + assert_eq!( + ws_client_ip(Some((H, &["198.51.100.1, 10.0.0.2"]))) + .await + .as_deref(), + Some("10.0.0.2") + ); + assert_eq!( + ws_client_ip(Some((H, &["198.51.100.1", "10.0.0.2"]))) + .await + .as_deref(), + Some("10.0.0.2") + ); + assert_eq!( + ws_client_ip(Some((H, &["198.51.100.1", "not-an-ip"]))).await, + None + ); + } + + /// Drive a real upgraded root connection through one AUTH (signed for + /// the wrong challenge unless `good`), a REQ if it logged in, and a + /// client close; return the signer and every captured access line. + /// `client_ip` is the relay's header setting and the value the + /// upgrade request sends in it, if any. + async fn ws_login_lines( + good: bool, + client_ip: Option<(&'static str, &'static [&'static str])>, + ) -> (Keys, Vec) { + use futures_util::{SinkExt as _, StreamExt as _}; + use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; + + let mut state = (*crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres")) + .clone(); + let config = Arc::make_mut(&mut state.config); + config.require_relay_membership = false; + config.client_ip_header = client_ip.map(|(name, _)| name.parse().unwrap()); + let state = Arc::new(state); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "test.local".to_string()); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let keys = Keys::generate(); + + let (guard, out) = crate::access::tests::capture_scoped(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let upgrade = move |headers: axum::http::HeaderMap, ws: WebSocketUpgrade| { + let (state, tenant) = (Arc::clone(&state), tenant.clone()); + // As the root route does before its upgrade. + let client_ip = + crate::access::client_ip(&headers, state.config.client_ip_header.as_ref()); + async move { + ws.on_upgrade(move |socket| async move { + let cancel = CancellationToken::new(); + let close = crate::access::CloseLine::open( + Uuid::new_v4(), + tenant.community(), + PEER.parse().unwrap(), + client_ip, + ); + handle_active_connection( + socket, + state, + PEER.parse().unwrap(), + tenant, + close, + crate::state::CommunityConnectionControl::new(cancel) + .with_client_ip(client_ip), + None, + chrono::Utc::now(), + None, + ) + .await + }) + } + }; + let app = Router::new().route("/", get(upgrade)); + axum::serve(listener, app).await.expect("test server"); + }); + + let mut request = format!("ws://{addr}/").into_client_request().unwrap(); + if let Some((name, values)) = client_ip { + for value in values { + request.headers_mut().append(name, value.parse().unwrap()); + } + } + let (mut client, _) = connect_async(request).await.expect("connect"); + async fn wait_for(client: &mut S, tag: &str) -> serde_json::Value + where + S: futures_util::Stream< + Item = Result, + > + Unpin, + { + loop { + let text = match client.next().await { + Some(Ok(Message::Text(text))) => text, + Some(Ok(_)) => continue, + other => panic!("socket ended before {tag}: {other:?}"), + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame[0] == tag { + return frame; + } + } + } + let mut challenge = wait_for(&mut client, "AUTH").await[1] + .as_str() + .unwrap() + .to_owned(); + if !good { + challenge.push('x'); + } + let event = EventBuilder::new(Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&keys) + .expect("sign auth"); + client + .send(Message::Text( + serde_json::json!(["AUTH", event]).to_string().into(), + )) + .await + .expect("send AUTH"); + let ok = wait_for(&mut client, "OK").await; + assert_eq!(ok[2], good, "login result: {ok}"); + if good { + client + .send(Message::Text( + r#"["REQ","s",{"kinds":[9],"limit":1}]"#.into(), + )) + .await + .expect("send REQ"); + let _ = wait_for(&mut client, "EOSE").await; + } + let _ = client.close(None).await; + + let closed = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if out.lines().iter().any(|l| l["event"] == "close") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + drop(guard); + server.abort(); + closed.expect("connection must log its close"); + (keys, out.lines()) + } + + /// Drive a real upgraded root connection (watcher, timeout and expiry + /// tasks included) through an AUTH that stalls after its policy reads, + /// end the connection with `end` while it waits, and return the captured + /// access lines once the connection has logged its close. + async fn stalled_auth_lines( + deadline: Option>, + end: impl FnOnce(&CancellationToken), + ) -> Vec { + use futures_util::{SinkExt as _, StreamExt as _}; + + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres"); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "test.local".to_string()); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let keys = Keys::generate(); + let assertion = deadline + .map(|d| buzz_auth::VerifiedAssertion::for_test(Some(keys.public_key()), vec![d])); + let cancel = CancellationToken::new(); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let (guard, out) = crate::access::tests::capture_scoped(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let upgrade = move |ws: WebSocketUpgrade| { + let (state, tenant) = (Arc::clone(&state), tenant.clone()); + let (control, assertion) = (control.clone(), assertion.clone()); + async move { + ws.on_upgrade(move |socket| async move { + let close = crate::access::CloseLine::open( + Uuid::new_v4(), + tenant.community(), + "127.0.0.1:9999".parse().unwrap(), + None, + ); + handle_active_connection( + socket, + state, + "127.0.0.1:9999".parse().unwrap(), + tenant, + close, + control, + assertion, + chrono::Utc::now(), + None, + ) + .await + }) + } + }; + let app = Router::new().route("/", get(upgrade)); + axum::serve(listener, app).await.expect("test server"); + }); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect"); + let challenge = loop { + let Some(Ok(Message::Text(text))) = client.next().await else { + panic!("socket ended before the AUTH challenge"); + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame[0] == "AUTH" { + break frame[1].as_str().unwrap().to_owned(); + } + }; + let event = EventBuilder::new(Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&keys) + .expect("sign auth"); + let (arrived, _release) = crate::nip_fi_test_hooks::auth_commit_hook::arm(community); + client + .send(Message::Text( + serde_json::json!(["AUTH", event]).to_string().into(), + )) + .await + .expect("send AUTH"); + tokio::time::timeout(Duration::from_secs(5), arrived) + .await + .expect("AUTH must stall at the commit hook") + .expect("hook dropped"); + end(&cancel); + + let closed = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if out.lines().iter().any(|l| l["event"] == "close") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + drop(guard); + server.abort(); + closed.expect("connection must log its close"); + out.lines() + .into_iter() + .filter(|l| l["event"] == "auth") + .collect() + } + + fn assert_one_aborted(lines: &[serde_json::Value], reason: &str) { + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "aborted"); + assert_eq!(lines[0]["reason"], reason); + assert_eq!(lines[0]["pubkey_verified"], false); + assert_eq!(lines[0]["peer_addr"], "127.0.0.1:9999"); + } + + /// The cancellation watcher ends the pending attempt as the connection + /// closes; the AUTH frame still logs exactly one `aborted` line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn auth_cut_off_by_a_closing_connection_logs_one_aborted_line() { + let lines = stalled_auth_lines(None, CancellationToken::cancel).await; + assert_one_aborted(&lines, "connection_closed"); + } + + /// The enterprise session lease expires while AUTH waits. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn auth_cut_off_by_session_expiry_logs_one_aborted_line() { + let deadline = chrono::Utc::now() + chrono::Duration::milliseconds(1500); + let lines = stalled_auth_lines(Some(deadline), |_| {}).await; + assert_one_aborted(&lines, "session_expired"); + } + + /// A disconnect racing a login's final step. + #[derive(Clone, Copy)] + enum LoginRace { + /// Session expiry's terminal transition lands at the decision. + ExpiryFirst, + /// A ban lands at the decision. + BanFirst, + /// A ban, then a NIP-FI denial, land at the decision before the + /// writer drains. + BanThenDenial, + /// A ban lands once the client has its `OK true`. + BanAfterLogin, + } + + /// Drive a real root connection through an AUTH raced by `race`; + /// return every frame the client received and every access line. + async fn raced_login(race: LoginRace) -> (Vec, Vec) { + use futures_util::{SinkExt as _, StreamExt as _}; + + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres"); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "test.local".to_string()); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let keys = Keys::generate(); + let pubkey = keys.public_key().to_bytes().to_vec(); + let ban = { + let (state, pubkey) = (Arc::clone(&state), pubkey.clone()); + move || { + let zero = "0".repeat(64); + let closed = state.disconnect_pubkey_local( + community, + &pubkey, + &zero, + "blocked: banned", + false, + ); + assert_eq!(closed, 1, "the ban reaches the login's socket"); + } + }; + let deny = |conn: &crate::connection::ConnectionState| { + conn.community_control.deny_authorization( + &conn.terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Root, + ); + conn.cancel.cancel(); + }; + let assertion = + matches!(race, LoginRace::ExpiryFirst | LoginRace::BanThenDenial).then(|| { + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + buzz_auth::VerifiedAssertion::for_test(Some(keys.public_key()), vec![deadline]) + }); + match race { + LoginRace::ExpiryFirst => { + crate::nip_fi_test_hooks::auth_decision_hook::arm(community, deny) + } + LoginRace::BanThenDenial => { + let ban = ban.clone(); + crate::nip_fi_test_hooks::auth_decision_hook::arm(community, move |conn| { + ban(); + deny(conn); + }); + } + LoginRace::BanFirst => { + let ban = ban.clone(); + crate::nip_fi_test_hooks::auth_decision_hook::arm(community, move |_| ban()); + } + LoginRace::BanAfterLogin => {} + } + + let (guard, out) = crate::access::tests::capture_scoped(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn({ + let state = Arc::clone(&state); + async move { + let upgrade = move |ws: WebSocketUpgrade| { + let (state, tenant) = (Arc::clone(&state), tenant.clone()); + let assertion = assertion.clone(); + async move { + ws.on_upgrade(move |socket| async move { + let close = crate::access::CloseLine::open( + Uuid::new_v4(), + tenant.community(), + PEER.parse().unwrap(), + None, + ); + handle_active_connection( + socket, + state, + PEER.parse().unwrap(), + tenant, + close, + crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), + assertion, + chrono::Utc::now(), + None, + ) + .await + }) + } + }; + let app = Router::new().route("/", get(upgrade)); + axum::serve(listener, app).await.expect("test server"); + } + }); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect"); + let challenge = loop { + let Some(Ok(Message::Text(text))) = client.next().await else { + panic!("socket ended before the AUTH challenge"); + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame[0] == "AUTH" { + break frame[1].as_str().unwrap().to_owned(); + } + }; + let event = EventBuilder::new(Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&keys) + .expect("sign auth"); + client + .send(Message::Text( + serde_json::json!(["AUTH", event]).to_string().into(), + )) + .await + .expect("send AUTH"); + let frames = tokio::time::timeout(Duration::from_secs(10), async { + let mut frames = Vec::new(); + while let Some(Ok(frame)) = client.next().await { + let ok_true = matches!(&frame, Message::Text(t) if t.contains("true")); + let closing = matches!(frame, Message::Close(_)); + frames.push(frame); + if ok_true && matches!(race, LoginRace::BanAfterLogin) { + ban(); + } + if closing { + break; + } + } + frames + }) + .await + .expect("the socket must close"); + + let closed = tokio::time::timeout(Duration::from_secs(10), async { + while !out.lines().iter().any(|l| l["event"] == "close") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + drop(guard); + server.abort(); + closed.expect("connection must log its close"); + (frames, out.lines()) + } + + fn texts(frames: &[Message]) -> Vec { + frames + .iter() + .filter_map(|f| match f { + Message::Text(t) => Some(serde_json::from_str(t).unwrap()), + _ => None, + }) + .collect() + } + + /// Lines other than the connection's own `close`, by `event`/`outcome`. + fn auth_outcomes(lines: &[serde_json::Value]) -> Vec { + lines + .iter() + .filter(|l| l["event"] == "auth") + .map(|l| l["outcome"].clone()) + .collect() + } + + /// Expiry that chose its denial before the login decided wins: the + /// client gets only the denial and its 1008, and no success is logged. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn expiry_at_the_login_decision_wins_and_logs_no_success() { + let (frames, lines) = raced_login(LoginRace::ExpiryFirst).await; + let texts = texts(&frames); + assert_eq!(texts, [nip_fi_denial_notice()], "{texts:?}"); + assert!( + matches!(frames.last(), Some(Message::Close(Some(c))) + if u16::from(c.code) == 1008 && c.reason == "authorization denied"), + "{frames:?}" + ); + assert!( + !auth_outcomes(&lines).contains(&"success".into()), + "{lines:?}" + ); + } + + /// A ban that claimed the socket before the login decided wins: the + /// client gets only the ban's `OK false` and a bare close. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn ban_at_the_login_decision_wins_and_logs_no_success() { + let (frames, lines) = raced_login(LoginRace::BanFirst).await; + let oks: Vec<_> = texts(&frames) + .into_iter() + .filter(|f| f[0] == "OK") + .collect(); + assert_eq!(oks.len(), 1, "{oks:?}"); + assert_eq!(oks[0][2], false, "{oks:?}"); + assert_eq!(oks[0][3], "blocked: banned", "{oks:?}"); + assert!( + matches!(frames.last(), Some(Message::Close(None))), + "{frames:?}" + ); + assert!( + !auth_outcomes(&lines).contains(&"success".into()), + "{lines:?}" + ); + } + + /// The root NIP-FI denial frame, as the client parses it. + fn nip_fi_denial_notice() -> serde_json::Value { + serde_json::json!(["NOTICE", "restricted: authorization denied"]) + } + + /// A NIP-FI denial after a ban still sends what it did before the + /// ban claimed the slot: its NOTICE, the ban's `OK false`, and 1008. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn denial_after_a_ban_still_sends_the_denial_and_1008() { + let (frames, lines) = raced_login(LoginRace::BanThenDenial).await; + let texts = texts(&frames); + assert_eq!( + texts, + [ + nip_fi_denial_notice(), + serde_json::json!(["OK", "0".repeat(64), false, "blocked: banned"]), + ], + "{texts:?}" + ); + assert!( + matches!(frames.last(), Some(Message::Close(Some(c))) + if u16::from(c.code) == 1008 && c.reason == "authorization denied"), + "{frames:?}" + ); + assert!( + !auth_outcomes(&lines).contains(&"success".into()), + "{lines:?}" + ); + } + + /// A ban landing after the login won closes the admitted socket: one + /// success line, then the close line. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn ban_after_the_login_won_logs_success_then_close() { + let (frames, lines) = raced_login(LoginRace::BanAfterLogin).await; + let oks: Vec<_> = texts(&frames) + .into_iter() + .filter(|f| f[0] == "OK") + .collect(); + assert_eq!(oks.len(), 2, "{oks:?}"); + assert_eq!((&oks[0][2], &oks[1][2]), (&true.into(), &false.into())); + assert!( + matches!(frames.last(), Some(Message::Close(None))), + "{frames:?}" + ); + let events: Vec<_> = lines.iter().map(|l| l["event"].clone()).collect(); + assert_eq!(events, ["auth", "close"], "{lines:?}"); + assert_eq!(lines[0]["outcome"], "success", "{lines:?}"); + } + } + fn ordinary_disconnect_reason() -> watch::Receiver> { let (_tx, rx) = watch::channel(None); rx @@ -3180,6 +4334,7 @@ pub(crate) mod tests { let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); let conn = Arc::new(ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), @@ -3451,12 +4606,18 @@ pub(crate) mod tests { pre_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Root, ); + let close = crate::access::CloseLine::open( + conn_id, + tenant_i.community(), + "127.0.0.1:9999".parse().unwrap(), + None, + ); handle_active_connection( socket, state_i, "127.0.0.1:9999".parse().unwrap(), tenant_i, - conn_id, + close, control, Some(assertion_i), conn_time, @@ -3581,6 +4742,7 @@ pub(crate) mod tests { socket, state_i, "127.0.0.1:9999".parse().unwrap(), + None, tenant_i, Some(assertion_i), None, @@ -3758,12 +4920,18 @@ pub(crate) mod terminal_close_delivery_tests { let permit = gate.acquire_effect().await.expect("effect permit"); let _ = hold.send(permit); } + let close = crate::access::CloseLine::open( + conn_id, + tenant.community(), + "127.0.0.1:1234".parse().expect("client address"), + None, + ); let handler = handle_active_connection( socket, state, "127.0.0.1:1234".parse().expect("client address"), tenant, - conn_id, + close, control, Some(assertion), chrono::Utc::now(), @@ -4112,7 +5280,8 @@ pub(crate) mod terminal_close_delivery_tests { ws.on_upgrade(move |socket| async move { let addr = "127.0.0.1:1234".parse().expect("client address"); let now = chrono::Utc::now(); - handle_connection(socket, state, addr, tenant, None, None, line, now).await; + handle_connection(socket, state, addr, None, tenant, None, None, line, now) + .await; notify.notify_one(); }) }), @@ -4324,6 +5493,7 @@ pub(crate) mod terminal_close_delivery_tests { socket, state, "127.0.0.1:1234".parse().expect("client address"), + None, tenant, assertion, None, @@ -4463,8 +5633,10 @@ pub(crate) mod terminal_close_delivery_tests { ws.on_upgrade(move |socket| async move { let addr = "127.0.0.1:1234".parse().expect("client address"); let now = chrono::Utc::now(); - handle_connection(socket, state, addr, tenant, None, None, line, now) - .await; + handle_connection( + socket, state, addr, None, tenant, None, None, line, now, + ) + .await; notify.notify_one(); }) }), diff --git a/crates/buzz-relay/src/handlers/artifact_sync.rs b/crates/buzz-relay/src/handlers/artifact_sync.rs index ec69fa586f..f753c2134f 100644 --- a/crates/buzz-relay/src/handlers/artifact_sync.rs +++ b/crates/buzz-relay/src/handlers/artifact_sync.rs @@ -21,6 +21,7 @@ use tracing::warn; use uuid::Uuid; use super::close::refuse; +use crate::access::ReadLog; use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -343,7 +344,7 @@ async fn publish_once( } /// The fan-out filter a sync registers: every change signal in its channels. -fn live_filter(channels: &[Uuid]) -> Filter { +pub(crate) fn live_filter(channels: &[Uuid]) -> Filter { Filter::new() .kinds(LIVE_KINDS.map(Kind::Custom)) .custom_tags( @@ -365,13 +366,17 @@ pub async fn handle_sync( Ok(filter) => filter, Err(reason) => { refuse(&sub_id, &format!("invalid: {reason}"), &conn, &state).await; + ReadLog::refused_sync(&conn, &sub_id, None, "invalid_filter"); return; } }; + // Written when the initial set ends, not when live delivery does. + let mut access = ReadLog::sync(&conn, &sub_id, &filter.channels); let (reader, bound) = match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => { if !ctx.scopes.is_empty() && !ctx.scopes.contains(&Scope::MessagesRead) { refuse(&sub_id, "restricted: insufficient scope", &conn, &state).await; + access.deny("insufficient_scope"); return; } // A channel-scoped token reaches only its own channels; outside @@ -379,6 +384,7 @@ pub async fn handle_sync( if let Some(allowed) = ctx.channel_ids.as_deref() { if filter.channels.iter().any(|id| !allowed.contains(id)) { refuse(&sub_id, RESTRICTED, &conn, &state).await; + access.deny("not_channel_member"); return; } } @@ -386,6 +392,7 @@ pub async fn handle_sync( } _ => { refuse(&sub_id, "auth-required: not authenticated", &conn, &state).await; + access.deny("auth_required"); return; } }; @@ -393,6 +400,7 @@ pub async fn handle_sync( Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { refuse(&sub_id, "restricted: authorization denied", &conn, &state).await; + access.deny("session_expired"); return; } }; @@ -400,7 +408,16 @@ pub async fn handle_sync( // Rule 1: live interest first, confirmed by Redis on an unbroken link. let start = *state.pubsub.link_state().borrow(); let handle = Arc::new(SyncHandle::new()); - let Some(owner) = claim(&sub_id, &filter.channels, &handle, &conn, &state).await else { + let Some(owner) = claim( + &sub_id, + &filter.channels, + &handle, + &mut access, + &conn, + &state, + ) + .await + else { return; }; if !start.up || !confirm(&filter.channels, start.epoch, &conn, &state).await { @@ -412,6 +429,7 @@ pub async fn handle_sync( &state, ) .await; + access.error("live_delivery_unavailable"); return; } @@ -419,7 +437,8 @@ pub async fn handle_sync( // time budget and the cancellation race, so a slow reader cannot hold // the handler slot past the budget or get EOSE after retirement. let initial = async { - let sent = send_initial_set(&sub_id, owner, &filter, &reader, &conn, &state).await?; + let sent = + send_initial_set(&sub_id, owner, &filter, &reader, &mut access, &conn, &state).await?; // A resync requested before EOSE is queued ends the sync without it. send_eose(&sub_id, owner, &handle, &conn).await?; Ok::<_, Stop>(sent) @@ -438,15 +457,25 @@ pub async fn handle_sync( Some(Err(_)) => { let reason = "error: sync snapshot budget exceeded"; close(&sub_id, owner, reason, &conn, &state).await; + access.error("query_timed_out"); return; } Some(Ok(Err(Stop::Retired))) => return, Some(Ok(Err(Stop::Close(reason)))) => { close(&sub_id, owner, reason, &conn, &state).await; + match reason { + RESTRICTED => access.deny("not_channel_member"), + OVER_MAX_EVENTS => access.deny("sync_set_too_large"), + READ_FAILED => access.error("database_error"), + // A resync or a closed connection: the client retries. + _ => {} + } return; } Some(Ok(Ok(sent))) => sent, }; + access.served(); + drop(access); drop(_effect); drop(permit); @@ -466,6 +495,7 @@ pub async fn handle_sync( const RESTRICTED: &str = "restricted: channel not readable"; const OVER_MAX_EVENTS: &str = "error: sync set exceeds max_events"; +const READ_FAILED: &str = "error: sync read failed"; /// Why a sync stops sending. enum Stop { @@ -481,6 +511,7 @@ async fn claim( sub_id: &str, channels: &[Uuid], handle: &Arc, + access: &mut ReadLog, conn: &ConnectionState, state: &AppState, ) -> Option { @@ -497,6 +528,7 @@ async fn claim( )) { conn.cancel.cancel(); } + access.deny("too_many_subscriptions"); return None; } super::close::retire_locked(&mut subs, sub_id, conn, state).await; @@ -549,6 +581,7 @@ async fn send_initial_set( owner: u64, filter: &ArtifactSyncFilter, reader: &[u8], + access: &mut ReadLog, conn: &ConnectionState, state: &AppState, ) -> Result, Stop> { @@ -560,8 +593,8 @@ async fn send_initial_set( Ok(Ok(snapshot)) => snapshot, Ok(Err(buzz_db::SnapshotDenied::Restricted)) => return Err(Stop::Close(RESTRICTED)), Err(e) => { - warn!(conn_id = %conn.conn_id, "artifact sync snapshot failed: {e}"); - return Err(Stop::Close("error: sync read failed")); + warn!(conn_id = %conn.conn_id, error_kind = %e.kind(), "artifact sync snapshot failed"); + return Err(Stop::Close(READ_FAILED)); } }; let mut sent = HashSet::new(); @@ -570,8 +603,8 @@ async fn send_initial_set( .next_page(&filter.tags, SNAPSHOT_PAGE) .await .map_err(|e| { - warn!(conn_id = %conn.conn_id, "artifact sync page failed: {e}"); - Stop::Close("error: sync read failed") + warn!(conn_id = %conn.conn_id, error_kind = %e.kind(), "artifact sync page failed"); + Stop::Close(READ_FAILED) })?; if page.is_empty() { break; @@ -588,12 +621,13 @@ async fn send_initial_set( conn, ) .await?; + access.queued_one(); } } snapshot .finish() .await - .map_err(|_| Stop::Close("error: sync read failed"))?; + .map_err(|_| Stop::Close(READ_FAILED))?; Ok(sent) } @@ -716,7 +750,7 @@ async fn run_live( }) }), Err(e) => { - warn!(conn_id = %conn.conn_id, "artifact sync head read failed: {e}"); + warn!(conn_id = %conn.conn_id, error_kind = %e.kind(), "artifact sync head read failed"); close(sub_id, owner, "error: sync read failed", conn, state).await; return; } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index b2bd26efe7..b667156fb0 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -12,8 +12,9 @@ use std::sync::Arc; use axum::extract::ws::Message as WsMessage; -use tracing::{debug, info, warn}; +use tracing::{debug, warn}; +use crate::access::{AuthAttempt, AuthResult}; use crate::connection::{AuthState, ConnectionState}; use crate::metrics::{AuthOutcome, AuthPostTerminalState}; use crate::protocol::RelayMessage; @@ -349,9 +350,14 @@ fn deny_nip_fi_auth(conn: &ConnectionState, class: buzz_auth::DenialClass) { /// Refuse a root AUTH that passed the early gates but failed admission: record /// the outcome, send the denial (the canonical NOTICE under NIP-FI, else an /// OK false on the control channel so it drains before the Close), and close. -fn deny_admission(conn: &ConnectionState, event_id_hex: &str, denial: AdmissionDenial) { +fn deny_admission( + conn: &ConnectionState, + attempt: &AuthAttempt<'_>, + event_id_hex: &str, + denial: AdmissionDenial, +) { metrics::counter!("buzz_auth_failures_total", "reason" => denial.metric).increment(1); - if !conn.reject_auth(denial.outcome) { + if !attempt.reject(conn, denial.outcome) { return; } if conn.nip_fi_assertion.is_some() { @@ -393,6 +399,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: AuthState::Pending { challenge, .. } => (challenge, conn.conn_id), AuthState::Authenticated(_) => { debug!(conn_id = %conn.conn_id, "AUTH received but already authenticated"); + AuthAttempt::nip42(&conn, event.pubkey, false).log_after_decision( + &conn, + AuthResult::Denied(crate::access::AuthReason::AlreadyAuthenticated), + ); crate::metrics::record_post_terminal_auth_frame( AuthPostTerminalState::Authenticated, ); @@ -405,6 +415,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } AuthState::Failed => { debug!(conn_id = %conn.conn_id, "AUTH received after failed auth"); + AuthAttempt::nip42(&conn, event.pubkey, false).log_after_decision( + &conn, + AuthResult::Denied(crate::access::AuthReason::AlreadyFailed), + ); crate::metrics::record_post_terminal_auth_frame(AuthPostTerminalState::Failed); conn.send(RelayMessage::ok( &event_id_hex, @@ -425,6 +439,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // The tag is integrity-protected by the event's Schnorr signature — if // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); + let claimed_pubkey = event.pubkey; let signed_auth_created_at = event.created_at.as_secs(); let relay_url = @@ -440,6 +455,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + let attempt = AuthAttempt::nip42(&conn, pubkey, true); conn.community_control.prove_nip_fi_line(pubkey); let shadow = conn.community_control.nip_fi_shadow(); @@ -503,10 +519,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: .await; if let Some((metric_reason, deny_reason, auth_outcome)) = ban_denial(outcome) { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) .increment(1); - if !conn.reject_auth(auth_outcome) { + if !attempt.reject(&conn, auth_outcome) { return; } // Decision 4: banned ⇒ OK false + immediate WebSocket close. @@ -549,10 +564,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: match classify_allowlist(allowlist) { PolicyCheck::Allowed(()) => {} PolicyCheck::Denied => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") .increment(1); - if !conn.reject_auth(AuthOutcome::AllowlistDenied) { + if !attempt.reject(&conn, AuthOutcome::AllowlistDenied) { return; } // Fix 4a: when an FI assertion is present, use the uniform @@ -574,7 +588,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: PolicyCheck::DependencyError => { metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_check_error") .increment(1); - if !conn.reject_auth(AuthOutcome::AllowlistCheckError) { + if !attempt.reject(&conn, AuthOutcome::AllowlistCheckError) { return; } if conn.nip_fi_assertion.is_some() { @@ -611,10 +625,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let nip_oa_owner = match classify_relay_membership(membership) { PolicyCheck::Allowed(owner) => owner, PolicyCheck::Denied => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member"); metrics::counter!("buzz_auth_failures_total", "reason" => "not_relay_member") .increment(1); - if !conn.reject_auth(AuthOutcome::NotRelayMember) { + if !attempt.reject(&conn, AuthOutcome::NotRelayMember) { return; } // With an FI assertion, membership status must not be @@ -633,7 +646,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: PolicyCheck::DependencyError => { metrics::counter!("buzz_auth_failures_total", "reason" => "relay_membership_check_error") .increment(1); - if !conn.reject_auth(AuthOutcome::RelayMembershipCheckError) { + if !attempt.reject(&conn, AuthOutcome::RelayMembershipCheckError) { return; } if conn.nip_fi_assertion.is_some() { @@ -719,7 +732,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: ?link, "agent owner could not be linked, denying" ); - deny_admission(&conn, &event_id_hex, denial); + deny_admission(&conn, &attempt, &event_id_hex, denial); return; } auth_ctx.agent_owner_pubkey = Some(owner); @@ -760,7 +773,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } Ok(None) => false, Err(denial) => { - deny_admission(&conn, &event_id_hex, denial); + deny_admission(&conn, &attempt, &event_id_hex, denial); return; } }; @@ -783,17 +796,17 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: denial => denial, }; if let Some(denial) = denial { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = denial.reason, "denied at final admission check"); - deny_admission(&conn, &event_id_hex, denial); + deny_admission(&conn, &attempt, &event_id_hex, denial); return; } - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); if !conn.authenticate(auth_ctx) { return; } - // The permit is held through the deny-set check and the OK send so - // the auth commit is atomic with respect to expiry. + // The login commits only if the close-reason slot is still empty + // when read under its lock (`is_open` below): a disconnect that + // chose a reason first wins, one that comes later closes the + // admitted socket. // // The proven key was registered with its NIP-FI issuer before the // final admission check: a concurrent disconnect either finds this @@ -805,11 +818,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { if crate::nip_fi_core::assertion_denied(deny_map, assertion, chrono::Utc::now()) { - warn!( - conn_id = %conn_id, - pubkey = %pubkey.to_hex(), - "NIP-FI deny-set hit at post-registration check — denying" - ); + attempt.log(AuthResult::Denied(crate::access::AuthReason::DenySet)); metrics::counter!( "buzz_nip_fi_admission_denied_total", "reason" => "deny_set_post_registration" @@ -829,6 +838,16 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if conn.cancel.is_cancelled() { return; } + #[cfg(test)] + crate::nip_fi_test_hooks::before_auth_decision(&conn); + // A disconnect that chose its reply before this point (expiry, + // ban, revoke) wins: its refusal is the only answer. One that + // lands after closes an admitted socket. + if !conn.community_control.is_open() { + return; + } + // Only the frame that won `authenticate` reaches here, once. + attempt.log(AuthResult::Success); state.conn_manager.mark_admitted(conn_id); conn.send(RelayMessage::ok(&event_id_hex, true, "")); crate::verified_profiles::admitted( @@ -864,11 +883,12 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // _auth_permit drops here — expiry's write guard may proceed. } Err(e) => { - warn!(conn_id = %conn_id, error = %e, "NIP-42 auth failed"); metrics::counter!("buzz_auth_failures_total", "reason" => "nip42_invalid").increment(1); if !conn.reject_auth(AuthOutcome::Invalid) { return; } + AuthAttempt::nip42(&conn, claimed_pubkey, false) + .log(AuthResult::Failed(crate::access::auth_error_code(&e))); if conn.nip_fi_assertion.is_some() { deny_nip_fi_auth(&conn, nip42_denial_class(&e)); return; @@ -1068,6 +1088,7 @@ mod tests { let cancel = CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), @@ -1197,6 +1218,7 @@ mod tests { cancel.cancel(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), @@ -1286,6 +1308,7 @@ mod tests { } }); let conn = std::sync::Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()), @@ -1945,6 +1968,7 @@ mod tests { let backpressure = Arc::new(std::sync::atomic::AtomicU8::new(0)); let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -2834,6 +2858,7 @@ mod tests { crate::state::CommunityConnectionControl::new(cancel.clone()); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -3040,6 +3065,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -3229,6 +3255,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -3407,6 +3434,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -3525,6 +3553,7 @@ mod tests { let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); let cancel = CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -3680,6 +3709,7 @@ mod tests { let cancel = CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), @@ -4222,4 +4252,37 @@ mod tests { 1 ); } + + #[tokio::test(flavor = "current_thread")] + async fn repeat_auth_after_a_terminal_logs_one_denial() { + let state = auth_test_state().await; + for (auth, reason) in [ + ( + crate::connection::tests::authenticated_state(), + "already_authenticated", + ), + (AuthState::Failed, "already_failed"), + ] { + let (conn, _rx) = test_conn_with_auth(auth); + let event = EventBuilder::new(Kind::Authentication, "") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let (guard, out) = crate::access::tests::capture_scoped(); + handle_auth( + event, + std::sync::Arc::clone(&conn), + std::sync::Arc::clone(&state), + ) + .await; + drop(guard); + let lines: Vec<_> = out + .lines() + .into_iter() + .filter(|l| l["event"] == "auth") + .collect(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "denied"); + assert_eq!(lines[0]["reason"], reason); + } + } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2c5a8ff5b5..73d4298517 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -153,7 +153,7 @@ async fn persist_command_event( ) .await .map_err(|e| { - IngestError::Internal(format!("error: replace parameterized event: {e}")) + IngestError::Internal(format!("error: replace parameterized event: {}", e.kind())) })?; return match result.status { @@ -184,7 +184,7 @@ async fn persist_command_event( let (_, was_inserted) = buzz_db::event::insert_event_in_transaction(&mut tx, event, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: insert event: {e}")))?; + .map_err(|e| IngestError::Internal(format!("error: insert event: {}", e.kind())))?; if was_inserted { Ok(PersistResult::Inserted(tx)) } else { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 08ff84a017..2e9958757d 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -23,6 +23,7 @@ pub async fn handle_count( conn: Arc, state: Arc, ) { + let mut access = crate::access::ReadLog::count(&conn, &sub_id, &filters); // Require auth let (pubkey_bytes, token_channel_ids) = { match conn.auth_state_snapshot() { @@ -34,6 +35,7 @@ pub async fn handle_count( &sub_id, "auth-required: not authenticated", )); + access.deny("auth_required"); return; } } @@ -47,6 +49,7 @@ pub async fn handle_count( &sub_id, "restricted: p-gated kinds require #p tag matching your pubkey", )); + access.deny("p_gated"); return; } if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { @@ -54,6 +57,7 @@ pub async fn handle_count( &sub_id, "restricted: agent-engram reads require authors=[self] or #p=[self]", )); + access.deny("engram_gated"); return; } if !super::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) { @@ -61,6 +65,7 @@ pub async fn handle_count( &sub_id, "restricted: author-only kinds require authors=[self]", )); + access.deny("author_only"); return; } @@ -77,6 +82,7 @@ pub async fn handle_count( &sub_id, "restricted: too many explicit channels", )); + access.deny("too_many_channels"); return; } }; @@ -88,8 +94,9 @@ pub async fn handle_count( { Ok(ids) => ids, Err(e) => { - warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); + warn!(sub_id = %sub_id, error_kind = %e.kind(), "Failed to get accessible channels"); conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + access.error("database_error"); return; } }; @@ -116,8 +123,9 @@ pub async fn handle_count( { Ok(ids) => ids, Err(e) => { - warn!(sub_id = %sub_id, "Failed to get readable named-only channels: {e}"); + warn!(sub_id = %sub_id, error_kind = %e.kind(), "Failed to get readable named-only channels"); conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + access.error("database_error"); return; } }; @@ -140,6 +148,7 @@ pub async fn handle_count( &sub_id, "restricted: authorization denied", )); + access.deny("session_expired"); return; } }; @@ -178,8 +187,9 @@ pub async fn handle_count( ) .await { - warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); + warn!(sub_id = %sub_id, error_kind = %e.kind(), "Channel membership confirmation failed"); conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + access.error("database_error"); return; } if let Err(e) = super::system_channels::keep_repaired_named_only_channels_named( @@ -191,8 +201,9 @@ pub async fn handle_count( ) .await { - warn!(sub_id = %sub_id, "Failed to classify repaired channels: {e}"); + warn!(sub_id = %sub_id, error_kind = %e.kind(), "Failed to classify repaired channels"); conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + access.error("database_error"); return; } let filter_scope = super::system_channels::filter_channel_scope( @@ -246,6 +257,7 @@ pub async fn handle_count( &sub_id, &super::req::db_read_closed_reason(&e), )); + access.db_failed(&e); return; } } @@ -265,6 +277,7 @@ pub async fn handle_count( &sub_id, "restricted: count filter requires narrower constraints", )); + access.deny("count_too_broad"); return; } for se in stored_events { @@ -283,6 +296,7 @@ pub async fn handle_count( &sub_id, &super::req::db_read_closed_reason(&e), )); + access.db_failed(&e); return; } } @@ -333,6 +347,7 @@ pub async fn handle_count( &sub_id, &super::req::db_read_closed_reason(&e), )); + access.db_failed(&e); return; } } @@ -351,6 +366,7 @@ pub async fn handle_count( &sub_id, "restricted: count filter requires narrower constraints", )); + access.deny("count_too_broad"); return; } for se in stored_events { @@ -369,6 +385,7 @@ pub async fn handle_count( &sub_id, &super::req::db_read_closed_reason(&e), )); + access.db_failed(&e); return; } } @@ -376,6 +393,7 @@ pub async fn handle_count( } } conn.send(RelayMessage::count(&sub_id, total)); + access.counted(total); } #[cfg(test)] @@ -425,6 +443,7 @@ mod tests { let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().unwrap(), diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 76ffc21f7f..2fc3b47fe4 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1794,6 +1794,7 @@ mod tests { let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), @@ -1881,6 +1882,7 @@ mod tests { let (send_tx, mut send_rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -2042,6 +2044,7 @@ mod tests { } let (send_tx, _send_rx) = mpsc::channel(10); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -2164,6 +2167,7 @@ mod tests { let (send_tx, mut send_rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -3678,6 +3682,7 @@ mod tests { let (ctrl_tx, _) = mpsc::channel(8); let (terminal_ctrl_tx, _) = mpsc::channel(1); let conn = std::sync::Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant, remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -4033,6 +4038,7 @@ mod tests { mpsc::channel::(1); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, @@ -4226,6 +4232,7 @@ mod tests { mpsc::channel::(1); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved( community, diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index b09c390fe5..5fbfd6799b 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -628,6 +628,7 @@ pub(crate) fn test_conn( let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(4); let cancel = tokio_util::sync::CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().expect("addr"), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9cae758646..51e5637330 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3474,7 +3474,7 @@ async fn ingest_event_inner( emoji, ) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|e| IngestError::Internal(format!("error: {}", e.kind())))? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected(format!( @@ -3530,7 +3530,6 @@ async fn ingest_event_inner( ) .await; - info!(event_id = %event_id_hex, kind = kind_u32, "Event ingested via pipeline"); return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -3570,7 +3569,7 @@ async fn ingest_event_inner( buzz_db::DbError::ChannelNameTaken(_) => { IngestError::Rejected(format!("invalid: {e}")) } - _ => IngestError::Internal(format!("error: {e}")), + _ => IngestError::Internal(format!("error: {}", e.kind())), })? { buzz_db::channel::ChannelCreate::Created(stored) => { metrics::counter!( @@ -3648,7 +3647,7 @@ async fn ingest_event_inner( .db .replace_addressable_event(tenant.community(), &event, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|e| IngestError::Internal(format!("error: {}", e.kind())))? } else if is_parameterized_replaceable(kind_u32) { // NIP-33 parameterized replaceable — keyed by (kind, pubkey, d_tag). let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); @@ -3663,7 +3662,7 @@ async fn ingest_event_inner( .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|e| IngestError::Internal(format!("error: {}", e.kind())))? } else if let Some(spec) = canvas_revision_spec.as_ref() { // Canvas write carrying an optimistic-concurrency precondition. Plain // canvas writes (no `expected-revision` tag) fall through to the generic @@ -3680,7 +3679,7 @@ async fn ingest_event_inner( .db .insert_canvas_head_checked(tenant.community(), &event, channel, precondition) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + .map_err(|e| IngestError::Internal(format!("error: {}", e.kind())))?; match status { buzz_db::ChannelHeadWriteStatus::RevisionMissing => { return Err(IngestError::CanvasConflict( @@ -3718,7 +3717,9 @@ async fn ingest_event_inner( buzz_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) } - other => IngestError::Internal(format!("error: database error: {other}")), + other => { + IngestError::Internal(format!("error: database error: {}", other.kind())) + } }); } } @@ -3805,8 +3806,6 @@ async fn ingest_event_inner( ) .await; - info!(event_id = %event_id_hex, kind = kind_u32, "Event ingested via pipeline"); - Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -7579,7 +7578,7 @@ mod postgres_tests { .expect("ban owner"); for keys in [&owner, &agent] { - let crate::api::relay_members::MembershipRefusal::Api((status, body)) = + let crate::api::relay_members::MembershipRefusal::Api(reason, (status, body)) = crate::api::relay_members::enforce_relay_membership( &state, &tenant, @@ -7593,6 +7592,7 @@ mod postgres_tests { else { panic!("without a token the relay's own refusal is returned"); }; + assert_eq!(reason, crate::access::AuthReason::Banned); assert_eq!(status, axum::http::StatusCode::FORBIDDEN); assert_eq!( body.0["error"], @@ -7672,10 +7672,10 @@ mod postgres_tests { .map_err(|refusal| { let status = refusal.status(); let body = match refusal { - crate::api::relay_members::MembershipRefusal::Api((_, body)) => { + crate::api::relay_members::MembershipRefusal::Api(_, (_, body)) => { body.0["error"].as_str().unwrap_or("").to_owned() } - crate::api::relay_members::MembershipRefusal::NipFi(class) => { + crate::api::relay_members::MembershipRefusal::NipFi(_, class) => { class.http_body().to_owned() } }; diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index e5e4b9c3e3..1abfa2108d 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -209,7 +209,8 @@ async fn publish_moderation_profile( let (stored, was_inserted) = state .db .replace_addressable_event(tenant.community(), &event, None) - .await?; + .await + .map_err(|e| anyhow::anyhow!("moderation notice write failed: {}", e.kind()))?; if was_inserted { let kind_u32 = event_kind_u32(&stored.event); dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index c0ff3b5356..71401ceb7b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -60,6 +60,7 @@ pub async fn handle_req( conn: Arc, state: Arc, ) { + let mut access = crate::access::ReadLog::req(&conn, &sub_id, &filters); let (conn_id, pubkey_bytes, token_channel_ids) = { match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => { @@ -67,6 +68,7 @@ pub async fn handle_req( conn.send(RelayMessage::notice("restricted: insufficient scope")); super::close::refuse(&sub_id, "restricted: insufficient scope", &conn, &state) .await; + access.deny("insufficient_scope"); return; } @@ -82,6 +84,7 @@ pub async fn handle_req( )) { conn.cancel.cancel(); } + access.deny("too_many_subscriptions"); return; } @@ -93,6 +96,7 @@ pub async fn handle_req( )); super::close::refuse(&sub_id, "auth-required: not authenticated", &conn, &state) .await; + access.deny("auth_required"); return; } } @@ -109,6 +113,7 @@ pub async fn handle_req( &state, ) .await; + access.deny("too_many_channels"); return; } }; @@ -124,8 +129,9 @@ pub async fn handle_req( { Ok(ids) => ids, Err(e) => { - warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); + warn!(conn_id = %conn_id, error_kind = %e.kind(), "Failed to get accessible channels"); super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; + access.error("database_error"); return; } } @@ -148,8 +154,9 @@ pub async fn handle_req( { Ok(ids) => ids, Err(e) => { - warn!(conn_id = %conn_id, "Failed to get readable named-only channels: {e}"); + warn!(conn_id = %conn_id, error_kind = %e.kind(), "Failed to get readable named-only channels"); super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; + access.error("database_error"); return; } }; @@ -187,8 +194,13 @@ pub async fn handle_req( { Ok(confirmed) => confirmed, Err(e) => { - warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); + warn!( + conn_id = %conn_id, + error_kind = %e.kind(), + "Channel membership confirmation failed" + ); super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; + access.error("database_error"); return; } }; @@ -206,8 +218,9 @@ pub async fn handle_req( ) .await { - warn!(conn_id = %conn_id, "Failed to classify repaired channels: {e}"); + warn!(conn_id = %conn_id, error_kind = %e.kind(), "Failed to classify repaired channels"); super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; + access.error("database_error"); return; } } @@ -233,6 +246,7 @@ pub async fn handle_req( .is_some_and(|authorized| authorized.is_empty()) { super::close::refuse(&sub_id, "restricted: not a channel member", &conn, &state).await; + access.deny("not_channel_member"); return; } @@ -253,6 +267,7 @@ pub async fn handle_req( // exists, so SessionExpired here always implies an active FI session. super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state) .await; + access.deny("session_expired"); return; } }; @@ -269,6 +284,7 @@ pub async fn handle_req( authorized_requested_channels.as_deref().unwrap_or_default(), &conn, &state, + &mut access, ), ) .await; @@ -294,6 +310,7 @@ pub async fn handle_req( &state, ) .await; + access.deny("p_gated"); return; } if !engram_filters_authorized(&filters, &authed_pubkey_hex) { @@ -304,6 +321,7 @@ pub async fn handle_req( &state, ) .await; + access.deny("engram_gated"); return; } if !author_only_filters_authorized(&filters, &authed_pubkey_hex) { @@ -314,6 +332,7 @@ pub async fn handle_req( &state, ) .await; + access.deny("author_only"); return; } } @@ -332,6 +351,7 @@ pub async fn handle_req( &state, ) .await; + access.deny("mixed_search"); return; } // IMPORTANT 6: acquire a REQ effect permit before the search query and @@ -345,6 +365,7 @@ pub async fn handle_req( // Fix 4: [FI-TRACE-DENIAL-ORACLE] super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state) .await; + access.deny("session_expired"); return; } }; @@ -366,6 +387,7 @@ pub async fn handle_req( &conn, &state, trace_state.as_ref(), + &mut access, ) .await; }; @@ -391,6 +413,7 @@ pub async fn handle_req( Err(crate::nip_fi_gate::SessionExpired) => { // Fix 4: [FI-TRACE-DENIAL-ORACLE] super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state).await; + access.deny("session_expired"); return; } }; @@ -415,6 +438,7 @@ pub async fn handle_req( // the CLOSED reason when a failed read ended it: retirement then runs // after the race, because a dropped retirement would leak its unreleased // topics. + let access = &mut access; let history = async { #[cfg(test)] crate::nip_fi_test_hooks::before_req_history(conn.tenant.community()).await; @@ -500,7 +524,17 @@ pub async fn handle_req( let events = match filter_events { Ok(evs) => evs, Err(e) => { - warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); + warn!( + conn_id = %conn_id, + sub_id = %sub_id, + error_kind = %e.kind(), + "Historical query failed" + ); + access.error(if e.is_statement_cancelled() { + "query_timed_out" + } else { + "database_error" + }); // CLOSED, never EOSE: EOSE tells the client it has every // stored event, so a resuming client would skip this history. return Some(history_read_closed_reason(&e)); @@ -534,7 +568,8 @@ pub async fn handle_req( Err(e) => { warn!( conn_id = %conn_id, sub_id = %sub_id, - "conformance row-community lookup failed: {e}" + error_kind = %e.kind(), + "conformance row-community lookup failed" ); std::collections::HashMap::new() } @@ -582,6 +617,7 @@ pub async fn handle_req( if !super::close::send_if_owner(&sub_id, owner, msg, &conn).await { return None; } + access.queued_one(); total_sent += 1; if total_sent.is_multiple_of(100) { tokio::task::yield_now().await; @@ -590,6 +626,7 @@ pub async fn handle_req( } super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn).await; + access.served(); debug!( conn_id = %conn_id, @@ -782,6 +819,7 @@ async fn handle_search_req( conn: &ConnectionState, state: &AppState, trace_state: Option<&crate::conformance::AbstractState>, + access: &mut crate::access::ReadLog, ) { // `None` means "no accessible channels and no global access" → EOSE, // exactly as the legacy string-filter helper short-circuited. A filter @@ -790,10 +828,12 @@ async fn handle_search_req( && named_only.is_empty() { super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + access.served(); return; } let mut seen_ids: HashSet = HashSet::new(); + let mut failure = None; for filter in filters { let search_text = match &filter.search { @@ -890,7 +930,13 @@ async fn handle_search_req( // CLOSED, not EOSE: an empty EOSE would read as "no // matches". Stop the whole REQ so later filters do not // each wait out the search pool's acquire timeout too. - warn!(sub_id = %sub_id, "NIP-50 search failed: {e}"); + // Fixed kind only: the error must never carry query text. + warn!( + sub_id = %sub_id, + error_kind = %e.kind(), + "NIP-50 search failed" + ); + access.error("search_failed"); super::close::close_if_owner( sub_id, owner, @@ -921,12 +967,18 @@ async fn handle_search_req( Ok(evs) => evs, Err(e) if e.is_statement_cancelled() => { // CLOSED (not EOSE) keeps partial results from reading as complete. - warn!(sub_id = %sub_id, "NIP-50 batch fetch timed out: {e}"); + warn!(sub_id = %sub_id, "NIP-50 batch fetch timed out"); + access.error("query_timed_out"); close_timed_out_subscription(sub_id, owner, conn, state).await; return; } Err(e) => { - warn!(sub_id = %sub_id, "NIP-50 batch fetch failed: {e}"); + warn!( + sub_id = %sub_id, + error_kind = %e.kind(), + "NIP-50 batch fetch failed" + ); + failure = Some("database_error"); break; } }; @@ -957,7 +1009,8 @@ async fn handle_search_req( Err(e) => { warn!( sub_id = %sub_id, - "conformance row-community lookup failed: {e}" + error_kind = %e.kind(), + "conformance row-community lookup failed" ); std::collections::HashMap::new() } @@ -1008,6 +1061,7 @@ async fn handle_search_req( if !super::close::send_if_owner(sub_id, owner, frame, conn).await { return; } + access.queued_one(); emitted += 1; } } @@ -1019,6 +1073,10 @@ async fn handle_search_req( } super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + match failure { + Some(reason) => access.error(reason), + None => access.served(), + } } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1442,6 +1500,7 @@ async fn handle_huddle_liveness_req( parent_channel_ids: &[uuid::Uuid], conn: &ConnectionState, state: &AppState, + access: &mut crate::access::ReadLog, ) { if parent_channel_ids.is_empty() { super::close::close_if_owner( @@ -1452,6 +1511,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.deny("not_channel_member"); return; } @@ -1467,9 +1527,13 @@ async fn handle_huddle_liveness_req( { Ok(links) => links, Err(error) => { - warn!("Huddle liveness linkage batch failed: {error}"); + warn!( + error_kind = %error.kind(), + "Huddle liveness linkage batch failed" + ); super::close::close_if_owner(sub_id, owner, Some(DATABASE_ERROR_CLOSED), conn, state) .await; + access.error("database_error"); return; } }; @@ -1495,6 +1559,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.error("liveness_unavailable"); return; } } @@ -1540,6 +1605,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.error("signing_failed"); return; } }; @@ -1547,9 +1613,11 @@ async fn handle_huddle_liveness_req( if !super::close::send_if_owner(sub_id, owner, frame, conn).await { return; } + access.queued_one(); } super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + access.served(); } /// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh @@ -1968,6 +2036,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); let cancel = tokio_util::sync::CancellationToken::new(); let conn = ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), remote_addr: "127.0.0.1:1234".parse().expect("addr"), @@ -2208,6 +2277,8 @@ mod tests { }) .collect(); + let (_guard, out) = crate::access::tests::capture_scoped(); + let mut access = crate::access::ReadLog::req(&conn, "s", &filters); let started = std::time::Instant::now(); handle_search_req( "s", @@ -2221,9 +2292,17 @@ mod tests { &conn, &state, None, + &mut access, ) .await; let elapsed = started.elapsed(); + drop(access); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["level"], "WARN", "{}", lines[0]); + assert_eq!(lines[0]["outcome"], "error", "{}", lines[0]); + assert_eq!(lines[0]["reason"], "search_failed", "{}", lines[0]); + assert_eq!(lines[0]["search"], true, "{}", lines[0]); assert_eq!( closed_frames(&mut send_rx), @@ -2472,6 +2551,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); let cancel = tokio_util::sync::CancellationToken::new(); let conn = Arc::new(ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), remote_addr: "127.0.0.1:1234".parse().expect("addr"), @@ -2532,6 +2612,7 @@ mod tests { let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); let cancel = tokio_util::sync::CancellationToken::new(); let conn = Arc::new(ConnectionState { + refusal_budget: Default::default(), conn_id: uuid::Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), remote_addr: "127.0.0.1:1234".parse().expect("addr"), @@ -3693,6 +3774,7 @@ mod tests { let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -3822,6 +3904,7 @@ mod tests { let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -3957,6 +4040,7 @@ mod tests { let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().unwrap(), @@ -4098,6 +4182,7 @@ mod tests { let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let conn = Arc::new(crate::connection::ConnectionState { + refusal_budget: Default::default(), conn_id: Uuid::new_v4(), tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().unwrap(), diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 61228f4e13..367a28abc4 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1005,7 +1005,7 @@ pub async fn emit_system_message( .db .insert_event(tenant.community(), &event, Some(channel_id)) .await - .map_err(|e| anyhow::anyhow!("system message insert failed: {e}"))?; + .map_err(|e| anyhow::anyhow!("system message insert failed: {}", e.kind()))?; fan_out_system_message(tenant, state, channel_id, &event).await; Ok(()) @@ -1250,7 +1250,8 @@ async fn emit_typed_membership_notification( let (stored, was_inserted) = state .db .insert_event(tenant.community(), &event, None) - .await?; + .await + .map_err(|e| anyhow::anyhow!("membership notification insert failed: {}", e.kind()))?; if !was_inserted { return Ok(()); } @@ -1401,7 +1402,8 @@ async fn emit_addressable_discovery_event( let (stored, was_inserted) = state .db .replace_addressable_event(tenant.community(), &event, Some(channel_id)) - .await?; + .await + .map_err(|e| anyhow::anyhow!("channel state write failed: {}", e.kind()))?; if was_inserted { let kind_u32 = event_kind_u32(&stored.event); dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; @@ -3125,14 +3127,19 @@ pub(crate) async fn persist_workflow_deletion( .ok_or_else(|| anyhow::anyhow!("missing workflow coordinate"))?; let mut parts = coordinate.splitn(3, ':'); let _kind = parts.next(); - let owner = hex::decode(parts.next().unwrap_or_default())?; + let owner = hex::decode(parts.next().unwrap_or_default()) + .map_err(|_| anyhow::anyhow!("invalid workflow coordinate"))?; let d_tag = parts .next() .ok_or_else(|| anyhow::anyhow!("invalid workflow coordinate"))?; let (stored, dispatch, channel_id) = state .db .insert_workflow_deletion(tenant.community(), event, &owner, d_tag) - .await?; + .await + .map_err(|e| { + // The write carries the whole event: log only a fixed kind. + anyhow::anyhow!("workflow deletion write failed: {}", e.kind()) + })?; if let Some(channel_id) = channel_id { state .workflow_engine @@ -4345,7 +4352,8 @@ pub async fn publish_nipia_archival_list( let (stored, was_inserted) = state .db .replace_addressable_event(tenant.community(), &event, None) - .await?; + .await + .map_err(|e| anyhow::anyhow!("archive snapshot write failed: {}", e.kind()))?; if !was_inserted { continue; } @@ -4450,7 +4458,8 @@ pub async fn publish_dm_visibility_snapshot( let (stored, was_inserted) = state .db .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None) - .await?; + .await + .map_err(|e| anyhow::anyhow!("DM visibility write failed: {}", e.kind()))?; if was_inserted { dispatch_persistent_event( tenant, diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 69f3432431..80f213942a 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -2,6 +2,8 @@ #![warn(missing_docs)] //! NIP-01 WebSocket relay for Buzz private team communication. +/// Stdout access log: reads, auth attempts, and connection lifecycle. +pub(crate) mod access; mod admission; mod build_info; mod rejection; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 59803a0aa8..d02cff3076 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1899,6 +1899,10 @@ mod env_filter_tests { otel.contains("relay-info") && otel.contains("datastore-info"), "{otel}" ); + let access = logged(otel_filter(None), || { + tracing::warn!(target: "buzz_relay::access", "access-warn"); + }); + assert!(access.is_empty(), "{access}"); } #[test] diff --git a/crates/buzz-relay/src/nip_fi_core.rs b/crates/buzz-relay/src/nip_fi_core.rs index 9e6b8b5278..1ca1dc5754 100644 --- a/crates/buzz-relay/src/nip_fi_core.rs +++ b/crates/buzz-relay/src/nip_fi_core.rs @@ -45,6 +45,27 @@ impl AssertionRejection { Self::Verifier(err) => err.denial_class(), } } + + /// A fixed code for access logs; never token contents. + pub(crate) const fn code(self) -> &'static str { + match self { + Self::Transport(DenialClass::MissingEvidence) => "assertion_missing", + Self::Transport(_) => "assertion_malformed", + Self::VerifierUnavailable => "verifier_unavailable", + Self::Verifier(err) => err.code(), + } + } + + /// The access-log result: an unavailable verifier is a refusal, every + /// other rejection is a failed proof. + pub(crate) const fn auth_result(self) -> crate::access::AuthResult { + match self { + Self::VerifierUnavailable => { + crate::access::AuthResult::Denied(crate::access::AuthReason::VerifierUnavailable) + } + _ => crate::access::AuthResult::Failed(self.code()), + } + } } /// The request `Host`, if present and readable. diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 6ff8a653ea..bd3df65bb3 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -61,10 +61,10 @@ use chrono::{DateTime, Utc}; use nostr::PublicKey; use std::fmt; +use crate::access::{AccessSlot, AuthMethod, AuthResult}; use crate::nip_fi_core::ResolvedCommunity; use crate::nip_fi_core::{ - asserted_key_matches, assertion_denied, evaluate_attached_assertion, http_denial, - AssertionRejection, Communities, + asserted_key_matches, assertion_denied, evaluate_attached_assertion, http_denial, Communities, }; use buzz_auth::CommunityBinding; @@ -225,6 +225,8 @@ pub(crate) struct NipFiAdmission { assertion: Option, /// Caller-supplied side-data from the NIP-98 extraction closure. extra: X, + /// False for a dev-mode `X-Pubkey` identity, which nothing signed. + signed: bool, } impl fmt::Debug for NipFiAdmission { @@ -267,6 +269,18 @@ impl NipFiAdmission { &self.extra } + /// Record this admitted request's identity: an enterprise assertion in + /// Enforce, the unsigned dev-mode `X-Pubkey` identity, or `proof_method` + /// (NIP-98 or Blossom) otherwise. + pub(crate) fn record(&self, slot: &AccessSlot, proof_method: AuthMethod) { + let method = match (&self.assertion, self.signed) { + (Some(_), _) => AuthMethod::NipFi, + (None, false) => AuthMethod::XPubkey, + (None, true) => proof_method, + }; + slot.attempt(method, Some(self.proven_pubkey), self.signed); + } + /// Consume the admission, returning ownership of the side-data. pub(crate) fn into_extra(self) -> X { self.extra @@ -328,8 +342,9 @@ impl NipFiAdmission { // Response is intentionally large (axum's design); boxing it here // would add allocation without architectural benefit. The large Err variant // is load-bearing: it IS the HTTP response, returned directly by handlers. -#[allow(clippy::result_large_err)] +#[allow(clippy::result_large_err, clippy::too_many_arguments)] pub(crate) async fn admit_nip_fi_http<'a, D, X, F>( + slot: &AccessSlot, headers: &HeaderMap, extract_nip98: F, communities: impl Into>, @@ -346,6 +361,10 @@ where // request shape (duplicate, missing, or invalid `Authorization`) can turn // it into a 401/403, and so neither NIP-98 nor the verifier runs. if mode.denies_unconditionally() { + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); return Err(http_denial(DenialClass::AuthorizationUnavailable)); } let communities = communities.into(); @@ -372,6 +391,7 @@ where proven_pubkey: proof.pubkey, assertion: None, extra: proof.extra, + signed: proof.signed, }); } @@ -383,21 +403,37 @@ where deny_map, extract_nip98, ) - .map_err(|deny| { - let reason = match deny.stage { - Stage::Pairing => Some("nip_fi_http_key_mismatch"), - Stage::DenySet => Some("nip_fi_http_denied_pubkey"), - _ => None, - }; - if let Some(reason) = reason { - metrics::counter!("buzz_auth_failures_total", "reason" => reason).increment(1); - } - http_denial(deny.class) - })?; + .map_err( + |EnforceDenial { + deny, + pubkey, + result, + }| { + if let Some(result) = result { + slot.attempt(AuthMethod::NipFi, pubkey, pubkey.is_some()); + slot.finish(result); + } + let reason = match deny.stage { + Stage::Pairing => Some("nip_fi_http_key_mismatch"), + Stage::DenySet => Some("nip_fi_http_denied_pubkey"), + _ => None, + }; + if let Some(reason) = reason { + metrics::counter!("buzz_auth_failures_total", "reason" => reason).increment(1); + } + http_denial(deny.class) + }, + )?; // The handler may have awaited (tenant lookup) since the guard resolved // the community; a snapshot that went stale meanwhile admits nothing. if let Ok(community) = &community { - community.deadline().check().map_err(http_denial)?; + community.deadline().check().map_err(|class| { + slot.attempt(AuthMethod::NipFi, Some(proof.pubkey), true); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + http_denial(class) + })?; } // Step 8: admit. @@ -405,6 +441,7 @@ where proven_pubkey: proof.pubkey, assertion: Some(assertion), extra: proof.extra, + signed: proof.signed, }) } @@ -425,6 +462,7 @@ async fn observe_shadow_verdict( evaluate_enforce_steps(headers, community.as_deref(), verifier, deny_map, || { signed.map(|key| Nip98Proof::new(key, ())).ok_or(()) }) + .map_err(|d| d.deny) .and_then(|(proof, assertion)| match &community { // Enforce's final re-check, after the handler's awaits. Ok(community) => community @@ -488,15 +526,34 @@ pub(crate) async fn observe_failed_proof(state: &crate::state::AppState, headers } } -/// Enforce admission steps 1–7 in normative order. A denial names the step -/// (the shadow `stage` label) and its class. +/// An enforce-step denial: the shadow would-deny, plus the access key and +/// result the enforcing caller logs. `result` is `None` when the NIP-98 +/// extraction closure already logged its own rejection. +struct EnforceDenial { + deny: WouldDeny, + pubkey: Option, + result: Option, +} + +impl EnforceDenial { + fn new(deny: WouldDeny, pubkey: Option, result: Option) -> Self { + Self { + deny, + pubkey, + result, + } + } +} + +/// Enforce admission steps 1–7 in normative order. Pure: no access-log side +/// effects, so shadow observation can share it. Only the enforcing caller logs. fn evaluate_enforce_steps( headers: &HeaderMap, community: Result<&CommunityBinding, &DenialClass>, verifier: Option<&dyn VerifyAssertion>, deny_map: &D, extract_nip98: F, -) -> Result<(Nip98Proof, VerifiedAssertion), WouldDeny> +) -> Result<(Nip98Proof, VerifiedAssertion), EnforceDenial> where D: HttpDenyMap, F: FnOnce() -> Result, E>, @@ -505,8 +562,13 @@ where // evidence (`Authorization`, NIP-98, assertion) is examined. Explicit // here because some admitted routes are exempt from the router guard. // [NIP-FI.md:266-268] - let community = community - .map_err(|c| WouldDeny::new(Stage::Community, *c, crate::nip_fi_shadow::UNRESOLVED))?; + let community = community.map_err(|c| { + EnforceDenial::new( + WouldDeny::new(Stage::Community, *c, crate::nip_fi_shadow::UNRESOLVED), + None, + Some(AuthResult::Failed("community_unresolved")), + ) + })?; // Step 2 — cardinality gate: exactly one Authorization field per // NIP-FI.md:695-700. @@ -517,46 +579,53 @@ where // closes the attack where a relay-aware adversary slips a second credential // past the NIP-98 verifier. [FI-INV-15] if headers.get_all("authorization").iter().count() > 1 { - return Err(WouldDeny::new( - Stage::Cardinality, - DenialClass::EvidenceRejected, - "duplicate_authorization", + return Err(EnforceDenial::new( + WouldDeny::new( + Stage::Cardinality, + DenialClass::EvidenceRejected, + "duplicate_authorization", + ), + None, + Some(AuthResult::Failed("duplicate_authorization")), )); } // Step 3: NIP-98. Failure is a NIP-FI DenialClass, not a legacy error. + // The extraction closure owns the access line for its own rejection. // [NIP-FI.md §Admission procedure step 3; FI-TRACE-DENIAL-ORACLE] let proof = extract_nip98().map_err(|_legacy| { - if headers.contains_key("authorization") { + let deny = if headers.contains_key("authorization") { WouldDeny::new(Stage::Nip98, DenialClass::EvidenceRejected, "invalid_proof") } else { WouldDeny::new(Stage::Nip98, DenialClass::MissingEvidence, "missing_proof") - } + }; + EnforceDenial::new(deny, None, None) })?; + let key = Some(proof.pubkey); // Steps 4–5: extract and verify the assertion. let assertion = evaluate_attached_assertion(headers, community, verifier).map_err(|rejection| { - if let AssertionRejection::Verifier(e) = rejection { - tracing::debug!(code = e.code(), "nip-fi assertion denied at http ingress"); - } - WouldDeny::assertion(rejection) + EnforceDenial::new( + WouldDeny::assertion(rejection), + key, + Some(rejection.auth_result()), + ) })?; // Step 6: key pairing — assertion.asserted_key MUST equal proven NIP-98 key. // A claimless assertion (no nostr_pubkey) is also a denial; key mismatch // is a private-state denial (403). [FI-INV-05] [FI-TRACE-DENIAL-ORACLE] + let denied = |deny, reason| EnforceDenial::new(deny, key, Some(AuthResult::Denied(reason))); if !asserted_key_matches(&assertion, proof.pubkey) { - tracing::debug!(proven = %proof.pubkey.to_hex(), "NIP-FI HTTP key pairing mismatch"); let reason = if assertion.asserted_key().is_some() { "key_mismatch" } else { "no_asserted_key" }; - return Err(WouldDeny::new( - Stage::Pairing, - DenialClass::AuthorizationDenied, - reason, + return Err(denied( + WouldDeny::new(Stage::Pairing, DenialClass::AuthorizationDenied, reason), + crate::access::AuthReason::KeyMismatch, )); } @@ -564,7 +633,10 @@ where // (iss, owner) must not be in an active deny window. Private-state // denial. [FI-INV-14] [NIP-FI.md:624-627] if assertion_denied(deny_map, &assertion, Utc::now()) { - return Err(crate::nip_fi_shadow::DENY_SET); + return Err(denied( + crate::nip_fi_shadow::DENY_SET, + crate::access::AuthReason::DenySet, + )); } Ok((proof, assertion)) @@ -589,6 +661,7 @@ where pub(crate) async fn admit_nip_fi_http_on_state( state: &crate::state::AppState, headers: &HeaderMap, + slot: &AccessSlot, extract_nip98: F, ) -> Result, Response> where @@ -597,9 +670,10 @@ where let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); - match state.nip_fi_deny_map.as_deref() { + let admission = match state.nip_fi_deny_map.as_deref() { Some(deny_map) => { admit_nip_fi_http( + slot, headers, extract_nip98, communities, @@ -612,6 +686,7 @@ where } None => { admit_nip_fi_http( + slot, headers, extract_nip98, communities, @@ -622,7 +697,9 @@ where ) .await } - } + }?; + admission.record(slot, AuthMethod::Nip98); + Ok(admission) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -648,6 +725,7 @@ mod tests { F: FnOnce() -> Result, Response>, { admit_nip_fi_http( + &AccessSlot::default(), headers, extract_nip98, communities, @@ -698,6 +776,28 @@ mod tests { nostr::Keys::generate().public_key() } + /// An unsigned dev-mode `X-Pubkey` identity never logs as a verified + /// signature; a signed NIP-98 proof does. + #[test] + fn unsigned_x_pubkey_logs_as_unverified_x_pubkey() { + for (signed, method) in [(false, "x_pubkey"), (true, "nip98")] { + let admission = NipFiAdmission { + proven_pubkey: any_pubkey(), + assertion: None, + extra: (), + signed, + }; + let slot = AccessSlot::default(); + admission.record(&slot, AuthMethod::Nip98); + slot.finish(AuthResult::Success); + let ((), out) = crate::access::tests::capture(|| slot.emit("/query", 200)); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["method"], method); + assert_eq!(lines[0]["pubkey_verified"], signed); + } + } + // ── extract_bearer_token ───────────────────────────────────────────────── // Absent header → MissingEvidence (401). @@ -1625,6 +1725,7 @@ mod tests { agent, owner, )); let admission = admit_nip_fi_http::<_, (), _>( + &AccessSlot::default(), &bearer_headers("Bearer a.b.c"), || Ok(Nip98Proof::new(agent, ())), &communities(), @@ -1891,7 +1992,13 @@ mod tests { Ok(Nip98Proof::new(key, ())) } else { crate::api::bridge::verify_bridge_auth_with_options( - &headers, "GET", "u", None, false, false, + &crate::access::AccessSlot::default(), + &headers, + "GET", + "u", + None, + false, + false, ) .map(|auth| auth.proof(())) .map_err(axum::response::IntoResponse::into_response) @@ -1938,7 +2045,13 @@ mod tests { &headers, || { crate::api::bridge::verify_bridge_auth_with_options( - &headers, "GET", "u", None, false, false, + &crate::access::AccessSlot::default(), + &headers, + "GET", + "u", + None, + false, + false, ) .map(|auth| auth.proof(())) .map_err(axum::response::IntoResponse::into_response) @@ -2001,4 +2114,60 @@ mod tests { }; assert_eq!(body_bytes(resp), b"legacy"); } + + /// Run admission, check it wrote no line itself, and render the line its + /// note would log. + fn admit_logged( + mode: NipFiMode, + asserted: PublicKey, + proven: PublicKey, + ) -> (bool, serde_json::Value) { + let verifier = ScriptedVerifier::new(Ok(Some(asserted))); + let slot = AccessSlot::default(); + let (admitted, early) = crate::access::tests::capture(|| { + admit_nip_fi_http::<_, (), _>( + &slot, + &bearer_headers("Bearer a.b.c"), + || Ok(Nip98Proof::new(proven, ())), + &communities(), + Some(&verifier as &dyn VerifyAssertion), + mode, + &AlwaysAdmitStubDenyMap, + None, + ) + .now_or_never() + .expect("env resolution never waits") + .is_ok() + }); + assert!(early.lines().is_empty(), "admission writes no access line"); + let ((), out) = crate::access::tests::capture(|| slot.emit("/test", 200)); + let mut lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + (admitted, lines.remove(0)) + } + + // Shadow evaluation of a would-deny (key mismatch) admits and leaves the + // decision to the caller. + #[test] + fn shadow_would_deny_leaves_the_request_undecided() { + let (admitted, line) = admit_logged(NipFiMode::Shadow, any_pubkey(), any_pubkey()); + assert!(admitted); + assert_eq!(line["outcome"], "undecided", "{line}"); + } + + // Enforce records a denial, and no decision for an admission (the caller + // records success once its last check passes). + #[test] + fn enforce_records_a_denial_and_leaves_an_admission_undecided() { + let (admitted, line) = admit_logged(NipFiMode::Enforce, any_pubkey(), any_pubkey()); + assert!(!admitted); + assert_eq!(line["method"], "nip_fi"); + assert_eq!(line["outcome"], "denied"); + assert_eq!(line["reason"], "key_mismatch"); + + let key = any_pubkey(); + let (admitted, line) = admit_logged(NipFiMode::Enforce, key, key); + assert!(admitted); + assert_eq!(line["outcome"], "undecided", "{line}"); + } } diff --git a/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs b/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs index eaa8974b01..8d0d027fb4 100644 --- a/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs +++ b/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs @@ -565,9 +565,12 @@ async fn key_disconnected_before_its_community_exists_is_denied_there() { .parse() .unwrap(), ); - crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &headers, || { - Ok(crate::nip_fi_http::Nip98Proof::new(key, ())) - }) + crate::nip_fi_http::admit_nip_fi_http_on_state( + &state, + &headers, + &crate::access::AccessSlot::default(), + || Ok(crate::nip_fi_http::Nip98Proof::new(key, ())), + ) .await .map(drop) } diff --git a/crates/buzz-relay/src/nip_fi_session.rs b/crates/buzz-relay/src/nip_fi_session.rs index 986efc0028..8074590d21 100644 --- a/crates/buzz-relay/src/nip_fi_session.rs +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -114,7 +114,8 @@ pub(crate) async fn enforce_nip_fi_key_pairing( proven_pubkey = %proven_pubkey.to_hex(), "NIP-FI key pairing mismatch — closing connection" ); - conn.reject_auth(crate::metrics::AuthOutcome::PairingMismatch); + crate::access::AuthAttempt::nip42(conn, proven_pubkey, true) + .reject(conn, crate::metrics::AuthOutcome::PairingMismatch); // Use the dedicated terminal channel — guaranteed one free slot even // when ctrl_tx (capacity 8) is saturated by ordinary control traffic — // through the shared first-writer-wins transition, so the close is diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index ca98b5d14e..269b7880e0 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -475,6 +475,7 @@ pub(crate) mod tests { let _guard = metrics::set_default_local_recorder(&recorder); super::observe_strict_proof(&state, &headers, "bridge", || { crate::api::bridge::verify_bridge_auth_with_options( + &crate::access::AccessSlot::default(), &headers, "POST", url, diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index b37209b451..6ed3624ce5 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -268,6 +268,39 @@ make_hook!(directory_acquire_hook, after_directory_acquire); // serialized against the join commit. make_hook!(audio_archive_recheck_hook, before_archive_recheck); +// ── Login decision hook ──────────────────────────────────────────────────── +// `before_auth_decision`: fires synchronously in the root WS auth handler +// after its last cancellation check and before the login decides it won. +// The armed action runs inline, with no await, so a disconnect lands in the +// window a concurrent thread would hit, without the handler being dropped. +pub(crate) mod auth_decision_hook { + use super::*; + + type Action = Box; + + static ACTIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + /// Run `action` once when a login in `community` reaches its decision. + pub(crate) fn arm( + community: CommunityId, + action: impl FnOnce(&crate::connection::ConnectionState) + Send + 'static, + ) { + ACTIONS.lock().unwrap().insert(community, Box::new(action)); + } + + pub(crate) fn trigger(conn: &crate::connection::ConnectionState) { + let action = ACTIONS.lock().unwrap().remove(&conn.tenant.community()); + if let Some(action) = action { + action(conn); + } + } +} + +pub(crate) fn before_auth_decision(conn: &crate::connection::ConnectionState) { + auth_decision_hook::trigger(conn); +} + // ── Deny-set admission hooks ─────────────────────────────────────────────── // `before_deny_set_check`: fires in both the root WS auth handler and the audio // handler after the proven identity is registered and before `is_denied`, so a diff --git a/crates/buzz-relay/src/nip_fi_upgrade.rs b/crates/buzz-relay/src/nip_fi_upgrade.rs index fdaa2cc693..c583cf2a48 100644 --- a/crates/buzz-relay/src/nip_fi_upgrade.rs +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -16,8 +16,7 @@ use axum::http::{HeaderMap, Response}; use buzz_auth::{DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion}; use crate::nip_fi_core::{ - evaluate_attached_assertion, http_denial, AdmissionDeadline, AssertionRejection, Communities, - ResolvedCommunity, + evaluate_attached_assertion, http_denial, AdmissionDeadline, Communities, ResolvedCommunity, }; /// Outcome of NIP-FI assertion validation at upgrade time. @@ -107,6 +106,7 @@ impl NipFiUpgradeOutcome { /// denied" would be false. "authorization unavailable, retry after repair" is /// the accurate and correct signal. [FI-TRACE-DENIAL-ORACLE] pub(crate) async fn check_nip_fi_at_upgrade<'a>( + slot: &crate::access::AccessSlot, route: crate::nip_fi_session::NipFiWsRoute, headers: &HeaderMap, communities: impl Into>, @@ -146,21 +146,25 @@ pub(crate) async fn check_nip_fi_at_upgrade<'a>( return NipFiUpgradeOutcome::NotRequired; } + use crate::access::{AuthMethod, AuthReason, AuthResult}; + slot.method(AuthMethod::NipFi); if mode.denies_unconditionally() { + slot.finish(AuthResult::Denied(AuthReason::AuthorizationUnavailable)); return NipFiUpgradeOutcome::Denied(http_denial(DenialClass::AuthorizationUnavailable)); } // Enforce mode: resolve the Host's community, then validate the assertion. let community = match communities.resolve(headers).await { Ok(community) => community, - Err(class) => return NipFiUpgradeOutcome::Denied(http_denial(class)), + Err(class) => { + slot.finish(AuthResult::Failed("community_unresolved")); + return NipFiUpgradeOutcome::Denied(http_denial(class)); + } }; match evaluate_attached_assertion(headers, &community, verifier) { Ok(assertion) => NipFiUpgradeOutcome::Admitted(assertion, community.deadline()), Err(rejection) => { - if let AssertionRejection::Verifier(err) = rejection { - tracing::debug!(code = err.code(), "nip-fi assertion denied at upgrade"); - } + slot.finish(rejection.auth_result()); NipFiUpgradeOutcome::Denied(http_denial(rejection.denial_class())) } } @@ -442,6 +446,7 @@ mod tests { axum::http::HeaderValue::from_static("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), ); let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &h, &communities(), @@ -464,6 +469,7 @@ mod tests { fn enforce_missing_header_returns_401_exact_bytes() { let headers = host_headers(); let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -496,6 +502,7 @@ mod tests { fn off_mode_returns_not_required() { let headers = host_headers(); // no assertion header let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -522,10 +529,35 @@ mod tests { // A) Change `DenyProtected` handler to use `AuthorizationDenied` → // status assertion panics (expected 503, got 403). // B) Body assertion: change the body text → panics. + #[test] + fn deny_protected_logs_one_denied_auth_line() { + let headers = host_headers(); + let slot = crate::access::AccessSlot::default(); + let (_, out) = crate::access::tests::capture(|| { + check_nip_fi_at_upgrade( + &slot, + ROOT, + &headers, + &communities(), + None, + buzz_auth::NipFiMode::DenyProtected, + ) + .now_or_never() + .expect("env resolution never waits"); + slot.emit("/", 503); + }); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["event"], "http"); + assert_eq!(lines[0]["outcome"], "denied"); + assert_eq!(lines[0]["reason"], "authorization_unavailable"); + } + #[test] fn deny_protected_returns_503_authorization_unavailable() { let headers = host_headers(); let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -630,6 +662,7 @@ mod tests { sign_assertion(typ, &agent_hex, claim) )); let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities_for(&[TEST_ISSUER]), @@ -676,6 +709,7 @@ mod tests { let verifier = ScriptedVerifier::new(Err(err)); let (got_status, got_body) = denied_parts( check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers_with("Bearer a.b.c"), &communities(), @@ -696,6 +730,7 @@ mod tests { fn characterize_upgrade_transport_precedes_verifier_presence() { let (status, body) = denied_parts( check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers_with("junk"), &communities(), @@ -716,6 +751,7 @@ mod tests { fn characterize_upgrade_admits_claimless_assertion_for_later_pairing() { let verifier = ScriptedVerifier::new(Ok(None)); match check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers_with("Bearer a.b.c"), &communities(), @@ -742,6 +778,7 @@ mod tests { let headers = headers_with("Bearer a.b.c"); assert!(matches!( check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -754,6 +791,7 @@ mod tests { )); let (status, _) = denied_parts( check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -777,6 +815,7 @@ mod tests { let headers = headers_with("Bearer a.b.c"); let check = || { check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -791,6 +830,7 @@ mod tests { }; assert_eq!(verifier.calls(), 1, "shadow must evaluate the assertion"); let Ok((None, None, _, Some(_))) = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &host_headers(), &communities(), @@ -818,6 +858,7 @@ mod tests { for headers in [host_headers(), headers_with("Bearer a.b.c")] { let verifier = Some(&rejecting as &dyn VerifyAssertion); let outcome = check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), route, &headers, &communities(), diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs index 8af208d2d8..72b228e97e 100644 --- a/crates/buzz-relay/src/rejection.rs +++ b/crates/buzz-relay/src/rejection.rs @@ -46,13 +46,14 @@ pub(crate) fn request_rejection_message(target: RejectionTarget<'_>, reason: &st } } -/// Applies the WebSocket admission quotas to `msg`, returning whether it may be -/// handled. A rejection is addressed to the frame's own acknowledgement channel. +/// Applies the WebSocket admission quotas to `msg`. A rejection is addressed +/// to the frame's own acknowledgement channel, and its fixed access-log reason +/// is returned. pub(crate) async fn enforce_ws_admission( msg: &ClientMessage, conn: &ConnectionState, state: &AppState, -) -> bool { +) -> Result<(), &'static str> { let is_event = matches!(msg, ClientMessage::Event(_)); if !is_event && !matches!( @@ -62,13 +63,13 @@ pub(crate) async fn enforce_ws_admission( | ClientMessage::ArtifactSync { .. } ) { - return true; + return Ok(()); } let (pubkey, is_agent) = { match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), - _ => return true, + _ => return Ok(()), } }; @@ -84,9 +85,7 @@ pub(crate) async fn enforce_ws_admission( ws_limit, ) .await; - if !send_admission_result(conn, state, ws_result, msg, "ws_operations").await { - return false; - } + send_admission_result(conn, state, ws_result, msg, "ws_operations").await?; if matches!(msg, ClientMessage::Event(event) if !buzz_core::kind::is_ephemeral(event.kind.as_u16() as u32)) { @@ -106,16 +105,14 @@ pub(crate) async fn enforce_ws_admission( .await; // Only persistable EVENT attempts consume the minute quota. Ephemeral activity // still passes the shared WS flood limit above and normal event auth. - if !send_admission_result(conn, state, message_result, msg, "messages").await { - return false; - } + send_admission_result(conn, state, message_result, msg, "messages").await?; } - true + Ok(()) } -/// Forwards an admission verdict to the client, returning whether the frame was -/// admitted. +/// Forwards an admission verdict to the client, returning the access-log +/// reason when the frame was refused. /// /// The rejection target is derived from `msg` here rather than supplied by the /// caller: every quota check in this module must address its rejection to the @@ -127,16 +124,18 @@ async fn send_admission_result( result: Result<(), AdmissionError>, msg: &ClientMessage, bucket: &'static str, -) -> bool { - let (reason, label) = match result { - Ok(()) => return true, +) -> Result<(), &'static str> { + let (reason, label, logged) = match result { + Ok(()) => return Ok(()), Err(AdmissionError::Exceeded { reset_in_secs }) => ( format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), "quota", + "quota_exceeded", ), Err(AdmissionError::Unavailable) => ( "rate-limited: shared admission unavailable".to_owned(), "unavailable", + "admission_unavailable", ), }; metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => label, "bucket" => bucket).increment(1); @@ -152,7 +151,7 @@ async fn send_admission_result( )); } } - false + Err(logged) } #[cfg(test)] @@ -221,7 +220,7 @@ mod tests { ) .await; - assert!(!admitted, "an over-quota frame is not admitted"); + assert!(admitted.is_err(), "an over-quota frame is not admitted"); let frame = sent_frame(&mut rx); assert_eq!( frame[0], "OK", @@ -249,7 +248,7 @@ mod tests { let (conn, mut rx) = test_conn(); let (msg, event_id) = parsed_event_message(); - send_admission_result( + let _ = send_admission_result( &conn, &state, Err(AdmissionError::Unavailable), @@ -273,7 +272,7 @@ mod tests { let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); let msg = ClientMessage::parse(&raw).expect("parse REQ"); - send_admission_result( + let _ = send_admission_result( &conn, &state, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), @@ -299,7 +298,7 @@ mod tests { let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); let msg = ClientMessage::parse(&raw).expect("parse COUNT"); - send_admission_result( + let _ = send_admission_result( &conn, &state, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), @@ -326,7 +325,7 @@ mod tests { let (conn, mut rx) = test_conn_with_auth(authenticated_state()); let msg = ClientMessage::parse(raw).expect("parse client frame"); - let admitted = enforce_ws_admission(&msg, &conn, &state).await; + let admitted = enforce_ws_admission(&msg, &conn, &state).await.is_ok(); assert!(!admitted, "an unadmitted frame must not be handled"); sent_frame(&mut rx) } diff --git a/crates/buzz-relay/src/rejection/quota_tests.rs b/crates/buzz-relay/src/rejection/quota_tests.rs index 9f546469e2..ac66b11638 100644 --- a/crates/buzz-relay/src/rejection/quota_tests.rs +++ b/crates/buzz-relay/src/rejection/quota_tests.rs @@ -130,22 +130,30 @@ async fn ephemeral_events_leave_human_and_agent_message_budgets_untouched() { for stored_kind in [0, 1, 9, 19999, 30000, 40002] { let (conn, mut rx) = connection(agent); for kind in ephemeral_kinds { - assert!(enforce_ws_admission(&event(kind), &conn, &state).await); + assert!(enforce_ws_admission(&event(kind), &conn, &state) + .await + .is_ok()); } assert_eq!(counter(&state, &conn, LimitType::Messages).await, None); assert_eq!(counter(&state, &conn, LimitType::WsEvents).await, Some(5)); for expected in 1..=limit { - assert!(enforce_ws_admission(&event(stored_kind), &conn, &state).await); + assert!(enforce_ws_admission(&event(stored_kind), &conn, &state) + .await + .is_ok()); assert_eq!( counter(&state, &conn, LimitType::Messages).await, Some(expected) ); } let rejected = event(stored_kind); - assert!(!enforce_ws_admission(&rejected, &conn, &state).await); + assert!(enforce_ws_admission(&rejected, &conn, &state) + .await + .is_err()); assert_quota_frame(&read_frame(&mut rx), &rejected, 60); for kind in ephemeral_kinds { - assert!(enforce_ws_admission(&event(kind), &conn, &state).await); + assert!(enforce_ws_admission(&event(kind), &conn, &state) + .await + .is_ok()); } assert_eq!( counter(&state, &conn, LimitType::Messages).await, @@ -173,11 +181,11 @@ async fn ws_flood_budget_is_shared_by_ephemeral_stored_req_and_count() { req.clone(), count.clone(), ] { - assert!(enforce_ws_admission(&msg, &conn, &state).await); + assert!(enforce_ws_admission(&msg, &conn, &state).await.is_ok()); } assert_eq!(counter(&state, &conn, LimitType::Messages).await, Some(1)); for msg in [event(24200), event(9), req, count] { - assert!(!enforce_ws_admission(&msg, &conn, &state).await); + assert!(enforce_ws_admission(&msg, &conn, &state).await.is_err()); assert_quota_frame(&read_frame(&mut rx), &msg, 5); } assert_eq!(counter(&state, &conn, LimitType::Messages).await, Some(1)); @@ -223,7 +231,7 @@ async fn ephemeral_and_http_admission_still_fail_closed_when_redis_is_unavailabl let _guard = metrics::set_default_local_recorder(&recorder); let (conn, mut rx) = connection(false); let msg = event(20002); - assert!(!enforce_ws_admission(&msg, &conn, &state).await); + assert!(enforce_ws_admission(&msg, &conn, &state).await.is_err()); let ClientMessage::Event(event) = msg else { unreachable!() }; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index adc19fe506..1773b9e05f 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -19,6 +19,7 @@ use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; use tower_http::trace::{HttpMakeClassifier, TraceLayer}; +use crate::access::{AuthMethod, AuthResult}; use crate::api; use crate::audio; use crate::connection::handle_connection; @@ -174,6 +175,7 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ /// In Off mode the middleware is fully transparent; in Shadow it only records. async fn nip_fi_assertion_guard( State(state): State>, + slot: crate::access::AccessSlot, request: Request, next: middleware::Next, ) -> axum::response::Response { @@ -185,8 +187,15 @@ async fn nip_fi_assertion_guard( // DenyProtected: unconditional 503 regardless of assertion presence. // (`admit_nip_fi_http_on_state` also does this; the guard is the backstop.) + // Refusals here end the request before any handler, so the guard logs + // them; shadow mode refuses nothing and logs nothing. if mode.denies_unconditionally() { - return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + let denial = http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + return denial; } // Shadow resolves exactly as enforce does; enforce hands the binding to @@ -199,6 +208,7 @@ async fn nip_fi_assertion_guard( // rechecks the same deadline enforce does. if mode.observes_only() { let label = crate::nip_fi_shadow::label(&community); + let verdict = verdict.map_err(|(would_deny, _)| would_deny); let run = crate::nip_fi_http::with_resolved_community(community, next.run(request)); return crate::nip_fi_shadow::observe_guard(label, verdict, run).await; } @@ -206,9 +216,34 @@ async fn nip_fi_assertion_guard( (Ok(()), Ok(community)) => { crate::nip_fi_http::with_resolved_community(Ok(community), next.run(request)).await } - (Err(crate::nip_fi_shadow::WouldDeny { class, .. }), _) | (Ok(()), Err(class)) => { - http_denial(class) + (Ok(()), Err(class)) => { + let denial = http_denial(class); + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Failed("community_unresolved")); + denial } + (Err((crate::nip_fi_shadow::WouldDeny { class, .. }, result)), _) => { + let denial = http_denial(class); + slot.method(AuthMethod::NipFi); + slot.finish(guard_result(&request, result)); + denial + } + } +} + +/// A git request with no credentials at all is git's normal first step, +/// asking for the challenge, so it is logged as `challenged`, not a failure. +fn guard_result(request: &Request, result: AuthResult) -> AuthResult { + let bare = !request + .headers() + .contains_key(axum::http::header::AUTHORIZATION); + match result { + AuthResult::Failed("assertion_missing") + if bare && request.uri().path().starts_with("/git/") => + { + AuthResult::Challenged + } + other => other, } } @@ -223,12 +258,15 @@ fn nip_fi_guard_steps( state: &AppState, headers: &axum::http::HeaderMap, community: Result<&buzz_auth::CommunityBinding, &buzz_auth::DenialClass>, -) -> Result<(), crate::nip_fi_shadow::WouldDeny> { +) -> Result<(), (crate::nip_fi_shadow::WouldDeny, AuthResult)> { let community = community.map_err(|class| { - crate::nip_fi_shadow::WouldDeny::new( - crate::nip_fi_shadow::Stage::Community, - *class, - crate::nip_fi_shadow::UNRESOLVED, + ( + crate::nip_fi_shadow::WouldDeny::new( + crate::nip_fi_shadow::Stage::Community, + *class, + crate::nip_fi_shadow::UNRESOLVED, + ), + AuthResult::Failed("community_unresolved"), ) })?; crate::nip_fi_core::evaluate_attached_assertion( @@ -237,7 +275,12 @@ fn nip_fi_guard_steps( state.nip_fi_verifier.as_deref(), ) .map(drop) - .map_err(crate::nip_fi_shadow::WouldDeny::assertion) + .map_err(|rejection| { + ( + crate::nip_fi_shadow::WouldDeny::assertion(rejection), + rejection.auth_result(), + ) + }) } /// Paths the assertion guard never checks. @@ -491,6 +534,10 @@ pub fn build_router(state: Arc) -> Router { nip_fi_assertion_guard, )) .layer(middleware::from_fn(track_metrics)) + .layer(middleware::from_fn_with_state( + state.clone(), + crate::access::http_access_log, + )) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } @@ -508,6 +555,23 @@ fn make_http_span(request: &Request) -> tracing::Span { ) } +/// Whether the SPA fallback answers `req` with a document or static file +/// (whatever the status), under the same host and config conditions it +/// serves them. Only meaningful for a request that matched no route. +pub(crate) fn is_document_request(state: &AppState, req: &Request) -> bool { + let path = req.uri().path(); + if api::admin::is_admin_host(state, req.headers()) { + let admin_bundle = state + .config + .admin + .as_ref() + .is_some_and(|config| config.web_dir.is_some()); + return admin_bundle && (is_admin_static_path(path) || is_admin_spa_path(path)); + } + state.config.web_dir.is_some() + && (path.starts_with("/assets/") || should_serve_spa(path, state.config.serve_git_web_gui)) +} + fn is_admin_spa_path(path: &str) -> bool { path == "/" || path == "/reports" @@ -592,6 +656,7 @@ pub fn build_health_router(state: Arc) -> Router { /// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise. async fn nip11_or_ws_handler( State(state): State>, + slot: crate::access::AccessSlot, headers: HeaderMap, req: axum::extract::Request, ) -> impl IntoResponse { @@ -600,6 +665,7 @@ async fn nip11_or_ws_handler( .get::>() .map(|ci| ci.0) .unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 0))); + let client_ip = crate::access::client_ip(&headers, state.config.client_ip_header.as_ref()); let accept = headers .get("accept") @@ -681,7 +747,7 @@ async fn nip11_or_ws_handler( let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); - match check_nip_fi_at_upgrade(Root, &headers, communities, verifier, mode) + match check_nip_fi_at_upgrade(&slot, Root, &headers, communities, verifier, mode) .await .into_assertions() { @@ -780,7 +846,7 @@ async fn nip11_or_ws_handler( let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); // Resolved after every await of this upgrade: nothing to re-check. - match check_nip_fi_at_upgrade(Root, &headers, communities, verifier, mode) + match check_nip_fi_at_upgrade(&slot, Root, &headers, communities, verifier, mode) .await .into_assertions() { @@ -822,6 +888,7 @@ async fn nip11_or_ws_handler( socket, state, addr, + client_ip, tenant, nip_fi_assertion, shadow, diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 5c4c55e108..e6d492c60a 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -50,6 +50,9 @@ pub(crate) enum CommunityDisconnectReason { AuthorizationDenied, /// The authenticated pubkey lost access to the community (e.g. a ban). AccessRevoked, + /// Root-socket ban or roster removal. Its `OK false` frame carries the + /// reason, so the close stays bare. + Removed, } impl CommunityDisconnectReason { @@ -71,6 +74,7 @@ impl CommunityDisconnectReason { code: axum::extract::ws::close_code::POLICY, reason: WsUtf8Bytes::from_static("access revoked"), })), + Self::Removed => WsMessage::Close(None), } } } @@ -110,6 +114,8 @@ pub(crate) struct CommunityConnectionControl { nip_fi_shadow: Arc>>, /// A shadow upgrade would-deny's line, owned by the connection task. nip_fi_line: Arc>>, + /// The upgrade request's configured client-IP header value. + client_ip: Option, } impl CommunityConnectionControl { @@ -124,9 +130,19 @@ impl CommunityConnectionControl { owner: Arc::default(), nip_fi_shadow: Arc::default(), nip_fi_line: Arc::default(), + client_ip: None, } } + /// This socket, upgraded by a request whose configured header gave `client_ip`. + pub(crate) fn with_client_ip(self, client_ip: Option) -> Self { + Self { client_ip, ..self } + } + + pub(crate) fn client_ip(&self) -> Option { + self.client_ip + } + /// Carries the socket's shadow session to its AUTH handler, fenced by /// the socket's cancellation so it records nothing once cancelled. pub(crate) fn attach_nip_fi_shadow( @@ -222,11 +238,12 @@ impl CommunityConnectionControl { /// First-writer-wins `authorization_denied` transition shared by every /// NIP-FI denial writer: under the transition lock, publishes - /// `AuthorizationDenied` only if no reason is set yet, and only the winner - /// enqueues `route`'s denial frame — on `frame_tx`, else on the sender - /// registered by `set_terminal_frame_sender`. Every writer therefore - /// yields the same single frame plus the reason's 1008 close. - /// Does not cancel. [FI-TRACE-DENIAL-ORACLE] + /// `AuthorizationDenied` only if no reason is set yet or the slot holds the + /// weak `Removed` claim (which sent no denial of its own), and only the + /// winner enqueues `route`'s denial frame — on `frame_tx`, else on the + /// sender registered by `set_terminal_frame_sender`. Every writer therefore + /// yields the same single frame plus the reason's 1008 close. Does not + /// cancel. [FI-TRACE-DENIAL-ORACLE] fn publish_authorization_denied( &self, route: crate::nip_fi_session::NipFiWsRoute, @@ -237,7 +254,7 @@ impl CommunityConnectionControl { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let won = self.reason_tx.send_if_modified(|current| match current { - None => { + None | Some(CommunityDisconnectReason::Removed) => { *current = Some(CommunityDisconnectReason::AuthorizationDenied); true } @@ -285,6 +302,29 @@ impl CommunityConnectionControl { self.cancel.cancel(); } + /// Whether no disconnect has chosen this socket's terminal response yet. + /// Read under the slot's lock, so it orders against every reason writer: + /// a login that sees the slot empty commits before any of them. + pub(crate) fn is_open(&self) -> bool { + let mut open = false; + self.reason_tx.send_if_modified(|current| { + open = current.is_none(); + false + }); + open + } + + /// Claims the slot for a ban or removal unless a reason is already set. + /// The claim is weak: a later NIP-FI denial still takes the slot. + fn claim_removed(&self) { + self.reason_tx.send_if_modified(|current| { + current.is_none() && { + *current = Some(CommunityDisconnectReason::Removed); + true + } + }); + } + fn disconnect_community(&self, reason: CommunityDisconnectReason) { self.reason_tx.send_replace(Some(reason)); self.cancel.cancel(); @@ -915,6 +955,9 @@ impl ConnectionManager { let _ = entry .ctrl_tx .try_send(WsMessage::Text(frame.clone().into())); + // Claimed before cancel so a login committing concurrently either + // finds the slot taken or wins and is then closed by this cancel. + entry.community_control.claim_removed(); entry.cancel.cancel(); closed += 1; } @@ -3559,6 +3602,7 @@ pub(crate) mod tests { let bp = Arc::new(AtomicU8::new(0)); let conn = ConnectionState { + refusal_budget: Default::default(), conn_id, tenant: buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 5e1158c4cf..1f0af77dd9 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -54,7 +54,11 @@ impl TraceContextLookup { /// Build a JSON formatter backed by this subscriber dispatch lookup. pub fn json_formatter(&self, enabled: bool) -> TraceContextJson { TraceContextJson { - inner: tracing_subscriber::fmt::format().json().flatten_event(true), + inner: tracing_subscriber::fmt::format() + .json() + .flatten_event(true) + // `spans` already ends with the current span. + .with_current_span(false), enabled, context_lookup: self.clone(), } @@ -181,9 +185,13 @@ where /// /// This is intentionally independent from `RUST_LOG`: changing stdout log /// verbosity must not remove parent spans from exported traces. Set -/// `BUZZ_OTEL_FILTER` to override the default targets. +/// `BUZZ_OTEL_FILTER` to override the default targets. Access lines +/// (`buzz_relay::access`) are off by default: they already reach stdout, and +/// exporting them again as span events would duplicate every pubkey. pub fn otel_filter(configured: Option<&str>) -> DirectiveFilter { - directive_filter(configured.unwrap_or("buzz_relay=info,buzz_datastore=info")) + directive_filter( + configured.unwrap_or("buzz_relay=info,buzz_relay::access=off,buzz_datastore=info"), + ) } /// A per-layer filter built from `RUST_LOG`-style directives. @@ -455,6 +463,8 @@ mod tests { .map(|line| serde_json::from_str(line).unwrap()) .collect(); assert_eq!(logs.len(), 11); + // The current span appears once, as the last entry of `spans`. + assert!(logs.iter().all(|log| log.get("span").is_none()), "{logs:?}"); assert_eq!(logs[0]["message"], "root event"); assert_eq!(logs[0]["answer"], 42); diff --git a/crates/buzz-search/src/error.rs b/crates/buzz-search/src/error.rs index f608b03b9b..bde59fc4cd 100644 --- a/crates/buzz-search/src/error.rs +++ b/crates/buzz-search/src/error.rs @@ -21,4 +21,11 @@ impl SearchError { SearchError::Db(sqlx::Error::Database(db)) if db.code().as_deref() == Some("57014") ) } + + /// A fixed description of this error for log lines: never the message, + /// which can repeat the search text. + pub fn kind(&self) -> String { + let SearchError::Db(e) = self; + buzz_db::error::sqlx_error_kind(e) + } } diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index 70a3a61790..25e2294cf5 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -29,6 +29,7 @@ reqwest = { workspace = true, optional = true } [dev-dependencies] sqlx = { workspace = true } +tracing-subscriber = { workspace = true } [features] reqwest = ["dep:reqwest"] diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs index b563f51ebb..f2ce45a286 100644 --- a/crates/buzz-workflow/src/error.rs +++ b/crates/buzz-workflow/src/error.rs @@ -88,6 +88,20 @@ impl WorkflowError { Self::NotImplemented(_) => "action_not_implemented", } } + + /// Text safe for logs. A condition error can quote message text through + /// the expression context, a webhook error can carry a secret webhook + /// URL, and a database error can repeat bound values, so those log as + /// their code; other errors keep their diagnostics. The run record still + /// stores the full `Display`. + pub fn log_text(&self) -> std::borrow::Cow<'_, str> { + match self { + Self::ConditionError(_) | Self::WebhookError(_) | Self::Database(_) => { + self.code().into() + } + other => other.to_string().into(), + } + } } impl From for WorkflowError { @@ -112,6 +126,11 @@ mod tests { let webhook = WorkflowError::WebhookError("secret-bearing detail".to_owned()); assert_eq!(webhook.code(), "webhook_failed"); assert!(!webhook.code().contains("secret-bearing detail")); + assert_eq!(webhook.log_text(), "webhook_failed"); + assert_eq!( + WorkflowError::Database("value \"secret\"".to_owned()).log_text(), + "database_error" + ); assert_eq!( WorkflowError::NotImplemented("SendDm".to_owned()).code(), diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 90a6a02e02..0fe33033bb 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -1205,7 +1205,12 @@ async fn execute_steps( continue; } Err(e) => { - warn!(run_id = %run_id, step = %step.id, "Condition error: {e}"); + warn!( + run_id = %run_id, + step = %step.id, + error_kind = e.code(), + "Condition error" + ); let progress = crate::error::PartialProgress { step_index: i, trace, diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 36a9bd9ea9..9dabc5732a 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -329,7 +329,7 @@ impl WorkflowEngine { } } Err((e, progress)) => { - tracing::error!(run_id = %run_id, "Workflow run failed: {e}"); + tracing::error!(run_id = %run_id, error = %e.log_text(), "Workflow run failed"); let mut full_trace = prefix; full_trace.extend(progress.trace); let trace_json = serde_json::Value::Array(full_trace); @@ -996,7 +996,8 @@ async fn should_fire_workflow( Err(e) => { tracing::warn!( workflow_id = %workflow_id, - "Trigger filter error: {e} — skipping workflow" + error_kind = e.code(), + "Trigger filter error — skipping workflow" ); return false; } @@ -2492,4 +2493,129 @@ steps: .await .expect("drop failing restriction schema"); } + + /// Condition errors quote the message they evaluated. The trigger-filter + /// WARN, the step-condition WARN and the run-failure ERROR log a fixed + /// code; the run keeps the full diagnostic for its owner. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn condition_errors_log_a_fixed_code_without_message_text() { + #[derive(Clone, Default)] + struct Captured(Arc>>); + impl std::io::Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel_id) = setup_channel(&db, &creator, &member).await; + let mut workflow_ids = Vec::new(); + for (name, trigger, steps) in [ + ( + "filter-error", + serde_json::json!({"on": "message_posted", "filter": "trigger_text > 5"}), + serde_json::json!([{"id": "s1", "action": "send_message", "text": "hi"}]), + ), + ( + "step-error", + serde_json::json!({"on": "message_posted"}), + serde_json::json!([{ + "id": "guarded", "if": "trigger_text > 5", + "action": "send_message", "text": "hi", + }]), + ), + ] { + let def = serde_json::json!({ + "name": name, "trigger": trigger, "steps": steps, "enabled": true, + }) + .to_string(); + let hash = [workflow_ids.len() as u8; 32]; + let id = db + .create_workflow(community, Some(channel_id), &member, name, &def, &hash) + .await + .expect("create workflow"); + workflow_ids.push(id); + } + let (filter_wf, step_wf) = (workflow_ids[0], workflow_ids[1]); + + let marker = format!("wf-marker-{}", Uuid::new_v4().simple()); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), &marker) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign"); + let event = buzz_core::StoredEvent::new(event, Some(channel_id)); + + let out = Captured::default(); + let writer = out.clone(); + let _guard = tracing::subscriber::set_default( + tracing_subscriber::fmt() + .json() + .flatten_event(true) + .with_max_level(tracing::Level::DEBUG) + .with_writer(move || writer.clone()) + .finish(), + ); + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + engine.on_event(community, &event).await.expect("on_event"); + let run = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let runs = db + .list_workflow_runs(community, step_wf, 10) + .await + .expect("list runs"); + if let Some(run) = runs.into_iter().find(|r| r.status == RunStatus::Failed) { + break run; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("the step-condition run must fail"); + let filter_runs = db + .list_workflow_runs(community, filter_wf, 10) + .await + .expect("list filter runs"); + assert!( + filter_runs.is_empty(), + "a failed filter must skip the workflow" + ); + + let raw = String::from_utf8(out.0.lock().unwrap().clone()).unwrap(); + let lines: Vec = raw + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let line = |message: &str| { + lines + .iter() + .find(|l| l["message"] == message) + .unwrap_or_else(|| panic!("no {message:?} line in {raw}")) + }; + assert_eq!( + line("Trigger filter error — skipping workflow")["error_kind"], + "condition_evaluation_failed" + ); + assert_eq!( + line("Condition error")["error_kind"], + "condition_evaluation_failed" + ); + assert_eq!( + line("Workflow run failed")["error"], + "condition_evaluation_failed" + ); + assert!(!raw.contains(&marker), "marker leaked: {raw}"); + assert!( + run.error_message + .as_deref() + .is_some_and(|m| m.contains(&marker)), + "the stored run error keeps the diagnostic: {:?}", + run.error_message + ); + } } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 02dc94039d..4706d13e2e 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -369,6 +369,9 @@ run_unit_tests() { local nip_fi_exact_tests=( audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge + connection::tests::startup_cancellation_writes_one_close_line + connection::tests::connection_limit_refusal_writes_one_close_line + connection::tests::dropped_authenticated_handler_writes_one_close_line audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta audio::room::tests::b2_commit_peer_emits_exactly_one_joined_delta_and_marks_visible audio::room::tests::b3_commit_peer_revision_is_monotone_between_concurrent_events