From 0a06c0b3cb1741447c122a4f2275ca4ee541014b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 3 Nov 2026 17:48:59 -0500 Subject: [PATCH] feat(relay): log reads, logins and connections on access target (#8376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Adds an access log for relay reads, logins, connections and HTTP requests on the dedicated `buzz_relay::access` target. It writes to stdout, not `audit_log`, so reads never take the audit advisory lock. The log is shaped for volume: one line per HTTP request, one line per WebSocket read, and one line per WebSocket connection. ## Lines per operation | Operation | Lines | |---|---| | HTTP request on a matched route | 1 (`http`), except the skip list below | | HTTP request matching no route | 1 (`http`, `route=""`) at status 400 and above, otherwise none | | WebSocket connection | 1 (`close`, carrying the login) | | WebSocket `REQ` / `COUNT` / NIP-AR sync | 1 each (`req` / `count` / `sync`), plus the `buzz_ws_reads_total` counter | | WebSocket NIP-42 login | 1 (`auth`) per decided or cut-off attempt, capped after the decision as below | | Audio login | 1 (`auth`, at its final admission check) | | WebSocket `EVENT` | 1 (`Event ingested`, not on the access target) | ## HTTP requests One middleware, `http_access_log`, writes every HTTP line. It sits between `track_metrics` and `http_trace_layer`, so rebuilt errors, body-limit refusals and localhost refusals all reach it. It writes the line when the response is ready, which is not proof the body was delivered. A request dropped before then logs `request=aborted` at WARN with no `status`. - Every line carries `route` (the matched route template, never the raw path), `peer_addr` when the listener has one, `status`, `time_to_response_ms` and `http_method` (the HTTP verb). The verb stays on the line itself because the `http.request` span is absent under an access-only filter and when a pending request is dropped. - Handlers record what they learn on the request's `AccessSlot`: the login (`method`, `pubkey`, `pubkey_verified`, `community`), the decision, the filter summary, and the route's own fields. The decision is `outcome` (`success` / `denied` / `failed` / `challenged`) with a fixed `reason`, or `undecided` when the route recorded none. A refusal is final: a later admission doesn't replace it, so a replayed NIP-98 proof that verifies and is then refused logs `failed` / `nip98_replay`. - `outcome` describes the login. What the route did is in `status` and the route's fields: a rejected `/events` write logs `outcome=success` with `status=400`, `accepted=false` and its `reject_reason`. - Route fields: - `/query` and `/count`: the filter summary and `result_count`. An incremental `/query` response (`{changes:[...]}`) counts its changes, removals included. Artifact reads record their summary before the query runs, so a read dropped mid-query keeps it. - `/events`: `accepted`, `kind`, `event_id`, and on a refused write the parse `category` / `line` / `column` or a fixed `reject_reason` (such as `invalid` or `restricted`), never the ingest message. - Invite mint: `invite_id`, `expires_at` and `max_uses`. Invite claim: `joined`. - Admin feedback attachment reads: `feedback_id` and `attachment_sha256`. With `BUZZ_ADMIN_AUTH=disabled` they log `method=network_trusted` with no key, and Host or Origin refusals log their fixed reason. - Every other route logs only the login fields. - No field can hold an invite code, a token, or a Blossom read hash. Media routes log the `/media/{sha256_ext}` template. - Skip list: NIP-11 and other non-upgrade requests on `/`, `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`, `/_readiness`, and the SPA and static files the relay serves when enabled (web assets, invite landing pages, git web pages, the admin bundle) at any status. Successful WebSocket upgrades (101) log no `http` line. Refusals before the upgrade on `/` and the audio path log one, with the status actually sent. - Routes that aren't Nostr-key logins (the command-token disconnect endpoint, the workflow webhook, the git server's internal callback) log `outcome=undecided` with no key. - Dev-mode `X-Pubkey` requests log `method=x_pubkey`, `pubkey_verified=false`. - A successful `/query` or `/count` that returned nothing (`result_count=0`) logs at DEBUG: on staging, 93% of `/query` lines were empty polls. Denied, failed, aborted and error reads, reads with no `result_count`, writes and logins keep their levels. - Other successful lines and git's credential-less first request (`challenged`, with its 401) are INFO. Refusals, statuses of 400 and above, and aborted requests are WARN. In Enforce mode the line records the remapped status (for example 403). Denial bodies and headers are unchanged. ## WebSocket reads - `REQ` / `COUNT` / `sync` lines carry `pubkey`, `conn_id`, `sub_id`, the filter summary, the outcome (`served` / `denied` / `aborted` / `error` plus a fixed reason), and `events_queued` (REQ and sync) or `count` (a COUNT that counted). A `REQ` is `served` only once its EOSE is queued to the client. A read logs WARN only when it ends in `error`. `served`, `denied` and `aborted` are INFO, since `aborted` means the read ended before completing: the client disconnected or sent CLOSE, a same-ID `REQ` replaced it, or the session was cancelled. - A NIP-AR sync subscription writes its `sync` line when its initial set completes, not when live delivery ends. Its summary is the registered live filter (kinds and channels, never artifact predicates), and `events_queued` counts the initial set. - `REQ` lines stay at INFO even when empty at EOSE, because the subscription can still deliver live events later. - Filter summary, shared with HTTP `/query` and `/count`: `kinds`, channel ids, authors and `p` values each list the first 10 distinct values. The exact `kind_count` / `channel_count` / `author_count` / `p_count` (summed per filter) appears only when the list is `*` or shows fewer values than the filters held. A dimension that any filter leaves open logs `*`. `since` / `until` / `limit` take the widest bound and are absent when any filter leaves them open, so no field understates what the read covered. `ids` is a count only. NIP-50 search logs `search: true`, never the text. Only well-formed channel ids and pubkeys are listed. An artifact read's summary uses only the standard dimensions of each raw filter, so artifact predicates and client-defined tags (such as `#1`) are never logged. The summary is skipped when the access target is off. Under `=warn`, every WARN line, HTTP or WebSocket, still carries it. - Per connection, only the first 16 reads that are refused before they run (`auth_required`, quota, concurrency, overload) or denied are logged. The rest are counted as `reads_suppressed` on the close line. A refusal that arrives after the close line has been written, from a read still running when the client left, logs its own line, so every refusal is either logged or counted, never both. Served reads are never capped. ## WebSocket read counter `buzz_ws_reads_total{type="req"|"count"|"sync"}` counts every parsed `REQ`, `COUNT` and NIP-AR sync frame before admission, exported at zero from startup. It is a metric, so it counts at any `RUST_LOG`, including production's current `error`. ## WebSocket logins A NIP-42 login writes one `auth` line when it is decided. A successful login logs INFO `outcome=success` at its commit point, decided under the connection's close-reason lock, so a disconnect racing the login (session expiry, admin disconnect, revocation, ban or roster removal) either wins, and the login logs no success, or loses and closes the connection right after the success line. A refused or failed login writes one WARN `auth` line with `outcome`, a fixed `reason` from the shared `AuthReason` code set, `pubkey` and `pubkey_verified`. Root WebSocket `auth` lines carry `peer_addr`. AUTH frames after the login is decided are capped at 16 per connection, and the rest are counted as `auth_suppressed`. An attempt cut off by close or session expiry logs `aborted`. Audio logins log one `auth` line at their final admission check. ## Connections The close line is the connection's only line. It is owned by the upgraded socket, so it is written exactly once however the connection ends: a normal close, a refusal while checking the community is active (`refused=not_admitted`), the connection limit (`refused=connection_limit`), or the handler being dropped. It carries `conn_id`, `community`, `peer_addr`, `duration_ms`, the login, `events_queued` / `bytes_queued` (frames the writer accepted and flushed, not client delivery), and the two suppression counters. ## Error text - Database errors log as `DbError::kind()` (SQLSTATE or variant), including the command-executor event insert. Search errors, on both read paths and in the search-cancel task, log as `SearchError::kind()` / the same SQLx classifier. Workflow database and webhook errors log as their codes, since webhook URLs can be secrets. - Git NIP-98 failures log a fixed code. Git subprocess stderr moved to DEBUG because hook output can contain message text. ## Operator notes `.env.example` documents the target, its `event` values (`http`, `auth`, `req`, `count`, `sync`, `close`), the skip list, the levels, and how to turn it down: `buzz_relay::access=warn` keeps refusals, errors and aborted HTTP requests, and of WebSocket reads only those that end in `error`, and `=off` drops everything. It also lists the removed lines: `NIP-42 auth successful/failed`, `WebSocket connection established/closed`, `event=connect`, `HTTP bridge request`, `Event ingested via pipeline`, `relay invite minted`, `relay member added via invite`, `admin feedback attachment read`, and `addr` (now `peer_addr`). JSON lines no longer repeat the current span as `span`, since `spans` already ends with it. A line logged under an explicit `parent:` span loses that parent's fields. The default OTel export filter sets `buzz_relay::access=off`, so access lines aren't exported a second time as span events. `peer_addr` is the TCP peer. `BUZZ_CLIENT_IP_HEADER` optionally names a request header (for example `cf-connecting-ip` behind Cloudflare or `x-real-ip` behind nginx, matched case-insensitively). When set, `http`, `auth` (root and audio), `close` and connection-limit lines also log `client_ip`: the rightmost comma-separated entry of the header's last field, only if it parses as an IP address. It never replaces `peer_addr`, and it is only trustworthy when every request arrives through a proxy that overwrites the header. --------- Signed-off-by: Duncan Signed-off-by: Will Pfleger Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .env.example | 75 +- .github/workflows/_ci-relay.yml | 12 + Cargo.lock | 1 + Justfile | 2 +- crates/buzz-db/src/error.rs | 38 + crates/buzz-relay/src/access.rs | 2023 +++++++++++++++++ crates/buzz-relay/src/api/admin/auth.rs | 414 +++- crates/buzz-relay/src/api/admin/direct.rs | 50 +- crates/buzz-relay/src/api/admin/mod.rs | 225 +- crates/buzz-relay/src/api/admin/reads.rs | 12 +- crates/buzz-relay/src/api/artifact.rs | 25 +- .../sync_access_postgres_tests.rs | 94 + crates/buzz-relay/src/api/bridge.rs | 1769 ++++++++++---- crates/buzz-relay/src/api/buzz_v1/auth.rs | 67 +- crates/buzz-relay/src/api/buzz_v1/handlers.rs | 6 +- .../src/api/buzz_v1/postgres_tests.rs | 16 +- crates/buzz-relay/src/api/gifs.rs | 16 +- crates/buzz-relay/src/api/git/settings.rs | 60 +- crates/buzz-relay/src/api/git/transport.rs | 352 ++- crates/buzz-relay/src/api/invites.rs | 301 ++- .../buzz-relay/src/api/invites/claim_auth.rs | 28 +- .../postgres_tests/owner_admission_tests.rs | 7 + .../postgres_tests/restriction_tests.rs | 44 +- crates/buzz-relay/src/api/media.rs | 311 ++- crates/buzz-relay/src/api/mod.rs | 108 +- crates/buzz-relay/src/api/nip_fi.rs | 24 +- crates/buzz-relay/src/api/operator.rs | 101 +- crates/buzz-relay/src/api/profiles.rs | 8 +- crates/buzz-relay/src/api/workflows.rs | 49 +- crates/buzz-relay/src/audio/handler.rs | 295 ++- crates/buzz-relay/src/config.rs | 33 +- crates/buzz-relay/src/connection.rs | 1285 ++++++++++- .../buzz-relay/src/handlers/artifact_sync.rs | 52 +- crates/buzz-relay/src/handlers/auth.rs | 111 +- .../src/handlers/command_executor.rs | 4 +- crates/buzz-relay/src/handlers/count.rs | 27 +- crates/buzz-relay/src/handlers/event.rs | 7 + .../handlers/group_state_postgres_tests.rs | 1 + crates/buzz-relay/src/handlers/ingest.rs | 24 +- .../src/handlers/moderation_notices.rs | 3 +- crates/buzz-relay/src/handlers/req.rs | 185 +- .../buzz-relay/src/handlers/side_effects.rs | 23 +- crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/main.rs | 4 + crates/buzz-relay/src/metrics.rs | 49 + crates/buzz-relay/src/nip_fi_core.rs | 21 + crates/buzz-relay/src/nip_fi_http.rs | 255 ++- .../src/nip_fi_resolver/tests/router_tests.rs | 9 +- crates/buzz-relay/src/nip_fi_session.rs | 3 +- crates/buzz-relay/src/nip_fi_shadow.rs | 1 + crates/buzz-relay/src/nip_fi_test_hooks.rs | 33 + crates/buzz-relay/src/nip_fi_upgrade.rs | 62 +- crates/buzz-relay/src/rejection.rs | 45 +- .../buzz-relay/src/rejection/quota_tests.rs | 22 +- crates/buzz-relay/src/router.rs | 100 +- crates/buzz-relay/src/state.rs | 56 +- crates/buzz-relay/src/telemetry.rs | 16 +- crates/buzz-search/src/cancel.rs | 4 +- crates/buzz-search/src/error.rs | 7 + crates/buzz-workflow/Cargo.toml | 1 + crates/buzz-workflow/src/error.rs | 19 + crates/buzz-workflow/src/executor.rs | 7 +- crates/buzz-workflow/src/lib.rs | 130 +- scripts/run-tests.sh | 45 + 64 files changed, 8142 insertions(+), 1037 deletions(-) create mode 100644 crates/buzz-relay/src/access.rs diff --git a/.env.example b/.env.example index 6b318519ab..3c8992a725 100644 --- a/.env.example +++ b/.env.example @@ -240,7 +240,80 @@ 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`, `sync`, 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` / `sync` is one line per WebSocket read (a NIP-AR sync +# subscription is a `sync` read), with the same filter summary +# (search text is never logged, only `search: true`). A `sync` line is written +# when its initial set completes, not when live delivery ends: its summary is +# the registered live filter (kinds and channels, never artifact predicates) +# and `events_queued` counts the initial set. `buzz_ws_reads_total` counts +# parsed reads by `type` (`req`, `count`, `sync`). The summary lists up to +# 10 kinds, channel ids, authors and `p` values each; `kind_count`, +# `channel_count`, `author_count` and `p_count` appear only when the list is +# `*` or shows fewer values than the filters held. `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 successful `/query` or `/count` that returned nothing +# (`result_count=0`) is DEBUG, since polling clients make most of them. A +# WebSocket read is WARN only when it ends in `error`; +# refused and `aborted` reads stay INFO, since `aborted` means the read ended +# before completing: the client closed the connection or sent CLOSE, a same-ID +# REQ replaced it, or the session was cancelled, all of which are 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 35a97012b6..9f8736bbfe 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::dispatcher_counts_each_req_and_count_frame_once) + test(=handlers::req::tests::unqueued_eose_logs_aborted_not_served) + test(=api::bridge::postgres_tests::reject_code_keeps_only_a_known_prefix) + test(=api::bridge::postgres_tests::nip98_replay_check_fails_closed_when_guard_errors) + test(=api::bridge::postgres_tests::verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host) + test(=api::bridge::postgres_tests::verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints) + test(=api::bridge::postgres_tests::verify_bridge_auth_accepts_nip98_event_signed_for_matching_host) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_verifies_with_matching_query) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_rejected_against_bare_path) + test(=api::bridge::postgres_tests::moderation_read_audit_query_bearing_nip98_event_verifies) + test(=api::bridge::postgres_tests::moderation_read_restricted_no_query_still_verifies) + test(=connection::tests::aborting_lifecycle_owner_is_drop_safe) + test(=connection::tests::shutdown_terminalizes_auth_stalled_on_database_before_late_success) + test(=connection::terminal_close_delivery_tests::upgrade_would_deny_line_without_auth_logs_absent_at_close) + test(=handlers::event::tests::observer_owner_cache_is_scoped_to_community) + test(=state::tests::shared_counter_between_direct_and_fanout) + 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(=api::bridge::moderation_refusal_tests::moderation_lookup_failure_is_unavailable) + 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..e71c9f21e9 --- /dev/null +++ b/crates/buzz-relay/src/access.rs @@ -0,0 +1,2023 @@ +//! 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. +//! A successful bridge read that returned nothing (`result_count=0`) logs at +//! DEBUG: polling clients make most of them. WebSocket `req` lines stay at +//! INFO, because an empty subscription can still deliver live events. +//! +//! 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; a longer list adds its `*_count`. +const LIST_CAP: usize = 10; + +macro_rules! emit { + ($level:expr, $($fields:tt)+) => { + match $level { + Level::WARN => tracing::warn!(target: TARGET, $($fields)+), + Level::INFO => tracing::info!(target: TARGET, $($fields)+), + _ => tracing::debug!(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, and +/// are absent when the list already shows that many values. 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: Option, + channel_ids: Option, + channel_count: Option, + authors: Option, + author_count: Option, + p_tags: Option, + p_count: Option, + 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, unless the list shows exactly that many values, 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) -> (Option, Option) { + let count = (open || self.seen != self.listed.len()).then_some(self.seen); + let shown = if open { + Some(OPEN.to_owned()) + } else { + (!self.listed.is_empty()) + .then(|| self.listed.iter().map(show).collect::>().join(",")) + }; + (count, 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, COUNT or sync, written when the handler +/// finishes (a sync's initial set completes), 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, + NotRelayAdmin, + Restricted, + 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::NotRelayAdmin => "not_relay_admin", + Self::Restricted => "restricted", + 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: Detail, +} + +/// What a route did, beyond its auth decision. Each route sets only its own +/// fields; invite codes, tokens and Blossom hashes have no field. +#[derive(Debug, Default)] +pub(crate) struct Detail { + /// A bridge read: events returned, or the count a `/count` answered. + pub(crate) result_count: Option, + /// A bridge write: whether it was stored, and the event's kind and id. + pub(crate) accepted: Option, + pub(crate) kind: Option, + pub(crate) event_id: Option, + /// A bridge 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>, + /// A minted invite: its id, expiry, and use limit (absent: unlimited). + pub(crate) invite_id: Option, + pub(crate) expires_at: Option, + pub(crate) max_uses: Option, + /// An invite claim: whether it added the member (`false`: already one). + pub(crate) joined: Option, + /// An admin feedback attachment read. + pub(crate) feedback_id: Option, + pub(crate) attachment_sha256: Option, +} + +/// 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 = 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 + }; + // A served read that returned nothing is routine polling. + let level = if level == Level::INFO && note.detail.result_count == Some(0) { + Level::DEBUG + } else { + level + }; + let pubkey = note.pubkey.as_ref().map(PublicKey::to_hex); + let s = note.summary.as_ref(); + let d = ¬e.detail; + 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 = d.result_count, + accepted = d.accepted, + kind = d.kind, + event_id = d.event_id.as_deref(), + category = d.category, + line = d.line, + column = d.column, + reject_reason = d.reject_reason, + invite_id = d.invite_id.map(tracing::field::display), + expires_at = d.expires_at, + max_uses = d.max_uses, + joined = d.joined, + feedback_id = d.feedback_id.map(tracing::field::display), + attachment_sha256 = d.attachment_sha256.as_deref(), + filters = s.map(|s| s.filters), + kinds = s.and_then(|s| s.kinds.as_deref()), + kind_count = s.and_then(|s| s.kind_count), + channel_ids = s.and_then(|s| s.channel_ids.as_deref()), + channel_count = s.and_then(|s| s.channel_count), + authors = s.and_then(|s| s.authors.as_deref()), + author_count = s.and_then(|s| s.author_count), + p_tags = s.and_then(|s| s.p_tags.as_deref()), + p_count = s.and_then(|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() + .event_format(crate::telemetry::TraceContextLookup::default().json_formatter(false)) + .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 captured, using production's JSON formatter. + 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) + } + + /// Like [`capture_scoped`], with only the access target on, at `level`: + /// the HTTP span (target `buzz_relay`) is filtered out. + pub(crate) fn capture_access_only( + level: Level, + ) -> (tracing::subscriber::DefaultGuard, Captured) { + let out = Captured::default(); + let subscriber = json_subscriber(&out) + .with(tracing_subscriber::filter::Targets::new().with_target(TARGET, level)); + (tracing::subscriber::set_default(subscriber), 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}"); + } + + /// Only a served bridge read that returned nothing drops to DEBUG. + #[test] + fn empty_served_http_reads_log_at_debug() { + let level = |result_count, result, status| { + let (_, out) = capture(|| { + let slot = AccessSlot::default(); + slot.attempt(AuthMethod::Nip98, None, false); + slot.finish(result); + slot.detail(Detail { + result_count, + ..Default::default() + }); + match status { + Some(status) => slot.emit("/query", status), + None => slot.abandon("/query"), + } + }); + out.lines()[0]["level"].as_str().unwrap().to_owned() + }; + let denied = AuthResult::Denied(AuthReason::NotRelayMember); + assert_eq!(level(Some(0), AuthResult::Success, Some(200)), "DEBUG"); + assert_eq!(level(Some(3), AuthResult::Success, Some(200)), "INFO"); + assert_eq!(level(None, AuthResult::Success, Some(200)), "INFO"); + assert_eq!(level(Some(0), AuthResult::Success, Some(500)), "WARN"); + assert_eq!(level(Some(0), AuthResult::Success, None), "WARN"); + assert_eq!(level(Some(0), denied, Some(403)), "WARN"); + } + + #[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); + // `7` is in both filters: the list hides one value, so the count shows. + assert_eq!((s.kinds.as_deref(), s.kind_count), (Some("7,9,5"), Some(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)); + } + + /// Lists stop at [`LIST_CAP`] distinct values, and then carry the exact + /// count; cross-filter duplicates count twice. A list that shows every + /// value has no count. + #[test] + fn summary_caps_lists_and_counts_only_what_they_hide() { + let channels: Vec = (0..14).map(|_| Uuid::new_v4().to_string()).collect(); + let authors: Vec = (0..5_000).map(|_| pk()).collect(); + let kinds: Vec = (0..=u16::MAX).collect(); + let s = FilterSummary::new(&[ + filter(serde_json::json!({ + "#h": channels, "authors": authors, "kinds": kinds + })), + filter(serde_json::json!({"#h": [channels[0]], "authors": [authors[0]], "kinds": [1]})), + ]); + assert_eq!(s.channel_count, Some(15)); + assert_eq!(s.author_count, Some(5_001)); + assert_eq!(s.kind_count, Some(65_537)); + for list in [&s.channel_ids, &s.authors, &s.kinds] { + let listed: BTreeSet<&str> = list.as_deref().unwrap().split(',').collect(); + assert_eq!(listed.len(), LIST_CAP); + } + let s = FilterSummary::new(&[filter(serde_json::json!({"#p": [PK_A], "ids": [PK_A]}))]); + assert_eq!( + (s.p_tags.as_deref(), s.p_count, s.ids), + (Some(PK_A), None, 1) + ); + } + + #[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), + (Some(1), Some(1), Some(1), Some(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_ids"], Uuid::nil().to_string(), "{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!( + line["channel_ids"].is_string() && line.get("channel_count").is_none(), + "{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, Some(1))); + assert_eq!((s.p_tags, s.p_count), (None, Some(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..c949f89eb7 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, AuthReason, 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( @@ -404,22 +478,26 @@ pub enum AdminAccess { impl AdminAccess { /// The act check: a write needs a signed staff member. Role-specific - /// checks run on the returned principal. - pub fn act(self) -> Result { + /// checks run on the returned principal. A refusal is recorded on `slot`. + pub fn act(self, slot: &AccessSlot) -> Result { match self { Self::Staff(principal) => Ok(principal), - Self::NetworkTrusted => Err(ApiError::forbidden_with_message( - "this endpoint requires BUZZ_ADMIN_AUTH=nip98", - )), + Self::NetworkTrusted => { + slot.finish(AuthResult::Denied(AuthReason::NotStaff)); + Err(ApiError::forbidden_with_message( + "this endpoint requires BUZZ_ADMIN_AUTH=nip98", + )) + } } } /// The operator check: roster routes need a signed Operator. - pub fn operator(self) -> Result { - let principal = self.act()?; + pub fn operator(self, slot: &AccessSlot) -> Result { + let principal = self.act(slot)?; if principal.role == AdminRole::Operator { Ok(principal) } else { + slot.finish(AuthResult::Denied(AuthReason::NotOperator)); Err(ApiError::forbidden_with_message( "staffing endpoints require operator role", )) @@ -446,13 +524,18 @@ 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()); + let reason = if headers.contains_key(header::AUTHORIZATION) { + "duplicate_authorization" + } else { + "nip98_missing" + }; + return Err((ApiError::unauthorized(), reason)); }; let auth_str = value .to_str() @@ -490,8 +573,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 +584,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 +601,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 +643,9 @@ 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, AccessSlot, AdminAccess, AdminPrincipal, AdminRole, + AdminSource, AuthResult, ADMIN_API_PREFIX, BASE64, }; #[test] @@ -784,4 +874,198 @@ 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}"); + } + } + + // A role check that refuses after `authorize` admitted logs `denied` + // with its fixed reason, not the earlier `success`. + #[test] + fn admin_role_refusal_logs_denied() { + let moderator = AdminAccess::Staff(AdminPrincipal { + pubkey: [7; 32], + role: AdminRole::Moderator, + source: AdminSource::Config, + }); + for (access, operator, reason) in [ + (AdminAccess::NetworkTrusted, false, "not_staff"), + (moderator, true, "not_operator"), + ] { + let slot = AccessSlot::default(); + slot.finish(AuthResult::Success); + let (guard, out) = crate::access::tests::capture_scoped(); + let refused = if operator { + access.operator(&slot) + } else { + access.act(&slot) + }; + slot.emit("/api/admin/users", 403); + drop(guard); + assert!(refused.is_err()); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "denied", "{reason}"); + assert_eq!(lines[0]["reason"], reason); + } + } + + // Two Authorization headers are a duplicate, not a missing credential. + #[tokio::test(flavor = "current_thread")] + async fn admin_duplicate_authorization_logs_duplicate() { + 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 mut headers = axum::http::HeaderMap::new(); + headers.append(header::AUTHORIZATION, "Nostr a".parse().unwrap()); + headers.append(header::AUTHORIZATION, "Nostr b".parse().unwrap()); + let slot = AccessSlot::default(); + let (guard, out) = crate::access::tests::capture_scoped(); + let refused = authorize(&state, &slot, &headers, "/users", "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", "{}", lines[0]); + assert_eq!( + lines[0]["reason"], "duplicate_authorization", + "{}", + lines[0] + ); + } } diff --git a/crates/buzz-relay/src/api/admin/direct.rs b/crates/buzz-relay/src/api/admin/direct.rs index a490b75610..1dee125616 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,9 +150,9 @@ 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()?; + .act(&slot)?; let body: DirectBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; let target = decode_hex_pubkey(&target_hex)?; diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 296eba8fec..2728aa7351 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,34 @@ 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 { + feedback_id: Some(id), + attachment_sha256: Some(sha256), + ..Default::default() + }); + } + 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,14 +369,14 @@ 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()); } // Resolve the tenant from server-owned feedback provenance, then assert the // resolved row still agrees with the feedback FK. Client input never names // a community, host, object key, extension, or upstream URL. - let tenant = crate::tenant::bind_community(&*state, community_host) + let tenant = crate::tenant::bind_community(state, community_host) .await .map_err(|_| ApiError::not_found())?; if tenant.community().as_uuid() != &community_id { @@ -363,19 +389,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 +464,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,9 +476,9 @@ 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()?; + .act(&slot)?; let body: ResolveReportBody = serde_json::from_slice(&body_bytes) .map_err(|_e| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -666,6 +687,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,9 +696,9 @@ 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()?; + .act(&slot)?; let body: ReopenReportBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -745,15 +767,16 @@ 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()?; + .act(&slot)?; let body: CancelReportBody = serde_json::from_slice(&body_bytes) .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; @@ -814,15 +837,16 @@ 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()?; + .act(&slot)?; #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -869,13 +893,14 @@ 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()?; + .operator(&slot)?; let config = state .config @@ -944,15 +969,16 @@ 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()?; + .operator(&slot)?; // Canonicalize the path param once: validate it decodes to 32 bytes, then // lowercase it. Config-backed pubkeys are lowercased at parse, so the 409 @@ -1015,14 +1041,15 @@ 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()?; + .operator(&slot)?; // Canonicalize the path param once (validate + lowercase) so the 409 check // and the DB delete use the same form config-backed pubkeys are stored in; @@ -1202,12 +1229,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,15 +1267,16 @@ 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()?; + .act(&slot)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = community_for_host(&state, &query.community_host).await?; @@ -1280,15 +1309,16 @@ 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()?; + .act(&slot)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = community_for_host(&state, &query.community_host).await?; @@ -1583,6 +1613,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 +1764,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 +1775,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 +3082,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/artifact.rs b/crates/buzz-relay/src/api/artifact.rs index c41ce5d15d..7284563be8 100644 --- a/crates/buzz-relay/src/api/artifact.rs +++ b/crates/buzz-relay/src/api/artifact.rs @@ -8,28 +8,39 @@ use serde_json::Value; use std::sync::Arc; /// Serve explicit artifact queries; `None` leaves the request to the generic -/// path (including existing extensions such as `#buzz-channel`). +/// path (including existing extensions such as `#buzz-channel`). `routed` +/// runs once the request is known to be an artifact read, before any of its +/// work awaits, so a request dropped mid-read is still described. pub(super) async fn query( state: &Arc, tenant: &TenantContext, reader: &nostr::PublicKey, filters: &[Value], count: bool, + routed: impl FnOnce(), ) -> Option, (StatusCode, Json)>> { - let mut artifact = false; + let mut artifact = Ok(false); for filter in filters { match route_filter(filter) { FilterRoute::Generic => {} - FilterRoute::Artifact => artifact = true, + FilterRoute::Artifact => artifact = Ok(true), FilterRoute::Rejected(reason) => { - return Some(Err(api_error(StatusCode::BAD_REQUEST, reason))) + artifact = Err(reason); + break; } } } - if !artifact { - return None; + match artifact { + Ok(false) => None, + Ok(true) => { + routed(); + Some(execute(state, tenant, reader, filters, count).await) + } + Err(reason) => { + routed(); + Some(Err(api_error(StatusCode::BAD_REQUEST, reason))) + } } - Some(execute(state, tenant, reader, filters, count).await) } async fn execute( diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index 9834c6d9ec..89f08e9338 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -535,3 +535,97 @@ async fn incremental_unlisted_channels_require_membership_or_named_scope() { .await; assert_eq!(listed["changes"][0]["event"]["id"], event.id.to_hex()); } + +/// An incremental poll's access line counts its changes, removals included: +/// an empty poll is DEBUG and a poll with changes is INFO. Mutation: count only array responses in `query_events` β†’ the +/// empty poll has no `result_count` and logs INFO β†’ RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn incremental_poll_access_level_follows_its_change_count() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; + let poll = |types: &str| json!([{"artifact":"incremental","query":{"artifact":"current","#type":[types]}}]); + let line = |out: &crate::access::tests::Captured| { + let lines = out.lines(); + assert_eq!(lines.len(), 1, "one access line: {lines:?}"); + lines[0].clone() + }; + + let (_capture, out) = crate::access::tests::capture_scoped(); + let (status, body) = f.request("/query", poll("buzz.none")).await; + assert!(status.is_success(), "{status}: {body}"); + let empty = line(&out); + assert_eq!( + (&empty["level"], &empty["result_count"]), + (&"DEBUG".into(), &0.into()), + "{empty}" + ); + + let (_capture, out) = crate::access::tests::capture_scoped(); + let (status, body) = f.request("/query", poll("buzz.task")).await; + assert!(status.is_success(), "{status}: {body}"); + let served = line(&out); + assert_eq!( + (&served["level"], &served["result_count"]), + (&"INFO".into(), &1.into()), + "{served}" + ); +} + +/// A wrapped incremental read dropped during its database work still +/// writes its one WARN aborted line with the standard filter summary, and +/// without its artifact predicates. Mutation: record the artifact summary +/// after `artifact::query` returns β†’ the line has no `kinds` β†’ RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn dropped_incremental_read_keeps_its_filter_summary() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let marker = "private-predicate-marker"; + let body = json!([{"artifact":"incremental","query":{ + "artifact":"current","kinds":[45010],"#h":[f.home],"#project":[marker]}}]); + let mut lock = f.pool.begin().await.unwrap(); + sqlx::query("LOCK TABLE artifact_heads IN ACCESS EXCLUSIVE MODE") + .execute(&mut *lock) + .await + .unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *lock) + .await + .unwrap(); + let (capture, out) = crate::access::tests::capture_access_only(tracing::Level::WARN); + let mut request = Box::pin(f.request("/query", body)); + // Poll the request until its candidate read waits on the lock. + loop { + tokio::select! { + _ = &mut request => panic!("the read must wait on the locked table"), + () = tokio::time::sleep(std::time::Duration::from_millis(20)) => {} + } + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid)))", + ) + .bind(blocker_pid) + .fetch_one(&f.pool) + .await + .unwrap(); + if blocked { + break; + } + } + drop(request); + drop(capture); + lock.rollback().await.unwrap(); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "exactly one access line: {lines:?}"); + let line = &lines[0]; + assert_eq!( + (&line["request"], &line["level"]), + (&"aborted".into(), &"WARN".into()), + "{line}" + ); + assert_eq!(line["route"], "/query", "{line}"); + assert_eq!(line["kinds"], "45010", "{line}"); + assert_eq!(line["channel_ids"], f.home.to_string(), "{line}"); + assert!(!out.raw().contains(marker), "{}", out.raw()); +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index e65b517f43..faee10c54b 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::Detail) { + self.slot.detail(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 { @@ -1015,12 +1267,13 @@ pub async fn submit_event( // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(&state, &headers, "bridge"); 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, || { proof .record(verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -1032,81 +1285,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()) } @@ -1119,6 +1304,7 @@ enum SubmitOutcome { Ok { accepted: bool, kind: u32, + event_id: String, response: Json, }, /// JSON parse failure before ingest β€” log category/line/column, not msg. @@ -1134,23 +1320,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::Detail { + use crate::access::Detail; + match self { + SubmitOutcome::Ok { + accepted, + kind, + event_id, + .. + } => Detail { + accepted: Some(*accepted), + kind: Some(*kind), + event_id: Some(event_id.clone()), + ..Detail::default() + }, + SubmitOutcome::ParseFail { + category, + line, + column, + .. + } => Detail { + accepted: Some(false), + category: Some(category), + line: Some(*line), + column: Some(*column), + ..Detail::default() + }, + SubmitOutcome::Rejected { kind, reason, .. } => Detail { + accepted: Some(false), + kind: Some(*kind), + reject_reason: Some(reason), + ..Detail::default() + }, + SubmitOutcome::Err { .. } => Detail { + accepted: Some(false), + ..Detail::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), } } @@ -1166,25 +1390,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) { @@ -1225,7 +1441,7 @@ async fn submit_event_authed( token_owner, ) .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( @@ -1239,8 +1455,7 @@ async fn submit_event_authed( }), Err(e) => { return SubmitOutcome::Err { - status: e.status(), - response: e, + response: e.into_response(), }; } }; @@ -1291,10 +1506,8 @@ async fn submit_event_authed( } BanRefusal::Banned(BanSource::Own) => {} } - let refusal = MembershipRefusal::NipFi(class); return SubmitOutcome::Err { - status: refusal.status(), - response: refusal, + response: crate::nip_fi_core::http_denial(class), }; } other => other.map_err(IngestError::into_legacy), @@ -1303,22 +1516,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, @@ -1331,7 +1543,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, @@ -1343,8 +1555,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. @@ -1352,16 +1563,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(), } } } @@ -1373,6 +1582,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 { @@ -1404,12 +1614,13 @@ pub async fn query_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(&state, &headers, "bridge"); 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, || { proof .record(verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -1421,47 +1632,24 @@ 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; + // A generic or artifact read returns its events; an incremental read + // returns `{changes: [...]}`, removals included. + let returned = match &result { + Ok(Json(Value::Array(events))) => Some(events.len()), + Ok(Json(value)) => value.get("changes").and_then(Value::as_array).map(Vec::len), + Err(_) => None, + }; + if let Some(n) = returned { + caller.complete(crate::access::Detail { + result_count: Some(n as u64), + ..Default::default() + }); } Ok(result.into_response()) } @@ -1470,32 +1658,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,14 +1697,17 @@ 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. let raw_filters: 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_filters, false).await + let routed = || caller.read_artifact(&raw_filters); + if let Some(result) = + super::artifact::query(state, tenant, &pubkey, &raw_filters, false, routed).await { return result; } @@ -1520,6 +1717,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 +2272,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 { @@ -2104,12 +2303,13 @@ pub async fn count_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(&state, &headers, "bridge"); 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, || { proof .record(verify_bridge_auth_with_options( + &slot, &headers, "POST", &url, @@ -2121,45 +2321,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::Detail { + result_count: value.get("count").and_then(Value::as_u64), + ..Default::default() + }); } Ok(result.into_response()) } @@ -2168,32 +2340,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. @@ -2201,16 +2379,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 { + let routed = || caller.read_artifact(&raw); + if let Some(result) = super::artifact::query(state, tenant, &pubkey, &raw, true, routed).await { 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"))?; @@ -2943,6 +3124,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>, @@ -2970,44 +3152,49 @@ async fn authorize_moderation_read( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(state, headers, "bridge"); 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, || { proof .record(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, @@ -3015,8 +3202,8 @@ async fn authorize_moderation_read( crate::handlers::moderation_authz::ModerationTarget::None, crate::handlers::moderation_authz::ModerationAction::ViewQueue, ) - .await - .map_err(|_| { + .await; + caller.admit(moderator, moderation_refusal).map_err(|_| { api_error( StatusCode::FORBIDDEN, "restricted: moderator access required", @@ -3027,6 +3214,16 @@ async fn authorize_moderation_read( Ok(tenant) } +/// A moderation authority refusal: a failed role lookup is unavailable, +/// anything else is a real role refusal. +fn moderation_refusal(e: &anyhow::Error) -> AuthResult { + AuthResult::Denied(if e.is::() { + crate::access::AuthReason::AuthorizationUnavailable + } else { + crate::access::AuthReason::NotModerator + }) +} + /// Cap on rows returned by a single moderation read. const MODERATION_READ_LIMIT: i64 = 500; @@ -3047,11 +3244,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(), @@ -3087,11 +3286,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(), @@ -3122,10 +3323,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, }; @@ -3191,6 +3395,26 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { }) } +#[cfg(test)] +mod moderation_refusal_tests { + // A failed role lookup logs `authorization_unavailable`; a real role + // refusal logs `not_moderator`. + #[test] + fn moderation_lookup_failure_is_unavailable() { + use crate::access::{AuthReason, AuthResult}; + let lookup = anyhow::Error::from(buzz_db::DbError::NotFound("pool".into())); + assert_eq!( + super::moderation_refusal(&lookup), + AuthResult::Denied(AuthReason::AuthorizationUnavailable) + ); + let refused = anyhow::anyhow!("moderator access required"); + assert_eq!( + super::moderation_refusal(&refused), + AuthResult::Denied(AuthReason::NotModerator) + ); + } +} + #[cfg(test)] #[path = "artifact_postgres_tests.rs"] mod artifact_postgres_tests; @@ -3219,6 +3443,40 @@ 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!( + line["channel_ids"].is_string() && line.get("channel_count").is_none(), + "{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()) @@ -3467,10 +3725,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 @@ -3492,10 +3750,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 { @@ -3549,9 +3875,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, @@ -3623,11 +3951,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, @@ -3652,6 +3987,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, @@ -3693,8 +4029,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(), @@ -3745,9 +4088,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()); } @@ -3771,8 +4120,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") @@ -3802,9 +4158,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()); } @@ -3827,9 +4189,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()); } @@ -4509,46 +4877,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"); } // ────────────────────────────────────────────────────────────────────────── @@ -5160,9 +5498,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. @@ -5223,21 +5560,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() { @@ -5275,6 +5619,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}" @@ -5284,8 +5629,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. @@ -5335,6 +5680,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}" @@ -5704,6 +6050,585 @@ 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() + } + + /// 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` that returns nothing writes one DEBUG 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"], "DEBUG", "{line}"); + assert_eq!(line["event"], "http", "{line}"); + assert_eq!(line["outcome"], "success", "{line}"); + assert!(line.get("reason").is_none(), "{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}"); + } + + /// Every HTTP line names its verb itself, since the `http.request` span + /// is absent under an access-only filter and when a request is dropped + /// (Tower enters the span only while polling). + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn http_lines_carry_the_verb_without_the_span() { + use crate::access::tests::{assert_one_aborted_line, capture_access_only}; + use tracing::Level; + let state = nip_fi_off_test_state().await.expect("local Postgres"); + let host = with_community(&state).await; + let community = crate::tenant::bind_community(&state.db, &host) + .await + .expect("bind community") + .community(); + let keys = Keys::generate(); + + // A served, non-empty read under an access-only INFO filter. + let event = EventBuilder::new(Kind::TextNote, "") + .sign_with_keys(&keys) + .expect("sign event"); + state + .db + .insert_event(community, &event, None) + .await + .expect("insert event"); + let body = format!( + r#"[{{"kinds":[1],"authors":["{}"]}}]"#, + keys.public_key().to_hex() + ); + let headers = make_nip98_headers( + &keys, + &format!("https://{host}/query"), + "POST", + body.as_bytes(), + ); + let (guard, out) = capture_access_only(Level::INFO); + let status = oneshot_request( + Arc::clone(&state), + "POST", + "/query", + &host, + headers, + body.as_bytes(), + ) + .await; + drop(guard); + assert_eq!(status, axum::http::StatusCode::OK); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "exactly one access line: {lines:?}"); + let line = &lines[0]; + assert_eq!( + (&line["level"], &line["result_count"]), + (&"INFO".into(), &1.into()), + "{line}" + ); + assert_eq!(line["http_method"], "POST", "{line}"); + assert_eq!(line["pubkey"], keys.public_key().to_hex(), "{line}"); + assert!(!out.raw().contains("http.request"), "{}", out.raw()); + + // A refusal under an access-only WARN filter. + let mut members_only = (*state).clone(); + Arc::make_mut(&mut members_only.config).require_relay_membership = true; + let outsider = Keys::generate(); + let headers = make_nip98_headers( + &outsider, + &format!("https://{host}/query"), + "POST", + QUERY_BODY, + ); + let (guard, out) = capture_access_only(Level::WARN); + let status = oneshot_request( + Arc::new(members_only), + "POST", + "/query", + &host, + headers, + QUERY_BODY, + ) + .await; + drop(guard); + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); + let line = assert_one_auth_line(&out.lines(), "denied", Some("not_relay_member")); + assert_eq!(line["level"], "WARN", "{line}"); + assert_eq!(line["http_method"], "POST", "{line}"); + assert_eq!(line["pubkey"], outsider.public_key().to_hex(), "{line}"); + + // A request dropped mid-replay, with every target on. + let mut stalled = (*state).clone(); + let replay = Arc::new(crate::access::tests::StallingReplayGuard::default()); + stalled.nip98_replay = replay.clone(); + let headers = + make_nip98_headers(&keys, &format!("https://{host}/query"), "POST", QUERY_BODY); + let (guard, out) = crate::access::tests::capture_scoped(); + tokio::select! { + _ = oneshot_request(Arc::new(stalled), "POST", "/query", &host, headers, QUERY_BODY) => { + panic!("the replay check must stall"); + } + () = replay.arrived.notified() => {} + } + drop(guard); + let line = assert_one_aborted_line(&out, &keys.public_key()); + assert_eq!(line["http_method"], "POST", "{line}"); + assert_eq!(line["route"], "/query", "{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. A client-defined tag the + /// plain filter parser rejects (`#1`) narrows the 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_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 marker = "CUSTOMTAGMARKER4b2e"; + let plain = serde_json::json!({"artifact": "current", "kinds": [45010], "#h": [channel], "limit": 1}); + let mut custom = plain.clone(); + custom["#1"] = serde_json::json!([marker]); + for filter in [plain, custom] { + let body = serde_json::to_vec(&[filter]).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["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}"); + 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 @@ -9354,10 +10279,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"), @@ -9592,6 +10529,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. @@ -9717,11 +10698,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()], @@ -9730,6 +10713,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}"]"#)], @@ -9751,7 +10745,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 @@ -9770,7 +10769,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; @@ -9959,10 +10963,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 9406f1150e..e6480b38a6 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; @@ -176,7 +200,11 @@ pub(super) async fn recheck( DenialClass::AuthorizationUnavailable, "owner_ban_unavailable", ); - refuse(DenialClass::AuthorizationUnavailable, Error::unavailable()) + refuse( + DenialClass::AuthorizationUnavailable, + Error::unavailable(), + crate::access::AuthReason::BanCheckUnavailable, + ) })?; if restrictions.banned { if !restrictions.own_banned { @@ -185,6 +213,7 @@ pub(super) async fn recheck( 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 6da1712d50..9dd70fbfbc 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) } })?; @@ -289,6 +268,7 @@ impl axum::extract::FromRequestParts> for GitAuth { buzz_auth::DenialClass::AuthorizationDenied, "not_stored_owner", ); + slot.finish(AuthResult::Denied(crate::access::AuthReason::OwnerMismatch)); return Err(crate::nip_fi_core::http_denial( buzz_auth::DenialClass::AuthorizationDenied, )); @@ -298,6 +278,9 @@ impl axum::extract::FromRequestParts> for GitAuth { buzz_auth::DenialClass::AuthorizationUnavailable, "owner_link_unavailable", ); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::NipOaOwnerUnlinked, + )); return Err(unavailable( ( StatusCode::SERVICE_UNAVAILABLE, @@ -309,6 +292,7 @@ impl axum::extract::FromRequestParts> for GitAuth { } } + slot.finish(AuthResult::Success); Ok(GitAuth { pubkey, tenant }) } } @@ -430,6 +414,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 @@ -1548,8 +1600,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` @@ -4295,6 +4349,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"); @@ -4326,6 +4384,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, @@ -5569,3 +5793,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..dc1709d32d 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, AuthReason, AuthResult}; 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,40 +301,44 @@ 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.check( + membership, + super::relay_members::MembershipRefusal::auth_result, + ) { 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 => {} super::relay_members::OwnerLink::Conflict => { + slot.finish(AuthResult::Denied(AuthReason::OwnerMismatch)); return api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch").into_response(); } super::relay_members::OwnerLink::Unavailable => { + slot.finish(AuthResult::Denied(AuthReason::NipOaOwnerUnlinked)); return api_error( StatusCode::SERVICE_UNAVAILABLE, crate::handlers::auth::OWNER_LINK_ERROR, @@ -339,32 +348,51 @@ async fn mint_invite_checked( } } - match mint_invite_inner(&state, body, tenant, pubkey).await { - Ok(json) => json.into_response(), + // The request's access line carries the minted invite. + match mint_invite_inner(&state, &mut caller, body, tenant, pubkey).await { + Ok((json, minted)) => { + slot.detail(minted); + json.into_response() + } Err(e) => e.into_response(), } } async fn mint_invite_inner( state: &AppState, + caller: &mut bridge::ProvenCaller, 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 - .db - .get_relay_member(tenant.community(), &sender_hex) - .await + let member = caller + .check( + state + .db + .get_relay_member(tenant.community(), &sender_hex) + .await, + |_| AuthResult::Denied(AuthReason::AuthorizationUnavailable), + ) .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; + // The role gate is the route's last admission step. let role = member.map(|m| m.role).unwrap_or_default(); - if role != "owner" && role != "admin" { - return Err(api_error( - StatusCode::FORBIDDEN, - "only relay owners and admins can create invites", - )); - } + caller + .admit( + if role == "owner" || role == "admin" { + Ok(()) + } else { + Err(()) + }, + |()| AuthResult::Denied(AuthReason::NotRelayAdmin), + ) + .map_err(|()| { + api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + ) + })?; let request: MintInviteRequest = if body.is_empty() { MintInviteRequest::default() @@ -394,25 +422,26 @@ 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 { + invite_id: Some(invite.invite_id), + expires_at: Some(invite.expires_at.timestamp()), + max_uses: invite.max_uses, + ..Default::default() + }; // 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 +452,39 @@ 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, &slot, tenant, pubkey, verified_owner, &body).await; + if let Ok(Json(json)) = &claimed { + slot.detail(crate::access::Detail { + joined: Some(json["status"] == "joined"), + ..Default::default() + }); + } + claimed +} - if claim_rate_limited(&state, tenant.community(), &pubkey) { +async fn claim_invite_authed( + state: &Arc, + slot: &AccessSlot, + 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(); @@ -478,7 +526,7 @@ pub async fn claim_invite( &state.relay_keypair, ) .await - .map_err(map_claim_error)?; + .map_err(|e| map_claim_error(slot, e))?; metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) .increment(write.outcome.users_created); if write.outcome.owner_link_created { @@ -490,21 +538,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", @@ -527,6 +568,7 @@ pub async fn claim_invite( Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) } buzz_db::relay_invite::ClaimOutcome::Restricted => { + slot.finish(AuthResult::Denied(AuthReason::Restricted)); Err(api_error(StatusCode::FORBIDDEN, "invite_restricted")) } buzz_db::relay_invite::ClaimOutcome::Invalid => { @@ -573,22 +615,14 @@ pub async fn claim_invite( &state.relay_keypair, ) .await - .map_err(map_claim_error)?; + .map_err(|e| map_claim_error(slot, e))?; metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) .increment(write.outcome.users_created); if write.outcome.owner_link_created { 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 +1021,7 @@ mod postgres_tests { .await .expect("lookup") .expect("community exists"); - state + let _ = state .db .add_relay_member( community.id, @@ -1739,6 +1773,137 @@ 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() + ); + } + + /// A member who is not an owner or admin is refused a mint, and a banned + /// claimer is refused a claim: each logs `denied` with its reason, never + /// the membership admission's `success`. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn invite_refusals_after_membership_log_denied() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let (owner, member, banned) = (Keys::generate(), 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") + .id; + for (key, role) in [(&owner, "owner"), (&member, "member")] { + let _ = state + .db + .add_relay_member( + community, + &key.public_key().to_hex(), + role, + None, + &Keys::generate(), + ) + .await + .expect("seed member"); + } + let code = mint_code(state.clone(), &host, &owner, serde_json::json!({})).await; + state + .db + .ban_community_member( + community, + banned.public_key().as_bytes(), + owner.public_key().as_bytes(), + None, + None, + ) + .await + .expect("seed ban"); + + let (_guard, out) = crate::access::tests::capture_scoped(); + let mint = post_json(state.clone(), &host, "/api/invites", &member, "{}".into()).await; + assert_eq!(mint.status(), StatusCode::FORBIDDEN); + let claim = post_json( + state, + &host, + "/api/invites/claim", + &banned, + serde_json::json!({ "code": code }).to_string(), + ) + .await; + assert_eq!(claim.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(claim).await["error"], "invite_restricted"); + let lines = out.lines(); + assert_eq!(lines.len(), 2, "one line per request: {lines:?}"); + for (line, reason) in lines.iter().zip(["not_relay_admin", "restricted"]) { + assert_eq!(line["outcome"], "denied", "{line}"); + assert_eq!(line["reason"], reason, "{line}"); + } + } + #[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 a85973e06a..a3089c2618 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), @@ -47,9 +56,18 @@ pub(super) async fn authenticate( Ok((tenant, pubkey, owner)) } -pub(super) fn map_claim_error(error: buzz_db::DbError) -> (StatusCode, Json) { +/// The claim write's refusal, recording an active ban or timeout on `slot`. +pub(super) fn map_claim_error( + slot: &crate::access::AccessSlot, + error: buzz_db::DbError, +) -> (StatusCode, Json) { match error { - buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), + buzz_db::DbError::InviteRestricted => { + slot.finish(crate::access::AuthResult::Denied( + crate::access::AuthReason::Restricted, + )); + api_error(StatusCode::FORBIDDEN, "invite_restricted") + } buzz_db::DbError::InviteOwnerMismatch => { api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch") } diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs index ee5a490d4f..8046353d7f 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs @@ -34,6 +34,7 @@ mod postgres_tests { let other_owner = Keys::generate(); let tag = buzz_sdk::nip_oa::compute_auth_tag(&other_owner, &agent.public_key(), "").unwrap(); + let (guard, out) = crate::access::tests::capture_scoped(); let response = post_with_owner_tag( state.clone(), &host, @@ -45,6 +46,12 @@ mod postgres_tests { .await; assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(read_json(response).await["error"], "invite_owner_mismatch"); + drop(guard); + // The mismatch, not the membership admission before it, decides the line. + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "denied", "{}", lines[0]); + assert_eq!(lines[0]["reason"], "owner_mismatch", "{}", lines[0]); assert!(state .db .is_agent_owner( diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs index 029dbbadfe..ae8b577bc9 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -77,19 +77,41 @@ pub(super) mod postgres_tests { ) .await .unwrap(); + let timed_out = Keys::generate(); + state + .db + .timeout_community_member( + community, + timed_out.public_key().as_bytes(), + issuer.public_key().as_bytes(), + chrono::Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + .unwrap(); let key = derive_invite_key(&state.relay_keypair); let (legacy, _) = crate::invite_token::mint_invite(&key, community, 3600); - for bearer in [&code, &legacy] { - let response = post_json( - state.clone(), - &host, - "/api/invites/claim", - &claimant, - serde_json::json!({"code":bearer}).to_string(), - ) - .await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert_eq!(read_json(response).await["error"], "invite_restricted"); + // A ban and a timeout each log one `denied`/`restricted` line on v2 + // and v1 alike, never the replay admission's `success`. + for restricted in [&claimant, &timed_out] { + for bearer in [&code, &legacy] { + let (guard, out) = crate::access::tests::capture_scoped(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + restricted, + serde_json::json!({"code":bearer}).to_string(), + ) + .await; + drop(guard); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_restricted"); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0]["outcome"], "denied", "{}", lines[0]); + assert_eq!(lines[0]["reason"], "restricted", "{}", lines[0]); + } } assert!(!state .db diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 904ea9d7a2..048b36c6d5 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, @@ -342,19 +344,31 @@ 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 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() + }; // Pre-body check: freshness, cardinality, and server tag. The x-tag // hash binding is checked after body completion. let auth_event = proof - .record(extract_blossom_auth(&headers_clone).and_then(|auth_event| { - buzz_media::auth::verify_blossom_auth_event( - &auth_event, - Some(&tenant_host), - strictness, - ) - .map(|()| auth_event) - })) - .map_err(|e| e.into_response())?; + .record( + extract_blossom_auth(&headers_clone) + .map_err(|e| (e, None)) + .and_then(|auth_event| { + let claimed = auth_event.pubkey; + buzz_media::auth::verify_blossom_auth_event( + &auth_event, + Some(&tenant_host), + strictness, + ) + .map(|()| auth_event) + .map_err(|e| (e, Some(claimed))) + }), + ) + .map_err(|(e, claimed)| reject(e, claimed))?; let pubkey = auth_event.pubkey; Ok(Nip98Proof::new(pubkey, auth_event)) }) @@ -363,6 +377,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(); @@ -372,27 +390,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(), @@ -400,17 +417,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( @@ -423,8 +442,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, @@ -704,9 +729,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(), @@ -714,9 +740,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 { @@ -805,6 +836,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 { @@ -836,7 +868,8 @@ pub(crate) async fn get_blob( .map(drop) }) .await; - let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { + let proof_slot = slot.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &req_headers, &slot, move || { proof .record(extract_blossom_read_proof( &headers_clone, @@ -844,13 +877,19 @@ pub(crate) async fn get_blob( &tenant_host, strictness, )) - .map_err(|e| e.into_response()) + .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. @@ -861,6 +900,7 @@ pub(crate) async fn get_blob( token_owner, &req_headers, strictness, + &slot, ) .await?; serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers) @@ -1126,6 +1166,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 { @@ -1149,7 +1190,8 @@ pub(crate) async fn head_blob( .map(drop) }) .await; - let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { + let proof_slot = slot.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &headers, &slot, move || { proof .record(extract_blossom_read_proof( &headers_clone, @@ -1157,13 +1199,19 @@ pub(crate) async fn head_blob( &tenant_host, strictness, )) - .map_err(|e| e.into_response()) + .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( @@ -1173,6 +1221,7 @@ pub(crate) async fn head_blob( token_owner, &headers, strictness, + &slot, ) .await?; let cache_control = blob_cache_control(); @@ -1252,6 +1301,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). @@ -2468,6 +2531,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 @@ -3114,6 +3341,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", @@ -3122,6 +3350,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 a446216afc..f38ad8f81b 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,48 @@ 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, with shadow's `owner` reason when the + /// agent token's owner decided it. + type Refused = ( + AuthReason, + (StatusCode, Json), + Option<&'static str>, + ); + 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 +328,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], @@ -356,12 +367,12 @@ pub mod relay_members { .await; // An agent token's owner-decided refusal is its request's NIP-FI // verdict, as shadow's verdict includes it. - if let (Some(_), Err((refusal, Some(reason)))) = (token_owner, &result) { - crate::nip_fi_http::owner_refused(refusal_class(refusal.0), reason); + if let (Some(_), Err((_, refusal, Some(owner_reason)))) = (token_owner, &result) { + crate::nip_fi_http::owner_refused(refusal_class(refusal.0), owner_reason); } - result.map_err(|(refusal, _)| match token_owner { - None => MembershipRefusal::Api(refusal), - Some(_) => MembershipRefusal::NipFi(refusal_class(refusal.0)), + result.map_err(|(reason, refusal, _)| match token_owner { + None => MembershipRefusal::Api(reason, refusal), + Some(_) => MembershipRefusal::NipFi(reason, refusal_class(refusal.0)), }) } @@ -373,10 +384,6 @@ pub mod relay_members { } } - /// A membership refusal, with shadow's `owner` reason when the agent - /// token's owner decided it. - type Refused = ((StatusCode, Json), Option<&'static str>); - async fn admit_member( state: &AppState, tenant: &TenantContext, @@ -400,6 +407,7 @@ pub mod relay_members { Ok(MembershipDecision::ViaOwner(owner)) => Some(owner), Ok(MembershipDecision::Denied) => { return Err(( + AuthReason::NotRelayMember, ( StatusCode::FORBIDDEN, Json(serde_json::json!({ @@ -412,7 +420,11 @@ pub mod relay_members { } Err(e) => { tracing::error!("relay membership check errored: {e}"); - return Err((super::internal_error(&e), None)); + return Err(( + AuthReason::MembershipUnavailable, + super::internal_error(&e), + None, + )); } }; deny_banned( @@ -426,18 +438,24 @@ 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"), None)); + return Err(( + AuthReason::MembershipUnavailable, + super::internal_error("invalid pubkey for owner link"), + None, + )); }; match link_agent_owner(state, tenant, &agent, &token_owner).await { OwnerLink::Linked => {} OwnerLink::Conflict => { return Err(( + AuthReason::OwnerMismatch, super::api_error(StatusCode::FORBIDDEN, OWNER_MISMATCH), Some("not_stored_owner"), )) } OwnerLink::Unavailable => { return Err(( + AuthReason::NipOaOwnerUnlinked, super::api_error( StatusCode::SERVICE_UNAVAILABLE, crate::handlers::auth::OWNER_LINK_ERROR, @@ -464,7 +482,11 @@ pub mod relay_members { token_owner: Option, ) -> Result<(), Refused> { let Ok(pubkey) = nostr::PublicKey::from_slice(pubkey_bytes) else { - return Err((super::internal_error("invalid pubkey for ban check"), None)); + return Err(( + AuthReason::MembershipUnavailable, + super::internal_error("invalid pubkey for ban check"), + None, + )); }; let verdict = crate::handlers::auth::community_ban_verdict( state, @@ -476,18 +498,24 @@ pub mod relay_members { true, ) .await; - let refusal = match verdict.outcome { + let (reason, refusal) = match verdict.outcome { crate::handlers::auth::BanOutcome::Clear => return Ok(()), - crate::handlers::auth::BanOutcome::Banned => super::api_error( - StatusCode::FORBIDDEN, - "blocked: you are banned from this community", + crate::handlers::auth::BanOutcome::Banned => ( + AuthReason::Banned, + super::api_error( + StatusCode::FORBIDDEN, + "blocked: you are banned from this community", + ), ), - crate::handlers::auth::BanOutcome::DbError => super::api_error( - StatusCode::SERVICE_UNAVAILABLE, - "error: internal error checking restriction state", + crate::handlers::auth::BanOutcome::DbError => ( + AuthReason::BanCheckUnavailable, + super::api_error( + StatusCode::SERVICE_UNAVAILABLE, + "error: internal error checking restriction state", + ), ), }; - Err((refusal, verdict.owner_reason())) + Err((reason, refusal, verdict.owner_reason())) } /// Extract NIP-OA owner from an auth tag without membership enforcement. 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 15077c994b..28ba059d49 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, @@ -156,10 +164,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", @@ -195,6 +208,7 @@ pub async fn ws_audio_handler( shadow, shadow_line, connection_time, + client_ip, ) }) } @@ -245,6 +259,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(); @@ -271,7 +286,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); if let Some(assertion) = &nip_fi_assertion { control.attach_nip_fi_enforce(crate::nip_fi_shadow::EnforceSession::new( "audio", @@ -711,12 +726,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::new(&control); + // 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), @@ -728,7 +767,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. @@ -759,6 +799,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; @@ -797,6 +838,7 @@ async fn run_active_audio_connection( .await == crate::nip_fi_session::PairingOutcome::Denied { + auth_line.finish(AuthResult::Denied(crate::access::AuthReason::KeyMismatch)); return; } @@ -814,6 +856,9 @@ async fn run_active_audio_connection( if let Some(session) = control.nip_fi_enforce() { session.expire(); } + auth_line.finish(AuthResult::Denied( + crate::access::AuthReason::SessionExpired, + )); deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } @@ -841,6 +886,7 @@ async fn run_active_audio_connection( "NIP-FI deny-set hit at audio post-registration check β€” denying" ); control.deny_nip_fi_enforce(crate::nip_fi_shadow::DENY_SET.by(Some(pubkey))); + auth_line.finish(AuthResult::Denied(crate::access::AuthReason::DenySet)); deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } @@ -882,6 +928,7 @@ async fn run_active_audio_connection( Some(&denial), nip_fi_assertion.as_ref(), ); + auth_line.finish(AuthResult::from_outcome(denial.outcome)); shadow_attempt.refused(); warn!(channel_id = %channel_id, pubkey = %pubkey_hex, reason = denial.reason, "audio: denied at ban check"); exit_authorization_refusal( @@ -930,6 +977,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; @@ -945,7 +998,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( @@ -959,6 +1013,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( @@ -981,7 +1038,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 @@ -995,6 +1053,7 @@ async fn run_active_audio_connection( Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { shadow_attempt.effect_refused(&audio_gate); + auth_line.abort_reason(abort_cause()); cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { let _ = t.await; @@ -1016,6 +1075,9 @@ async fn run_active_audio_connection( Some(&denial), nip_fi_assertion.as_ref(), ); + 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"); @@ -1066,6 +1128,7 @@ async fn run_active_audio_connection( Some(&denial), nip_fi_assertion.as_ref(), ); + 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"); @@ -1080,7 +1143,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. // @@ -5266,6 +5333,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 @@ -6420,6 +6652,7 @@ mod tests { None, None, conn_time, + None, ) .await }) @@ -11165,6 +11398,7 @@ mod tests { None, line, Utc::now(), + None, ) .await; ended.notify_one(); @@ -11446,6 +11680,7 @@ mod tests { None, None, Utc::now(), + None, ) }) } @@ -15832,6 +16067,53 @@ mod tests { ); } + /// A banned delegated NIP-FI agent refused at the early ban check logs + /// one `denied`/`banned` auth line, not `aborted`. + /// Mutation: drop the early `auth_line.finish` β†’ `aborted` β†’ RED. + #[tokio::test] + #[ignore = "requires Postgres β€” runs in postgres-ci nextest lane"] + async fn nip_fi_agent_banned_at_early_check_logs_denied() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available β€” set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, _member) = seed_audio_fixture(state.db.pool()).await; + let (agent, owner) = (nostr::Keys::generate(), nostr::Keys::generate()); + add_channel_member(state.db.pool(), &tenant, channel_id, &agent).await; + state + .db + .ban_community_member( + tenant.community(), + &agent.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed ban"); + let assertion = buzz_auth::VerifiedAssertion::for_test( + Some(agent.public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ) + .with_owner_for_test(owner.public_key()); + + let (guard, out) = crate::access::tests::capture_scoped(); + let (mut client, server) = + start_audio_auth(&state, tenant, channel_id, &agent, None, Some(assertion)).await; + audio_frames_until_close(&mut client).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"], "denied", "{}", lines[0]); + assert_eq!(lines[0]["reason"], "banned", "{}", lines[0]); + } + /// A delegated NIP-FI agent whose session expires just before its /// owner-link permit gets the canonical denial and its close, with no /// owner write and no huddle admission. @@ -16464,6 +16746,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 294f6b8299..e0d484d1ee 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) @@ -180,6 +180,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 @@ -444,6 +447,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 @@ -790,6 +806,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); @@ -1454,6 +1473,7 @@ impl Config { send_buffer_size, max_frame_bytes, slow_client_grace_limit, + client_ip_header, auth, require_auth_token, buzz_v1_enabled, @@ -2954,6 +2974,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 a2c6ba7d66..c2970fb443 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); if let Some(assertion) = &nip_fi_assertion { control.attach_nip_fi_enforce(crate::nip_fi_shadow::EnforceSession::new( "ws", @@ -464,7 +487,7 @@ pub(crate) async fn handle_connection( run_state, addr, tenant, - conn_id, + close_for_run, control, nip_fi_assertion, connection_time, @@ -478,6 +501,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(); @@ -505,7 +530,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, @@ -514,6 +539,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 @@ -593,6 +619,7 @@ async fn handle_active_connection( }); let conn = Arc::new(ConnectionState { + refusal_budget: Arc::clone(&close.refusals), conn_id, tenant, remote_addr: addr, @@ -613,7 +640,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() @@ -634,7 +660,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( @@ -654,6 +680,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, @@ -784,7 +811,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. @@ -793,15 +819,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, @@ -1335,21 +1364,24 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } }; - if !enforce_ws_admission(&msg, &conn, &state).await { + match &msg { + ClientMessage::Req { .. } => crate::metrics::record_ws_read("req"), + ClientMessage::Count { .. } => crate::metrics::record_ws_read("count"), + ClientMessage::ArtifactSync { .. } => crate::metrics::record_ws_read("sync"), + _ => {} + } + + 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); @@ -1400,6 +1432,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; } }; @@ -1426,6 +1465,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; } }; @@ -1448,6 +1493,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; } }; @@ -1466,6 +1518,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::*; @@ -1552,6 +1658,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"), @@ -1855,6 +1962,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")] @@ -1871,7 +2149,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; }) @@ -1887,7 +2171,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; }) @@ -1977,12 +2267,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(), @@ -2423,11 +2719,45 @@ pub(crate) mod tests { } } + /// Each parsed REQ, sync and COUNT frame advances its read counter by exactly + /// one, even when refused; other frames count nothing. + #[tokio::test(flavor = "current_thread")] + async fn dispatcher_counts_each_req_and_count_frame_once() { + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = crate::state::tests::test_state().await; + let (conn, _rx) = test_conn_with_auth(AuthState::Failed); + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + for raw in [ + serde_json::json!(["REQ", "r", {"kinds": [1]}]), + serde_json::json!(["COUNT", "c", {"kinds": [1]}]), + serde_json::json!(["REQ", "y", {"artifact": "current", "#h": [uuid::Uuid::new_v4().to_string()]}]), + serde_json::json!(["CLOSE", "r"]), + ] { + handle_text_message(raw.to_string(), Arc::clone(&conn), Arc::clone(&state)).await; + } + + let scrape = handle.render(); + for kind in ["req", "count", "sync"] { + assert!( + scrape.contains(&format!("buzz_ws_reads_total{{type=\"{kind}\"}} 1\n")), + "{scrape}" + ); + } + } + #[derive(Debug, Default)] struct MockSinkState { messages: Vec, flush_count: usize, fail_after_flushes: Option, + reject_sends: bool, + pending_flushes: usize, } #[derive(Debug, Clone)] @@ -2461,11 +2791,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(()) } @@ -2475,6 +2808,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) @@ -2530,6 +2867,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 @@ -3202,6 +4395,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()), @@ -3473,12 +4667,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, @@ -3603,6 +4803,7 @@ pub(crate) mod tests { socket, state_i, "127.0.0.1:9999".parse().unwrap(), + None, tenant_i, Some(assertion_i), None, @@ -3780,12 +4981,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(), @@ -4134,7 +5341,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(); }) }), @@ -4346,6 +5554,7 @@ pub(crate) mod terminal_close_delivery_tests { socket, state, "127.0.0.1:1234".parse().expect("client address"), + None, tenant, assertion, None, @@ -4485,8 +5694,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 a8476042c5..76792fe649 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; @@ -468,14 +469,19 @@ 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, +) { record_enforce_refusal( &conn.community_control, Some(&denial), conn.nip_fi_assertion.as_ref(), ); 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() { @@ -517,6 +523,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, ); @@ -529,6 +539,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, @@ -549,6 +563,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 = @@ -563,6 +578,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(); @@ -627,7 +643,6 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let outcome = verdict.outcome; 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"); record_enforce_refusal( &conn.community_control, ban_verdict_denial(verdict).as_ref(), @@ -635,7 +650,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: ); 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. @@ -678,10 +693,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 @@ -703,7 +717,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() { @@ -740,10 +754,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 @@ -762,7 +775,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() { @@ -851,7 +864,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); @@ -892,7 +905,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; } }; @@ -916,17 +929,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 @@ -938,11 +951,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" @@ -964,6 +973,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); if let Some(session) = conn.community_control.nip_fi_enforce() { session.admit(pubkey); @@ -1002,11 +1021,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; @@ -1206,6 +1226,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()), @@ -1355,6 +1376,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()), @@ -1444,6 +1466,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()), @@ -2397,6 +2420,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, @@ -3286,6 +3310,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, @@ -3492,6 +3517,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, @@ -3681,6 +3707,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, @@ -3859,6 +3886,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, @@ -3977,6 +4005,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, @@ -4132,6 +4161,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()), @@ -4674,4 +4704,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 a7f9a775bb..256a7d09c7 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1785,6 +1785,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"), @@ -1872,6 +1873,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(), @@ -2033,6 +2035,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(), @@ -2155,6 +2158,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(), @@ -3669,6 +3673,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(), @@ -4031,6 +4036,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, @@ -4224,6 +4230,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 3bda8d329e..f9b6f4b58d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3458,7 +3458,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!( @@ -3514,7 +3514,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, @@ -3555,7 +3554,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!( @@ -3647,7 +3646,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(); @@ -3662,7 +3661,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 @@ -3679,7 +3678,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( @@ -3717,7 +3716,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, @@ -7586,7 +7585,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, @@ -7600,6 +7599,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"], @@ -7681,10 +7681,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 d3914704c9..d5d6bc2c5c 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -240,7 +240,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..5f83536442 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; @@ -589,7 +625,9 @@ pub async fn handle_req( } } - super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn).await; + if super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn).await { + access.served(); + } debug!( conn_id = %conn_id, @@ -782,6 +820,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 @@ -789,11 +828,14 @@ async fn handle_search_req( if build_search_channel_scope_filter(implicit_channels, include_global).is_none() && named_only.is_empty() { - super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + if 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 +932,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 +969,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 +1011,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 +1063,7 @@ async fn handle_search_req( if !super::close::send_if_owner(sub_id, owner, frame, conn).await { return; } + access.queued_one(); emitted += 1; } } @@ -1018,7 +1074,12 @@ async fn handle_search_req( } } - super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + let queued = super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; + match failure { + Some(reason) => access.error(reason), + None if queued => access.served(), + None => {} + } } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1442,6 +1503,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 +1514,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.deny("not_channel_member"); return; } @@ -1467,9 +1530,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 +1562,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.error("liveness_unavailable"); return; } } @@ -1540,6 +1608,7 @@ async fn handle_huddle_liveness_req( state, ) .await; + access.error("signing_failed"); return; } }; @@ -1547,9 +1616,12 @@ 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; + if 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 +2040,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 +2281,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 +2296,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), @@ -2242,6 +2325,72 @@ mod tests { ); } + /// A REQ whose `EOSE` is not queued did not complete, so it must log + /// `aborted`, not `served`: once when a same-ID replacement takes the ID + /// while the empty search waits to send its `EOSE`, and once when the + /// outbound queue rejects the frame. Mutation: ignore `send_if_owner`'s + /// result before `access.served()` β†’ `served` β†’ RED. + #[tokio::test] + async fn unqueued_eose_logs_aborted_not_served() { + use super::super::close::test_seam::{Pause, SEND_PAUSE}; + let state = crate::state::tests::test_state().await; + let filters = vec![Filter::new().search("needle")]; + for case in ["replaced", "queue_rejected"] { + let (conn, send_rx) = lifecycle_conn(); + let owner = claim_search_subscription("s", &conn, &state) + .await + .expect("claim"); + let (_guard, out) = crate::access::tests::capture_scoped(); + let mut access = crate::access::ReadLog::req(&conn, "s", &filters); + let mut send_rx = Some(send_rx); + if case == "queue_rejected" { + drop(send_rx.take()); + } + let pause = Arc::new(Pause::default()); + let named_only = super::super::system_channels::NamedOnlyChannels::default(); + { + let search = SEND_PAUSE.scope( + Arc::clone(&pause), + handle_search_req( + "s", + owner, + &filters, + &[], + &named_only, + false, + &conn.tenant, + &[0; 32], + &conn, + &state, + None, + &mut access, + ), + ); + tokio::pin!(search); + tokio::select! { + _ = &mut search => panic!("{case}: the search must pause before EOSE"), + _ = pause.reached.notified() => {} + } + if case == "replaced" { + conn.subscriptions + .lock() + .await + .insert("s".to_string(), owner + 1_000_000); + } + pause.resume.notify_one(); + search.await; + } + drop(access); + let lines = out.lines(); + assert_eq!(lines.len(), 1, "{case}: {lines:?}"); + assert_eq!(lines[0]["outcome"], "aborted", "{case}: {}", lines[0]); + assert_eq!(lines[0]["level"], "INFO", "{case}: {}", lines[0]); + if let Some(rx) = send_rx.as_mut() { + assert!(closed_frames(rx).is_empty(), "{case}: no stale EOSE"); + } + } + } + /// Concurrent same-ID claims and stale teardowns: whichever claim lands /// last owns the ID, and every stale teardown is a silent no-op. #[tokio::test] @@ -2472,6 +2621,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 +2682,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 +3844,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 +3974,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 +4110,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 +4252,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 069f56c6f5..38b6dbc38a 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1043,7 +1043,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(()) @@ -1406,7 +1406,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(()); } @@ -1557,7 +1558,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; @@ -3299,14 +3301,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 @@ -4607,7 +4614,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; } @@ -4712,7 +4720,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 6955cc1252..f21d0181af 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 4c1e6d2f29..f5fe7d0913 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1907,6 +1907,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/metrics.rs b/crates/buzz-relay/src/metrics.rs index 893813a6fb..b4bc7df953 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -314,9 +314,11 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric describe_community_admission_metrics(); describe_db_pool_metrics(); describe_auth_metrics(); + describe_ws_read_metrics(); crate::startup_steps::describe_metrics(); buzz_relay_mesh::telemetry::describe_metrics(); initialize_auth_metric_series(); + initialize_ws_read_metric_series(); initialize_partition_audit_metric_series(); tokio::spawn(exporter); Ok(()) @@ -541,6 +543,29 @@ pub(crate) fn initialize_auth_metric_series() { metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0); } +/// The `type` label of each WebSocket read counted by [`record_ws_read`]. +const WS_READ_TYPES: [&str; 3] = ["req", "count", "sync"]; + +/// Register the WebSocket read counter. +fn describe_ws_read_metrics() { + metrics::describe_counter!( + "buzz_ws_reads_total", + "WebSocket REQ (including NIP-AR sync) and COUNT frames received, by type, whatever the log level" + ); +} + +/// Export every WebSocket read series at zero. +fn initialize_ws_read_metric_series() { + for kind in WS_READ_TYPES { + metrics::counter!("buzz_ws_reads_total", "type" => kind).increment(0); + } +} + +/// Count one parsed WebSocket REQ, sync or COUNT frame, before admission. +pub(crate) fn record_ws_read(kind: &'static str) { + metrics::counter!("buzz_ws_reads_total", "type" => kind).increment(1); +} + /// Export the partition audit counters at zero before the first audit, so /// `increase()`-based alerts count the first failure and every new outcome. pub(crate) fn initialize_partition_audit_metric_series() { @@ -1049,6 +1074,30 @@ mod contract_tests { } } + #[test] + fn ws_reads_count_by_type_from_zero() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::describe_ws_read_metrics(); + super::initialize_ws_read_metric_series(); + assert!(handle + .render() + .contains("buzz_ws_reads_total{type=\"count\"} 0")); + super::record_ws_read("req"); + super::record_ws_read("req"); + super::record_ws_read("count"); + }); + let scrape = handle.render(); + assert!( + scrape.contains("buzz_ws_reads_total{type=\"req\"} 2"), + "{scrape}" + ); + assert!( + scrape.contains("buzz_ws_reads_total{type=\"count\"} 1"), + "{scrape}" + ); + } + #[test] fn production_builder_exports_bounded_auth_contract_and_stable_zeros() { let (recorder, handle) = super::readiness_test_recorder(); diff --git a/crates/buzz-relay/src/nip_fi_core.rs b/crates/buzz-relay/src/nip_fi_core.rs index b60cc44e2d..fb536f9156 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 44de612789..c5bb447bc6 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; @@ -298,6 +298,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 { @@ -340,6 +342,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 @@ -401,8 +415,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>, @@ -419,6 +434,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(); @@ -445,6 +464,7 @@ where proven_pubkey: proof.pubkey, assertion: None, extra: proof.extra, + signed: proof.signed, }); } @@ -467,22 +487,32 @@ where deny_map, extract_nip98, ) - .map_err(|deny| { - record(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); - } - if deny.stage == Stage::Community { - crate::nip_fi_core::community_denial(communities, headers, deny.class) - } else { - http_denial(deny.class) - } - })?; + .map_err( + |EnforceDenial { + deny, + pubkey, + result, + }| { + record(deny); + 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); + } + if deny.stage == Stage::Community { + crate::nip_fi_core::community_denial(communities, headers, deny.class) + } else { + 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 { @@ -492,6 +522,10 @@ where class, crate::nip_fi_shadow::STALE, )); + slot.attempt(AuthMethod::NipFi, Some(proof.pubkey), true); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); http_denial(class) })?; } @@ -506,6 +540,7 @@ where proven_pubkey: proof.pubkey, assertion: Some(assertion), extra: proof.extra, + signed: proof.signed, }) } @@ -526,6 +561,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 @@ -589,15 +625,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>, @@ -606,8 +661,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. @@ -618,46 +678,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, )); } @@ -665,7 +732,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)) @@ -690,6 +760,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 @@ -698,9 +769,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, @@ -713,6 +785,7 @@ where } None => { admit_nip_fi_http( + slot, headers, extract_nip98, communities, @@ -723,7 +796,9 @@ where ) .await } - } + }?; + admission.record(slot, AuthMethod::Nip98); + Ok(admission) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -749,6 +824,7 @@ mod tests { F: FnOnce() -> Result, Response>, { admit_nip_fi_http( + &AccessSlot::default(), headers, extract_nip98, communities, @@ -799,6 +875,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). @@ -1814,6 +1912,7 @@ mod tests { agent, owner, )); let admission = admit_nip_fi_http::<_, (), _>( + &AccessSlot::default(), &bearer_headers("Bearer a.b.c"), || Ok(Nip98Proof::new(agent, ())), &communities(), @@ -2089,7 +2188,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) @@ -2136,7 +2241,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) @@ -2199,4 +2310,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 7b13ca4cdd..8a6919574a 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 @@ -621,9 +621,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 ef0e846f7b..3e989bcd0b 100644 --- a/crates/buzz-relay/src/nip_fi_session.rs +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -120,7 +120,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 fb5e120e53..54aad44556 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -734,6 +734,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 1dd85493e3..1dbe9dbecb 100644 --- a/crates/buzz-relay/src/nip_fi_upgrade.rs +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -124,6 +124,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>, @@ -163,7 +164,10 @@ 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)); } @@ -175,13 +179,19 @@ pub(crate) async fn check_nip_fi_at_upgrade<'a>( let deny = match community { Ok(community) => match evaluate_attached_assertion(headers, &community, verifier) { Ok(assertion) => return NipFiUpgradeOutcome::Admitted(assertion, community.deadline()), - Err(rejection) => crate::nip_fi_shadow::WouldDeny::assertion(rejection), + Err(rejection) => { + slot.finish(rejection.auth_result()); + crate::nip_fi_shadow::WouldDeny::assertion(rejection) + } }, - Err(class) => crate::nip_fi_shadow::WouldDeny::new( - crate::nip_fi_shadow::Stage::Community, - class, - crate::nip_fi_shadow::UNRESOLVED, - ), + Err(class) => { + slot.finish(AuthResult::Failed("community_unresolved")); + crate::nip_fi_shadow::WouldDeny::new( + crate::nip_fi_shadow::Stage::Community, + class, + crate::nip_fi_shadow::UNRESOLVED, + ) + } }; crate::nip_fi_shadow::record_denial(route.shadow_label(), label, deny); NipFiUpgradeOutcome::Denied(if deny.stage == crate::nip_fi_shadow::Stage::Community { @@ -467,6 +477,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(), @@ -489,6 +500,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(), @@ -521,6 +533,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(), @@ -547,10 +560,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(), @@ -655,6 +693,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]), @@ -701,6 +740,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(), @@ -721,6 +761,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(), @@ -741,6 +782,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(), @@ -767,6 +809,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(), @@ -779,6 +822,7 @@ mod tests { )); let (status, _) = denied_parts( check_nip_fi_at_upgrade( + &crate::access::AccessSlot::default(), ROOT, &headers, &communities(), @@ -802,6 +846,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(), @@ -816,6 +861,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(), @@ -843,6 +889,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(), @@ -885,7 +932,8 @@ mod tests { (&attached, Some(&pass)), ] { let verifier = verifier.map(|v| v as &dyn VerifyAssertion); - let _ = check_nip_fi_at_upgrade(ROOT, headers, &map, verifier, mode).await; + let slot = crate::access::AccessSlot::default(); + let _ = check_nip_fi_at_upgrade(&slot, ROOT, headers, &map, verifier, mode).await; } }) .await; 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 029f8b7a32..2719907057 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -23,6 +23,7 @@ use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; +use crate::access::{AuthMethod, AuthResult}; use crate::api; use crate::audio; use crate::connection::handle_connection; @@ -178,6 +179,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 { @@ -189,8 +191,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 @@ -203,6 +212,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; } @@ -211,10 +221,10 @@ async fn nip_fi_assertion_guard( let run = crate::nip_fi_http::with_resolved_community(Ok(community), next.run(request)); crate::nip_fi_http::with_enforce_verdict(run).await } - (Err(deny), community) => { + (Err((deny, result)), community) => { let label = crate::nip_fi_shadow::label(&community); crate::nip_fi_shadow::record_denial("http", label, deny); - if community.is_err() { + let denial = if community.is_err() { crate::nip_fi_core::community_denial( state.nip_fi_communities(), request.headers(), @@ -222,13 +232,37 @@ async fn nip_fi_assertion_guard( ) } else { http_denial(deny.class) - } + }; + slot.method(AuthMethod::NipFi); + slot.finish(guard_result(&request, result)); + denial } - (Ok(()), Err(class)) => crate::nip_fi_core::community_denial( - state.nip_fi_communities(), - request.headers(), - class, - ), + (Ok(()), Err(class)) => { + let denial = crate::nip_fi_core::community_denial( + state.nip_fi_communities(), + request.headers(), + class, + ); + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Failed("community_unresolved")); + 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, } } @@ -243,12 +277,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( @@ -257,7 +294,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. @@ -511,6 +553,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)) } @@ -564,6 +610,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" @@ -648,6 +711,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 { @@ -656,6 +720,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") @@ -737,7 +802,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() { @@ -847,7 +912,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() { @@ -889,6 +954,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 3131fca1f3..a807b4a53e 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), } } } @@ -112,6 +116,8 @@ pub(crate) struct CommunityConnectionControl { nip_fi_line: Arc>>, /// An enforced NIP-FI session's labels for its expiry record. nip_fi_enforce: Arc>, + /// The upgrade request's configured client-IP header value. + client_ip: Option, } impl CommunityConnectionControl { @@ -127,9 +133,19 @@ impl CommunityConnectionControl { nip_fi_shadow: Arc::default(), nip_fi_line: Arc::default(), nip_fi_enforce: 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( @@ -242,11 +258,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] /// `enforce` is the NIP-FI end this transition records on the socket's /// enforce session, only when it is the socket's winning terminal: a /// close another reason or a cancellation already chose records nothing. @@ -261,7 +278,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 } @@ -333,6 +350,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(); @@ -1006,6 +1046,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; } @@ -3966,6 +4009,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/cancel.rs b/crates/buzz-search/src/cancel.rs index 9549d39ba2..f8b72fe2e5 100644 --- a/crates/buzz-search/src/cancel.rs +++ b/crates/buzz-search/src/cancel.rs @@ -121,7 +121,7 @@ async fn cancel(pool: &PgPool, (pid, started_us): (i32, i64)) -> &'static str { let mut conn = match tokio::time::timeout(CANCEL_ACQUIRE_TIMEOUT, pool.acquire()).await { Ok(Ok(conn)) => conn, Ok(Err(e)) => { - tracing::warn!(error = %e, pid, "search cancel could not get a connection"); + tracing::warn!(error_kind = %buzz_db::error::sqlx_error_kind(&e), pid, "search cancel could not get a connection"); return "error"; } Err(_) => return "pool_busy", @@ -135,7 +135,7 @@ async fn cancel(pool: &PgPool, (pid, started_us): (i32, i64)) -> &'static str { Ok(None) => elsewhere.push(conn), Ok(Some(signalled)) => return signal_outcome(signalled), Err(e) => { - tracing::warn!(error = %e, pid, "search cancel failed"); + tracing::warn!(error_kind = %buzz_db::error::sqlx_error_kind(&e), pid, "search cancel failed"); return "error"; } } 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 163771cb5c..a8a4aea48d 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -293,6 +293,48 @@ run_unit_tests() { handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions \ handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds + # Access log line tests, mirroring `just test-unit`'s module filters. + run_test_step "buzz-relay access log tests" \ + cargo test -p buzz-relay --lib access::tests:: -- --nocapture + + run_test_step "buzz-relay git access log tests" \ + cargo test -p buzz-relay --lib api::git::transport::access_log_tests:: -- --nocapture + + # Infra-free tests the access-logging change added or edited, by exact + # name: their modules otherwise need a database. + run_test_step "buzz-relay access logging infra-free tests" \ + cargo test -p buzz-relay --lib -- --exact --nocapture \ + handlers::req::tests::unqueued_eose_logs_aborted_not_served \ + api::bridge::postgres_tests::reject_code_keeps_only_a_known_prefix \ + api::bridge::postgres_tests::nip98_replay_check_fails_closed_when_guard_errors \ + api::bridge::postgres_tests::verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host \ + api::bridge::postgres_tests::verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints \ + api::bridge::postgres_tests::verify_bridge_auth_accepts_nip98_event_signed_for_matching_host \ + api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_verifies_with_matching_query \ + api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_rejected_against_bare_path \ + api::bridge::postgres_tests::moderation_read_audit_query_bearing_nip98_event_verifies \ + api::bridge::postgres_tests::moderation_read_restricted_no_query_still_verifies \ + connection::tests::aborting_lifecycle_owner_is_drop_safe \ + connection::tests::shutdown_terminalizes_auth_stalled_on_database_before_late_success \ + connection::terminal_close_delivery_tests::upgrade_would_deny_line_without_auth_logs_absent_at_close \ + handlers::event::tests::observer_owner_cache_is_scoped_to_community \ + state::tests::shared_counter_between_direct_and_fanout \ + api::bridge::postgres_tests::nip98_replay_check_logs_only_its_refusal \ + api::bridge::postgres_tests::warn_only_artifact_read_lines_keep_the_filter_summary \ + connection::tests::close_totals_count_a_batch_flushed_by_the_drain_after_cancellation \ + connection::tests::close_totals_count_frames_the_writer_flushed \ + connection::tests::close_totals_skip_a_frame_the_socket_rejects \ + connection::tests::close_totals_skip_frames_stranded_by_a_failed_flush \ + connection::tests::dispatcher_counts_each_req_and_count_frame_once \ + connection::tests::read_before_login_logs_one_denial_from_the_handler \ + connection::tests::read_refused_by_a_full_handler_semaphore_logs_one_denial \ + connection::tests::read_refused_by_unavailable_admission_logs_one_denial \ + handlers::auth::tests::repeat_auth_after_a_terminal_logs_one_denial \ + nip_fi_upgrade::tests::deny_protected_logs_one_denied_auth_line \ + api::bridge::moderation_refusal_tests::moderation_lookup_failure_is_unavailable \ + api::admin::auth::tests::admin_role_refusal_logs_denied \ + api::admin::auth::tests::admin_duplicate_authorization_logs_duplicate + # Infra-free bridge search extension parser tests, by exact name: the rest of # api::bridge::postgres_tests needs a database. run_test_step "buzz-relay bridge search extension tests" \ @@ -376,6 +418,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