diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7e5df8d711..c4adb7fc63c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -738,6 +738,8 @@ pub struct ConnectionState { pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed } ``` +**Community FI admission:** Protected HTTP and root/audio WebSocket requests read the normalized Host's active authentication policy from the primary database. Stored `requires_nip_fi` OR global enforce requires FI; repair mode denies first. HTTP middleware shares one request-local policy read with handlers. Root NIP-11 and `/info` publish a no-store host-specific requirement and compatible capability fields. Policy lookup failure is unavailable, never optional. See [community auth upgrades](docs/community-auth-upgrades.md); completed activation additionally requires existing-session drain evidence. + **HTTP endpoints:** | Method | Path | Handler | diff --git a/Justfile b/Justfile index cb186312dfe..acdbfb0fe6d 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(/^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::/))' + -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_policy::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-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs index 967d6c3719a..403de76dbdf 100644 --- a/crates/buzz-auth/src/nip_fi/startup/mod.rs +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -29,6 +29,23 @@ pub enum NipFiMode { } impl NipFiMode { + /// Enforce stored community requirements only in Shadow; Off disables FI. + /// Repair mode remains unconditional. + pub const fn with_requirement(self, required: bool) -> Self { + if self.denies_unconditionally() { + self + } else if self.observes_only() && required { + Self::Enforce + } else { + self + } + } + + /// Fail closed when a dependency needed to decide policy is unavailable. + pub const fn unavailable() -> Self { + Self::DenyProtected + } + /// NIP-FI is disabled. pub const fn is_off(self) -> bool { matches!(self, Self::Off) diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 123289ee189..4cb89441b0a 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -247,3 +247,26 @@ fn mode_predicates_separate_evaluation_from_authority() { assert_eq!(mode.observes_only(), evaluates && !restricts, "{mode:?}"); } } + +#[test] +fn community_requirements_follow_relay_mode() { + for stored in [false, true] { + assert_eq!(NipFiMode::Off.with_requirement(stored), NipFiMode::Off); + assert_eq!( + NipFiMode::Shadow.with_requirement(stored), + if stored { + NipFiMode::Enforce + } else { + NipFiMode::Shadow + } + ); + assert_eq!( + NipFiMode::Enforce.with_requirement(stored), + NipFiMode::Enforce + ); + assert_eq!( + NipFiMode::DenyProtected.with_requirement(stored), + NipFiMode::DenyProtected + ); + } +} diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 0a2b1d457bd..53d6c598d29 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -8,6 +8,12 @@ use uuid::Uuid; use crate::{relay_members, Db, DbError, ReadAt, Result}; +// Admission and policy must select the same serving row. A deletion request +// alone does not stop serving; begin_quiescing changes deletion_state. +pub(super) const SERVING_HOST_QUERY: &str = "SELECT id, host, requires_nip_fi, policy_generation + FROM communities WHERE lower(host) = lower($1) + AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active'"; + /// Community host-map row returned by [`Db::lookup_community_by_host`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommunityRecord { @@ -133,19 +139,10 @@ impl Db { ) .await?; let read_at = std::time::Instant::now(); - let row = sqlx::query( - r#" - SELECT id, host - FROM communities - WHERE lower(host) = lower($1) - AND archived_at IS NULL - AND deleted_at IS NULL - AND deletion_state = 'active' - "#, - ) - .bind(normalized_host) - .fetch_optional(&mut *connection) - .await?; + let row = sqlx::query(SERVING_HOST_QUERY) + .bind(normalized_host) + .fetch_optional(&mut *connection) + .await?; let value = row .map(|row| { diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index a69b908112d..88c6b93edfc 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -11,6 +11,15 @@ use uuid::Uuid; use crate::{Db, Result}; +/// Fleet protocol bound for a cached host's policy, measured before checkout. +/// Increasing this requires a coordinated acceptance-writer rollout first. +pub const POLICY_LEASE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(1); +// Allow clock-rate drift between pods. Never shorten below the fleet lease bound. +const POLICY_LEASE_DRAIN: std::time::Duration = + POLICY_LEASE_MAX_AGE.saturating_add(std::time::Duration::from_millis(100)); +// Two-int advisory key space, separate from the existing bigint lock protocols. +const POLICY_LEASE_NAMESPACE: i32 = 0x4649504c; + /// Canonical request tuple, with the authenticated operator added by the relay. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpgradeRequest { @@ -82,6 +91,9 @@ pub enum UpgradeOutcome { /// Authoritative policy for a community in the active lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq, FromRow)] pub struct CommunityAuthPolicy { + /// Immutable identity from the same row as this policy. + #[sqlx(rename = "id")] + pub community_id: Uuid, /// Durable monotonic requirement. Global enforce remains an additional floor. pub requires_nip_fi: bool, /// Zero for legacy optional communities; positive after the first enable. @@ -106,6 +118,99 @@ const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi, WHERE u.operation_id = $1"; impl Db { + /// Read a cacheable serving policy under the host's shared lease lock. + /// + /// Callers must anchor expiry BEFORE invoking this method and refuse an + /// expired result. Acceptance excludes new leases and drains old ones before + /// committing. The lock and row read MUST be separate READ COMMITTED + /// statements: a snapshot from before lock acquisition is not a lease. + /// Contention refuses immediately; it never leaves a server-side lock wait. + pub async fn community_auth_policy_lease_for_host( + &self, + host: &str, + ) -> Result> { + let host = buzz_core::tenant::normalize_host(host); + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL statement_timeout = '400ms'") + .execute(&mut *tx) + .await?; + let acquired: bool = + sqlx::query_scalar("SELECT pg_try_advisory_xact_lock_shared($1, hashtext($2))") + .bind(POLICY_LEASE_NAMESPACE) + .bind(&host) + .fetch_one(&mut *tx) + .await?; + if !acquired { + return Err(crate::DbError::AccessDenied( + "community policy transition".into(), + )); + } + let policy = sqlx::query_as(super::community::SERVING_HOST_QUERY) + .bind(&host) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + Ok(policy) + } + + /// Bind a serving host and its authentication policy from one writer row. + /// A lifecycle transition between earlier checks and this lookup cannot + /// supply a tenant with another snapshot's optional policy. + #[datastore_span(name = "community_auth_binding_for_host", system = "postgresql")] + pub async fn community_auth_binding_for_host( + &self, + host: &str, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; + let row = sqlx::query(super::community::SERVING_HOST_QUERY) + .bind(host) + .fetch_optional(&mut *connection) + .await?; + row.map(|row| { + Ok(( + crate::CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }, + CommunityAuthPolicy { + community_id: row.try_get("id")?, + requires_nip_fi: row.try_get("requires_nip_fi")?, + policy_generation: row.try_get("policy_generation")?, + }, + )) + }) + .transpose() + } + + /// Resolve active host policy in one writer read. No replica or cross-request cache. + #[datastore_span(name = "community_auth_policy_for_host", system = "postgresql")] + pub async fn community_auth_policy_for_host( + &self, + host: &str, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + Ok(sqlx::query_as(super::community::SERVING_HOST_QUERY) + .bind(host) + .fetch_optional(&mut *connection) + .await?) + } + /// Read current active-community policy from the writer, never a replica. #[datastore_span(name = "community_auth_policy", system = "postgresql")] pub async fn community_auth_policy( @@ -117,7 +222,7 @@ impl Db { crate::observability::WriterOperation::Authorization, ) .await?; - Ok(sqlx::query_as("SELECT requires_nip_fi, policy_generation FROM communities + Ok(sqlx::query_as("SELECT id, requires_nip_fi, policy_generation FROM communities WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active'") .bind(community.as_uuid()).fetch_optional(&mut *connection).await?) } @@ -200,6 +305,21 @@ impl Db { if row.try_get::("host")? != request.expected_host { return Ok(UpgradeOutcome::Refused("host_conflict")); } + // Lock order: deletion-shared, community row, host lease. Fills take + // only the shared host lease and never a community row lock. Derive + // the key from the locked, validated row, not an earlier host snapshot. + let lease_host = buzz_core::tenant::normalize_host(&row.try_get::("host")?); + sqlx::query("SELECT pg_advisory_xact_lock($1, hashtext($2))") + .bind(POLICY_LEASE_NAMESPACE) + .bind(&lease_host) + .execute(&mut *tx) + .await?; + // Keep the row stable while ALL optional leases expire, including + // leases under former host keys. No remap (single or double) can + // refill another host's optional cache while this drain is running. + if !row.try_get::("requires_nip_fi")? { + tokio::time::sleep(POLICY_LEASE_DRAIN).await; + } let deletion_pending: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_deletion_requests WHERE community_id = $1 AND stage <> 'aborted')", diff --git a/crates/buzz-db/src/store/community_auth/postgres_tests.rs b/crates/buzz-db/src/store/community_auth/postgres_tests.rs index 53cd4733f30..e8ef0862c4c 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -191,6 +191,7 @@ async fn failed_preconditions_leave_free_policy_and_no_receipt() { assert_eq!( policy(&db, &request).await, CommunityAuthPolicy { + community_id: request.community_id, requires_nip_fi: false, policy_generation: 0 } @@ -437,6 +438,7 @@ async fn migration_schema_expansion_preserves_existing_optional_rows_with_and_wi assert_eq!( db.community_auth_policy(community).await.unwrap(), Some(CommunityAuthPolicy { + community_id: *community.as_uuid(), requires_nip_fi: false, policy_generation: 0, }) @@ -498,3 +500,61 @@ async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrain ); assert_eq!(policy(&db, &request).await.policy_generation, 1); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { + let (db, request, _) = fixture().await; + let host = &request.expected_host; + assert_eq!( + db.community_auth_policy_for_host(host).await.unwrap(), + Some(CommunityAuthPolicy { + community_id: request.community_id, + requires_nip_fi: false, + policy_generation: 0 + }) + ); + let upgraded = accepted(&db, &request).await; + assert_eq!( + db.community_auth_policy_for_host(host).await.unwrap(), + Some(CommunityAuthPolicy { + community_id: request.community_id, + requires_nip_fi: true, + policy_generation: upgraded.policy_generation + }) + ); + assert!(db + .community_auth_policy_for_host("unknown.example") + .await + .unwrap() + .is_none()); + sqlx::query("INSERT INTO community_deletion_requests (community_id, community_host, requested_by) VALUES ($1, $2, 'policy-test')") + .bind(request.community_id).bind(host).execute(db.pool()).await.unwrap(); + for stage in ["submitted", "inventoried", "approved"] { + sqlx::query("UPDATE community_deletion_requests SET stage = $2 WHERE community_id = $1") + .bind(request.community_id) + .bind(stage) + .execute(db.pool()) + .await + .unwrap(); + assert!(db.lookup_community_by_host(host).await.unwrap().is_some()); + assert!( + db.community_auth_policy_for_host(host) + .await + .unwrap() + .unwrap() + .requires_nip_fi, + "{stage}" + ); + } + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(request.community_id) + .execute(db.pool()) + .await + .unwrap(); + assert!(db + .community_auth_policy_for_host(host) + .await + .unwrap() + .is_none()); +} diff --git a/crates/buzz-pubsub/src/cache_invalidation.rs b/crates/buzz-pubsub/src/cache_invalidation.rs index f48158c9c57..f7fd477b1fd 100644 --- a/crates/buzz-pubsub/src/cache_invalidation.rs +++ b/crates/buzz-pubsub/src/cache_invalidation.rs @@ -56,6 +56,11 @@ pub fn parse_cache_invalidation_channel(channel: &str) -> Option { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "op")] pub enum CacheInvalidation { + /// Drop a serving-host snapshot after policy or binding/lifecycle changes. + CommunityHost { + /// Normalized host whose routing and policy snapshot changed. + host: String, + }, /// Drop the `(channel_id, pubkey)` membership entry and the user's /// accessible-channels entry. Mirrors `invalidate_membership`. Membership { @@ -235,6 +240,9 @@ mod tests { #[test] fn unit_variants_roundtrip_through_json() { for msg in [ + CacheInvalidation::CommunityHost { + host: "a.example".into(), + }, CacheInvalidation::AccessibleAll, CacheInvalidation::ChannelDeleted, CacheInvalidation::Visibility { diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index b078699abb4..007a21ebefc 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -555,30 +555,32 @@ mod tests { let channel_id = Uuid::new_v4(); let pubkey = Keys::generate().public_key().to_bytes().to_vec(); - let sent = CacheInvalidation::Membership { - channel_id, - pubkey: pubkey.clone(), - }; - let ctx = ctx(0xaaaa, "a.example"); - - manager - .publish_cache_invalidation(&ctx, &sent) - .await - .expect("publish failed"); - - let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout") - .expect("channel closed"); - - assert_eq!( - received, - ScopedCacheInvalidation { - community_id: ctx.community(), - invalidation: sent, - } - ); + for sent in [ + CacheInvalidation::Membership { + channel_id, + pubkey: pubkey.clone(), + }, + CacheInvalidation::CommunityHost { + host: ctx.host().to_owned(), + }, + ] { + manager + .publish_cache_invalidation(&ctx, &sent) + .await + .expect("publish failed"); + let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + assert_eq!( + received, + ScopedCacheInvalidation { + community_id: ctx.community(), + invalidation: sent, + } + ); + } } #[tokio::test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 24b2c3136b5..e3cc1489449 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1256,11 +1256,15 @@ pub async fn submit_event( // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory — // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] - let nip_fi_active = state.config.nip_fi.mode.restricts(); + let nip_fi_active = crate::nip_fi_policy::mode(&state, &headers) + .await + .restricts(); // POST /events carries an authorization-relevant body (the event determines // resource, effect, and state change), so a payload tag is required in // NIP-FI enforce mode. [NIP-FI.md:619-637] - let nip_fi_enforce = state.config.nip_fi.mode.enforces(); + let nip_fi_enforce = crate::nip_fi_policy::mode(&state, &headers) + .await + .enforces(); // NIP-FI admission: NIP-98 extraction runs inside the closure, followed by // assertion verify → pair → deny-map in fixed order. The proven pubkey is @@ -1605,11 +1609,15 @@ pub async fn query_events( let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] - let nip_fi_active = state.config.nip_fi.mode.restricts(); + let nip_fi_active = crate::nip_fi_policy::mode(&state, &headers) + .await + .restricts(); // POST /query carries an authorization-relevant body (filter selects the // resources returned), so a payload tag is required in enforce mode. // [NIP-FI.md:619-637] - let nip_fi_enforce = state.config.nip_fi.mode.enforces(); + let nip_fi_enforce = crate::nip_fi_policy::mode(&state, &headers) + .await + .enforces(); // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(&state, &headers, "bridge"); @@ -2294,11 +2302,15 @@ pub async fn count_events( let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] - let nip_fi_active = state.config.nip_fi.mode.restricts(); + let nip_fi_active = crate::nip_fi_policy::mode(&state, &headers) + .await + .restricts(); // POST /count carries an authorization-relevant body (filter selects what // is counted), so a payload tag is required in enforce mode. // [NIP-FI.md:619-637] - let nip_fi_enforce = state.config.nip_fi.mode.enforces(); + let nip_fi_enforce = crate::nip_fi_policy::mode(&state, &headers) + .await + .enforces(); // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(&state, &headers, "bridge"); @@ -3147,7 +3159,7 @@ async fn authorize_moderation_read( // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] - let nip_fi_active = state.config.nip_fi.mode.restricts(); + let nip_fi_active = crate::nip_fi_policy::mode(state, headers).await.restricts(); // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let proof = crate::nip_fi_shadow::EnforcedProof::new(state, headers, "bridge"); @@ -10332,6 +10344,12 @@ pub(crate) mod postgres_tests { use axum::body::Body; use axum::http::{header, Request}; use tower::ServiceExt; + // Keep the deliberately divergent replica selected for each + // cancellation probe, independent of earlier probes' elapsed time. + self.state + .db + .fence() + .force_open_for_tests(chrono::Utc::now()); let mut request = Request::builder() .method("POST") .uri(uri) @@ -10361,6 +10379,10 @@ pub(crate) mod postgres_tests { F: FnOnce(Arc) -> Fut, Fut: std::future::Future, { + self.state + .db + .fence() + .force_open_for_tests(chrono::Utc::now()); let (conn, mut send_rx) = self.ws_conn(); drive(conn).await; match send_rx.try_recv().expect("handler sent a frame") { diff --git a/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs index 8ec3fe3ab95..e2bb8e2a311 100644 --- a/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs +++ b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs @@ -255,15 +255,14 @@ mod postgres_tests { assert_eq!(body["community_id"], community.as_uuid().to_string()); assert_eq!(body["pubkey"], key.public_key().to_hex()); assert_eq!(body["events"], json!([event])); - let info = crate::nip11::nip11_document(&state, &host).await; + let info = crate::nip11::nip11_document(&state, &host).await.unwrap(); assert_eq!( info.read_state_snapshot.as_ref().unwrap()["community_id"], body["community_id"] ); assert!(crate::nip11::nip11_document(&state, "unmapped.invalid") .await - .read_state_snapshot - .is_none()); + .is_err()); let (_, other_body) = request(state.clone(), &other_host, Some(&key), filter(&key)).await; assert_eq!( other_body["community_id"], @@ -353,7 +352,7 @@ mod postgres_tests { #[ignore = "requires Postgres"] async fn read_state_snapshot_router_advertised_event_array_byte_boundary() { let (state, host, community, key) = setup().await; - let info = crate::nip11::nip11_document(&state, &host).await; + let info = crate::nip11::nip11_document(&state, &host).await.unwrap(); let descriptor = info.read_state_snapshot.unwrap(); let budget = descriptor["max_event_array_bytes"].as_u64().unwrap() as usize; assert_eq!(budget, buzz_db::read_state::MAX_SNAPSHOT_BYTES); diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index e6480b38a65..934ba304ef3 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -101,6 +101,7 @@ pub(super) async fn authorize( let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); // Private state always requires cryptographic identity, even on a dev relay. // NIP-FI admission owns NIP-98 extraction, assertion pairing and deny map. + let mode = crate::nip_fi_policy::mode(state, headers).await; let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( state, headers, @@ -117,7 +118,7 @@ pub(super) async fn authorize( ) .await .map_err(|response| { - if state.config.nip_fi.mode.restricts() { + if mode.restricts() { // Preserve the shared NIP-FI wire contract, not just its status. Error::Admission(Box::new(response)) } else { 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 1c15804004a..0fd441e7e5d 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -503,6 +503,14 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { ) .await .unwrap(); + if !known_host { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers()[axum::http::header::CACHE_CONTROL], + "no-store" + ); + continue; + } assert_eq!(response.status(), StatusCode::OK); let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); let doc: Value = serde_json::from_slice(&bytes).unwrap(); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 9dd70fbfbc8..3fafe58eafc 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -89,6 +89,38 @@ impl axum::extract::FromRequestParts> for GitAuth { parts: &mut axum::http::request::Parts, state: &Arc, ) -> Result { + let global = state.config.nip_fi.mode; + if !global.evaluates() { + return Self::from_parts_with_policy(parts, state, global).await; + } + match crate::nip_fi_policy::resolve(state, &parts.headers).await { + Ok(policy) => { + crate::nip_fi_policy::with_policy(policy, async { + let mode = crate::nip_fi_policy::mode(state, &parts.headers).await; + Self::from_parts_with_policy(parts, state, mode).await + }) + .await + } + Err(response) => { + // Preserve the early gate's legacy host-miss ordering. A failed + // lookup is never turned into a usable optional policy scope. + let mode = if response.status() == StatusCode::NOT_FOUND { + global + } else { + buzz_auth::NipFiMode::unavailable() + }; + Self::from_parts_with_policy(parts, state, mode).await + } + } + } +} + +impl GitAuth { + async fn from_parts_with_policy( + parts: &mut axum::http::request::Parts, + state: &Arc, + mode: buzz_auth::NipFiMode, + ) -> Result { let method = parts.method.as_str(); // Off mode: parse and validate the Authorization header BEFORE tenant @@ -96,7 +128,8 @@ impl axum::extract::FromRequestParts> for GitAuth { // missing credentials → 401 + WWW-Authenticate challenge; // malformed credentials (bad base64, bad UTF-8) → 401 without challenge; // regardless of whether the Host resolves to a known community. No - // database work for syntactically bad requests in Off mode. + // tenant-binding work for syntactically bad requests. In evaluating modes, + // policy resolution first prevents a required row taking the legacy path. // // Enforce: the header syntax is validated // inside the NIP-FI admission closure below, where proof failures are @@ -105,7 +138,6 @@ impl axum::extract::FromRequestParts> for GitAuth { // admission (immediately after this block) because the signed `u` tag // 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) { @@ -4588,7 +4620,7 @@ mod off_mode_precedence_tests { // and bad-UTF8 do NOT (those use `into_response()` without the header). #[tokio::test] #[ignore = "requires Postgres"] - async fn off_mode_unmapped_host_missing_auth_returns_401_before_db() { + async fn off_mode_unmapped_host_missing_auth_returns_401_before_tenant_binding() { let Some(state) = off_mode_state().await else { panic!("local Postgres not reachable"); }; @@ -4631,7 +4663,7 @@ mod off_mode_precedence_tests { // Falsifying mutation: delete transport.rs:101-104 → 404. #[tokio::test] #[ignore = "requires Postgres"] - async fn off_mode_unmapped_host_wrong_scheme_returns_401_before_db() { + async fn off_mode_unmapped_host_wrong_scheme_returns_401_before_tenant_binding() { let Some(state) = off_mode_state().await else { panic!("local Postgres not reachable"); }; @@ -4671,7 +4703,7 @@ mod off_mode_precedence_tests { // Falsifying mutation: delete transport.rs:101-104 → 404. #[tokio::test] #[ignore = "requires Postgres"] - async fn off_mode_unmapped_host_invalid_base64_returns_401_before_db() { + async fn off_mode_unmapped_host_invalid_base64_returns_401_before_tenant_binding() { let Some(state) = off_mode_state().await else { panic!("local Postgres not reachable"); }; @@ -4705,7 +4737,7 @@ mod off_mode_precedence_tests { // Falsifying mutation: delete transport.rs:101-104 → 404. #[tokio::test] #[ignore = "requires Postgres"] - async fn off_mode_unmapped_host_bad_utf8_returns_401_before_db() { + async fn off_mode_unmapped_host_bad_utf8_returns_401_before_tenant_binding() { let Some(state) = off_mode_state().await else { panic!("local Postgres not reachable"); }; diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 94d599f425e..e4e5f71cc33 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2491,9 +2491,102 @@ mod route_integration_tests { state.config.nip_fi.registry.clone(), key_source, ))); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } + struct RequiredPolicy; + impl crate::nip_fi_policy::PolicySource for RequiredPolicy { + fn lookup<'a>( + &'a self, + _host: &'a str, + ) -> futures_util::future::BoxFuture< + 'a, + Result, ()>, + > { + Box::pin(async { + Ok(Some(buzz_db::community_auth::CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), + requires_nip_fi: true, + policy_generation: 1, + })) + }) + } + } + + #[tokio::test] + #[allow(clippy::result_large_err)] // Real Axum admission closure. + async fn required_mode_matrix_validates_crypto_only_when_enabled() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + for mode in [ + buzz_auth::NipFiMode::Off, + buzz_auth::NipFiMode::Shadow, + buzz_auth::NipFiMode::Enforce, + ] { + let mut state = (*http_enforce_state().await).clone(); + Arc::make_mut(&mut state.config).nip_fi.mode = mode; + Arc::make_mut(&mut state.config) + .nip_fi + .max_connection_lifetime_secs = 3600; + state.nip_fi_policy = Arc::new(RequiredPolicy); + let key = nostr::Keys::generate().public_key(); + let now = chrono::Utc::now().timestamp(); + for bad in ["valid", "issuer", "audience", "key", "expiry", "signature"] { + let mut claims = serde_json::json!({"iss": TEST_ISS, "aud": TEST_AUD, + "sub": TEST_SUB, "iat": now, "exp": now + 600, + "nostr_pubkey": key.to_hex()}); + match bad { + "issuer" => claims["iss"] = serde_json::json!("https://untrusted.example"), + "audience" => claims["aud"] = serde_json::json!("https://other.example"), + "key" => { + claims["nostr_pubkey"] = + serde_json::json!(nostr::Keys::generate().public_key().to_hex()) + } + "expiry" => { + claims["iat"] = serde_json::json!(now - 1200); + claims["exp"] = serde_json::json!(now - 600); + } + _ => {} + } + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let encoding = EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap(); + let mut token = encode(&header, &claims, &encoding).unwrap(); + if bad == "signature" { + let at = token.rfind('.').unwrap() + 1; + let replacement = if token.as_bytes()[at] == b'A' { + "B" + } else { + "A" + }; + token.replace_range(at..at + 1, replacement); + } + let mut headers = crate::nip_fi_core::test_support::host_headers(); + headers.insert( + CLIENT_ATTACHED_HEADER, + format!("Bearer {token}").parse().unwrap(), + ); + let result = 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; + if mode.is_off() || bad == "valid" { + assert!(result.is_ok(), "{mode:?} valid assertion"); + } else { + assert_eq!( + result.err().unwrap().status(), + StatusCode::FORBIDDEN, + "{mode:?} {bad}" + ); + } + } + } + } + /// Deny `key` under issuer A through the real disconnect route. async fn deny_via_route( state: &Arc, @@ -2655,7 +2748,9 @@ mod route_integration_tests { // Off mode: the command route still records the entry, but HTTP // admission never consults the deny map — the NIP-98 closure result is // returned unchanged, byte for byte. - let state = build_test_state(1000).await; + let mut state = (*build_test_state(1000).await).clone(); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); + let state = Arc::new(state); assert!(matches!( state.config.nip_fi.mode, buzz_auth::NipFiMode::Off @@ -2673,7 +2768,7 @@ mod route_integration_tests { let admitted = crate::nip_fi_http::admit_nip_fi_http_on_state( &state, - &HeaderMap::new(), + &crate::nip_fi_core::test_support::host_headers(), &crate::access::AccessSlot::default(), || Ok(crate::nip_fi_http::Nip98Proof::new(key, 7u8)), ) @@ -2692,7 +2787,7 @@ mod route_integration_tests { }; let resp = crate::nip_fi_http::admit_nip_fi_http_on_state::<(), _>( &state, - &HeaderMap::new(), + &crate::nip_fi_core::test_support::host_headers(), &crate::access::AccessSlot::default(), || Err(legacy()), ) diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index ae81ea14831..2558edd7255 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -497,6 +497,7 @@ pub async fn archive_community( .map_err(|e| internal_error(&format!("archive community: {e}")))? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; let tenant = TenantContext::resolved(record.id, &record.host); + state.invalidate_community_host(&tenant); let closed = match state .disconnect_community_clusterwide(&tenant, record.archived_at) .await @@ -568,6 +569,7 @@ pub async fn unarchive_community( } }; tracing::info!(community = %record.id, host = %record.host, "community unarchived"); + state.invalidate_community_host(&TenantContext::resolved(record.id, &record.host)); Ok(Json(serde_json::json!({ "community_id": record.id.to_string(), "host": record.host, @@ -1307,6 +1309,10 @@ pub async fn enable_community_nip_fi( })?; match outcome { buzz_db::community_auth::UpgradeOutcome::Accepted(record) => { + state.invalidate_community_host(&TenantContext::resolved( + buzz_core::CommunityId::from_uuid(record.community_id), + &record.expected_host, + )); Ok(upgrade_response(&state, record)) } buzz_db::community_auth::UpgradeOutcome::Refused(code) => { diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 5bc0964a54e..9b35fccd7eb 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -65,7 +65,7 @@ async fn authorize_workflow_read( // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] - let nip_fi_active = state.config.nip_fi.mode.restricts(); + let nip_fi_active = crate::nip_fi_policy::mode(state, headers).await.restricts(); // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] let require_auth = state.config.require_auth_token || nip_fi_active; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 28ba059d494..fdb3469dea5 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -102,9 +102,9 @@ pub async fn ws_audio_handler( // WebSocket handshake. Running pre-lookup means a denied request skips the tenant // lookup (a resolver miss costs one bounded binding lookup) and the gate is reachable in tests without a live community. // [FI-TRACE-TRANSPORT-CLOSED] [NIP-FI.md §Admission pairing sequence] - let (nip_fi_assertion, shadow_assertion, nip_fi_deadline, shadow_line) = { + let (mut nip_fi_assertion, mut shadow_assertion, mut nip_fi_deadline, mut shadow_line) = { use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; - let mode = state.config.nip_fi.mode; + let mode = crate::nip_fi_policy::mode(&state, &headers).await; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); match check_nip_fi_at_upgrade( @@ -132,16 +132,35 @@ pub async fn ws_audio_handler( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = match crate::tenant::bind_community(&*state, raw_host).await { - Ok(ctx) => ctx, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - .into_response(); - } + let (tenant, bound_mode) = match crate::nip_fi_policy::bind_for_upgrade(&state, raw_host).await + { + Ok(binding) => binding, + Err(response) => return response, }; + if bound_mode.restricts() && (nip_fi_assertion.is_none() || bound_mode.denies_unconditionally()) + { + match crate::nip_fi_upgrade::check_nip_fi_at_upgrade( + &slot, + crate::nip_fi_session::NipFiWsRoute::Audio, + &headers, + state.nip_fi_communities(), + state.nip_fi_verifier.as_deref(), + bound_mode, + ) + .await + .into_assertions() + { + Ok(assertions) => { + ( + nip_fi_assertion, + shadow_assertion, + nip_fi_deadline, + shadow_line, + ) = assertions + } + Err(response) => return (*response).into_response(), + } + } // The tenant lookup awaited since the community resolved; a snapshot // that went stale meanwhile accepts no socket. diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 533f2eacbbf..377783abec1 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -308,6 +308,7 @@ pub async fn provision_community( &record.host, nip_fi_issuers, ); + state.invalidate_community_host(&TenantContext::resolved(record.id, &record.host)); fan_out_membership_snapshot(state, record.id, &record.host, write.snapshot).await; return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), @@ -325,6 +326,7 @@ pub async fn provision_community( .ensure_configured_community_with_nip_fi_issuers(&request.host, nip_fi_issuers) .await .map_err(|e| format!("failed to create community: {e}"))?; + state.invalidate_community_host(&TenantContext::resolved(record.id, &record.host)); if record.created { record_binding_creation( state.nip_fi_resolver.as_deref(), diff --git a/crates/buzz-relay/src/host_cache.rs b/crates/buzz-relay/src/host_cache.rs new file mode 100644 index 00000000000..b265948c498 --- /dev/null +++ b/crates/buzz-relay/src/host_cache.rs @@ -0,0 +1,225 @@ +//! One host snapshot for routing and FI policy, with bounded read leases. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use buzz_core::CommunityId; +use buzz_db::community_auth::{CommunityAuthPolicy, POLICY_LEASE_MAX_AGE}; + +#[derive(Clone, Copy)] +struct Entry { + community: CommunityId, + read_at: Instant, + // A routing refresh must never renew the policy lease. + policy: Option<(CommunityAuthPolicy, Instant)>, +} + +/// Bounded host cache. Routing-only entries never supply FI policy. +pub struct HostCache { + entries: moka::sync::Cache, + // Serialize invalidation with fill publication. A single epoch avoids an + // unbounded map of per-host tombstones; unrelated invalidation only skips + // publication of an in-flight fill, never changes an admission decision. + epoch: Mutex, +} + +impl HostCache { + pub(crate) fn new() -> Self { + Self { + entries: moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(Duration::from_secs(10)) + .support_invalidation_closures() + .build(), + epoch: Mutex::new(0), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, u64> { + self.epoch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// A routing snapshot and its original read time, for legacy routing. + pub fn get(&self, host: &str) -> Option<(CommunityId, Instant)> { + self.entries + .get(host) + .map(|entry| (entry.community, entry.read_at)) + } + + /// Populate legacy routing without making a claim about FI policy. + pub fn insert(&self, host: String, (community, read_at): (CommunityId, Instant)) { + let _epoch = self.lock(); + let existing = self.entries.get(&host); + let policy = existing + .filter(|entry| entry.community == community) + .and_then(|entry| entry.policy) + .filter(|(_, at)| at.elapsed() < POLICY_LEASE_MAX_AGE); + self.entries.insert( + host, + Entry { + community, + read_at, + policy, + }, + ); + } + + pub(crate) fn policy(&self, host: &str) -> Option { + let _epoch = self.lock(); + self.entries + .get(host) + .and_then(|entry| entry.policy) + .filter(|(_, at)| at.elapsed() < POLICY_LEASE_MAX_AGE) + .map(|(policy, _)| policy) + } + + pub(crate) fn epoch(&self) -> u64 { + *self.lock() + } + + #[cfg(test)] + pub(crate) fn policy_read_started_at(&self, host: &str) -> Option { + self.entries + .get(host) + .and_then(|entry| entry.policy) + .map(|(_, read_at)| read_at) + } + + pub(crate) fn publish( + &self, + host: String, + policy: CommunityAuthPolicy, + read_at: Instant, + observed_epoch: u64, + ) -> Result { + let epoch = self.lock(); + if *epoch != observed_epoch || read_at.elapsed() >= POLICY_LEASE_MAX_AGE { + return Err(()); + } + if let Some(existing) = self + .entries + .get(&host) + .and_then(|entry| entry.policy) + .filter(|(_, at)| at.elapsed() < POLICY_LEASE_MAX_AGE) + .map(|(policy, _)| policy) + { + if existing.community_id == policy.community_id + && (existing.policy_generation > policy.policy_generation + || (existing.requires_nip_fi && !policy.requires_nip_fi)) + { + return Ok(existing); + } + } + self.entries.insert( + host, + Entry { + community: CommunityId::from_uuid(policy.community_id), + read_at, + policy: Some((policy, read_at)), + }, + ); + Ok(policy) + } + + /// Drop a host snapshot and fence every fill begun before this drop. + pub fn invalidate(&self, host: &str) { + let mut epoch = self.lock(); + *epoch = epoch.wrapping_add(1); + self.entries.invalidate(host); + } + + /// Drop all snapshots and fence in-flight fills. + pub fn invalidate_all(&self) { + let mut epoch = self.lock(); + *epoch = epoch.wrapping_add(1); + self.entries.invalidate_all(); + } + + pub(crate) fn invalidate_community(&self, community: CommunityId) { + let mut epoch = self.lock(); + *epoch = epoch.wrapping_add(1); + if let Err(error) = self + .entries + .invalidate_entries_if(move |_, entry| entry.community == community) + { + tracing::error!( + ?error, + "host cache invalidation unavailable; dropping all entries" + ); + self.entries.invalidate_all(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshots_expire_and_invalidation_fences_late_or_older_fills() { + let cache = HostCache::new(); + let host = "team.example"; + let mut policy = CommunityAuthPolicy { + community_id: uuid::Uuid::new_v4(), + requires_nip_fi: false, + policy_generation: 0, + }; + let epoch = cache.epoch(); + assert_eq!( + cache.publish(host.into(), policy, Instant::now(), epoch), + Ok(policy) + ); + assert_eq!(cache.policy(host), Some(policy)); + let lease_at = cache.entries.get(host).unwrap().policy.unwrap().1; + cache.insert( + host.into(), + (CommunityId::from_uuid(policy.community_id), Instant::now()), + ); + assert_eq!( + cache.policy(host), + Some(policy), + "routing erased the policy lease" + ); + assert_eq!( + cache.entries.get(host).unwrap().policy.unwrap().1, + lease_at, + "routing renewed the policy lease" + ); + let before_drop = cache.epoch(); + cache.invalidate(host); + assert!(cache.policy(host).is_none()); + assert!(cache + .publish(host.into(), policy, Instant::now(), before_drop) + .is_err()); + policy.requires_nip_fi = true; + policy.policy_generation = 1; + let epoch = cache.epoch(); + assert_eq!( + cache.publish(host.into(), policy, Instant::now(), epoch), + Ok(policy) + ); + let mut old = policy; + old.requires_nip_fi = false; + old.policy_generation = 0; + assert_eq!( + cache.publish(host.into(), old, Instant::now(), epoch), + Ok(policy) + ); + assert_eq!(cache.policy(host), Some(policy)); + // The cache age is measured from the original read, even when a slow + // query or scheduler delivers its result after the full lease elapsed. + let expired = Instant::now().checked_sub(Duration::from_secs(1)).unwrap(); + assert!(cache + .publish("slow.example".into(), old, expired, epoch) + .is_err()); + assert!(cache.policy("slow.example").is_none()); + cache.invalidate_all(); + assert!(cache + .publish(host.into(), old, Instant::now(), epoch) + .is_err()); + assert!(cache.policy(host).is_none()); + assert_eq!(cache.entries.policy().max_capacity(), Some(10_000)); + } +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 8b8fa0807bc..dc7050cce2d 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -6,6 +6,7 @@ pub(crate) mod access; mod admission; mod build_info; +mod host_cache; mod rejection; /// Shared NIP-FI assertion evaluation, denial rendering, and key pairing. @@ -54,6 +55,7 @@ pub mod nip_fi_config; /// NIP-FI HTTP ingress enforcement: assertion extraction, verification, /// key-pairing check, and deny-map gate for every protected HTTP surface. pub(crate) mod nip_fi_http; +pub(crate) mod nip_fi_policy; pub mod nip_fi_resolver; /// Deployment-global operator-listener mention delivery worker. pub mod operator_listener; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index f5fe7d0913c..fb27cb48ca2 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -935,6 +935,13 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!("NIP-FI: binding snapshot loaded"); app_state.nip_fi_resolver = Some(resolver); } + metrics::gauge!("buzz_nip_fi_required_admission_ready").set( + if app_state.nip_fi_required_admission_ready() { + 1.0 + } else { + 0.0 + }, + ); let state = Arc::new(app_state); if let Some(tenant) = &deployment_community { buzz_relay::handlers::side_effects::fan_out_nip43_membership_list( diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index bcce89f1e63..c250c2949a5 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -23,6 +23,9 @@ pub(crate) const NIP_RELAY_MEMBERSHIP: u32 = 43; /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayInfo { + /// Host-specific policy, independent of NIP-FI capability advertisement. + #[serde(default)] + pub requires_nip_fi: bool, /// Human-readable relay name. pub name: String, /// Human-readable relay description. @@ -202,7 +205,7 @@ impl RelayInfo { /// `self`. Pass `Some` whenever the relay has a stable signing key. /// /// `icon` is the community's workspace icon (see - /// [`workspace_icon_for_host`]) — a host-scoped scalar, pre-fetched by + /// [`workspace_icon_for_tenant`]) — a host-scoped scalar, pre-fetched by /// the caller so `build` itself stays static-input. /// /// `advertise_nip43` controls whether NIP-43 (relay membership) is added @@ -314,6 +317,7 @@ impl RelayInfo { gif, relay_self: relay_self.map(|s| s.to_string()), federated_identity, + requires_nip_fi: false, } } } @@ -322,12 +326,12 @@ impl RelayInfo { pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, headers: axum::http::HeaderMap, -) -> axum::response::Json { +) -> axum::response::Response { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - axum::response::Json(nip11_document(&state, raw_host).await) + nip11_response(&state, raw_host).await } fn push_descriptor( @@ -378,11 +382,75 @@ fn push_descriptor( /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the /// host-scoped workspace icon. Optional provider capabilities are passed as /// config-derived scalar identifiers; no provider credential enters NIP-11. -pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { +pub(crate) async fn nip11_response( + state: &crate::state::AppState, + raw_host: &str, +) -> axum::response::Response { + use axum::response::IntoResponse; + let mut response = match nip11_document(state, raw_host).await { + Ok(document) => axum::Json(document).into_response(), + Err(response) => response, + }; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +#[allow(clippy::result_large_err)] // Axum responses carry the discovery denial contract. +pub(crate) async fn nip11_document( + state: &crate::state::AppState, + raw_host: &str, +) -> Result { + use axum::response::IntoResponse; + let mut headers = axum::http::HeaderMap::new(); + if let Ok(host) = axum::http::HeaderValue::from_str(raw_host) { + headers.insert(axum::http::header::HOST, host); + } + let policy = if state.config.nip_fi.mode.is_off() { + // Off discovery uses ordinary tenant routing, independent of FI policy + // and issuer dependencies. A missing tenant remains a routing refusal. + let tenant = crate::tenant::bind_community(state, raw_host) + .await + .map_err(|_| { + ( + axum::http::StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + .into_response() + })?; + crate::nip_fi_policy::Policy { + community_id: *tenant.community().as_uuid(), + mode: state.config.nip_fi.mode, + required: false, + stored_required: false, + mapped: true, + } + } else { + crate::nip_fi_policy::resolve(state, &headers).await? + }; + if !state.config.nip_fi.mode.is_off() && policy.stored_required { + // Required implies a usable binding and verifier, not merely a bit. + state + .nip_fi_communities() + .resolve(&headers) + .await + .map_err(crate::nip_fi_core::http_denial)?; + if !crate::nip_fi_policy::required_dependencies_ready(state) { + return Err(crate::nip_fi_core::http_denial( + buzz_auth::DenialClass::AuthorizationUnavailable, + )); + } + } let (relay_self, advertise_nip43) = nip11_facts(state); - let icon = workspace_icon_for_host(state, raw_host).await; + let tenant = policy.tenant(raw_host); + let icon = match tenant.as_ref() { + Some(tenant) => workspace_icon_for_tenant(state, tenant).await, + None => None, + }; let admin_api = admin_api_advertisement(state.config.admin.as_ref()); - let advertise_fi = state.config.nip_fi.mode.evaluates(); + let advertise_fi = state.config.nip_fi.mode.evaluates() || policy.required; let mut info = RelayInfo::build( relay_self.as_deref(), icon.as_deref(), @@ -395,9 +463,10 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st admin_api.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); + info.requires_nip_fi = policy.required; info.artifacts["incremental"] = crate::api::artifact_sync::capability(); - if let Ok(tenant) = crate::tenant::bind_community(state, raw_host).await { - if let Some(profile) = crate::verified_profiles::discovery(state, &tenant).await { + if let Some(tenant) = tenant.as_ref() { + if let Some(profile) = crate::verified_profiles::discovery(state, tenant).await { if let Some(fi) = info.federated_identity.as_mut() { fi["public_profile"] = profile; } @@ -421,10 +490,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st })); } let tenant_host = if state.config.push_enabled { - crate::tenant::bind_community(state, raw_host) - .await - .ok() - .map(|tenant| tenant.host().to_owned()) + tenant.as_ref().map(|tenant| tenant.host().to_owned()) } else { None }; @@ -440,20 +506,15 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st .push("buzz-push-v1".to_string()); info.push = Some(push); } - info + Ok(info) } -/// Fetches the workspace icon for the community bound to `raw_host`, as the -/// host-scoped scalar consumed by [`RelayInfo::build`]. -/// -/// The icon is per-community state (`communities.icon`, set by relay -/// admins/owners via the kind:9033 command) served in the standard NIP-11 -/// `icon` field. The lookup is scoped through -/// [`crate::tenant::bind_community`] — never an unscoped query. Fails open to -/// `None` (no `icon` field): NIP-11 is intentionally served to unmapped hosts -/// too, and an icon lookup failure must not break that. -async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) -> Option { - let tenant = crate::tenant::bind_community(state, raw_host).await.ok()?; +/// Fetch the icon for the same immutable tenant that supplied NIP-11 policy. +/// A decoration lookup failure omits the icon. +async fn workspace_icon_for_tenant( + state: &crate::state::AppState, + tenant: &buzz_core::TenantContext, +) -> Option { state .db .get_community_icon(tenant.community()) @@ -500,10 +561,9 @@ fn admin_api_advertisement(admin: Option<&crate::config::AdminConfig>) -> Option /// DB/search/audit inputs", so an unauthenticated NIP-11 read can never become /// an enumeration oracle for *other* communities. `build` takes only static /// and scalar inputs — the per-deployment facts arrive pre-derived through -/// [`nip11_facts`] (config + relay keypair), and the one host-scoped fact -/// (the workspace `icon`) arrives as a scalar from -/// [`workspace_icon_for_host`], whose DB lookup is scoped through -/// [`crate::tenant::bind_community`] and can therefore only ever surface the +/// [`nip11_facts`] (config + relay keypair). Host-scoped policy and optional +/// presentation fields are resolved before construction or assigned afterward +/// as scalars by [`nip11_document`]. These lookups can surface only the /// requesting host's own community state. /// /// This const binds `RelayInfo::build` to its **exact** allowed signature. The @@ -536,16 +596,28 @@ mod tests { "postgres://127.0.0.1:1/none", ) .await; + base.tenant_host_cache.insert( + "relay.example".into(), + ( + buzz_core::CommunityId::from_uuid(uuid::Uuid::nil()), + std::time::Instant::now(), + ), + ); let mut docs = Vec::new(); for mode in [NipFiMode::Off, NipFiMode::Enforce, NipFiMode::Shadow] { let mut state = (*base).clone(); let mut config = (*state.config).clone(); config.nip_fi.mode = mode; state.config = std::sync::Arc::new(config); - docs.push( - serde_json::to_string(&super::nip11_document(&state, "relay.example").await) + let mut document = serde_json::to_value( + super::nip11_document(&state, "relay.example") + .await .unwrap(), - ); + ) + .unwrap(); + assert_eq!(document["requires_nip_fi"], mode.enforces()); + document.as_object_mut().unwrap().remove("requires_nip_fi"); + docs.push(document); } assert_ne!(docs[0], docs[1], "enforce advertises NIP-FI"); assert_eq!(docs[2], docs[1]); diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index ccc3190563b..fa1b54f4d7a 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -750,9 +750,8 @@ fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConf // ── Duration helpers ────────────────────────────────────────────────────────── impl NipFiRelayConfig { - /// Whether startup builds the database resolver: only for the `database` - /// source in a mode that evaluates evidence. Off and deny-protected never - /// resolve a community, so they load no snapshot. + /// Only evaluating modes load FI bindings. Off starts without an FI + /// snapshot and cannot establish required-admission readiness. pub fn builds_resolver(&self) -> bool { self.source == CommunitySource::Database && self.mode.evaluates() } @@ -2004,10 +2003,10 @@ mod tests { } } - // Mutation: building the resolver for the database source in every mode - // loads a snapshot that off and deny-protected never read. + // Off must not depend on an FI snapshot. + // Repair mode remains unconditional and needs no snapshot. #[test] - fn only_evaluating_modes_with_the_database_source_build_a_resolver() { + fn database_source_builds_a_resolver_only_in_evaluating_modes() { let base = { let _guard = super::NIP_FI_ENV_LOCK .lock() diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index c5bb447bc6b..a7ef3620a98 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -702,6 +702,24 @@ where })?; let key = Some(proof.pubkey); + // A legacy dev header is an identity hint, never proof of key possession. + // Required rows must reject it even if a caller accidentally returns one. + if !proof.signed { + return Err(EnforceDenial::new( + WouldDeny::new( + Stage::Nip98, + if headers.contains_key("authorization") { + DenialClass::EvidenceRejected + } else { + DenialClass::MissingEvidence + }, + "unsigned_proof", + ), + None, + Some(AuthResult::Failed("unsigned_proof")), + )); + } + // Steps 4–5: extract and verify the assertion. let assertion = evaluate_attached_assertion(headers, community, verifier).map_err(|rejection| { @@ -766,7 +784,14 @@ pub(crate) async fn admit_nip_fi_http_on_state( where F: FnOnce() -> Result, Response>, { - let mode = state.config.nip_fi.mode; + let mode = crate::nip_fi_policy::admission_mode(state, headers) + .await + .inspect_err(|_| { + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + })?; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); let admission = match state.nip_fi_deny_map.as_deref() { @@ -1575,8 +1600,9 @@ mod tests { "{lines:?}" ); assert!(lines[1].contains("pubkey=\"absent\""), "{lines:?}"); + let unsigned = enforce_count(["http", "nip98", "missing", TEST_COMMUNITY_URI]); let pairing = enforce_count(["http", "pairing", "denied", TEST_COMMUNITY_URI]); - assert_eq!(counts, [pairing.clone(), pairing]); + assert_eq!(counts, [unsigned, pairing]); } // ── R3 regression: Authorization cardinality ───────────────────────────── @@ -1606,52 +1632,80 @@ mod tests { /// outcome and pass the result through unchanged, and a request refused /// at cardinality, before its proof, records none. Mutation: recording /// in `EnforcedProof::new` instead (a side check) counts the duplicate. - #[test] - fn enforce_strict_proof_records_the_deciding_check_once() { + #[tokio::test] + async fn enforce_strict_proof_records_the_deciding_check_once() { use crate::nip_fi_shadow::EnforcedProof; - let recorder = metrics_util::debugging::DebuggingRecorder::new(); - metrics::with_local_recorder(&recorder, || { - let proof = EnforcedProof::for_test("bridge"); - assert_eq!(proof.record(Ok::<_, ()>(7)), Ok(7)); - assert_eq!(proof.record(Err::<(), _>("bad")), Err("bad")); - let mut headers = host_headers(); - for token in ["Nostr first.token", "Nostr second.token"] { - headers.append( - axum::http::header::AUTHORIZATION, - HeaderValue::from_static(token), - ); + let mut state = (*crate::state::tests::test_state().await).clone(); + for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::Enforce] { + for stored_required in [false, true] { + std::sync::Arc::make_mut(&mut state.config).nip_fi.mode = global; + let mode = global.with_requirement(stored_required); + let policy = crate::nip_fi_policy::Policy { + community_id: uuid::Uuid::new_v4(), + mode, + required: mode.enforces(), + stored_required, + mapped: true, + }; + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + crate::nip_fi_policy::with_policy(policy, async { + let proof = EnforcedProof::new(&state, &host_headers(), "bridge"); + assert_eq!(proof.record(Ok::<_, ()>(7)), Ok(7)); + assert_eq!(proof.record(Err::<(), _>("bad")), Err("bad")); + let mut headers = host_headers(); + for token in ["Nostr first.token", "Nostr second.token"] { + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static(token), + ); + } + let pubkey = any_pubkey(); + let outcome = admit_stateless::<_, (), _>( + &headers, + || proof.record(Ok(Nip98Proof::new(pubkey, ()))), + &communities(), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ) + .now_or_never() + .expect("env resolution never waits"); + // This Enforce probe refuses cardinality before proof. + assert!(outcome.is_err(), "invalid evidence is refused"); + }) + .now_or_never() + .expect("scoped proof check never waits"); + }); + let mut strict: Vec = recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| { + key.key().name() == "buzz_nip_fi_enforce_strict_proof_total" + }) + .map(|(key, _, _, value)| { + let outcome = key + .key() + .labels() + .find(|l| l.key() == "outcome") + .map(|l| l.value().to_owned()) + .unwrap_or_default(); + format!("{outcome}={value:?}") + }) + .collect(); + strict.sort(); + if mode.enforces() { + assert_eq!(strict, ["pass=Counter(1)", "rejected=Counter(1)"]); + } else { + assert!( + strict.is_empty(), + "{global:?} stored={stored_required}: {strict:?}" + ); + } } - let pubkey = any_pubkey(); - let outcome = admit_stateless::<_, (), _>( - &headers, - || proof.record(Ok(Nip98Proof::new(pubkey, ()))), - &communities(), - None::<&dyn VerifyAssertion>, - NipFiMode::Enforce, - &AlwaysAdmitStubDenyMap, - ) - .now_or_never() - .expect("env resolution never waits"); - assert!(outcome.is_err(), "cardinality refuses"); - }); - let mut strict: Vec = recorder - .snapshotter() - .snapshot() - .into_vec() - .into_iter() - .filter(|(key, ..)| key.key().name() == "buzz_nip_fi_enforce_strict_proof_total") - .map(|(key, _, _, value)| { - let outcome = key - .key() - .labels() - .find(|l| l.key() == "outcome") - .map(|l| l.value().to_owned()) - .unwrap_or_default(); - format!("{outcome}={value:?}") - }) - .collect(); - strict.sort(); - assert_eq!(strict, ["pass=Counter(1)", "rejected=Counter(1)"]); + } } #[test] diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs new file mode 100644 index 00000000000..a4a7fff42b2 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -0,0 +1,302 @@ +//! Host policy uses short primary-read leases, shared with tenant routing. +//! Acceptance excludes new leases and drains old ones before commit. Request +//! scopes retain one snapshot; existing work is drained separately by BUZZ-512. + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, + response::IntoResponse, +}; +use buzz_auth::{DenialClass, NipFiMode}; +use buzz_db::community_auth::CommunityAuthPolicy; +use futures_util::future::{BoxFuture, FutureExt}; + +const POLICY_READ_BUDGET: std::time::Duration = std::time::Duration::from_millis(500); + +/// Host policy source, shared by admission and discovery. +pub(crate) trait PolicySource: Send + Sync { + fn lookup<'a>( + &'a self, + host: &'a str, + ) -> BoxFuture<'a, Result, ()>>; +} +impl PolicySource for buzz_db::Db { + fn lookup<'a>( + &'a self, + host: &'a str, + ) -> BoxFuture<'a, Result, ()>> { + async move { + self.community_auth_policy_lease_for_host(host) + .await + .map_err(|_| ()) + } + .boxed() + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Policy { + pub community_id: uuid::Uuid, + pub mode: NipFiMode, + pub required: bool, + pub stored_required: bool, + + // False only for the guard's legacy rejection scope after an authoritative + // host miss. Such a scope must never yield an admission proof. + pub mapped: bool, +} + +impl Policy { + /// Bind tenant-scoped data to the row that supplied admission policy. + pub(crate) fn tenant(self, raw_host: &str) -> Option { + let host = buzz_core::tenant::normalize_host(raw_host); + (self.mapped && !host.is_empty()).then(|| { + buzz_core::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(self.community_id), + host, + ) + }) + } +} + +tokio::task_local! { static REQUEST_POLICY: Policy; } + +/// None outside a guarded request; Some(None) is an authoritative host miss. +/// A guarded request must never fall back to another host-cache snapshot. +pub(crate) fn request_tenant(raw_host: &str) -> Option> { + REQUEST_POLICY + .try_with(|policy| policy.tenant(raw_host)) + .ok() +} + +/// Effective mode already resolved for this request, without another lookup. +pub(crate) fn request_mode(global: NipFiMode) -> NipFiMode { + REQUEST_POLICY + .try_with(|policy| policy.mode) + .unwrap_or(global) +} + +pub(crate) async fn with_policy(policy: Policy, next: F) -> F::Output { + REQUEST_POLICY.scope(policy, next).await +} + +/// Repair mode precedes all policy and evidence failures. +pub(crate) fn effective(global: NipFiMode, required: bool) -> NipFiMode { + global.with_requirement(required) +} + +/// One bounded policy fill. Its serving row also supplies ordinary routing. +/// Misses, errors, expired reads and invalidated in-flight results are not cached. +pub(crate) async fn cached_lookup( + state: &crate::state::AppState, + host: &str, +) -> Result, ()> { + tokio::time::timeout(POLICY_READ_BUDGET, async { + if let Some(policy) = state.tenant_host_cache.policy(host) { + metrics::counter!("buzz_nip_fi_policy_cache_hits_total").increment(1); + return Ok(Some(policy)); + } + let _turn = state.tenant_host_reads.turn(host.to_owned()).await; + if let Some(policy) = state.tenant_host_cache.policy(host) { + metrics::counter!("buzz_nip_fi_policy_cache_hits_total").increment(1); + return Ok(Some(policy)); + } + // Misses and errors are not cached. A waiter with no shared result + // retries rather than inventing a 503 for an unmapped host. + metrics::counter!("buzz_nip_fi_policy_cache_misses_total").increment(1); + let epoch = state.tenant_host_cache.epoch(); + // Anchor before checkout, not at query completion or cache insertion. + let read_at = std::time::Instant::now(); + metrics::counter!("buzz_nip_fi_policy_reads_total").increment(1); + let result = state.nip_fi_policy.lookup(host).await; + metrics::histogram!("buzz_nip_fi_policy_read_seconds") + .record(read_at.elapsed().as_secs_f64()); + match result? { + // Invalidation prevents publication, not the use of a snapshot + // already read before commit. Such a request is in flight, just + // like an uncached primary reader; activation fences its work. + Some(policy) => Ok(Some( + state + .tenant_host_cache + .publish(host.to_owned(), policy, read_at, epoch) + .unwrap_or(policy), + )), + None => Ok(None), + } + }) + .await + .map_err(|_| ())? +} + +#[allow(clippy::result_large_err)] // Axum responses are the ingress error contract. +pub(crate) async fn resolve( + state: &crate::state::AppState, + headers: &HeaderMap, +) -> Result> { + if let Ok(policy) = REQUEST_POLICY.try_with(|policy| *policy) { + return Ok(policy); + } + let global = state.config.nip_fi.mode; + let host = headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let host = buzz_core::tenant::normalize_host(host); + if host.is_empty() || host.chars().count() > buzz_core::tenant::MAX_HOST_LEN { + return Err(not_found()); + } + let policy = match cached_lookup(state, &host).await { + Ok(Some(policy)) => policy, + Ok(None) => return Err(not_found()), + _ => { + return Err(crate::nip_fi_core::http_denial( + DenialClass::AuthorizationUnavailable, + )) + } + }; + tracing::trace!( + generation = policy.policy_generation, + "resolved community auth policy" + ); + Ok(Policy { + community_id: policy.community_id, + mode: effective(global, policy.requires_nip_fi), + required: !global.is_off() && (policy.requires_nip_fi || global.enforces()), + + stored_required: policy.requires_nip_fi, + mapped: true, + }) +} + +/// Preserve route-specific rejection ordering for an authoritative host miss. +/// This is not admission: the full gate below still requires a mapped policy. +pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> NipFiMode { + if state.config.nip_fi.mode.is_off() { + return state.config.nip_fi.mode; + } + if state.config.nip_fi.mode.denies_unconditionally() { + return NipFiMode::unavailable(); + } + match resolve(state, headers).await { + Ok(policy) => configured_mode(state, policy), + Err(response) if response.status() == StatusCode::NOT_FOUND => state.config.nip_fi.mode, + Err(_) => NipFiMode::unavailable(), + } +} + +/// Row-zero binding and the policy belonging to that exact primary row. +/// Early rejection ordering may use `mode`, but cannot authorize an upgrade. +#[allow(clippy::result_large_err)] +pub(crate) async fn bind_for_upgrade( + state: &crate::state::AppState, + raw_host: &str, +) -> Result<(buzz_core::tenant::TenantContext, NipFiMode), Response> { + if state.config.nip_fi.mode.is_off() { + return crate::tenant::bind_community(state, raw_host) + .await + .map(|tenant| (tenant, state.config.nip_fi.mode)) + .map_err(|_| not_found()); + } + let host = buzz_core::tenant::normalize_host(raw_host); + if host.is_empty() || host.chars().count() > buzz_core::tenant::MAX_HOST_LEN { + return Err(not_found()); + } + let binding = tokio::time::timeout( + POLICY_READ_BUDGET, + state.db.community_auth_binding_for_host(&host), + ) + .await; + let (row, policy) = match binding { + Ok(Ok(Some(binding))) => binding, + // Row-zero binding preserves its generic refusal on a missing row or + // failed lookup. Neither outcome can yield a tenant or an upgrade. + _ => return Err(not_found()), + }; + let policy = Policy { + community_id: policy.community_id, + mode: effective(state.config.nip_fi.mode, policy.requires_nip_fi), + required: !state.config.nip_fi.mode.is_off() + && (policy.requires_nip_fi || state.config.nip_fi.mode.enforces()), + + stored_required: policy.requires_nip_fi, + mapped: true, + }; + Ok(( + buzz_core::tenant::TenantContext::resolved(row.id, host), + configured_mode(state, policy), + )) +} + +/// A shared admission proof is impossible without a serving host policy, +/// even when a route preserves legacy unauthenticated error precedence. +#[allow(clippy::result_large_err)] // Axum responses are the ingress error contract. +pub(crate) async fn admission_mode( + state: &crate::state::AppState, + headers: &HeaderMap, +) -> Result> { + if state.config.nip_fi.mode.is_off() { + return Ok(state.config.nip_fi.mode); + } + if state.config.nip_fi.mode.denies_unconditionally() { + return Ok(NipFiMode::unavailable()); + } + let policy = resolve(state, headers) + .await + .map_err(|_| crate::nip_fi_core::http_denial(DenialClass::AuthorizationUnavailable))?; + if !policy.mapped { + return Err(crate::nip_fi_core::http_denial( + DenialClass::AuthorizationUnavailable, + )); + } + Ok(configured_mode(state, policy)) +} + +pub(crate) fn required_dependencies_ready(state: &crate::state::AppState) -> bool { + state.nip_fi_verifier.is_some() + && state.nip_fi_deny_map.is_some() + && state.config.nip_fi.max_connection_lifetime().is_some() +} + +fn configured_mode(state: &crate::state::AppState, policy: Policy) -> NipFiMode { + if !state.config.nip_fi.mode.is_off() + && policy.stored_required + && !required_dependencies_ready(state) + { + NipFiMode::unavailable() + } else { + policy.mode + } +} + +fn not_found() -> Response { + ( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + .into_response() +} + +#[cfg(test)] +pub(crate) struct OptionalPolicy; +#[cfg(test)] +impl PolicySource for OptionalPolicy { + fn lookup<'a>( + &'a self, + _host: &'a str, + ) -> BoxFuture<'a, Result, ()>> { + async { + Ok(Some(CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), + requires_nip_fi: false, + policy_generation: 0, + })) + } + .boxed() + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs new file mode 100644 index 00000000000..6fc5d9b4cdb --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -0,0 +1,825 @@ +use super::*; +use axum::{ + body::to_bytes, + http::{header, Request}, +}; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use tower::ServiceExt; + +// The namespace is a fleet protocol constant, deliberately pinned by the test. +const LEASE_NAMESPACE: i32 = 0x4649504c; + +async fn host_lock_pid(db: &buzz_db::Db, host: &str, granted: bool) -> i32 { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let pid: Option = sqlx::query_scalar( + "SELECT pid FROM pg_locks WHERE locktype='advisory' + AND classid = $1::int::oid AND objid = hashtext($2)::oid + AND objsubid = 2 AND mode = 'ExclusiveLock' AND granted = $3", + ) + .bind(LEASE_NAMESPACE) + .bind(host) + .bind(granted) + .fetch_optional(db.pool()) + .await + .unwrap(); + if let Some(pid) = pid { + return pid; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("acceptance must reach the host lease barrier") +} + +async fn wait_for_blocker(db: &buzz_db::Db, waiter: i32, blocker: i32) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let blocked: bool = sqlx::query_scalar("SELECT $2 = ANY(pg_blocking_pids($1))") + .bind(waiter) + .bind(blocker) + .fetch_one(db.pool()) + .await + .unwrap(); + if blocked { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("remap must wait behind the acceptance row lock"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +#[allow(clippy::result_large_err)] // Exercise the real Axum admission response. +async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without_invalidation() { + use buzz_db::community_auth::{UpgradeOutcome, UpgradeRequest}; + let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let owner = Keys::generate().public_key().to_hex(); + let issuer = "https://lease-issuer.example"; + for (notify, double_remap) in [(false, false), (false, true), (true, false), (true, true)] { + let host = format!("lease-{}.example:8443", uuid::Uuid::new_v4().simple()); + let remapped = format!("new-{host}"); + let created = base + .db + .create_community_with_owner_and_nip_fi_issuers( + &host, + &owner, + &[issuer.into()], + &Keys::generate(), + ) + .await + .unwrap(); + let buzz_db::CreateCommunityWithOwnerResult::Created(row) = created.outcome else { + panic!("fixture"); + }; + let mut pod_a = (*base).clone(); + let config = Arc::make_mut(&mut pod_a.config); + config.nip_fi.mode = NipFiMode::Shadow; + config.nip_fi.max_connection_lifetime_secs = 3600; + config.nip_fi.communities = + crate::nip_fi_core::test_support::any_host("https://relay.example"); + let key = Keys::generate().public_key(); + pod_a.nip_fi_verifier = Some(Arc::new(crate::nip_fi_core::tests::ScriptedVerifier::new( + Ok(Some(key)), + ))); + pod_a.nip_fi_deny_map = Some(Arc::new(buzz_auth::NipFiDenyMap::new(100, vec![]))); + pod_a.nip_fi_policy = Arc::new(base.db.clone()); + pod_a.tenant_host_cache = Arc::new(crate::host_cache::HostCache::new()); + let mut pod_b = pod_a.clone(); + // A single connection makes the refused-fill rollback witness real. + pod_b.db = buzz_db::Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&crate::test_support::database_url()) + .await + .unwrap(), + ); + pod_b.nip_fi_policy = Arc::new(pod_b.db.clone()); + pod_b.tenant_host_cache = Arc::new(crate::host_cache::HostCache::new()); + pod_b.tenant_host_reads = Arc::new(crate::shared_reads::SharedReads::new( + 10_000, + std::time::Duration::from_millis(500), + )); + let request = UpgradeRequest { + operation_id: uuid::Uuid::new_v4(), + community_id: *row.id.as_uuid(), + expected_host: host.clone(), + actor_pubkey: owner.clone(), + expected_owner_pubkey: owner.clone(), + expected_issuer: issuer.into(), + operator_pubkey: owner.clone(), + }; + let mut wrong_host = request.clone(); + wrong_host.operation_id = uuid::Uuid::new_v4(); + wrong_host.expected_host = remapped.clone(); + assert_eq!( + pod_a + .db + .enable_community_nip_fi(&wrong_host, Ok(())) + .await + .unwrap(), + UpgradeOutcome::Refused("host_conflict") + ); + + // Keep acceptance paused AFTER it locks the community row, without + // using a delay to guess when either operation reached its boundary. + let mut host_gate = base.db.pool().begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock_shared($1, hashtext($2))") + .bind(LEASE_NAMESPACE) + .bind(&host) + .execute(&mut *host_gate) + .await + .unwrap(); + // A second barrier holds the operation after the drain if the test + // process is slow, so lock/refusal assertions cannot race commit. + let mut issuer_gate = base.db.pool().begin().await.unwrap(); + sqlx::query("SELECT issuer FROM community_nip_fi_issuers WHERE community_id=$1 FOR UPDATE") + .bind(row.id.as_uuid()) + .fetch_all(&mut *issuer_gate) + .await + .unwrap(); + let mut h = HeaderMap::new(); + h.insert(header::HOST, host.to_uppercase().parse().unwrap()); + assert!(!resolve(&pod_b, &h).await.unwrap().stored_required); + let primed_at = pod_b + .tenant_host_cache + .policy_read_started_at(&host) + .unwrap(); + assert!( + crate::nip_fi_http::admit_nip_fi_http_on_state( + &pod_b, + &h, + &crate::access::AccessSlot::default(), + || Ok(crate::nip_fi_http::Nip98Proof::new(key, ())), + ) + .await + .is_ok(), + "optional community retains legacy admission" + ); + let acceptance = { + let db = pod_a.db.clone(); + let request = request.clone(); + tokio::spawn(async move { db.enable_community_nip_fi(&request, Ok(())).await }) + }; + let acceptance_pid = host_lock_pid(&base.db, &host, false).await; + let mut remap_connection = base.db.pool().acquire().await.unwrap(); + let remap_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *remap_connection) + .await + .unwrap(); + let remap = { + let community = row.id; + let new_host = remapped.clone(); + let old_host = host.clone(); + tokio::spawn(async move { + sqlx::query("UPDATE communities SET host=$2 WHERE id=$1") + .bind(community.as_uuid()) + .bind(&new_host) + .execute(&mut *remap_connection) + .await + .unwrap(); + if double_remap { + sqlx::query("UPDATE communities SET host=$2 WHERE id=$1") + .bind(community.as_uuid()) + .bind(&old_host) + .execute(&mut *remap_connection) + .await + .unwrap(); + } + }) + }; + wait_for_blocker(&base.db, remap_pid, acceptance_pid).await; + host_gate.commit().await.unwrap(); + assert_eq!(host_lock_pid(&base.db, &host, true).await, acceptance_pid); + assert!(pod_b + .db + .community_auth_policy_lease_for_host(&host.to_uppercase()) + .await + .is_err()); + assert_eq!( + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(pod_b.db.pool()) + .await + .unwrap(), + 1 + ); + assert!( + !remap.is_finished(), + "single/double remap must remain blocked until acceptance commits" + ); + issuer_gate.commit().await.unwrap(); + let UpgradeOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { + panic!("acceptance"); + }; + let prime_to_commit = primed_at.elapsed(); + eprintln!("optional lease prime-to-observed-commit: {prime_to_commit:?}"); + remap.await.unwrap(); + // Do this before notifications: lease safety cannot rely on fan-out. + assert!( + pod_b.tenant_host_cache.policy(&host).is_none(), + "optional lease survived commit after {prime_to_commit:?}" + ); + assert!(pod_b.tenant_host_cache.policy(&remapped).is_none()); + if notify { + pod_a.invalidate_community_host(&buzz_core::TenantContext::resolved(row.id, &host)); + pod_b.apply_cache_invalidation( + row.id, + buzz_pubsub::cache_invalidation::CacheInvalidation::CommunityHost { + host: host.clone(), + }, + ); + } + for candidate in [&host, &remapped] { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, candidate.parse().unwrap()); + let serving = candidate == if double_remap { &host } else { &remapped }; + if serving { + assert!(resolve(&pod_b, &headers).await.unwrap().stored_required); + assert_eq!( + crate::nip_fi_http::admit_nip_fi_http_on_state( + &pod_b, + &headers, + &crate::access::AccessSlot::default(), + || Ok(crate::nip_fi_http::Nip98Proof::new(key, ())), + ) + .await + .err() + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } else { + assert_eq!( + resolve(&pod_b, &headers).await.err().unwrap().status(), + StatusCode::NOT_FOUND + ); + } + } + } +} + +struct Replay(Mutex>); +impl buzz_auth::Nip98ReplayGuard for Replay { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + id: &'a nostr::EventId, + _ttl: u64, + ) -> BoxFuture<'a, Result> { + let inserted = self.0.lock().unwrap().insert(*id.as_bytes()); + async move { Ok(inserted) }.boxed() + } +} + +struct PausedPolicyRead { + db: buzz_db::Db, + read: tokio::sync::Notify, + resume: tokio::sync::Notify, +} +impl PolicySource for PausedPolicyRead { + fn lookup<'a>( + &'a self, + host: &'a str, + ) -> BoxFuture<'a, Result, ()>> { + async move { + let result = self + .db + .community_auth_policy_for_host(host) + .await + .map_err(|_| ())?; + assert!( + result.is_none(), + "the initial primary snapshot must miss the archived host" + ); + self.read.notify_one(); + self.resume.notified().await; + Ok(result) + } + .boxed() + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn bound_row_policy_wins_when_required_host_becomes_serving_after_initial_miss() { + use crate::nip_fi_core::test_support::{ + assertion_verifier, sign_assertion, TEST_COMMUNITY_URI, + }; + use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, Error}, + }; + let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let host = format!( + "unarchive-required-{}.example", + uuid::Uuid::new_v4().simple() + ); + let community = base.db.ensure_configured_community(&host).await.unwrap(); + sqlx::query( + "UPDATE communities SET requires_nip_fi = true, policy_generation = 1 WHERE id = $1", + ) + .bind(community.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + let key = Keys::generate(); + let token = sign_assertion("nip-fi+jwt", &key.public_key().to_hex(), None); + for global in [NipFiMode::Shadow, NipFiMode::Enforce] { + for path in ["/", "/huddle/00000000-0000-4000-8000-000000000001/audio"] { + for assertion in [false, true] { + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(community.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + let pause = Arc::new(PausedPolicyRead { + db: base.db.clone(), + read: Default::default(), + resume: Default::default(), + }); + let mut state = (*base).clone(); + let config = Arc::make_mut(&mut state.config); + config.nip_fi.mode = global; + config.nip_fi.max_connection_lifetime_secs = 3600; + config.nip_fi.communities = + crate::nip_fi_core::test_support::any_host(TEST_COMMUNITY_URI); + state.nip_fi_policy = pause.clone(); + state.nip_fi_verifier = Some(Arc::new(assertion_verifier( + buzz_auth::TokenClass::DedicatedNipFi, + ))); + state.nip_fi_deny_map = Some(Arc::new(buzz_auth::NipFiDenyMap::new(100, vec![]))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = crate::router::build_router(Arc::new(state)) + .into_make_service_with_connect_info::(); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let mut request = format!("ws://{address}{path}") + .into_client_request() + .unwrap(); + request.headers_mut().insert("host", host.parse().unwrap()); + if assertion { + request.headers_mut().insert( + "nostr-federated-identity", + format!("Bearer {token}").parse().unwrap(), + ); + } + let connect = tokio::spawn(async move { connect_async(request).await }); + tokio::time::timeout(std::time::Duration::from_secs(5), pause.read.notified()) + .await + .unwrap(); + // The initial primary query already observed no serving row. + // Make that SAME required row serving before its result reaches + // mode selection and the authoritative row-zero binding. + sqlx::query("UPDATE communities SET archived_at = NULL WHERE id = $1") + .bind(community.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + pause.resume.notify_one(); + let result = tokio::time::timeout(std::time::Duration::from_secs(5), connect) + .await + .unwrap() + .unwrap(); + if assertion { + let (mut socket, response) = result.expect("valid bound-row evidence upgrades"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let _ = socket.close(None).await; + } else { + match result { + Err(Error::Http(response)) => assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{global:?} {path}" + ), + other => panic!( + "bound required row must not become free: {global:?} {path} {other:?}" + ), + } + } + server.abort(); + let _ = server.await; + } + } + } +} + +// Exercise actual Hyper upgrades, the primary host policy query and tenant +// binding together. A deletion request does not fence serving until quiescing. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn real_required_root_audio_upgrades_retain_policy_during_pending_deletion() { + use crate::nip_fi_core::test_support::{ + assertion_verifier, sign_assertion, TEST_COMMUNITY_URI, + }; + use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, Error}, + }; + + let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let host = format!("ws-required-{}.example", uuid::Uuid::new_v4().simple()); + let community = base.db.ensure_configured_community(&host).await.unwrap(); + sqlx::query( + "UPDATE communities SET requires_nip_fi = true, policy_generation = 1 WHERE id = $1", + ) + .bind(community.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + let key = Keys::generate(); + let token = sign_assertion("nip-fi+jwt", &key.public_key().to_hex(), None); + for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::Enforce] { + let mut state = (*base).clone(); + let config = Arc::make_mut(&mut state.config); + config.nip_fi.mode = global; + config.nip_fi.max_connection_lifetime_secs = 3600; + config.nip_fi.communities = crate::nip_fi_core::test_support::any_host(TEST_COMMUNITY_URI); + state.nip_fi_verifier = Some(Arc::new(assertion_verifier( + buzz_auth::TokenClass::DedicatedNipFi, + ))); + state.nip_fi_deny_map = Some(Arc::new(buzz_auth::NipFiDenyMap::new(100, vec![]))); + if global.is_off() { + state.nip_fi_verifier = None; + state.nip_fi_deny_map = None; + Arc::make_mut(&mut state.config) + .nip_fi + .max_connection_lifetime_secs = 0; + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = crate::router::build_router(Arc::new(state)) + .into_make_service_with_connect_info::(); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + for stage in ["none", "submitted", "inventoried", "approved"] { + if stage == "none" { + sqlx::query("DELETE FROM community_deletion_requests WHERE community_id = $1") + .bind(community.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + } else if stage == "submitted" { + sqlx::query("INSERT INTO community_deletion_requests (community_id, community_host, requested_by) VALUES ($1, $2, 'policy-test')") + .bind(community.id.as_uuid()).bind(&host).execute(base.db.pool()).await.unwrap(); + } else { + sqlx::query( + "UPDATE community_deletion_requests SET stage = $2 WHERE community_id = $1", + ) + .bind(community.id.as_uuid()) + .bind(stage) + .execute(base.db.pool()) + .await + .unwrap(); + } + for path in ["/", "/huddle/00000000-0000-4000-8000-000000000001/audio"] { + for assertion in [false, true] { + let mut request = format!("ws://{address}{path}") + .into_client_request() + .unwrap(); + request.headers_mut().insert("host", host.parse().unwrap()); + if assertion { + request.headers_mut().insert( + "nostr-federated-identity", + format!("Bearer {token}").parse().unwrap(), + ); + } + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + connect_async(request), + ) + .await + .unwrap(); + if assertion || global.is_off() { + let (mut socket, response) = + result.expect("valid signed assertion must upgrade"); + assert_eq!( + response.status(), + StatusCode::SWITCHING_PROTOCOLS, + "{global:?} {stage} {path}" + ); + let _ = socket.close(None).await; + } else { + match result { + Err(Error::Http(response)) => assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{global:?} {stage} {path}" + ), + other => panic!( + "missing assertion must deny: {global:?} {stage} {path}: {other:?}" + ), + } + } + } + } + } + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows() { + let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("isolated Postgres and local Redis"); + let mut state = (*base).clone(); + let keys = Keys::generate(); + let free = format!("free-{}.example", uuid::Uuid::new_v4().simple()); + let required = format!("required-{}.example", uuid::Uuid::new_v4().simple()); + for host in [&free, &required] { + state.db.ensure_configured_community(host).await.unwrap(); + } + sqlx::query( + "UPDATE communities SET requires_nip_fi = true, policy_generation = 1 WHERE host = $1", + ) + .bind(&required) + .execute(state.db.pool()) + .await + .unwrap(); + let config = Arc::make_mut(&mut state.config); + // Stored requirements and primary policy binding apply in evaluating modes. + config.nip_fi.mode = NipFiMode::Shadow; + config.nip_fi.max_connection_lifetime_secs = 3600; + config.nip_fi.communities = crate::nip_fi_core::test_support::any_host("https://relay.example"); + config.relay_url = "wss://relay.example".to_owned(); + config.require_relay_membership = false; + config.require_auth_token = false; + state.nip98_replay = Arc::new(Replay(Mutex::new(HashSet::new()))); + state.nip_fi_verifier = Some(Arc::new(crate::nip_fi_core::tests::ScriptedVerifier::new( + Ok(Some(keys.public_key())), + ))); + state.nip_fi_deny_map = Some(Arc::new(buzz_auth::NipFiDenyMap::new(100, vec![]))); + let state = Arc::new(state); + let app = crate::router::build_router(state.clone()); + let body = br#"[{"kinds":[1]}]"#; + for host in [&free, &required] { + for signed in [false, true] { + let mut request = Request::post("/count") + .header(header::HOST, host) + .header(header::CONTENT_TYPE, "application/json"); + if signed { + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{host}/count")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + request = request.header( + header::AUTHORIZATION, + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(event.as_json()) + ), + ); + } else { + request = request.header("X-Pubkey", keys.public_key().to_hex()); + } + let response = app + .clone() + .oneshot(request.body(Body::from(body.to_vec())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + assert_eq!( + status, + if host == &free { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + }, + "{host} signed={signed}: {}", + String::from_utf8_lossy(&bytes) + ); + } + let response = app + .clone() + .oneshot( + Request::get("/info") + .header(header::HOST, host) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let doc: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap(); + assert_eq!(doc["requires_nip_fi"], host == &required); + } + // The durable requirement remains stored, but Off admits ordinary HTTP + // without FI evidence and advertises no effective requirement. + let mut off = (*state).clone(); + Arc::make_mut(&mut off.config).nip_fi.mode = NipFiMode::Off; + off.nip_fi_verifier = None; + off.nip_fi_deny_map = None; + let off_app = crate::router::build_router(Arc::new(off)); + let response = off_app + .clone() + .oneshot( + Request::post("/count") + .header(header::HOST, &required) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Pubkey", keys.public_key().to_hex()) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "Off ignores stored FI requirements" + ); + let response = off_app + .oneshot( + Request::get("/info") + .header(header::HOST, &required) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let doc: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap(); + assert_eq!(doc["requires_nip_fi"], false); + // Policy read overhead: 100 serial, uncached reads, measured by the real source. + let start = std::time::Instant::now(); + for _ in 0..100 { + assert!( + state + .db + .community_auth_policy_for_host(&required) + .await + .unwrap() + .unwrap() + .requires_nip_fi + ); + } + eprintln!("100 primary policy queries: {:?}", start.elapsed()); +} + +/// A host-cache entry must not choose data under another row's optional policy. +/// Mutation: ignore request_tenant in bind_tenant => the A-only member is admitted. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn remapped_host_uses_one_policy_tenant_for_query_and_discovery() { + let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("isolated Postgres and local Redis"); + let host = format!("policy-remap-{}.example", uuid::Uuid::new_v4().simple()); + let old_host = format!("old-{host}"); + let old = base.db.ensure_configured_community(&host).await.unwrap(); + sqlx::query( + "UPDATE communities SET requires_nip_fi = true, policy_generation = 1 WHERE id = $1", + ) + .bind(old.id.as_uuid()) + .execute(base.db.pool()) + .await + .unwrap(); + let caller = Keys::generate(); + let _ = base + .db + .add_relay_member( + old.id, + &caller.public_key().to_hex(), + "member", + None, + &base.relay_keypair, + ) + .await + .unwrap(); + let secret = EventBuilder::new(Kind::TextNote, "old required community data") + .sign_with_keys(&caller) + .unwrap(); + base.db.insert_event(old.id, &secret, None).await.unwrap(); + // Prime the actual trunk cache before the operator remaps the same host. + let bound = crate::tenant::bind_community(&*base, &host).await.unwrap(); + assert_eq!(bound.community(), old.id); + let mut tx = base.db.pool().begin().await.unwrap(); + sqlx::query("UPDATE communities SET host = $1 WHERE id = $2") + .bind(&old_host) + .bind(old.id.as_uuid()) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + let new = base.db.ensure_configured_community(&host).await.unwrap(); + assert_ne!(old.id, new.id); + // Refresh only the witness's cache age: this models an unexpired entry + // deterministically, without requiring database setup to finish inside 1s. + base.tenant_host_cache + .insert(host.clone(), (old.id, std::time::Instant::now())); + let mut state = (*base).clone(); + let config = Arc::make_mut(&mut state.config); + // Stored requirements and primary policy binding apply in evaluating modes. + config.nip_fi.mode = NipFiMode::Shadow; + config.require_relay_membership = true; + config.require_auth_token = true; + config.relay_url = "wss://relay.example".into(); + state.nip98_replay = Arc::new(Replay(Mutex::new(HashSet::new()))); + let state = Arc::new(state); + let app = crate::router::build_router(state.clone()); + let body = serde_json::to_vec(&serde_json::json!([{"ids":[secret.id.to_hex()]}])).unwrap(); + for is_new_member in [false, true] { + if is_new_member { + let _ = state + .db + .add_relay_member( + new.id, + &caller.public_key().to_hex(), + "member", + None, + &state.relay_keypair, + ) + .await + .unwrap(); + } + state + .tenant_host_cache + .insert(host.clone(), (old.id, std::time::Instant::now())); + let proof = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{host}/query")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&caller) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::post("/query") + .header(header::HOST, &host) + .header(header::CONTENT_TYPE, "application/json") + .header( + header::AUTHORIZATION, + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(proof.as_json()) + ), + ) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + assert_eq!( + status, + if is_new_member { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }, + "membership must belong to the policy tenant: {}", + String::from_utf8_lossy(&bytes) + ); + if is_new_member { + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + serde_json::json!([]), + "new community membership must never expose the old community's event" + ); + } + let response = app + .clone() + .oneshot( + Request::get("/info") + .header(header::HOST, &host) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let doc: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap(); + assert_eq!(doc["requires_nip_fi"], false); + assert_eq!( + doc["read_state_snapshot"]["community_id"], + new.id.as_uuid().to_string(), + "discovery must describe the same row as its fresh optional policy" + ); + } +} diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs new file mode 100644 index 00000000000..30102051ab8 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -0,0 +1,659 @@ +#![allow(clippy::result_large_err)] // Test closures return the real Axum admission response. + +use super::*; +use crate::nip_fi_core::tests::ScriptedVerifier; +use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; +use axum::{ + body::to_bytes, + http::{header, HeaderValue, Request}, +}; +use buzz_auth::{VerifierError, CLIENT_ATTACHED_HEADER}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; +use tower::ServiceExt; + +struct Source { + required: AtomicBool, + calls: AtomicUsize, + unavailable: bool, +} +impl PolicySource for Source { + fn lookup<'a>( + &'a self, + host: &'a str, + ) -> BoxFuture<'a, Result, ()>> { + async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.unavailable { + return Err(()); + } + if host == "unknown.example" { + return Ok(None); + } + let required = host == "required.example" && self.required.load(Ordering::SeqCst); + Ok(Some(CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), + requires_nip_fi: required, + policy_generation: i64::from(required), + })) + } + .boxed() + } +} +fn headers(host: &str, assertion: bool) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(header::HOST, HeaderValue::from_str(host).unwrap()); + if assertion { + h.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer aaa.bbb.ccc"), + ); + } + h +} +async fn fixture( + mode: NipFiMode, +) -> ( + Arc, + Arc, + nostr::PublicKey, + Arc, +) { + let base = crate::state::tests::test_state_with_unreachable_database_url( + "postgres://127.0.0.1:1/none", + ) + .await; + let mut state = (*base).clone(); + Arc::make_mut(&mut state.config).nip_fi.mode = mode; + Arc::make_mut(&mut state.config) + .nip_fi + .max_connection_lifetime_secs = 3600; + Arc::make_mut(&mut state.config).nip_fi.communities = + crate::nip_fi_core::test_support::any_host("https://relay.example"); + let key = nostr::Keys::generate().public_key(); + let verifier = Arc::new(ScriptedVerifier::new(Ok(Some(key)))); + state.nip_fi_verifier = Some(verifier.clone()); + state.nip_fi_deny_map = Some(Arc::new(buzz_auth::NipFiDenyMap::new(100, vec![]))); + let source = Arc::new(Source { + required: AtomicBool::new(true), + calls: AtomicUsize::new(0), + unavailable: false, + }); + state.nip_fi_policy = source.clone(); + for host in ["free.example", "required.example"] { + state.tenant_host_cache.insert( + host.into(), + ( + buzz_core::CommunityId::from_uuid(uuid::Uuid::nil()), + std::time::Instant::now(), + ), + ); + } + (Arc::new(state), source, key, verifier) +} + +#[test] +fn effective_mode_truth_table() { + for global in [ + NipFiMode::Off, + NipFiMode::Shadow, + NipFiMode::Enforce, + NipFiMode::DenyProtected, + ] { + for required in [false, true] { + let expected = match (global, required) { + (NipFiMode::DenyProtected, _) => NipFiMode::DenyProtected, + (NipFiMode::Shadow, true) => NipFiMode::Enforce, + _ => global, + }; + assert_eq!(effective(global, required), expected); + } + } +} + +#[tokio::test] +async fn mode_stored_requirement_and_proof_matrix() { + for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::Enforce] { + for stored in [false, true] { + let (state, source, key, verifier) = fixture(global).await; + source.required.store(stored, Ordering::SeqCst); + let required = global.enforces() || (global.observes_only() && stored); + for signed in [false, true] { + for assertion in [false, true] { + let result = admit_nip_fi_http_on_state( + &state, + &headers("required.example", assertion), + &crate::access::AccessSlot::default(), + || { + Ok(if signed { + Nip98Proof::new(key, ()) + } else { + Nip98Proof::unsigned(key, ()) + }) + }, + ) + .await; + if required && (!signed || !assertion) { + assert_eq!( + result.err().unwrap().status(), + StatusCode::UNAUTHORIZED, + "{global:?} stored={stored} signed={signed} assertion={assertion}" + ); + } else { + assert!( + result.is_ok(), + "{global:?} stored={stored} signed={signed} assertion={assertion}" + ); + } + } + } + if global.is_off() { + assert_eq!( + source.calls.load(Ordering::SeqCst), + 0, + "Off never reads FI policy for admission" + ); + assert_eq!(verifier.calls(), 0, "Off never evaluates assertions"); + } + } + } +} + +#[tokio::test] +async fn required_retains_verifier_and_key_pairing_denials() { + for global in [NipFiMode::Shadow, NipFiMode::Enforce] { + let (base, _, key, _) = fixture(global).await; + for error in [ + VerifierError::UnknownIssuer, + VerifierError::ClaimRejected, + VerifierError::InvalidSignatureOrClaims, + VerifierError::Expired, + VerifierError::KeySourceUnavailable, + ] { + let mut state = (*base).clone(); + state.nip_fi_verifier = Some(Arc::new(ScriptedVerifier::new(Err(error)))); + let result = admit_nip_fi_http_on_state( + &state, + &headers("required.example", true), + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(key, ())), + ) + .await; + let expected = if error == VerifierError::KeySourceUnavailable { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::FORBIDDEN + }; + assert_eq!( + result.err().unwrap().status(), + expected, + "{global:?} {error:?}" + ); + } + let wrong = nostr::Keys::generate().public_key(); + assert_eq!( + admit_nip_fi_http_on_state( + &base, + &headers("required.example", true), + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(wrong, ())) + ) + .await + .err() + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + } +} + +#[tokio::test] +async fn protected_routes_cannot_bypass_required_rows() { + for global in [NipFiMode::Shadow, NipFiMode::Enforce] { + let (state, _, _, _) = fixture(global).await; + let app = crate::router::build_router(state); + for path in [ + "/", + "/huddle/00000000-0000-4000-8000-000000000001/audio", + "/query", + "/count", + "/events", + "/buzz/v1/sync", + "/git/o/r.git/info/refs?service=git-upload-pack", + "/upload", + ] { + let mut req = Request::builder() + .uri(path) + .header(header::HOST, "required.example"); + if matches!(path, "/query" | "/count" | "/events") { + req = req.method("POST"); + } else if path == "/upload" { + req = req.method("PUT"); + } + if path == "/" || path.contains("/audio") { + req = req + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + } + let response = app + .clone() + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{global:?} {path}" + ); + } + } +} + +#[tokio::test] +async fn off_routes_ignore_stored_policy_and_unavailable_fi_dependencies() { + let (base, source, _, _) = fixture(NipFiMode::Off).await; + let control = crate::router::build_router(base.clone()); + let mut state = (*base).clone(); + state.nip_fi_verifier = None; + state.nip_fi_deny_map = None; + Arc::make_mut(&mut state.config) + .nip_fi + .max_connection_lifetime_secs = 0; + let unavailable = Arc::new(Source { + required: AtomicBool::new(true), + calls: AtomicUsize::new(0), + unavailable: true, + }); + state.nip_fi_policy = unavailable.clone(); + let app = crate::router::build_router(Arc::new(state)); + for (method, path) in [ + ("GET", "/"), + ("GET", "/huddle/00000000-0000-4000-8000-000000000001/audio"), + ("POST", "/query"), + ("POST", "/count"), + ("POST", "/events"), + ("GET", "/buzz/v1/sync"), + ("GET", "/git/o/r.git/info/refs?service=git-upload-pack"), + ("PUT", "/upload"), + ("GET", "/info"), + ] { + let request = || { + let mut req = Request::builder() + .method(method) + .uri(path) + .header(header::HOST, "required.example"); + if path == "/" || path.contains("/audio") { + req = req + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + } + req.body(Body::empty()).unwrap() + }; + let expected = control.clone().oneshot(request()).await.unwrap().status(); + let actual = app.clone().oneshot(request()).await.unwrap().status(); + assert_eq!( + actual, expected, + "Off changed legacy response for {method} {path}" + ); + } + assert_eq!(source.calls.load(Ordering::SeqCst), 0); + assert_eq!(unavailable.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn off_upgrade_uses_cached_tenant_without_fi_reads_and_rejects_long_hosts() { + let (state, source, _, _) = fixture(NipFiMode::Off).await; + let tenant_id = uuid::Uuid::new_v4(); + state.tenant_host_cache.insert( + "cached.example".into(), + ( + buzz_core::CommunityId::from_uuid(tenant_id), + std::time::Instant::now(), + ), + ); + // The actual database is unreachable. Only ordinary cached routing can + // satisfy this call; a primary FI binding lookup cannot. + let (tenant, mode) = bind_for_upgrade(&state, "cached.example").await.unwrap(); + assert_eq!(*tenant.community().as_uuid(), tenant_id); + assert!(mode.is_off()); + assert_eq!(source.calls.load(Ordering::SeqCst), 0); + let long_host = "x".repeat(buzz_core::tenant::MAX_HOST_LEN + 1); + state.tenant_host_cache.insert( + long_host.clone(), + ( + buzz_core::CommunityId::from_uuid(tenant_id), + std::time::Instant::now(), + ), + ); + assert_eq!( + bind_for_upgrade(&state, &long_host) + .await + .err() + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn discovery_is_host_specific_compatible_uncached_and_consistent() { + for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::Enforce] { + let (state, source, _, _) = fixture(global).await; + let app = crate::router::build_router(state); + for host in ["free.example", "required.example", "unknown.example"] { + let mut docs = Vec::new(); + for path in ["/", "/info"] { + let response = app + .clone() + .oneshot( + Request::get(path) + .header(header::HOST, host) + .header(header::ACCEPT, "application/nostr+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + if host == "unknown.example" { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + continue; + } + assert_eq!(response.status(), StatusCode::OK); + let doc: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()) + .unwrap(); + let required = + !global.is_off() && (host == "required.example" || global.enforces()); + let capability = global.evaluates() || required; + assert_eq!(doc["requires_nip_fi"], required); + assert_eq!( + doc["limitation"]["federated_identity"] + .as_bool() + .unwrap_or(false), + capability + ); + assert_eq!(!doc["federated_identity"].is_null(), capability); + docs.push(doc); + } + if docs.len() == 2 { + assert_eq!(docs[0], docs[1]); + } + } + // Serving hosts share a policy snapshot; unmapped results are not cached. + assert_eq!( + source.calls.load(Ordering::SeqCst), + if global.is_off() { 0 } else { 4 } + ); + } +} + +#[tokio::test] +async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { + for global in [ + NipFiMode::Off, + NipFiMode::Shadow, + NipFiMode::Enforce, + NipFiMode::DenyProtected, + ] { + let (base, _, key, _) = fixture(global).await; + for missing in ["verifier", "deny_map", "lifetime", "policy"] { + let mut state = (*base).clone(); + match missing { + "verifier" => state.nip_fi_verifier = None, + "deny_map" => state.nip_fi_deny_map = None, + "lifetime" => { + Arc::make_mut(&mut state.config) + .nip_fi + .max_connection_lifetime_secs = 0 + } + _ => { + if !global.is_off() { + // This case exercises an unavailable COLD policy + // source, independent of the preceding valid leases. + state.tenant_host_cache.invalidate("required.example"); + } + state.nip_fi_policy = Arc::new(Source { + required: AtomicBool::new(true), + calls: AtomicUsize::new(0), + unavailable: true, + }) + } + } + let result = admit_nip_fi_http_on_state( + &state, + &headers("required.example", false), + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(key, ())), + ) + .await; + if global.is_off() { + assert!(result.is_ok(), "Off ignores missing FI {missing}"); + } else { + assert_eq!( + result.err().unwrap().status(), + StatusCode::SERVICE_UNAVAILABLE, + "{global:?} {missing}" + ); + } + if global != NipFiMode::DenyProtected { + assert!(!state.nip_fi_required_admission_ready() || missing == "policy"); + let app = crate::router::build_router(Arc::new(state)); + for path in ["/", "/info"] { + let response = app + .clone() + .oneshot( + Request::get(path) + .header(header::HOST, "required.example") + .header(header::ACCEPT, "application/nostr+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + if global.is_off() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }, + "{global:?} {missing} {path}" + ); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + } + } + } +} + +#[tokio::test] +async fn request_scope_retains_its_snapshot_without_overwriting_newer_policy() { + let (state, source, key, _) = fixture(NipFiMode::Shadow).await; + let h = headers("required.example", true); + source.required.store(false, Ordering::SeqCst); + let earlier = resolve(&state, &h).await.unwrap(); + source.required.store(true, Ordering::SeqCst); + state.tenant_host_cache.invalidate("required.example"); + let newer = resolve(&state, &h).await.unwrap(); + assert!(!earlier.required); + assert!(newer.required); + with_policy(earlier, async { + assert!(mode(&state, &h).await.observes_only()); + }) + .await; + // Finishing the old request cannot publish over the newer observation. + assert!(resolve(&state, &h).await.unwrap().required); + let before = source.calls.load(Ordering::SeqCst); + with_policy(newer, async { + for _ in 0..3 { + assert!(mode(&state, &h).await.enforces()); + } + assert!(admit_nip_fi_http_on_state( + &state, + &h, + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(key, ())) + ) + .await + .is_ok()); + }) + .await; + assert_eq!(source.calls.load(Ordering::SeqCst), before); +} + +struct PausedSource { + read: tokio::sync::Notify, + resume: tokio::sync::Notify, + calls: AtomicUsize, +} +impl PolicySource for PausedSource { + fn lookup<'a>( + &'a self, + host: &'a str, + ) -> BoxFuture<'a, Result, ()>> { + async move { + if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { + self.read.notify_one(); + self.resume.notified().await; + } + if host == "unknown.example" { + return Ok(None); + } + Ok(Some(CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), + requires_nip_fi: false, + policy_generation: 0, + })) + } + .boxed() + } +} + +#[tokio::test] +async fn concurrent_fills_share_one_read_and_a_drop_fences_publication_only() { + let (base, _, _, _) = fixture(NipFiMode::Shadow).await; + for (host, invalidated_host) in [ + ("team.example", None), + ("team.example", Some("team.example")), + ("team.example", Some("other.example")), + ("unknown.example", None), + ] { + let invalidate = invalidated_host.is_some(); + let source = Arc::new(PausedSource { + read: Default::default(), + resume: Default::default(), + calls: AtomicUsize::new(0), + }); + let mut state = (*base).clone(); + state.tenant_host_cache = Arc::new(crate::host_cache::HostCache::new()); + state.nip_fi_policy = source.clone(); + let state = Arc::new(state); + let leader = { + let state = state.clone(); + tokio::spawn(async move { cached_lookup(&state, host).await }) + }; + source.read.notified().await; + let observed_source = std::time::Instant::now(); + let mut waiters = Vec::new(); + for _ in 0..8 { + let mut waiter = Box::pin(cached_lookup(&state, host)); + assert!(futures_util::poll!(&mut waiter).is_pending()); + waiters.push(waiter); + } + if let Some(invalidated_host) = invalidated_host { + state.apply_cache_invalidation( + buzz_core::CommunityId::from_uuid(uuid::Uuid::nil()), + buzz_pubsub::cache_invalidation::CacheInvalidation::CommunityHost { + host: invalidated_host.into(), + }, + ); + } + source.resume.notify_one(); + let result = leader.await.unwrap().unwrap(); + assert_eq!(result.is_none(), host == "unknown.example"); + if !invalidate && result.is_some() { + assert!( + state + .tenant_host_cache + .policy_read_started_at(host) + .unwrap() + <= observed_source, + "lease anchor must precede the paused source read" + ); + } + assert_eq!( + state.tenant_host_cache.policy(host).is_none(), + invalidate || result.is_none() + ); + for waiter in waiters { + assert_eq!(waiter.await.unwrap().is_none(), result.is_none()); + } + let expected_reads = if result.is_none() { + 9 + } else if invalidate { + 2 + } else { + 1 + }; + assert_eq!(source.calls.load(Ordering::SeqCst), expected_reads); + assert_eq!( + state.tenant_host_cache.policy(host).is_none(), + result.is_none() + ); + } +} + +#[tokio::test] +async fn unmapped_legacy_rejection_scope_never_yields_an_admission_proof() { + let (state, _, key, _) = fixture(NipFiMode::Shadow).await; + let policy = Policy { + community_id: uuid::Uuid::nil(), + mode: NipFiMode::Off, + required: false, + stored_required: false, + + mapped: false, + }; + with_policy(policy, async { + state.tenant_host_cache.insert( + "unknown.example".into(), + ( + buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()), + std::time::Instant::now(), + ), + ); + assert!( + crate::nip_fi_shadow::bind_tenant(&state, &headers("unknown.example", false)) + .await + .is_none(), + "an authoritative miss cannot reuse a cached tenant" + ); + + let mut called = false; + let response = admit_nip_fi_http_on_state( + &state, + &headers("unknown.example", false), + &crate::access::AccessSlot::default(), + || { + called = true; + Ok(Nip98Proof::new(key, ())) + }, + ) + .await + .err() + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + !called, + "unmapped scope must refuse before credential extraction" + ); + }) + .await; +} 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 8a6919574a0..ef2e67db11d 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 @@ -170,6 +170,9 @@ async fn pod_on( resolver.refresh().await.expect("startup load"); let mut state = (*base).clone(); + // These witnesses advance the binding clock at the tenant-read seam; + // keep policy independent so its added writer read cannot consume that hook. + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); let config = Arc::make_mut(&mut state.config); config.nip_fi.mode = mode; config.nip_fi.source = crate::nip_fi_config::CommunitySource::Database; @@ -663,17 +666,63 @@ async fn key_disconnected_before_its_community_exists_is_denied_there() { /// Requests reach their handlers: the tenant store is real PostgreSQL with a /// row for each test host, and protected HTTP uses a valid workflow path with /// a NIP-98 proof, so the handler itself calls `admit_nip_fi_http_on_state`. -/// The first tenant-store connection a request takes can move the resolver -/// clock, which places a deadline crossing inside the tenant-lookup await. +/// Advance the binding clock after resolution: during socket tenant binding, +/// or during the HTTP handler's verification of the guard's shared binding. mod postgres_tests { use super::*; + /// Keep the policy row's real identity without consuming the socket's SQL barrier. + struct TenantPolicy(uuid::Uuid); + impl crate::nip_fi_policy::PolicySource for TenantPolicy { + fn lookup<'a>( + &'a self, + _host: &'a str, + ) -> BoxFuture<'a, Result, ()>> + { + async move { + Ok(Some(buzz_db::community_auth::CommunityAuthPolicy { + community_id: self.0, + requires_nip_fi: false, + policy_generation: 0, + })) + } + .boxed() + } + } + + /// Delegate real crypto, then cross the binding deadline at the handler's + /// second verification. The guard's first verification remains fresh. + struct HandlerVerificationClock { + inner: Arc, + clock: Arc, + advance: Arc>>, + } + impl buzz_auth::VerifyAssertion for HandlerVerificationClock { + fn verify_assertion( + &self, + token: &str, + community: &buzz_auth::CommunityBinding, + ) -> Result { + let assertion = self.inner.verify_assertion(token, community)?; + let mut advance = self.advance.lock().unwrap(); + if let Some((count, at)) = advance.as_mut() { + *count += 1; + if *count == 2 { + self.clock.set(*at); + *advance = None; + } + } + Ok(assertion) + } + } + /// Pod over a one-connection pool whose next acquire, once armed, moves /// the clock to the armed time. struct TenantPod { pod: Pod, clock: Arc, advance_on_acquire: Arc>>, + advance_on_handler_verify: Arc>>, label: String, } @@ -698,19 +747,32 @@ mod postgres_tests { .expect("PostgreSQL must be available"); // Seeding leaves the pool's only connection idle, so every request // acquire reuses it and runs the hook. + let community_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(uuid::Uuid::new_v4()) + .bind(community_id) .bind(host(&label)) .execute(&pool) .await .expect("seed community"); store.bind(&label, &[ISS]); let base = crate::state::tests::test_state_with_database_pool(pool).await; - let pod = pod_on(base, store, clock, mode).await; + let mut pod = pod_on(base, store, clock, mode).await; + let advance_on_handler_verify = Arc::new(Mutex::new(None)); + let state = Arc::make_mut(&mut pod.state); + state.nip_fi_policy = Arc::new(TenantPolicy(community_id)); + state.nip_fi_verifier = Some(Arc::new(HandlerVerificationClock { + inner: state + .nip_fi_verifier + .clone() + .expect("real fixture verifier"), + clock: Arc::clone(clock), + advance: Arc::clone(&advance_on_handler_verify), + })); TenantPod { pod, clock: Arc::clone(clock), advance_on_acquire, + advance_on_handler_verify, label, } } @@ -734,7 +796,8 @@ mod postgres_tests { /// Each admission site's outcome for one fresh key: root and audio /// upgrades, then the workflow-runs handler with a matching proof. /// With `crossing = Some((start, at))` every request resolves at - /// `start` and its tenant lookup moves the clock to `at`. + /// `start`; socket binding or the HTTP handler's real verification moves + /// the clock to `at` before the final admission deadline check. async fn every_site( &self, crossing: Option<(Duration, Duration)>, @@ -760,16 +823,30 @@ mod postgres_tests { for (path, proof) in requests { if let Some((start, at)) = crossing { self.clock.set(start); - *self.advance_on_acquire.lock().unwrap() = Some(at); - // The clock moves when the tenant lookup acquires a - // connection, so the lookup must miss the host cache. - self.pod.state.tenant_host_cache.invalidate_all(); + if proof.is_some() { + // HTTP reuses the policy tenant; no second SQL bind occurs. + *self.advance_on_acquire.lock().unwrap() = None; + *self.advance_on_handler_verify.lock().unwrap() = Some((0, at)); + } else { + *self.advance_on_acquire.lock().unwrap() = Some(at); + self.pod.state.tenant_host_cache.invalidate_all(); + } } outcomes.push( self.pod .send_signed(&path, &raw_host, Some(&token), proof) .await, ); + if crossing.is_some() { + assert!( + self.advance_on_handler_verify.lock().unwrap().is_none(), + "HTTP must reach its real handler verification before the deadline check" + ); + assert!( + self.advance_on_acquire.lock().unwrap().is_none(), + "socket binding must reach the primary tenant read" + ); + } } outcomes } @@ -811,7 +888,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn snapshot_going_stale_during_the_tenant_lookup_admits_nowhere() { + async fn snapshot_going_stale_before_final_admission_admits_nowhere() { let (store, clock) = setup(); let p = tenant_pod(&store, &clock, NipFiMode::Enforce).await; let inside = (secs(119.0), secs(119.9)); @@ -856,14 +933,13 @@ mod postgres_tests { (output, records) } - // Shadow re-checks the binding's deadline where enforce does, after the - // tenant lookup, and records enforce's refusal without changing any - // response. Mutation: dropping the upgrade's observed deadline, or - // resolving HTTP afresh instead of reusing the guard's binding, records - // no `deadline` would-deny at that site. + // Shadow re-checks the binding's deadline where enforce does, after + // socket binding or HTTP verification, without changing the response. + // Removing either transport's final deadline check removes its would-deny. + // The separate exact-once witness protects reuse of the guard's binding. #[tokio::test] #[ignore = "requires Postgres"] - async fn shadow_records_a_snapshot_going_stale_during_the_tenant_lookup() { + async fn shadow_records_a_snapshot_going_stale_before_final_admission() { let (store, clock) = setup(); let p = tenant_pod(&store, &clock, NipFiMode::Shadow).await; let inside = (secs(119.0), secs(119.9)); diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index 54aad44556a..1c5f4b815e9 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -524,7 +524,7 @@ pub(crate) async fn observe_strict_proof( /// Enforce's strict proof on a route: records the outcome of the proof check /// that decides admission, once, where it runs. Nothing is verified twice, /// and a request refused before its proof (duplicate Authorization) records -/// no proof. Inert outside enforce. +/// no proof. Inert when this request does not enforce FI. pub(crate) struct EnforcedProof(Option<(&'static str, SharedString)>); impl EnforcedProof { @@ -533,19 +533,17 @@ impl EnforcedProof { headers: &HeaderMap, route: &'static str, ) -> Self { - Self(state.config.nip_fi.mode.enforces().then(|| { - let host = headers - .get(axum::http::header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - (route, enforce_label(state, host)) - })) - } - - /// An enforcing observation of `route`, without a state. - #[cfg(test)] - pub(crate) fn for_test(route: &'static str) -> Self { - Self(Some((route, SharedString::const_str(BOUND)))) + Self( + crate::nip_fi_policy::request_mode(state.config.nip_fi.mode) + .enforces() + .then(|| { + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + (route, enforce_label(state, host)) + }), + ) } /// Passes the deciding check's `result` through, counting it. @@ -579,7 +577,10 @@ pub(crate) async fn bind_tenant( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(state, raw_host).await.ok(); + let tenant = match crate::nip_fi_policy::request_tenant(raw_host) { + Some(tenant) => tenant, + None => crate::tenant::bind_community(state, raw_host).await.ok(), + }; if tenant.is_none() { observe_unbound(state, headers).await; } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index fdf36acb8b0..a7861a9b786 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -28,7 +28,7 @@ use crate::api; use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; -use crate::nip11::{nip11_document, relay_info_handler}; +use crate::nip11::{nip11_response, relay_info_handler}; use crate::nip_fi_core::http_denial; use crate::readiness::{self, DependencySnapshot, ReadinessReason}; use crate::state::AppState; @@ -72,8 +72,8 @@ use crate::state::AppState; // // ## Relationship to Off and Shadow // -// When `NipFiMode::Off` or `NipFiMode::Shadow` the guard is fully transparent — -// no request is touched; Shadow records its verdict elsewhere. [FI-INV-15] +// Off skips FI policy reads and enforcement. Shadow preserves optional-community +// admission and enforces stored requirements. Enforce requires FI everywhere. // // ## What this guard checks (and does NOT check) // @@ -155,6 +155,14 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ "/internal/git/policy", ]; +fn nip_fi_unavailable(slot: &crate::access::AccessSlot) -> axum::response::Response { + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + http_denial(buzz_auth::DenialClass::AuthorizationUnavailable) +} + /// Middleware: full offline assertion guard for NIP-FI protected paths. /// /// Fires before any handler. In Enforce mode, if the request path is not @@ -175,17 +183,79 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ /// Only a cryptographically verified assertion reaches the handler; the /// handler then performs the key pairing and deny-map checks via /// `admit_nip_fi_http_on_state`. -/// -/// 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 { - let mode = state.config.nip_fi.mode; - // Off: fully transparent. [FI-INV-15] - if mode.is_off() || nip_fi_guard_exempts(&state, &request) { + if state.config.nip_fi.mode.is_off() || nip_fi_guard_exempts(&state, &request) { + return next.run(request).await; + } + if state.config.nip_fi.mode.denies_unconditionally() { + return nip_fi_unavailable(&slot); + } + let policy = match crate::nip_fi_policy::resolve(&state, request.headers()).await { + Ok(policy) => policy, + Err(response) if response.status() == StatusCode::NOT_FOUND => { + // The authoritative read found no serving policy. Let the route + // retain its legacy rejection (e.g. Git's credential challenge). + // The shared admission gate refuses this scope unconditionally; + // this is never an optional-policy fallback or a data admission. + let mode = state.config.nip_fi.mode; + let policy = crate::nip_fi_policy::Policy { + community_id: uuid::Uuid::nil(), + mode, + required: mode.enforces(), + stored_required: false, + + mapped: false, + }; + return crate::nip_fi_policy::with_policy( + policy, + nip_fi_assertion_guard_with_policy(state, slot, request, next, mode), + ) + .await; + } + Err(response) => { + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + if state.config.nip_fi.mode.observes_only() { + crate::nip_fi_shadow::record_for( + "http", + crate::nip_fi_shadow::label(&Err( + buzz_auth::DenialClass::AuthorizationUnavailable, + )), + Err(crate::nip_fi_shadow::WouldDeny::new( + crate::nip_fi_shadow::Stage::Community, + buzz_auth::DenialClass::AuthorizationUnavailable, + crate::nip_fi_shadow::UNRESOLVED, + )), + ); + } + return response; + } + }; + if policy.stored_required && state.nip_fi_deny_map.is_none() { + return nip_fi_unavailable(&slot); + } + crate::nip_fi_policy::with_policy( + policy, + nip_fi_assertion_guard_with_policy(state, slot, request, next, policy.mode), + ) + .await +} + +async fn nip_fi_assertion_guard_with_policy( + state: Arc, + slot: crate::access::AccessSlot, + request: Request, + next: middleware::Next, + mode: buzz_auth::NipFiMode, +) -> axum::response::Response { + if mode.is_off() { return next.run(request).await; } @@ -749,7 +819,7 @@ async fn nip11_or_ws_handler( } if accept.contains("application/nostr+json") { - return Json(nip11_document(&state, raw_host).await).into_response(); + return nip11_response(&state, raw_host).await; } // NIP-FI assertion gate at WebSocket upgrade. @@ -783,7 +853,13 @@ async fn nip11_or_ws_handler( // // Keying on the header pair (not on `Accept`) means an HTML Accept header // on a real WS upgrade is still gated correctly. - let (upgrade_checked, nip_fi_assertion, shadow_assertion, nip_fi_deadline, shadow_line) = { + let ( + upgrade_checked, + mut nip_fi_assertion, + shadow_assertion, + mut nip_fi_deadline, + mut shadow_line, + ) = { let is_h1_ws_upgrade = headers .get(axum::http::header::UPGRADE) .and_then(|v| v.to_str().ok()) @@ -808,7 +884,7 @@ async fn nip11_or_ws_handler( if is_h1_ws_upgrade || is_h2_ws_connect { use crate::nip_fi_session::NipFiWsRoute::Root; use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; - let mode = state.config.nip_fi.mode; + let mode = crate::nip_fi_policy::mode(&state, &headers).await; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); match check_nip_fi_at_upgrade(&slot, Root, &headers, communities, verifier, mode) @@ -856,7 +932,7 @@ async fn nip11_or_ws_handler( } } } - let shadow_assertion = shadow_assertion.filter(|(a, c)| { + let mut shadow_assertion = shadow_assertion.filter(|(a, c)| { !crate::nip_fi_shadow_session::upgrade_denied(&state, &c.shadow_label(), a) }); @@ -864,25 +940,45 @@ async fn nip11_or_ws_handler( // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a // lookup failure fails closed with a generic rejection — never a default - // tenant. NIP-11 above is served before binding and stays fail-open: an - // unmapped host still gets the document (with host-scoped fields like - // `icon` simply absent), so the doc cannot leak which hosts are mapped. + // tenant. NIP-11 above resolves host policy independently and fails closed + // when that policy cannot be loaded. // // The NIP-FI upgrade gate runs above (before bind_community) so denied // upgrades skip the tenant lookup; the `Ok(ws)` backstop runs below. - let tenant = match crate::tenant::bind_community(&*state, raw_host).await { - Ok(ctx) => ctx, - Err(_) => { - // Generic rejection: do not distinguish "unmapped" from "lookup - // error", and never echo the host, so an unauthenticated caller - // cannot probe which communities exist on this deployment. - return ( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - .into_response(); - } + let (tenant, bound_mode) = match crate::nip_fi_policy::bind_for_upgrade(&state, raw_host).await + { + Ok(binding) => binding, + Err(response) => return response, }; + // An initially missing/optional host may have become required while the + // early gate awaited. Only the policy read with the bound row authorizes + // this socket; a shadow observation is not an enforced admission. + if upgrade_checked + && bound_mode.restricts() + && (nip_fi_assertion.is_none() || bound_mode.denies_unconditionally()) + { + match crate::nip_fi_upgrade::check_nip_fi_at_upgrade( + &slot, + crate::nip_fi_session::NipFiWsRoute::Root, + &headers, + state.nip_fi_communities(), + state.nip_fi_verifier.as_deref(), + bound_mode, + ) + .await + .into_assertions() + { + Ok(assertions) => { + ( + nip_fi_assertion, + shadow_assertion, + nip_fi_deadline, + shadow_line, + ) = assertions + } + Err(response) => return (*response).into_response(), + } + } // The tenant lookup awaited since the community resolved; a snapshot // that went stale meanwhile accepts no socket. if let Err(class) = nip_fi_deadline.check() { @@ -917,7 +1013,7 @@ async fn nip11_or_ws_handler( // Only re-check if the pre-extractor gate did not fire (h2 path). use crate::nip_fi_session::NipFiWsRoute::Root; use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; - let mode = state.config.nip_fi.mode; + let mode = bound_mode; 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. @@ -986,7 +1082,7 @@ async fn nip11_or_ws_handler( } } // Not a WS upgrade request — serve NIP-11 as fallback. - Json(nip11_document(&state, raw_host).await).into_response() + nip11_response(&state, raw_host).await } } } @@ -1318,7 +1414,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( + let (mut state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -1330,6 +1426,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } @@ -2351,7 +2448,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( + let (mut state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -2363,6 +2460,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } @@ -3814,7 +3912,7 @@ mod tests { )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( + let (mut state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -3826,6 +3924,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } @@ -4275,6 +4374,7 @@ mod tests { ); state.nip_fi_deny_map = Some(deny_map); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 11abba15963..b52615a9c38 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1347,7 +1347,8 @@ pub const RESTRICTION_CACHE_MAX_AGE: std::time::Duration = std::time::Duration:: /// How long a pod serves a cached host → community mapping after the /// database read that produced it began, at its connection checkout (see /// [`AppState::tenant_host_cache`]). -pub const TENANT_HOST_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(1); +pub const TENANT_HOST_CACHE_MAX_AGE: std::time::Duration = + buzz_db::community_auth::POLICY_LEASE_MAX_AGE; /// How long a cache miss waits for a shared read's statement, beyond the /// writer pool's acquire timeout that its holder may spend waiting for a @@ -1580,7 +1581,7 @@ pub struct AppState { /// an entry is served only for [`TENANT_HOST_CACHE_MAX_AGE`] after its /// read's connection checkout: a host remapped, archived or deleted /// elsewhere resolves to its old community for at most that long. - pub tenant_host_cache: Arc>, + pub tenant_host_cache: Arc, /// Lets concurrent misses on [`Self::tenant_host_cache`] share one read. pub(crate) tenant_host_reads: Arc>, @@ -1601,7 +1602,8 @@ pub struct AppState { /// NIP-FI federated-identity assertion verifier, shared across all HTTP /// ingress and WebSocket upgrade checks. /// - /// `None` when `config.nip_fi.mode` is `Off`. When present, the verifier + /// `None` when FI evaluation is disabled, including database-sourced Off + /// deployments. When present, the verifier /// is the single offline authority for assertion validation on every /// protected HTTP surface and WebSocket upgrade. The backing `ProductionJwksSource` is also /// shared and performs bounded periodic JWKS refresh internally. @@ -1614,6 +1616,9 @@ pub struct AppState { /// relative to the JWT crypto. pub nip_fi_verifier: Option>, + /// Primary-database policy lookup; tests can substitute the same source seam. + pub(crate) nip_fi_policy: Arc, + /// The shared JWKS source backing `nip_fi_verifier`, exposed so `main.rs` /// can warm it at startup and drive the background refresh loop. /// `None` iff `nip_fi_verifier` is `None`. @@ -1650,6 +1655,12 @@ pub struct AppState { } impl AppState { + /// Whether local FI admission dependencies can serve a stored requirement. + /// This is not fleet activation or registry-baseline verification. + pub fn nip_fi_required_admission_ready(&self) -> bool { + crate::nip_fi_policy::required_dependencies_ready(self) + } + /// The NIP-FI community source admission resolves against. pub(crate) fn nip_fi_communities(&self) -> crate::nip_fi_core::Communities<'_> { use crate::nip_fi_config::CommunitySource; @@ -1679,6 +1690,7 @@ impl AppState { relay_keypair: nostr::Keys, media_storage: MediaStorage, ) -> (Self, AuditShutdownHandle) { + let nip_fi_policy = Arc::new(db.clone()); let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; let search_arc = Arc::new(search); @@ -1878,12 +1890,7 @@ impl AppState { 100_000, shared_read_wait, )), - tenant_host_cache: Arc::new( - moka::sync::Cache::builder() - .max_capacity(10_000) - .time_to_live(std::time::Duration::from_secs(10)) - .build(), - ), + tenant_host_cache: Arc::new(crate::host_cache::HostCache::new()), tenant_host_reads: Arc::new(crate::shared_reads::SharedReads::new( 10_000, shared_read_wait, @@ -1895,6 +1902,7 @@ impl AppState { tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), nip_fi_verifier, + nip_fi_policy, nip_fi_jwks_source, // NIP-FI deny map and command verifier are initialized lazily by // `build_nip_fi_command_components` in `api::nip_fi`, called from @@ -2197,6 +2205,9 @@ impl AppState { invalidation: CacheInvalidation, ) { match invalidation { + CacheInvalidation::CommunityHost { host } => { + self.tenant_host_cache.invalidate(&host); + } CacheInvalidation::Membership { channel_id, pubkey } => { self.invalidate_membership_local(community_id, channel_id, &pubkey); } @@ -2212,6 +2223,18 @@ impl AppState { } } + /// Drop the host's policy/routing snapshot locally and best-effort on peers. + /// Lease expiry, not notification delivery, protects acceptance consistency. + pub fn invalidate_community_host(&self, tenant: &TenantContext) { + self.tenant_host_cache.invalidate(tenant.host()); + self.spawn_cache_invalidation( + tenant, + CacheInvalidation::CommunityHost { + host: tenant.host().to_owned(), + }, + ); + } + /// Close everything `pubkey` has open in `community` on this pod: root /// sockets (with a final `OK false` carrying `reason`) and audio sockets. /// @@ -2491,6 +2514,7 @@ impl AppState { community_id: CommunityId, archived_at: Option>, ) -> Result, buzz_db::DbError> { + self.tenant_host_cache.invalidate_community(community_id); match archived_at { Some(archived_at) => { self.db @@ -2766,10 +2790,8 @@ impl AuditShutdownHandle { /// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. /// -/// Returns `(None, None)` when the mode is `Off` or `DenyProtected` (the -/// verifier is never consulted there; admission always returns 503). In -/// `Enforce` mode, constructs a `ProductionJwksSource` (shared via `Arc`) -/// and a `FederatedAssertionVerifier` over a clone of that `Arc`. +/// Only evaluating modes construct these components. Off and repair mode +/// need no verifier or outbound JWKS traffic. The JWKS source is shared via `Arc`. /// The source starts empty; HTTP admission returns `authorization_unavailable` /// (503) until the startup warm in `main.rs` succeeds. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] type NipFiComponents = ( @@ -2873,6 +2895,37 @@ pub(crate) mod tests { use std::collections::HashMap; use tokio::sync::Mutex; + #[test] + fn fi_components_exist_only_in_evaluating_modes() { + let mut config = crate::config::Config::for_test(); + config.nip_fi.jwks_configs = vec![buzz_auth::IssuerJwksConfig { + issuer: "https://issuer.example".to_owned(), + contract: buzz_auth::JwksSourceContract::new( + "https://issuer.example/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid test contract"), + }]; + for source in [ + crate::nip_fi_config::CommunitySource::Env, + crate::nip_fi_config::CommunitySource::Database, + ] { + config.nip_fi.source = source; + for mode in [ + buzz_auth::NipFiMode::Off, + buzz_auth::NipFiMode::Shadow, + buzz_auth::NipFiMode::Enforce, + buzz_auth::NipFiMode::DenyProtected, + ] { + config.nip_fi.mode = mode; + let (verifier, jwks) = build_nip_fi_components(&config); + assert_eq!(verifier.is_some(), mode.evaluates(), "{source:?} {mode:?}"); + assert_eq!(jwks.is_some(), mode.evaluates(), "{source:?} {mode:?}"); + } + } + } + /// Helper: create a ConnectionManager with one registered connection. /// Returns (manager, conn_id, receiver, ctrl_receiver, cancel, /// shared_backpressure_count). @@ -3434,7 +3487,9 @@ pub(crate) mod tests { config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - build_test_state(config, pool).await + let mut state = (*build_test_state(config, pool).await).clone(); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); + Arc::new(state) } /// The same test state with an unreachable database target. This lets @@ -3446,12 +3501,15 @@ pub(crate) mod tests { pub(crate) async fn test_state_with_unreachable_database_url( database_url: &str, ) -> Arc { - test_state_with_database_url_and_pool_options( + let mut state = (*test_state_with_database_url_and_pool_options( database_url, sqlx::postgres::PgPoolOptions::new() .acquire_timeout(std::time::Duration::from_millis(100)), ) - .await + .await) + .clone(); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); + Arc::new(state) } /// Test state whose config and writer pool both target `database_url`, diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 45f5e41eb83..c6f752c0b71 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -142,8 +142,9 @@ signature replay uses the existing operator replay error independently of this t Migration 0082 is additive: legacy rows keep `requires_nip_fi=false` and generation zero. It adds no trigger, function, procedure, rule or foreign key and rewrites no -existing rows. Lock and statement budgets are 5 and 30 seconds. Existing global -`enforce` remains an additional floor; this layer changes no data-plane gate. +existing rows. Lock and statement budgets are 5 and 30 seconds. This migration +changes no data-plane gate. Relay mode determines whether stored requirements +are enforced, as described below. Do not infer requirements from issuer rows, backfill free communities, or remove the enterprise enforce floor as part of this migration. @@ -157,8 +158,12 @@ No tenant serving writer owns this table and no fence trigger is attached. The active-community primary accessor is `Db::community_auth_policy(CommunityId) -> Result>`. -`None` means no active target; errors must fail closed. Per-request enforcement must combine -its stored requirement with global enforce and honor repair denial first. +`None` means no active target. Repair mode denies protected traffic first. In +`off`, no community requires FI, including a stored-required community; FI lookup +or dependency failures must not deny ordinary admission. In `shadow`, enforce +stored requirements and observe optional communities. In `enforce`, require FI +for every community. Evaluating policy lookups fail closed on errors. Mode +changes do not clear the stored requirement, generation, issuer trust or receipts. ## Mistaken acceptance and containment @@ -170,13 +175,21 @@ working FI path or containing the affected community through the established archive/lifecycle controls. Coordinate any necessary issuer/configuration repair through its existing operator procedure; never broaden trust to make a probe pass. -Disabling calls or rolling back code does not undo acceptance. Never restore a -serving binary that ignores the durable requirement; retain effective enforcement +Disabling calls or rolling back code does not undo acceptance. In Shadow, never +restore a serving binary that ignores the durable requirement; retain effective enforcement or contain the community while repairing the FI path. There is no -clear-policy API or routine SQL reset, and neither a timeout nor a mistaken call -is permission to return the community to free admission. Recovery must retain -the accepted requirement and operation evidence. No fleet-wide drain or deployment -is an automatic fallback. +clear-policy API or routine SQL reset. A timeout or mistaken call does not +authorize clearing the requirement or discarding operation evidence. + +An explicit operator change to `off` intentionally suspends FI enforcement for +all communities while preserving durable requirements and receipts. This is a +relay-wide mode choice, not an undo of acceptance or a binary rollback. Readback +must distinguish stored policy from effective enforcement and must not report +active enforcement in Off. Returning to `shadow` resumes enforcement for stored +required communities; `enforce` requires FI everywhere. Off suspends FI for +every community; it is not per-community containment. Choose relay-wide Off +suspension or archive/lifecycle containment explicitly; neither a fleet-wide +drain nor a deployment is an automatic fallback. Last-issuer removal is refused once the community requires NIP-FI, including when the operator binding path requests `allow_last`. Bind a replacement before @@ -185,3 +198,198 @@ the issuer recorded by a receipt while another remains makes that receipt `unavailable`; it never clears the requirement or destroys the receipt. Bulk backfill does not recreate missing bindings for required communities; its unbound report still exposes them for explicit operator repair. + +## Request enforcement and discovery (BUZZ-511) + +Relay mode controls the effective requirement without changing durable storage: +`off` disables FI for all communities, including stored `requires_nip_fi=true`; +`shadow` enforces FI only for stored required communities and observes optional +communities; `enforce` requires FI everywhere. `deny_protected` remains an +unconditional repair refusal. Off ignores missing FI policy, verifier, deny map +and issuer dependencies; ordinary tenant routing and Nostr authorization remain. +Outside Off, protected requests resolve their normalized Host's active policy +through a bounded primary-read lease cache, with a 500 ms budget on misses. +Existing Nostr proof, key pairing, deny map, membership and resource checks remain. +Discovery, pairing, enrollment and operator exceptions keep their own authentication. + +The router shares one policy snapshot with HTTP handlers for that request. Git +also scopes its extractor to one policy and tenant snapshot, retaining its +existing header, signed-URL and denial ordering. Routing and policy share one +host cache capped at 10,000 entries. A serving entry carries immutable tenant ID, +requirement, generation and original read time from the same row. It is usable +for strictly less than one second from before the read's connection checkout; +neither a slow read nor a cache hit renews its lifetime. Concurrent fills for a +host share a successful read. Misses, errors and timeouts are never cached. A late result +after invalidation is usable once by its in-flight request but never published, +and an older generation cannot replace a newer +observed generation for the same community. Off retains ordinary routing-only +cache population and does no FI-policy fills. + +Cache fills take a shared transaction-scoped advisory lock keyed by normalized +host in a dedicated namespace. Lock acquisition and the serving-row SELECT are +separate statements in an explicit READ COMMITTED transaction. A contended fill +refuses immediately; a statement has a 400 ms server-side timeout inside the +500 ms overall budget. Waiters on the per-host fill share its successful result; +when no result was cached, each waiter makes at most one lease read of its own +inside that same budget, with no retry loop. Unmapped hosts remain 404. +Each cold policy fill uses six database statements: BEGIN, isolation setup, +statement-timeout setup, try-lock, row SELECT and COMMIT. Ordinary routing keeps +its single plain serving-row query and a routing-only snapshot in the same +bounded host cache. That snapshot cannot supply FI policy. A routing refresh +preserves a fresh policy for the same tenant without extending its lease. + +Acceptance takes the shared deletion lock and locks the target community row +`FOR NO KEY UPDATE`. It checks the row's host against `expected_host`, then takes +the exclusive advisory lock derived from that locked host. An optional row waits +1.1 seconds before the remaining owner/issuer checks and commit. The row stays +locked throughout, preventing single or double host remaps during the drain. +This also expires leases issued under any former host before row-lock acquisition. +An initially wrong host precondition is refused before waiting. + +This drains every optional one-second lease across all pods BEFORE commit, +without relying on Redis delivery or synchronized wall clocks. The extra 100 ms +is a clock-rate margin. Lease duration and acceptance wait are compile-time fleet +protocol constants; increasing the lease requires a coordinated writer rollout +first. Existing receipt recovery skips this wait. Already-required targets need +no lease drain. Fills take only a shared advisory host lock and never lock the +community row, preserving the deletion-shared → row → host-lock ordering. + +Policy resolutions that start after acceptance commits therefore cannot use an +optional lease for that community, even on a pod that missed invalidation. +Admissions that resolved policy before commit can still be in flight, exactly +as with primary reads; their completion and cancellation belong to BUZZ-512's +generation-registration and drain fence. Cache expiry is not activation evidence. +The acceptance drain blocks new policy fills and other acceptances for the +host. Its community row lock also blocks archive/unarchive, deletion admission +and fences, ownership transfer/convergence and issuer-binding writers for that +community during the 1.1-second wait. These are operator actions; ordinary +serving reads and writes do not take a conflicting row lock. Foreign-key +`FOR KEY SHARE` locks do not conflict with `FOR NO KEY UPDATE`, and the shared +deletion lock remains compatible with ordinary tenant writers. Cold policy +requests during the transition may return 503; acceptance is rare and this +avoids allowing stale optional leases after commit. + +Relay acceptance, provisioning and archive/unarchive writes drop the local host +snapshot and publish a best-effort Redis invalidation to other pods. An epoch +fences fills that began before a local or received drop. Direct administrative +database writes and missed notifications retain the one-second routing bound. +Host remap, archive and deletion do not drain leases: cached tenant binding can +lag those changes by up to one second, as ordinary host routing already does. +Policy and tenant data always use the same cached row, including NIP-11 +descriptors and decorations. Issuer-binding and JWKS freshness keep their own +existing bounds and checks; cached status never substitutes for those checks. + +`buzz_nip_fi_policy_cache_hits_total` and `buzz_nip_fi_policy_cache_misses_total` +measure cache use; `buzz_nip_fi_policy_reads_total` counts fill attempts and +`buzz_nip_fi_policy_read_seconds` measures completed source reads. A warm ordinary +protected request or discovery request performs no policy writer read. Root and +audio upgrades intentionally keep a final uncached authoritative tenant/policy +binding after the early evidence gate: one read on a cache hit, two on a cold +successful upgrade. Its optional-to-required recheck preserves the unarchive and +host-remap race fence, as well as early refusal and deadline ordering. + +Issuer-binding/JWKS caches keep their existing bounds; unavailable dependencies +refuse required admission, rather than treating it as optional. + +WebSocket upgrades also bind tenant identity and its policy from the same primary +row. An earlier missing or optional policy result cannot authorize a socket for a +required row that becomes serving during lookup (for example, on unarchive). +Before upgrade, enforce that bound row's requirement even when the early check +ran in Shadow. Off never promotes a stored requirement. This changes no discovery fields. + +Database-sourced deployments load the FI binding snapshot and command components +only in evaluating modes. Off does not depend on that snapshot and cannot report +required-admission readiness. Configure finite +`BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS`, issuer JWKS and command configuration +before enforcing any row in Shadow or Enforce. Missing required components return +503 only when FI is enabled. An optional row +still follows global off/shadow behavior, including signed and unsigned legacy +proofs where the endpoint permits them. + +Both `GET /info` and root NIP-11 negotiation return `Cache-Control: no-store`. +An unknown or non-serving Host returns generic 404. Outside Off, policy lookup +failure returns 503. Off uses ordinary tenant routing and returns generic 404 on +a tenant lookup failure, without consulting FI policy. The top-level `requires_nip_fi` is a host-specific boolean. Existing +`limitation.federated_identity` and `federated_identity` descriptor fields retain +capability semantics: advertise when global mode evaluates OR the host requires +FI. Shadow optional hosts still advertise capability. Off hosts advertise neither +FI capability nor an effective requirement, even when stored policy is required. No issuer, audience or identity +provider address is added to discovery. Existing clients can keep reading their +current fields; new clients must use the requirement field to distinguish optional +capability from mandatory evidence. + +This is admission enforcement only. Existing requests and sockets may predate the +new policy. Only BUZZ-512's application and drain evidence may establish completed +activation; the operator receipt remains pending until that evidence is recorded. + +### Staging and containment + +Outside Off, this changes every serving host, including free hosts: protected +routes and discovery require a valid primary policy lease. Off retains ordinary routing. Unmapped discovery previously could +return a generic document; it now returns 404. Policy failures return 503. +Database-source startup loads its FI binding snapshot only in evaluating modes; +Off does not load it. A failed load still fails startup in Shadow or Enforce. The local dependency gauge +`buzz_nip_fi_required_admission_ready` must be 1 before accepting any upgrade; +it covers verifier, deny map and finite lifetime, not fleet baseline verification. +Binding and JWKS readiness must also be verified through a signed request. + +Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` throughout a mixed-version rollout +until EVERY acceptance path implements the host-lock lease-drain protocol. +Uncached serving pods can coexist with caching pods, but an old acceptance +writer cannot safely coexist with cached optional leases. Before rolling an +acceptance writer back or removing this protocol, disable new acceptance on +every writer and verify that disablement; keep the lease-aware writers until +in-flight acceptance has finished. Disabling new calls does not undo existing +requirements. Existing required-community enforcement and BUZZ-512 activation +and revocation prerequisites still apply. Never enable an older acceptance path +while any caching pod serves optional communities. +Older pods may log an unknown `CommunityHost` cache-invalidation message during +the mixed-version rollout. They keep serving through their existing uncached +policy reads; missing that best-effort notification does not weaken the lease +protocol. The acceptance-writer gate above still applies. + +Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` during the initial staging rollout. +Start with zero required rows and record a 30-minute baseline using representative +free signed/unsigned HTTP, root/audio WS and discovery traffic. Compare identical +traffic before and after this change. Record request rate, policy-read p95/p99, +writer pool acquisition p99 (`buzz_db_pool_acquire_duration_seconds`, +`pool_role=writer`, `operation=authorization`), policy timeout/error count, HTTP +503 rate and startup/restart success. A local PostgreSQL 16 probe measured 100 +serial reads in 13.36 ms; that is not a production capacity result. + +Initial staging abort criteria (validate before increasing traffic): any policy +timeout, policy-induced 503, unexpected free-admission denial or startup failure; +policy-read p99 above 50 ms; writer acquisition p99 above 20 ms or more than twice +its pre-change baseline; total 503 rate more than 0.1 percentage point above +baseline over five minutes. These are conservative rollout limits, not measured +production guarantees. Do not expand until the full 30-minute run stays within +them and the operator has reviewed measured results. Verify unknown-host 404 and +dependency-failure 503 with no-store explicitly. + +Then test a disposable required community with complete FI dependencies, in both +Shadow and Enforce: unsigned/missing evidence denies, signed valid FI succeeds, +root/audio upgrades agree, discovery agrees, and a pending deletion request does +not weaken the requirement. Before production acceptance, complete BUZZ-512's +session application/drain and the production baseline-verifier gate. No such +production verification is established by this PR. + +Before any required row exists, an operator may stop the rollout and restore the +previous binary after verifying the primary contains zero required rows. Once a +requirement has been accepted, a pre-511 binary in Shadow is unsafe: it ignores +that row. Off explicitly suspends FI; global Enforce still requires all hosts. +Stop new upgrade calls, retain policy and receipt records, and keep an enforcing +binary while repairing dependencies. If admission cannot be restored, contain the +affected serving hosts with an operator-approved repair-mode/traffic action; +Off explicitly disables FI; use it only as an operator mode decision and never +clear accepted policy. Returning to Shadow restores stored requirements. No automatic fleet +drain or deployment is prescribed here. Existing receipt recovery remains usable +while new acceptances are disabled. + +Required sessions admitted under global Shadow are real enforced FI sessions. +The existing global-Shadow admin disconnect path still only observes would-close; +BUZZ-512 must close these enforced sessions independently of global rollout mode, +while preserving the separate optional-shadow registry. Before enabling upgrade +acceptance for any serving community in Shadow, deploy and verify BUZZ-512's local +and cross-pod disconnect behavior, including real Redis propagation. Keep upgrade +acceptance disabled until that prerequisite is met; admission tests alone do not +establish it. Production also requires the baseline-verifier gate above. diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 26b0af8cca5..0eca3ff8d03 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -40,7 +40,7 @@ Conformance obligations: | Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test | |---|---|---|---|---|---|---|---| | Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. | -| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, the community's workspace icon (NIP-WP), and the config-derived `buzz-gif` descriptor when GIF search is configured. | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); GIF provider availability is passed as a credential-free config scalar; no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version and GIF provider configuration may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community. The GIF descriptor reveals provider availability but never its credential; NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today and the opt-in GIF descriptor. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped or config-derived scalars (like `icon`, pairing URL, and GIF provider) are passed in pre-derived. | +| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, the community's workspace icon (NIP-WP), host-specific `requires_nip_fi` and compatible FI capability fields, and the config-derived `buzz-gif` descriptor when GIF search is configured. | Host-derived community for community-specific facts; host policy is read from the primary by normalized Host (unknown/non-serving host → generic 404, unavailable policy → 503); optional workspace `icon` is pre-fetched as a scalar via `bind_community`; GIF provider availability is passed as a credential-free config scalar; no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version and GIF provider configuration may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: host-specific fields reveal only the requesting host's public icon, requirement and capabilities, never a neighboring community's facts. Discovery is no-store. The GIF descriptor reveals provider availability but never its credential; NIP-43 advertisement reflects membership enforcement for that community only. | One community retains existing capability/descriptor semantics; the additive requirement boolean reflects stored policy OR global enforce. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped or config-derived scalars (like `icon`, pairing URL, and GIF provider) are passed in pre-derived. | | API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. | | Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. | | Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. | diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5e7e97a2874..f1e02027e68 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -905,6 +905,15 @@ A relay SHOULD advertise core support in NIP-11 as: } ``` +A multi-community relay MAY additionally publish a host-specific top-level +`requires_nip_fi` boolean. This expresses mandatory admission evidence, separately +from support for the protocol. In Buzz it is the stored community requirement OR +global enforce; capability is advertised when global mode evaluates OR that +requirement is true. Required hosts therefore retain both existing capability +fields and the descriptor above. Optional shadow hosts still advertise capability. +Root NIP-11 and `/info` MUST agree and use `Cache-Control: no-store`. An unknown or +non-serving Host returns generic 404; unavailable host policy returns 503. + `maximum_residual_upstream_revocation_seconds` is `null` for the `offline-jwt` class because this spec provides no unconditional finite upstream-revocation bound. The deny-until-TTL mechanism closes the diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 92951b95a57..504e4896db9 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -407,6 +407,9 @@ run_unit_tests() { run_test_step "buzz-relay Git transport Off-mode precedence tests" \ cargo test -p buzz-relay --lib api::git::transport::off_mode_precedence_tests:: -- --nocapture + run_test_step "buzz-relay community FI policy tests" \ + cargo test -p buzz-relay --lib nip_fi_policy::tests:: -- --nocapture + run_test_step "buzz-relay NIP-FI upgrade tests" \ cargo test -p buzz-relay --lib nip_fi_upgrade:: -- --nocapture