From 51dcee1953b721807b091be36a1623a2ff52adba Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:01:06 -0500 Subject: [PATCH 01/27] feat(relay): enforce community FI policy (BUZZ-511) Co-authored-by: Honor Signed-off-by: Brad Seiler --- Justfile | 2 +- crates/buzz-db/src/store/community_auth.rs | 23 ++ .../store/community_auth/postgres_tests.rs | 37 ++ crates/buzz-relay/src/api/bridge.rs | 28 +- .../src/api/bridge/read_state_snapshot.rs | 7 +- crates/buzz-relay/src/api/buzz_v1/auth.rs | 3 +- crates/buzz-relay/src/api/git/transport.rs | 2 +- crates/buzz-relay/src/api/workflows.rs | 4 +- crates/buzz-relay/src/audio/handler.rs | 2 +- crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/main.rs | 9 +- crates/buzz-relay/src/nip11.rs | 66 +++- crates/buzz-relay/src/nip_fi_config.rs | 15 +- crates/buzz-relay/src/nip_fi_http.rs | 2 +- crates/buzz-relay/src/nip_fi_policy.rs | 153 ++++++++ .../src/nip_fi_policy/postgres_tests.rs | 134 +++++++ crates/buzz-relay/src/nip_fi_policy/tests.rs | 353 ++++++++++++++++++ crates/buzz-relay/src/router.rs | 57 ++- crates/buzz-relay/src/state.rs | 31 +- docs/community-auth-upgrades.md | 39 ++ 20 files changed, 907 insertions(+), 61 deletions(-) create mode 100644 crates/buzz-relay/src/nip_fi_policy.rs create mode 100644 crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs create mode 100644 crates/buzz-relay/src/nip_fi_policy/tests.rs 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-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index a69b908112d..592a093567b 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -106,6 +106,29 @@ const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi, WHERE u.operation_id = $1"; impl Db { + /// 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( + "SELECT requires_nip_fi, policy_generation FROM communities c + WHERE lower(host) = $1 AND archived_at IS NULL AND deleted_at IS NULL + AND deletion_state = 'active' AND NOT EXISTS ( + SELECT 1 FROM community_deletion_requests d + WHERE d.community_id = c.id AND d.stage <> 'aborted')", + ) + .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( 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..11361ae0548 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -498,3 +498,40 @@ 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 { + 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 { + requires_nip_fi: true, + policy_generation: upgraded.policy_generation + }) + ); + assert!(db + .community_auth_policy_for_host("unknown.example") + .await + .unwrap() + .is_none()); + 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-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 24b2c3136b5..db5d89f7c4d 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,9 @@ 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"); 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/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 9dd70fbfbc8..565e9bf1ecb 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -105,7 +105,7 @@ 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 mode = crate::nip_fi_policy::mode(state, &parts.headers).await; let slot = AccessSlot::of(parts); if !mode.restricts() { if let Err(rejection) = parse_git_auth_header(&parts.headers, method) { diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 5bc0964a54e..f4235def18b 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -65,7 +65,9 @@ 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..164b2216203 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -104,7 +104,7 @@ pub async fn ws_audio_handler( // [FI-TRACE-TRANSPORT-CLOSED] [NIP-FI.md §Admission pairing sequence] let (nip_fi_assertion, shadow_assertion, nip_fi_deadline, 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( diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 8b8fa0807bc..b30bd1e414c 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -54,6 +54,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..a301a26ab20 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -892,7 +892,14 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { buzz_relay::api::nip_fi::install_nip_fi_command_components( &mut app_state.nip_fi_deny_map, &mut app_state.nip_fi_command_verifier, - nip_fi.mode, + if nip_fi.source == buzz_relay::nip_fi_config::CommunitySource::Database + && nip_fi.mode.is_off() + && !nip_fi.command_configs.is_empty() + { + buzz_auth::NipFiMode::Enforce + } else { + nip_fi.mode + }, &nip_fi.registry, key_source, &nip_fi.command_configs, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index bcce89f1e63..75664f95354 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. @@ -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,48 @@ 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 +} + +pub(crate) async fn nip11_document( + state: &crate::state::AppState, + raw_host: &str, +) -> Result { + 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 = crate::nip_fi_policy::resolve(state, &headers).await?; + if 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 state.nip_fi_verifier.is_none() { + 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 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,6 +436,7 @@ 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 { @@ -440,7 +482,7 @@ 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 @@ -449,9 +491,9 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st /// 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. +/// [`crate::tenant::bind_community`] — never an unscoped query. An icon failure +/// omits this optional decoration; authoritative host policy has already been +/// checked by [`nip11_document`]. 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()?; state @@ -543,8 +585,12 @@ mod tests { 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) - .unwrap(), + serde_json::to_string( + &super::nip11_document(&state, "relay.example") + .await + .unwrap(), + ) + .unwrap(), ); } assert_ne!(docs[0], docs[1], "enforce advertises NIP-FI"); diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index ccc3190563b..5b79a23fd3b 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -750,11 +750,10 @@ 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. + /// Database deployments resolve issuer bindings even in Off: a stored + /// community requirement still enforces. Repair mode needs no verifier. pub fn builds_resolver(&self) -> bool { - self.source == CommunitySource::Database && self.mode.evaluates() + self.source == CommunitySource::Database && !self.mode.denies_unconditionally() } /// Returns the configured `max_connection_lifetime` as a `Duration`. @@ -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. + // Stored requirements still need issuer resolution under global Off. + // 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_unless_in_repair_mode() { let base = { let _guard = super::NIP_FI_ENV_LOCK .lock() @@ -2016,7 +2015,7 @@ mod tests { NipFiRelayConfig::from_env().expect("off config") }; for (mode, source, expected) in [ - (NipFiMode::Off, CommunitySource::Database, false), + (NipFiMode::Off, CommunitySource::Database, true), (NipFiMode::DenyProtected, CommunitySource::Database, false), (NipFiMode::Shadow, CommunitySource::Database, true), (NipFiMode::Enforce, CommunitySource::Database, true), diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index c5bb447bc6b..017e6928556 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -766,7 +766,7 @@ 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::mode(state, &headers).await; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); let admission = match state.nip_fi_deny_map.as_deref() { 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..2f36e1b587f --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -0,0 +1,153 @@ +//! Request-scoped community policy. Each request reads the writer once; no +//! cross-request policy cache can publish a stale optional generation over an +//! observed requirement. Existing requests/sessions are drained by BUZZ-512. + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, + response::IntoResponse, +}; +use buzz_auth::{DenialClass, NipFiMode}; +use buzz_db::CommunityAuthPolicy; +use futures_util::future::{BoxFuture, FutureExt}; + +/// 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_for_host(host) + .await + .map_err(|_| ()) + } + .boxed() + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Policy { + pub mode: NipFiMode, + pub required: bool, + pub stored_required: bool, +} + +tokio::task_local! { static REQUEST_POLICY: Policy; } + +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 { + if global.denies_unconditionally() { + global + } else if required { + NipFiMode::Enforce + } else { + global + } +} + +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() { + return Err(not_found()); + } + let start = std::time::Instant::now(); + let read = tokio::time::timeout( + std::time::Duration::from_millis(500), + state.nip_fi_policy.lookup(&host), + ) + .await; + metrics::histogram!("buzz_nip_fi_policy_read_seconds").record(start.elapsed().as_secs_f64()); + metrics::counter!("buzz_nip_fi_policy_reads_total").increment(1); + let policy = match read { + Ok(Ok(Some(policy))) => policy, + Ok(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 { + mode: effective(global, policy.requires_nip_fi), + required: policy.requires_nip_fi || global.enforces(), + stored_required: policy.requires_nip_fi, + }) +} + +/// Admission callers share the router's decision. Direct callers fail closed +/// when policy is unavailable, even if the global rollout mode is Off/Shadow. +pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> NipFiMode { + if state.config.nip_fi.mode.denies_unconditionally() { + return NipFiMode::DenyProtected; + } + match resolve(state, headers).await { + Ok(p) + if p.stored_required + && (state.nip_fi_verifier.is_none() + || state.nip_fi_deny_map.is_none() + || state.config.nip_fi.max_connection_lifetime().is_none()) => + { + NipFiMode::DenyProtected + } + Ok(p) => p.mode, + Err(_) => NipFiMode::DenyProtected, + } +} + +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 { + 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..64d97561198 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -0,0 +1,134 @@ +use super::*; +use axum::{ + body::to_bytes, + http::{header, Request}, +}; +use base64::Engine; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use tower::ServiceExt; + +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() + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows() { + let base = crate::state::tests::test_state_with_database_url_and_pool_options( + &crate::test_support::database_url(), + sqlx::postgres::PgPoolOptions::new(), + ) + .await; + 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); + config.nip_fi.mode = NipFiMode::Off; + 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); + } + // 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()); +} 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..0599d75df4a --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -0,0 +1,353 @@ +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 { + 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(); + (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, + (_, true) => NipFiMode::Enforce, + _ => global, + }; + assert_eq!(effective(global, required), expected); + } + } +} + +#[tokio::test] +async fn mixed_required_and_signed_unsigned_optional_admission() { + for global in [NipFiMode::Off, NipFiMode::Shadow] { + let (state, _, key, _) = fixture(global).await; + for signed in [false, true] { + // Optional controls retain both legacy proofs, regardless of attached FI. + let h = headers("free.example", false); + let result = admit_nip_fi_http_on_state(&state, &h, || { + Ok(if signed { + Nip98Proof::new(key, ()) + } else { + Nip98Proof::unsigned(key, ()) + }) + }) + .await; + assert!(result.is_ok(), "{global:?} signed={signed}"); + let h = headers("required.example", true); + let result = admit_nip_fi_http_on_state(&state, &h, || { + Ok(if signed { + Nip98Proof::new(key, ()) + } else { + Nip98Proof::unsigned(key, ()) + }) + }) + .await; + if signed { + assert!(result.is_ok()); + } else { + assert_eq!(result.err().unwrap().status(), StatusCode::FORBIDDEN); + } + } + let h = headers("required.example", false); + assert_eq!( + admit_nip_fi_http_on_state(&state, &h, || Ok(Nip98Proof::new(key, ()))) + .await + .err() + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } + let (state, _, key, _) = fixture(NipFiMode::Enforce).await; + let h = headers("free.example", false); + assert_eq!( + admit_nip_fi_http_on_state(&state, &h, || Ok(Nip98Proof::new(key, ()))) + .await + .err() + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn required_retains_verifier_and_key_pairing_denials() { + for global in [NipFiMode::Off, NipFiMode::Shadow] { + 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), || { + 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), || Ok( + Nip98Proof::new(wrong, ()) + )) + .await + .err() + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + } +} + +#[tokio::test] +async fn protected_routes_cannot_bypass_required_rows() { + for global in [NipFiMode::Off, NipFiMode::Shadow] { + 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 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 = 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]); + } + } + // One primary policy query per discovery request, no cross-host policy reuse. + assert_eq!(source.calls.load(Ordering::SeqCst), 6); + } +} + +#[tokio::test] +async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { + for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::DenyProtected] { + let (base, _, key, _) = fixture(global).await; + for missing in ["verifier", "deny_map", "policy"] { + let mut state = (*base).clone(); + match missing { + "verifier" => state.nip_fi_verifier = None, + "deny_map" => state.nip_fi_deny_map = None, + _ => { + 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), || { + Ok(Nip98Proof::new(key, ())) + }) + .await; + assert_eq!( + result.err().unwrap().status(), + StatusCode::SERVICE_UNAVAILABLE, + "{global:?} {missing}" + ); + } + } +} + +#[tokio::test] +async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries() { + let (state, source, key, _) = fixture(NipFiMode::Off).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); + let newer = resolve(&state, &h).await.unwrap(); + assert!(!earlier.required); + assert!(newer.required); + with_policy(earlier, async { + assert!(mode(&state, &h).await.is_off()); + }) + .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, || Ok(Nip98Proof::new(key, ()))) + .await + .is_ok() + ); + }) + .await; + assert_eq!(source.calls.load(Ordering::SeqCst), before); +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index fdf36acb8b0..55f5e0a495c 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; @@ -183,9 +183,33 @@ async fn nip_fi_assertion_guard( 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 nip_fi_guard_exempts(&state, &request) { + return next.run(request).await; + } + if state.config.nip_fi.mode.denies_unconditionally() { + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + let policy = match crate::nip_fi_policy::resolve(&state, request.headers()).await { + Ok(policy) => policy, + Err(response) => return response, + }; + if policy.stored_required && state.nip_fi_deny_map.is_none() { + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + crate::nip_fi_policy::with_policy( + policy, + nip_fi_assertion_guard_with_policy(state, request, next, policy.mode), + ) + .await +} + +async fn nip_fi_assertion_guard_with_policy( + state: Arc, + request: Request, + next: middleware::Next, + mode: buzz_auth::NipFiMode, +) -> axum::response::Response { + if mode.is_off() { return next.run(request).await; } @@ -749,7 +773,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. @@ -808,7 +832,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) @@ -864,9 +888,8 @@ 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. @@ -917,7 +940,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 = crate::nip_fi_policy::mode(&state, &headers).await; 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 +1009,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 +1341,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, @@ -1365,7 +1388,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, @@ -2351,7 +2374,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 +2386,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } @@ -3712,7 +3736,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, @@ -3814,7 +3838,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, @@ -4275,6 +4299,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..de9462b47aa 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1601,7 +1601,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` for unconfigured OSS deployments. Database-sourced deployments + /// construct it even in Off, for stored requirements. 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 +1615,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`. @@ -1679,6 +1683,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); @@ -1895,6 +1900,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 @@ -2766,10 +2772,9 @@ 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`. +/// Database-sourced deployments construct the verifier in Off too, because +/// a required community still evaluates assertions. Unconfigured OSS Off and +/// repair mode need no verifier. 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 = ( @@ -2780,7 +2785,10 @@ type NipFiComponents = ( fn build_nip_fi_components(config: &crate::config::Config) -> NipFiComponents { use buzz_auth::{FederatedAssertionVerifier, HttpJwksFetcher, ProductionJwksSource}; - if !config.nip_fi.mode.evaluates() { + if config.nip_fi.mode.denies_unconditionally() + || (!config.nip_fi.mode.evaluates() + && config.nip_fi.source != crate::nip_fi_config::CommunitySource::Database) + { // Off: no enforcement. DenyProtected: verifier never consulted (always 503). return (None, None); } @@ -3434,7 +3442,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 +3456,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..6dbd87052dc 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -185,3 +185,42 @@ 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) + +Each protected request resolves the normalized Host's active policy from the +primary database, with a 500 ms budget. The effective requirement is the stored +`requires_nip_fi` OR global `enforce`. Global `deny_protected` always returns 503 +before evidence checks; neither `off` nor `shadow` exempts a required row. +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 the policy read with HTTP handlers for that request. Root and +audio WebSocket admission use the same policy source. There is no cross-request +policy cache, positive or negative: an older read cannot overwrite a newer cached +generation. This adds one writer query per protected request or discovery request; +`buzz_nip_fi_policy_reads_total` and `buzz_nip_fi_policy_read_seconds` measure it. +Issuer-binding/JWKS caches keep their existing bounds; unavailable dependencies +refuse required admission, rather than treating it as optional. + +Database-sourced deployments construct the issuer resolver and verifier even in +`off`, because some rows may require FI. Configure finite +`BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS`, issuer JWKS and command configuration +before enabling any row. Missing required components return 503. 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; policy lookup failure returns +503. 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. Thus shadow optional hosts still advertise capability, and required off hosts +advertise capability and requirement together. 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. From 95e9fcb4ea917d17de7ab65ba9d657a2b486bf09 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:11:46 -0500 Subject: [PATCH 02/27] test(relay): keep immutable router fixtures Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/router.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 55f5e0a495c..eea5cf0cecc 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -3736,7 +3736,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (mut state, _audit_shutdown) = AppState::new( + let (state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -3838,7 +3838,7 @@ mod tests { )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (mut state, _audit_shutdown) = AppState::new( + let (state, _audit_shutdown) = AppState::new( config, db, redis_pool, From 2ec7ac31864885319f7e85590038ff2e0063510b Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:38:10 -0500 Subject: [PATCH 03/27] fix(relay): require signed proofs for FI admission Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-auth/src/nip_fi/startup/mod.rs | 16 ++++ crates/buzz-relay/src/api/bridge.rs | 4 +- crates/buzz-relay/src/api/nip_fi.rs | 93 ++++++++++++++++++- crates/buzz-relay/src/api/workflows.rs | 4 +- crates/buzz-relay/src/main.rs | 2 +- crates/buzz-relay/src/nip11.rs | 17 ++-- crates/buzz-relay/src/nip_fi_http.rs | 16 +++- crates/buzz-relay/src/nip_fi_policy.rs | 16 +--- .../src/nip_fi_policy/postgres_tests.rs | 2 +- crates/buzz-relay/src/nip_fi_policy/tests.rs | 2 +- crates/buzz-relay/src/router.rs | 23 ++++- scripts/run-tests.sh | 3 + 12 files changed, 163 insertions(+), 35 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs index 967d6c3719a..9ee25192d24 100644 --- a/crates/buzz-auth/src/nip_fi/startup/mod.rs +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -29,6 +29,22 @@ pub enum NipFiMode { } impl NipFiMode { + /// Add a durable community requirement without weakening repair mode. + pub const fn with_requirement(self, required: bool) -> Self { + if self.denies_unconditionally() { + self + } else if 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-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index db5d89f7c4d..49999730532 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3159,9 +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 = crate::nip_fi_policy::mode(&state, &headers) - .await - .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"); diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 94d599f425e..038380d5654 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2491,9 +2491,94 @@ 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 { + requires_nip_fi: true, + policy_generation: 1, + })) + }) + } + } + + #[tokio::test] + async fn required_off_and_shadow_validate_real_assertion_crypto() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + for mode in [buzz_auth::NipFiMode::Off, buzz_auth::NipFiMode::Shadow] { + 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, || { + Ok(crate::nip_fi_http::Nip98Proof::new(key, ())) + }) + .await; + if 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 +2740,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 +2760,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 +2779,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/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index f4235def18b..9b35fccd7eb 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -65,9 +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 = crate::nip_fi_policy::mode(state, &headers) - .await - .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/main.rs b/crates/buzz-relay/src/main.rs index a301a26ab20..37fae7c7adf 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -896,7 +896,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { && nip_fi.mode.is_off() && !nip_fi.command_configs.is_empty() { - buzz_auth::NipFiMode::Enforce + nip_fi.mode.with_requirement(true) } else { nip_fi.mode }, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 75664f95354..eac6593db63 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -584,14 +584,15 @@ mod tests { 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 - .unwrap(), - ) - .unwrap(), - ); + 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_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 017e6928556..d7bc85dbd67 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -702,6 +702,20 @@ 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(WouldDeny::new( + Stage::Nip98, + if headers.contains_key("authorization") { + DenialClass::EvidenceRejected + } else { + DenialClass::MissingEvidence + }, + "unsigned_proof", + )); + } + // Steps 4–5: extract and verify the assertion. let assertion = evaluate_attached_assertion(headers, community, verifier).map_err(|rejection| { @@ -766,7 +780,7 @@ pub(crate) async fn admit_nip_fi_http_on_state( where F: FnOnce() -> Result, Response>, { - let mode = crate::nip_fi_policy::mode(state, &headers).await; + let mode = crate::nip_fi_policy::mode(state, headers).await; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); let admission = match state.nip_fi_deny_map.as_deref() { diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 2f36e1b587f..d2c8a500104 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -8,7 +8,7 @@ use axum::{ response::IntoResponse, }; use buzz_auth::{DenialClass, NipFiMode}; -use buzz_db::CommunityAuthPolicy; +use buzz_db::community_auth::CommunityAuthPolicy; use futures_util::future::{BoxFuture, FutureExt}; /// Host policy source, shared by admission and discovery. @@ -47,13 +47,7 @@ pub(crate) async fn with_policy(policy: Policy, next: F) /// Repair mode precedes all policy and evidence failures. pub(crate) fn effective(global: NipFiMode, required: bool) -> NipFiMode { - if global.denies_unconditionally() { - global - } else if required { - NipFiMode::Enforce - } else { - global - } + global.with_requirement(required) } pub(crate) async fn resolve( @@ -104,7 +98,7 @@ pub(crate) async fn resolve( /// when policy is unavailable, even if the global rollout mode is Off/Shadow. pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> NipFiMode { if state.config.nip_fi.mode.denies_unconditionally() { - return NipFiMode::DenyProtected; + return NipFiMode::unavailable(); } match resolve(state, headers).await { Ok(p) @@ -113,10 +107,10 @@ pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> || state.nip_fi_deny_map.is_none() || state.config.nip_fi.max_connection_lifetime().is_none()) => { - NipFiMode::DenyProtected + NipFiMode::unavailable() } Ok(p) => p.mode, - Err(_) => NipFiMode::DenyProtected, + Err(_) => NipFiMode::unavailable(), } } diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 64d97561198..de4fffa7e06 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -4,7 +4,7 @@ use axum::{ http::{header, Request}, }; use base64::Engine; -use nostr::{EventBuilder, Keys, Kind, Tag}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 0599d75df4a..0df0be3232e 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -129,7 +129,7 @@ async fn mixed_required_and_signed_unsigned_optional_admission() { if signed { assert!(result.is_ok()); } else { - assert_eq!(result.err().unwrap().status(), StatusCode::FORBIDDEN); + assert_eq!(result.err().unwrap().status(), StatusCode::UNAUTHORIZED); } } let h = headers("required.example", false); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index eea5cf0cecc..218ab59c8f3 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -191,7 +191,22 @@ async fn nip_fi_assertion_guard( } let policy = match crate::nip_fi_policy::resolve(&state, request.headers()).await { Ok(policy) => policy, - Err(response) => return response, + Err(response) => { + 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 http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); @@ -1353,6 +1368,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } @@ -1388,7 +1404,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (mut state, _audit_shutdown) = AppState::new( + let (state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -3838,7 +3854,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, @@ -3850,6 +3866,7 @@ mod tests { nostr::Keys::generate(), media_storage, ); + state.nip_fi_policy = Arc::new(crate::nip_fi_policy::OptionalPolicy); Arc::new(state) } 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 From 06a2b491897a34855ac2d15881b05aa0e2f54447 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:43:14 -0500 Subject: [PATCH 04/27] docs(relay): describe host-specific FI discovery Co-authored-by: Honor Signed-off-by: Brad Seiler --- ARCHITECTURE.md | 2 ++ crates/buzz-relay/src/nip11.rs | 7 +++---- docs/multi-tenant-conformance.md | 2 +- docs/nips/NIP-FI.md | 9 +++++++++ 4 files changed, 15 insertions(+), 5 deletions(-) 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/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index eac6593db63..e2f93515c40 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -542,10 +542,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 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 From 1ac1cdcb349aace9b7c6495b19ff88d9dd2d8cfa Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:50:04 -0500 Subject: [PATCH 05/27] fix(relay): retain unmapped-host rejection contracts Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/api/buzz_v1/postgres_tests.rs | 8 ++++++ crates/buzz-relay/src/nip_fi_http.rs | 5 ++++ crates/buzz-relay/src/nip_fi_policy.rs | 8 ++++++ .../src/nip_fi_policy/postgres_tests.rs | 8 ++---- crates/buzz-relay/src/nip_fi_policy/tests.rs | 28 +++++++++++++++++++ .../src/nip_fi_resolver/tests/router_tests.rs | 3 ++ crates/buzz-relay/src/router.rs | 18 ++++++++++++ 7 files changed, 73 insertions(+), 5 deletions(-) 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/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index d7bc85dbd67..cf169ed8a0c 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -780,6 +780,11 @@ pub(crate) async fn admit_nip_fi_http_on_state( where F: FnOnce() -> Result, Response>, { + if crate::nip_fi_policy::unmapped_request() { + return Err(http_denial( + buzz_auth::DenialClass::AuthorizationUnavailable, + )); + } let mode = crate::nip_fi_policy::mode(state, headers).await; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index d2c8a500104..9fca55e7713 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -37,6 +37,7 @@ pub(crate) struct Policy { pub mode: NipFiMode, pub required: bool, pub stored_required: bool, + pub mapped: bool, } tokio::task_local! { static REQUEST_POLICY: Policy; } @@ -91,9 +92,16 @@ pub(crate) async fn resolve( mode: effective(global, policy.requires_nip_fi), required: policy.requires_nip_fi || global.enforces(), stored_required: policy.requires_nip_fi, + mapped: true, }) } +/// An unmapped request may retain legacy pre-auth error ordering, but must +/// never obtain a shared admission proof if a handler omits its host check. +pub(crate) fn unmapped_request() -> bool { + REQUEST_POLICY.try_with(|p| !p.mapped).unwrap_or(false) +} + /// Admission callers share the router's decision. Direct callers fail closed /// when policy is unavailable, even if the global rollout mode is Off/Shadow. pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> NipFiMode { diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index de4fffa7e06..9df0f8ce568 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -26,11 +26,9 @@ impl buzz_auth::Nip98ReplayGuard for Replay { #[tokio::test] #[ignore = "requires Postgres"] async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows() { - let base = crate::state::tests::test_state_with_database_url_and_pool_options( - &crate::test_support::database_url(), - sqlx::postgres::PgPoolOptions::new(), - ) - .await; + 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()); diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 0df0be3232e..592b3f7a201 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -351,3 +351,31 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries .await; assert_eq!(source.calls.load(Ordering::SeqCst), before); } + +#[tokio::test] +async fn unmapped_legacy_rejection_scope_never_yields_an_admission_proof() { + let (state, _, key, _) = fixture(NipFiMode::Off).await; + let policy = Policy { + mode: NipFiMode::Off, + required: false, + stored_required: false, + mapped: false, + }; + with_policy(policy, async { + let mut called = false; + let response = + admit_nip_fi_http_on_state(&state, &headers("unknown.example", false), || { + 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..79fea61505d 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; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 218ab59c8f3..17bb37ac4ac 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -191,6 +191,24 @@ async fn nip_fi_assertion_guard( } 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 { + mode, + required: mode.enforces(), + stored_required: false, + mapped: false, + }; + return crate::nip_fi_policy::with_policy( + policy, + nip_fi_assertion_guard_with_policy(state, request, next, mode), + ) + .await; + } Err(response) => { if state.config.nip_fi.mode.observes_only() { crate::nip_fi_shadow::record_for( From ccd0309eb450cfce38030a6c891316e1a27a0d34 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:57:51 -0500 Subject: [PATCH 06/27] fix(relay): separate pre-auth policy from admission Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/git/transport.rs | 11 ++-- crates/buzz-relay/src/api/nip_fi.rs | 1 + crates/buzz-relay/src/nip11.rs | 1 + crates/buzz-relay/src/nip_fi_http.rs | 7 +-- crates/buzz-relay/src/nip_fi_policy.rs | 55 +++++++++++++------ .../src/nip_fi_policy/postgres_tests.rs | 2 +- crates/buzz-relay/src/nip_fi_policy/tests.rs | 2 + 7 files changed, 50 insertions(+), 29 deletions(-) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 565e9bf1ecb..8f45a321691 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -96,7 +96,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. The policy read + // still comes first so a required row cannot take the legacy path. // // Enforce: the header syntax is validated // inside the NIP-FI admission closure below, where proof failures are @@ -4588,7 +4589,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 +4632,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 +4672,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 +4706,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 038380d5654..d3c097d9bd1 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2514,6 +2514,7 @@ mod route_integration_tests { } #[tokio::test] + #[allow(clippy::result_large_err)] // Real Axum admission closure. async fn required_off_and_shadow_validate_real_assertion_crypto() { use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; for mode in [buzz_auth::NipFiMode::Off, buzz_auth::NipFiMode::Shadow] { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index e2f93515c40..e8870589520 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -398,6 +398,7 @@ pub(crate) async fn nip11_response( 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, diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index cf169ed8a0c..ba7d0916877 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -780,12 +780,7 @@ pub(crate) async fn admit_nip_fi_http_on_state( where F: FnOnce() -> Result, Response>, { - if crate::nip_fi_policy::unmapped_request() { - return Err(http_denial( - buzz_auth::DenialClass::AuthorizationUnavailable, - )); - } - let mode = crate::nip_fi_policy::mode(state, headers).await; + let mode = crate::nip_fi_policy::admission_mode(state, headers).await?; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); let admission = match state.nip_fi_deny_map.as_deref() { diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 9fca55e7713..66f6d15ddbc 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -51,6 +51,7 @@ pub(crate) fn effective(global: NipFiMode, required: bool) -> NipFiMode { global.with_requirement(required) } +#[allow(clippy::result_large_err)] // Axum responses are the ingress error contract. pub(crate) async fn resolve( state: &crate::state::AppState, headers: &HeaderMap, @@ -96,32 +97,52 @@ pub(crate) async fn resolve( }) } -/// An unmapped request may retain legacy pre-auth error ordering, but must -/// never obtain a shared admission proof if a handler omits its host check. -pub(crate) fn unmapped_request() -> bool { - REQUEST_POLICY.try_with(|p| !p.mapped).unwrap_or(false) -} - -/// Admission callers share the router's decision. Direct callers fail closed -/// when policy is unavailable, even if the global rollout mode is Off/Shadow. +/// 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.denies_unconditionally() { return NipFiMode::unavailable(); } match resolve(state, headers).await { - Ok(p) - if p.stored_required - && (state.nip_fi_verifier.is_none() - || state.nip_fi_deny_map.is_none() - || state.config.nip_fi.max_connection_lifetime().is_none()) => - { - NipFiMode::unavailable() - } - Ok(p) => p.mode, + Ok(policy) => configured_mode(state, policy), + Err(response) if response.status() == StatusCode::NOT_FOUND => state.config.nip_fi.mode, Err(_) => NipFiMode::unavailable(), } } +/// 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.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)) +} + +fn configured_mode(state: &crate::state::AppState, policy: Policy) -> NipFiMode { + if policy.stored_required + && (state.nip_fi_verifier.is_none() + || state.nip_fi_deny_map.is_none() + || state.config.nip_fi.max_connection_lifetime().is_none()) + { + NipFiMode::unavailable() + } else { + policy.mode + } +} + fn not_found() -> Response { ( StatusCode::NOT_FOUND, diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 9df0f8ce568..cf4ac8d2f9e 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -57,7 +57,7 @@ async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows 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]}"#; + let body = br#"[{"kinds":[1]}]"#; for host in [&free, &required] { for signed in [false, true] { let mut request = Request::post("/count") diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 592b3f7a201..9f59c44c34d 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -1,3 +1,5 @@ +#![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}; From dddd005c3ff57954e102be137c3347977a0649d0 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 02:41:53 -0500 Subject: [PATCH 07/27] fix(relay): retain required policy during deletion Resolve serving hosts and their policy with one shared query. Exercise real root/audio upgrades and pending deletion in Off and Shadow. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community.rs | 23 ++-- crates/buzz-db/src/store/community_auth.rs | 14 +-- .../store/community_auth/postgres_tests.rs | 19 +++ .../src/nip_fi_policy/postgres_tests.rs | 109 ++++++++++++++++++ 4 files changed, 142 insertions(+), 23 deletions(-) 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 592a093567b..f732204396c 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -117,16 +117,10 @@ impl Db { crate::observability::WriterOperation::Authorization, ) .await?; - Ok(sqlx::query_as( - "SELECT requires_nip_fi, policy_generation FROM communities c - WHERE lower(host) = $1 AND archived_at IS NULL AND deleted_at IS NULL - AND deletion_state = 'active' AND NOT EXISTS ( - SELECT 1 FROM community_deletion_requests d - WHERE d.community_id = c.id AND d.stage <> 'aborted')", - ) - .bind(host) - .fetch_optional(&mut *connection) - .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. 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 11361ae0548..68dc26f46cb 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -524,6 +524,25 @@ async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { .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()) diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index cf4ac8d2f9e..7ab52b9699f 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -23,6 +23,115 @@ impl buzz_auth::Nip98ReplayGuard for Replay { } } +// 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] { + 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![]))); + 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 { + 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() { From 7c474ca783296d4847cca6ee06dc64356314dd7b Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 02:41:57 -0500 Subject: [PATCH 08/27] fix(relay): share required admission readiness Require verifier, deny map and finite lifetime in discovery and admission. Expose local dependency readiness without claiming activation. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/main.rs | 7 +++++ crates/buzz-relay/src/nip11.rs | 2 +- crates/buzz-relay/src/nip_fi_policy.rs | 22 +++++++------- crates/buzz-relay/src/nip_fi_policy/tests.rs | 30 +++++++++++++++++++- crates/buzz-relay/src/router.rs | 4 +-- crates/buzz-relay/src/state.rs | 6 ++++ 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 37fae7c7adf..e891a5b2129 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -942,6 +942,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 e8870589520..3d3098f20cc 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -415,7 +415,7 @@ pub(crate) async fn nip11_document( .resolve(&headers) .await .map_err(crate::nip_fi_core::http_denial)?; - if state.nip_fi_verifier.is_none() { + if !crate::nip_fi_policy::required_dependencies_ready(state) { return Err(crate::nip_fi_core::http_denial( buzz_auth::DenialClass::AuthorizationUnavailable, )); diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 66f6d15ddbc..218b371c59f 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -11,6 +11,8 @@ 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>( @@ -37,6 +39,8 @@ pub(crate) struct Policy { 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, } @@ -69,11 +73,7 @@ pub(crate) async fn resolve( return Err(not_found()); } let start = std::time::Instant::now(); - let read = tokio::time::timeout( - std::time::Duration::from_millis(500), - state.nip_fi_policy.lookup(&host), - ) - .await; + let read = tokio::time::timeout(POLICY_READ_BUDGET, state.nip_fi_policy.lookup(&host)).await; metrics::histogram!("buzz_nip_fi_policy_read_seconds").record(start.elapsed().as_secs_f64()); metrics::counter!("buzz_nip_fi_policy_reads_total").increment(1); let policy = match read { @@ -131,12 +131,14 @@ pub(crate) async fn admission_mode( 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 policy.stored_required - && (state.nip_fi_verifier.is_none() - || state.nip_fi_deny_map.is_none() - || state.config.nip_fi.max_connection_lifetime().is_none()) - { + if policy.stored_required && !required_dependencies_ready(state) { NipFiMode::unavailable() } else { policy.mode diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 9f59c44c34d..065ec0e9f35 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -296,11 +296,16 @@ async fn discovery_is_host_specific_compatible_uncached_and_consistent() { async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::DenyProtected] { let (base, _, key, _) = fixture(global).await; - for missing in ["verifier", "deny_map", "policy"] { + 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 + } _ => { state.nip_fi_policy = Arc::new(Source { required: AtomicBool::new(true), @@ -319,6 +324,29 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { 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(), + StatusCode::SERVICE_UNAVAILABLE, + "{global:?} {missing} {path}" + ); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + } } } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 17bb37ac4ac..e9ae45f2ae7 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -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 and Shadow preserve optional-community admission after the policy read. +// A stored requirement enforces FI independently of the global mode. // // ## What this guard checks (and does NOT check) // diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index de9462b47aa..190e4b8b8ff 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1654,6 +1654,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; From 17a45bb9ce52c68e28272890c28dd22cb07f1f1d Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 02:42:00 -0500 Subject: [PATCH 09/27] docs(relay): define policy rollout containment Stage with zero required rows and measured abort limits. Retain enforcement and receipts after acceptance; pre-511 rollback is unsafe. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-upgrades.md | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 6dbd87052dc..cf5e1990d8f 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -224,3 +224,55 @@ 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 + +This changes every serving host, including free hosts: protected routes and +discovery now require a primary policy read. Unmapped discovery previously could +return a generic document; it now returns 404. Policy failures return 503. +Database-source startup loads its binding snapshot even in Off and fails startup +if that load fails. 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` 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 +Off and Shadow: 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 is unsafe: it ignores that row. +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; +never change them to Off as a bypass or clear accepted policy. 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. Do not enable production +upgrades until this activation/control gap is verified closed. From c8f5828ef0e96a30ea003f92f8d6e688e66a746c Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 02:56:50 -0500 Subject: [PATCH 10/27] test(relay): refresh cancellation fixture fence Keep each timeout probe on its deliberately locked replica. Prior probes can outlive the synthetic five-second routing budget. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/bridge.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 49999730532..e3cc1489449 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -10344,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) @@ -10373,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") { From 18841b9eadb6546e83992ac6a187ba1c401f5e87 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 03:37:00 -0500 Subject: [PATCH 11/27] fix(relay): enforce the bound community policy Read tenant identity and policy from the same serving row before upgrading. Recheck evidence if a required row appeared after the early lookup, including Off and Shadow unarchive races. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 32 +++++ crates/buzz-relay/src/audio/handler.rs | 38 +++-- crates/buzz-relay/src/nip_fi_policy.rs | 34 +++++ .../src/nip_fi_policy/postgres_tests.rs | 134 ++++++++++++++++++ crates/buzz-relay/src/router.rs | 56 ++++++-- docs/community-auth-upgrades.md | 6 + 6 files changed, 275 insertions(+), 25 deletions(-) diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index f732204396c..cffb9b7e3a1 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -106,6 +106,38 @@ const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi, WHERE u.operation_id = $1"; impl Db { + /// 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 { + 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( diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 164b2216203..43712db10a4 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -102,7 +102,7 @@ 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 = crate::nip_fi_policy::mode(&state, &headers).await; let verifier = state.nip_fi_verifier.as_deref(); @@ -132,16 +132,34 @@ 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( + 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/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 218b371c59f..b5f338e7e59 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -110,6 +110,40 @@ pub(crate) async fn mode(state: &crate::state::AppState, headers: &HeaderMap) -> } } +/// 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> { + let host = buzz_core::tenant::normalize_host(raw_host); + if host.is_empty() { + 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 { + mode: effective(state.config.nip_fi.mode, policy.requires_nip_fi), + required: 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. diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 7ab52b9699f..c307c1422f2 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -23,6 +23,140 @@ impl buzz_auth::Nip98ReplayGuard for Replay { } } +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::Off, NipFiMode::Shadow] { + 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] diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index e9ae45f2ae7..02369a32315 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -840,7 +840,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()) @@ -913,7 +919,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) }); @@ -926,19 +932,39 @@ async fn nip11_or_ws_handler( // // 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( + 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() { @@ -973,7 +999,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 = crate::nip_fi_policy::mode(&state, &headers).await; + 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. diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index cf5e1990d8f..3991110fd31 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -203,6 +203,12 @@ generation. This adds one writer query per protected request or discovery reques 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 Off or Shadow. This changes no discovery fields. + Database-sourced deployments construct the issuer resolver and verifier even in `off`, because some rows may require FI. Configure finite `BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS`, issuer JWKS and command configuration From b9144193e946d9377f4ba7ff4e65f27a5b883d38 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 12:35:03 -0500 Subject: [PATCH 12/27] fix(relay): pin HTTP data to the admission policy tenant Reuse the immutable community ID from the primary policy row for protected HTTP tenant binding and NIP-11 metadata. A cached host mapping cannot select another community's data under an optional policy after a host remap. Unguarded routes retain their existing cache behavior. Exercise remapping through the real router with old-only and new-community membership, an old-community event, and discovery descriptors. Reject cache fallback after an authoritative host miss. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 6 +- .../store/community_auth/postgres_tests.rs | 4 + crates/buzz-relay/src/api/nip_fi.rs | 1 + crates/buzz-relay/src/nip11.rs | 34 ++-- crates/buzz-relay/src/nip_fi_policy.rs | 25 +++ .../src/nip_fi_policy/postgres_tests.rs | 146 ++++++++++++++++++ crates/buzz-relay/src/nip_fi_policy/tests.rs | 16 ++ crates/buzz-relay/src/nip_fi_shadow.rs | 5 +- crates/buzz-relay/src/router.rs | 1 + docs/community-auth-upgrades.md | 7 + 10 files changed, 224 insertions(+), 21 deletions(-) diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index cffb9b7e3a1..3481fcf8dd4 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -82,6 +82,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. @@ -130,6 +133,7 @@ impl Db { 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")?, }, @@ -166,7 +170,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?) } 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 68dc26f46cb..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, }) @@ -507,6 +509,7 @@ async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { 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 }) @@ -515,6 +518,7 @@ async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { 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 }) diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index d3c097d9bd1..0b55963317c 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2506,6 +2506,7 @@ mod route_integration_tests { > { Box::pin(async { Ok(Some(buzz_db::community_auth::CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), requires_nip_fi: true, policy_generation: 1, })) diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3d3098f20cc..95573e5191b 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -205,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 @@ -422,7 +422,11 @@ pub(crate) async fn nip11_document( } } 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() || policy.required; let mut info = RelayInfo::build( @@ -439,8 +443,8 @@ pub(crate) async fn nip11_document( ); 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; } @@ -464,10 +468,7 @@ pub(crate) async fn nip11_document( })); } 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 }; @@ -486,17 +487,12 @@ pub(crate) async fn nip11_document( 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. An icon failure -/// omits this optional decoration; authoritative host policy has already been -/// checked by [`nip11_document`]. -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()) diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index b5f338e7e59..1d1a1229367 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -36,6 +36,7 @@ impl PolicySource for buzz_db::Db { #[derive(Clone, Copy)] pub(crate) struct Policy { + pub community_id: uuid::Uuid, pub mode: NipFiMode, pub required: bool, pub stored_required: bool, @@ -44,8 +45,29 @@ pub(crate) struct Policy { 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() +} + pub(crate) async fn with_policy(policy: Policy, next: F) -> F::Output { REQUEST_POLICY.scope(policy, next).await } @@ -90,6 +112,7 @@ pub(crate) async fn resolve( "resolved community auth policy" ); Ok(Policy { + community_id: policy.community_id, mode: effective(global, policy.requires_nip_fi), required: policy.requires_nip_fi || global.enforces(), stored_required: policy.requires_nip_fi, @@ -133,6 +156,7 @@ pub(crate) async fn bind_for_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: policy.requires_nip_fi || state.config.nip_fi.mode.enforces(), stored_required: policy.requires_nip_fi, @@ -197,6 +221,7 @@ impl PolicySource for OptionalPolicy { ) -> BoxFuture<'a, Result, ()>> { async { Ok(Some(CommunityAuthPolicy { + community_id: uuid::Uuid::nil(), requires_nip_fi: false, policy_generation: 0, })) diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index c307c1422f2..303a20bf503 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -373,3 +373,149 @@ async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows } 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); + config.nip_fi.mode = NipFiMode::Off; + 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 index 065ec0e9f35..cb2c1e7604c 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -34,6 +34,7 @@ impl PolicySource for Source { } 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), })) @@ -386,12 +387,27 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries async fn unmapped_legacy_rejection_scope_never_yields_an_admission_proof() { let (state, _, key, _) = fixture(NipFiMode::Off).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), || { diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index 54aad44556a..59220e5f600 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -579,7 +579,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 02369a32315..ebdf540f710 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -198,6 +198,7 @@ async fn nip_fi_assertion_guard( // 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, diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 3991110fd31..ccd40d57bfd 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -200,6 +200,13 @@ audio WebSocket admission use the same policy source. There is no cross-request policy cache, positive or negative: an older read cannot overwrite a newer cached generation. This adds one writer query per protected request or discovery request; `buzz_nip_fi_policy_reads_total` and `buzz_nip_fi_policy_read_seconds` measure it. +Protected HTTP handlers use the immutable tenant ID from the same primary row +that supplied the request's policy. They do not resolve tenant-scoped data from +another host-cache snapshot. NIP-11 uses that policy tenant for its descriptors +and optional decorations too, so a remapped host cannot combine one community's +optional policy with another community's data. Exempt and unguarded routes keep +their existing host-cache behavior. + Issuer-binding/JWKS caches keep their existing bounds; unavailable dependencies refuse required admission, rather than treating it as optional. From f0f1f8a25ae515015415c8f83899498226504bd0 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 14:27:12 -0500 Subject: [PATCH 13/27] test(relay): cross binding deadlines at final admission HTTP now reuses its policy tenant, so it has no second database binding lookup. Advance the fixture clock after the handler's real signature verification, preserving the fresh guard and stale final admission checks in Enforce and Shadow. Keep the primary acquisition barrier for socket routes and require each armed seam to fire. Use the seeded community ID for the fixture's policy tenant. Keep exact refusal bytes, shadow labels, and fresh controls unchanged. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/nip_fi_resolver/tests/router_tests.rs | 105 +++++++++++++++--- 1 file changed, 89 insertions(+), 16 deletions(-) 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 79fea61505d..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 @@ -666,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, } @@ -701,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, } } @@ -737,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)>, @@ -763,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 } @@ -814,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)); @@ -859,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)); From f29c001c806867eea0b681f35a8b342377198f38 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 17:03:50 -0500 Subject: [PATCH 14/27] test(relay): expect NIP-98 denial for unsigned HTTP proof Keep signed-key pairing attribution while asserting that unsigned dev proofs are rejected at the earlier NIP-98 evidence check. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/nip_fi_http.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index ba7d0916877..237b4c16612 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -1589,8 +1589,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 ───────────────────────────── From 7bad47a088fefa09c83383d314d4504a0133cfcd Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 18:19:43 -0500 Subject: [PATCH 15/27] fix(relay): preserve unsigned proof attribution after restack Adapt the strict proof rejection and direct admission fixtures to the access logging contract without attributing an unsigned signer. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/nip_fi.rs | 12 +- crates/buzz-relay/src/nip_fi_http.rs | 30 +++-- crates/buzz-relay/src/nip_fi_policy/tests.rs | 124 ++++++++++++------- 3 files changed, 104 insertions(+), 62 deletions(-) diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 0b55963317c..af029d9d6e6 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2563,11 +2563,13 @@ mod route_integration_tests { CLIENT_ATTACHED_HEADER, format!("Bearer {token}").parse().unwrap(), ); - let result = - crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &headers, || { - Ok(crate::nip_fi_http::Nip98Proof::new(key, ())) - }) - .await; + 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 bad == "valid" { assert!(result.is_ok(), "{mode:?} valid assertion"); } else { diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 237b4c16612..521e6fd973f 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -705,14 +705,18 @@ where // 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(WouldDeny::new( - Stage::Nip98, - if headers.contains_key("authorization") { - DenialClass::EvidenceRejected - } else { - DenialClass::MissingEvidence - }, - "unsigned_proof", + 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")), )); } @@ -780,7 +784,15 @@ pub(crate) async fn admit_nip_fi_http_on_state( where F: FnOnce() -> Result, Response>, { - let mode = crate::nip_fi_policy::admission_mode(state, headers).await?; + let mode = crate::nip_fi_policy::admission_mode(state, headers) + .await + .map_err(|response| { + slot.method(AuthMethod::NipFi); + slot.finish(AuthResult::Denied( + crate::access::AuthReason::AuthorizationUnavailable, + )); + response + })?; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); let admission = match state.nip_fi_deny_map.as_deref() { diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index cb2c1e7604c..d702fac6e39 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -111,23 +111,33 @@ async fn mixed_required_and_signed_unsigned_optional_admission() { for signed in [false, true] { // Optional controls retain both legacy proofs, regardless of attached FI. let h = headers("free.example", false); - let result = admit_nip_fi_http_on_state(&state, &h, || { - Ok(if signed { - Nip98Proof::new(key, ()) - } else { - Nip98Proof::unsigned(key, ()) - }) - }) + let result = admit_nip_fi_http_on_state( + &state, + &h, + &crate::access::AccessSlot::default(), + || { + Ok(if signed { + Nip98Proof::new(key, ()) + } else { + Nip98Proof::unsigned(key, ()) + }) + }, + ) .await; assert!(result.is_ok(), "{global:?} signed={signed}"); let h = headers("required.example", true); - let result = admit_nip_fi_http_on_state(&state, &h, || { - Ok(if signed { - Nip98Proof::new(key, ()) - } else { - Nip98Proof::unsigned(key, ()) - }) - }) + let result = admit_nip_fi_http_on_state( + &state, + &h, + &crate::access::AccessSlot::default(), + || { + Ok(if signed { + Nip98Proof::new(key, ()) + } else { + Nip98Proof::unsigned(key, ()) + }) + }, + ) .await; if signed { assert!(result.is_ok()); @@ -137,22 +147,26 @@ async fn mixed_required_and_signed_unsigned_optional_admission() { } let h = headers("required.example", false); assert_eq!( - admit_nip_fi_http_on_state(&state, &h, || Ok(Nip98Proof::new(key, ()))) - .await - .err() - .unwrap() - .status(), + admit_nip_fi_http_on_state(&state, &h, &crate::access::AccessSlot::default(), || Ok( + Nip98Proof::new(key, ()) + )) + .await + .err() + .unwrap() + .status(), StatusCode::UNAUTHORIZED ); } let (state, _, key, _) = fixture(NipFiMode::Enforce).await; let h = headers("free.example", false); assert_eq!( - admit_nip_fi_http_on_state(&state, &h, || Ok(Nip98Proof::new(key, ()))) - .await - .err() - .unwrap() - .status(), + admit_nip_fi_http_on_state(&state, &h, &crate::access::AccessSlot::default(), || Ok( + Nip98Proof::new(key, ()) + )) + .await + .err() + .unwrap() + .status(), StatusCode::UNAUTHORIZED ); } @@ -170,11 +184,13 @@ async fn required_retains_verifier_and_key_pairing_denials() { ] { 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), || { - Ok(Nip98Proof::new(key, ())) - }) - .await; + 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 { @@ -188,9 +204,12 @@ async fn required_retains_verifier_and_key_pairing_denials() { } let wrong = nostr::Keys::generate().public_key(); assert_eq!( - admit_nip_fi_http_on_state(&base, &headers("required.example", true), || Ok( - Nip98Proof::new(wrong, ()) - )) + admit_nip_fi_http_on_state( + &base, + &headers("required.example", true), + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(wrong, ())) + ) .await .err() .unwrap() @@ -315,11 +334,13 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { }) } } - let result = - admit_nip_fi_http_on_state(&state, &headers("required.example", false), || { - Ok(Nip98Proof::new(key, ())) - }) - .await; + let result = admit_nip_fi_http_on_state( + &state, + &headers("required.example", false), + &crate::access::AccessSlot::default(), + || Ok(Nip98Proof::new(key, ())), + ) + .await; assert_eq!( result.err().unwrap().status(), StatusCode::SERVICE_UNAVAILABLE, @@ -373,11 +394,14 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries for _ in 0..3 { assert!(mode(&state, &h).await.enforces()); } - assert!( - admit_nip_fi_http_on_state(&state, &h, || Ok(Nip98Proof::new(key, ()))) - .await - .is_ok() - ); + 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); @@ -409,14 +433,18 @@ async fn unmapped_legacy_rejection_scope_never_yields_an_admission_proof() { ); let mut called = false; - let response = - admit_nip_fi_http_on_state(&state, &headers("unknown.example", 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(); + }, + ) + .await + .err() + .unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( !called, From 38cf1cc35d50baf93da1fad244b02eefe6386eab Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 19:58:34 -0500 Subject: [PATCH 16/27] fix(relay): carry access slots in policy layer Keep the policy enforcement layer independently buildable after the access logging integration. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/audio/handler.rs | 1 + crates/buzz-relay/src/router.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 43712db10a4..fdb3469dea5 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -140,6 +140,7 @@ pub async fn ws_audio_handler( 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(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index ebdf540f710..56cdf70e444 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -232,13 +232,14 @@ async fn nip_fi_assertion_guard( } crate::nip_fi_policy::with_policy( policy, - nip_fi_assertion_guard_with_policy(state, request, next, policy.mode), + 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, @@ -946,6 +947,7 @@ async fn nip11_or_ws_handler( && (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(), From d0f781c2034bd732d3fc6fa357355745ba9d9568 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 20:08:51 -0500 Subject: [PATCH 17/27] fix(relay): forward bypass policy access slot Carry the same request slot through the bypass-policy branch as the stored-policy branch. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/router.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 56cdf70e444..3e7508fc52d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -206,7 +206,7 @@ async fn nip_fi_assertion_guard( }; return crate::nip_fi_policy::with_policy( policy, - nip_fi_assertion_guard_with_policy(state, request, next, mode), + nip_fi_assertion_guard_with_policy(state, slot, request, next, mode), ) .await; } From f6396f1b0948e2621a8f2203809b8c05e542b593 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 20:21:52 -0500 Subject: [PATCH 18/27] fix(relay): inspect policy denial without mapping Keep the original denial response while recording its access result, using the lint-compliant Result inspection method. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/nip_fi_http.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 521e6fd973f..89d1356d54f 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -786,12 +786,11 @@ where { let mode = crate::nip_fi_policy::admission_mode(state, headers) .await - .map_err(|response| { + .inspect_err(|_| { slot.method(AuthMethod::NipFi); slot.finish(AuthResult::Denied( crate::access::AuthReason::AuthorizationUnavailable, )); - response })?; let verifier = state.nip_fi_verifier.as_deref(); let communities = state.nip_fi_communities(); From 30f14539ac05e172616cee35edd4d3c8c9b580ee Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 20:51:18 -0500 Subject: [PATCH 19/27] fix(relay): finalize early policy refusals Record the shared request access result before early HTTP policy denials in the enforcement layer. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/router.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 3e7508fc52d..135684aeca8 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -177,6 +177,14 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ /// `admit_nip_fi_http_on_state`. /// /// In Off mode the middleware is fully transparent; in Shadow it only records. +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) +} + async fn nip_fi_assertion_guard( State(state): State>, slot: crate::access::AccessSlot, @@ -187,7 +195,7 @@ async fn nip_fi_assertion_guard( return next.run(request).await; } if state.config.nip_fi.mode.denies_unconditionally() { - return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + return nip_fi_unavailable(&slot); } let policy = match crate::nip_fi_policy::resolve(&state, request.headers()).await { Ok(policy) => policy, @@ -211,6 +219,10 @@ async fn nip_fi_assertion_guard( .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", @@ -228,7 +240,7 @@ async fn nip_fi_assertion_guard( } }; if policy.stored_required && state.nip_fi_deny_map.is_none() { - return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + return nip_fi_unavailable(&slot); } crate::nip_fi_policy::with_policy( policy, From 7c21ebd16073204960d3cd1d6386db65386b23c2 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 18:16:35 -0500 Subject: [PATCH 20/27] fix(auth): honor relay FI mode boundaries Disable FI enforcement and FI startup dependencies in Off while preserving stored community requirements for Shadow and global Enforce behavior. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-auth/src/nip_fi/startup/mod.rs | 5 +- crates/buzz-auth/src/nip_fi/startup/tests.rs | 23 ++ crates/buzz-relay/src/api/nip_fi.rs | 10 +- crates/buzz-relay/src/main.rs | 9 +- crates/buzz-relay/src/nip11.rs | 33 ++- crates/buzz-relay/src/nip_fi_config.rs | 12 +- crates/buzz-relay/src/nip_fi_policy.rs | 27 +- .../src/nip_fi_policy/postgres_tests.rs | 56 +++- crates/buzz-relay/src/nip_fi_policy/tests.rs | 246 ++++++++++++------ crates/buzz-relay/src/router.rs | 4 +- docs/community-auth-upgrades.md | 51 ++-- 11 files changed, 348 insertions(+), 128 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs index 9ee25192d24..403de76dbdf 100644 --- a/crates/buzz-auth/src/nip_fi/startup/mod.rs +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -29,11 +29,12 @@ pub enum NipFiMode { } impl NipFiMode { - /// Add a durable community requirement without weakening repair mode. + /// 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 required { + } else if self.observes_only() && required { Self::Enforce } else { self 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-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index af029d9d6e6..e4e5f71cc33 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2516,9 +2516,13 @@ mod route_integration_tests { #[tokio::test] #[allow(clippy::result_large_err)] // Real Axum admission closure. - async fn required_off_and_shadow_validate_real_assertion_crypto() { + 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] { + 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) @@ -2570,7 +2574,7 @@ mod route_integration_tests { || Ok(crate::nip_fi_http::Nip98Proof::new(key, ())), ) .await; - if bad == "valid" { + if mode.is_off() || bad == "valid" { assert!(result.is_ok(), "{mode:?} valid assertion"); } else { assert_eq!( diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index e891a5b2129..fb27cb48ca2 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -892,14 +892,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { buzz_relay::api::nip_fi::install_nip_fi_command_components( &mut app_state.nip_fi_deny_map, &mut app_state.nip_fi_command_verifier, - if nip_fi.source == buzz_relay::nip_fi_config::CommunitySource::Database - && nip_fi.mode.is_off() - && !nip_fi.command_configs.is_empty() - { - nip_fi.mode.with_requirement(true) - } else { - nip_fi.mode - }, + nip_fi.mode, &nip_fi.registry, key_source, &nip_fi.command_configs, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 95573e5191b..c250c2949a5 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -403,12 +403,34 @@ 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 = crate::nip_fi_policy::resolve(state, &headers).await?; - if policy.stored_required { + 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() @@ -574,6 +596,13 @@ 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(); diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 5b79a23fd3b..fa1b54f4d7a 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -750,10 +750,10 @@ fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConf // ── Duration helpers ────────────────────────────────────────────────────────── impl NipFiRelayConfig { - /// Database deployments resolve issuer bindings even in Off: a stored - /// community requirement still enforces. Repair mode needs no verifier. + /// 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.denies_unconditionally() + self.source == CommunitySource::Database && self.mode.evaluates() } /// Returns the configured `max_connection_lifetime` as a `Duration`. @@ -2003,10 +2003,10 @@ mod tests { } } - // Stored requirements still need issuer resolution under global Off. + // Off must not depend on an FI snapshot. // Repair mode remains unconditional and needs no snapshot. #[test] - fn database_source_builds_a_resolver_unless_in_repair_mode() { + fn database_source_builds_a_resolver_only_in_evaluating_modes() { let base = { let _guard = super::NIP_FI_ENV_LOCK .lock() @@ -2015,7 +2015,7 @@ mod tests { NipFiRelayConfig::from_env().expect("off config") }; for (mode, source, expected) in [ - (NipFiMode::Off, CommunitySource::Database, true), + (NipFiMode::Off, CommunitySource::Database, false), (NipFiMode::DenyProtected, CommunitySource::Database, false), (NipFiMode::Shadow, CommunitySource::Database, true), (NipFiMode::Enforce, CommunitySource::Database, true), diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 1d1a1229367..4c0f968bc23 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -40,6 +40,7 @@ pub(crate) struct Policy { 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, @@ -114,7 +115,8 @@ pub(crate) async fn resolve( Ok(Policy { community_id: policy.community_id, mode: effective(global, policy.requires_nip_fi), - required: policy.requires_nip_fi || global.enforces(), + required: !global.is_off() && (policy.requires_nip_fi || global.enforces()), + stored_required: policy.requires_nip_fi, mapped: true, }) @@ -123,6 +125,9 @@ pub(crate) async fn resolve( /// 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(); } @@ -140,8 +145,14 @@ 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() { + if host.is_empty() || host.chars().count() > buzz_core::tenant::MAX_HOST_LEN { return Err(not_found()); } let binding = tokio::time::timeout( @@ -158,7 +169,9 @@ pub(crate) async fn bind_for_upgrade( let policy = Policy { community_id: policy.community_id, mode: effective(state.config.nip_fi.mode, policy.requires_nip_fi), - required: policy.requires_nip_fi || state.config.nip_fi.mode.enforces(), + 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, }; @@ -175,6 +188,9 @@ 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()); } @@ -196,7 +212,10 @@ pub(crate) fn required_dependencies_ready(state: &crate::state::AppState) -> boo } fn configured_mode(state: &crate::state::AppState, policy: Policy) -> NipFiMode { - if policy.stored_required && !required_dependencies_ready(state) { + if !state.config.nip_fi.mode.is_off() + && policy.stored_required + && !required_dependencies_ready(state) + { NipFiMode::unavailable() } else { policy.mode diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 303a20bf503..cfe38415851 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -78,7 +78,7 @@ async fn bound_row_policy_wins_when_required_host_becomes_serving_after_initial_ .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] { + 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") @@ -184,7 +184,7 @@ async fn real_required_root_audio_upgrades_retain_policy_during_pending_deletion .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] { + 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; @@ -194,6 +194,13 @@ async fn real_required_root_audio_upgrades_retain_policy_during_pending_deletion 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)) @@ -237,7 +244,7 @@ async fn real_required_root_audio_upgrades_retain_policy_during_pending_deletion ) .await .unwrap(); - if assertion { + if assertion || global.is_off() { let (mut socket, response) = result.expect("valid signed assertion must upgrade"); assert_eq!( @@ -287,7 +294,8 @@ async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows .await .unwrap(); let config = Arc::make_mut(&mut state.config); - config.nip_fi.mode = NipFiMode::Off; + // 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(); @@ -358,6 +366,43 @@ async fn signed_unsigned_free_controls_and_required_discovery_use_real_host_rows 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 { @@ -427,7 +472,8 @@ async fn remapped_host_uses_one_policy_tenant_for_query_and_discovery() { .insert(host.clone(), (old.id, std::time::Instant::now())); let mut state = (*base).clone(); let config = Arc::make_mut(&mut state.config); - config.nip_fi.mode = NipFiMode::Off; + // 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(); diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index d702fac6e39..1ee16b9f2a4 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -82,6 +82,15 @@ async fn fixture( 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) } @@ -96,7 +105,7 @@ fn effective_mode_truth_table() { for required in [false, true] { let expected = match (global, required) { (NipFiMode::DenyProtected, _) => NipFiMode::DenyProtected, - (_, true) => NipFiMode::Enforce, + (NipFiMode::Shadow, true) => NipFiMode::Enforce, _ => global, }; assert_eq!(effective(global, required), expected); @@ -105,75 +114,56 @@ fn effective_mode_truth_table() { } #[tokio::test] -async fn mixed_required_and_signed_unsigned_optional_admission() { - for global in [NipFiMode::Off, NipFiMode::Shadow] { - let (state, _, key, _) = fixture(global).await; - for signed in [false, true] { - // Optional controls retain both legacy proofs, regardless of attached FI. - let h = headers("free.example", false); - let result = admit_nip_fi_http_on_state( - &state, - &h, - &crate::access::AccessSlot::default(), - || { - Ok(if signed { - Nip98Proof::new(key, ()) - } else { - Nip98Proof::unsigned(key, ()) - }) - }, - ) - .await; - assert!(result.is_ok(), "{global:?} signed={signed}"); - let h = headers("required.example", true); - let result = admit_nip_fi_http_on_state( - &state, - &h, - &crate::access::AccessSlot::default(), - || { - Ok(if signed { - Nip98Proof::new(key, ()) +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 { - Nip98Proof::unsigned(key, ()) - }) - }, - ) - .await; - if signed { - assert!(result.is_ok()); - } else { - assert_eq!(result.err().unwrap().status(), StatusCode::UNAUTHORIZED); + 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"); } } - let h = headers("required.example", false); - assert_eq!( - admit_nip_fi_http_on_state(&state, &h, &crate::access::AccessSlot::default(), || Ok( - Nip98Proof::new(key, ()) - )) - .await - .err() - .unwrap() - .status(), - StatusCode::UNAUTHORIZED - ); } - let (state, _, key, _) = fixture(NipFiMode::Enforce).await; - let h = headers("free.example", false); - assert_eq!( - admit_nip_fi_http_on_state(&state, &h, &crate::access::AccessSlot::default(), || Ok( - Nip98Proof::new(key, ()) - )) - .await - .err() - .unwrap() - .status(), - StatusCode::UNAUTHORIZED - ); } #[tokio::test] async fn required_retains_verifier_and_key_pairing_denials() { - for global in [NipFiMode::Off, NipFiMode::Shadow] { + for global in [NipFiMode::Shadow, NipFiMode::Enforce] { let (base, _, key, _) = fixture(global).await; for error in [ VerifierError::UnknownIssuer, @@ -221,7 +211,7 @@ async fn required_retains_verifier_and_key_pairing_denials() { #[tokio::test] async fn protected_routes_cannot_bypass_required_rows() { - for global in [NipFiMode::Off, NipFiMode::Shadow] { + for global in [NipFiMode::Shadow, NipFiMode::Enforce] { let (state, _, _, _) = fixture(global).await; let app = crate::router::build_router(state); for path in [ @@ -263,6 +253,94 @@ async fn protected_routes_cannot_bypass_required_rows() { } } +#[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] { @@ -291,7 +369,8 @@ async fn discovery_is_host_specific_compatible_uncached_and_consistent() { let doc: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()) .unwrap(); - let required = host == "required.example" || global.enforces(); + let required = + !global.is_off() && (host == "required.example" || global.enforces()); let capability = global.evaluates() || required; assert_eq!(doc["requires_nip_fi"], required); assert_eq!( @@ -308,13 +387,21 @@ async fn discovery_is_host_specific_compatible_uncached_and_consistent() { } } // One primary policy query per discovery request, no cross-host policy reuse. - assert_eq!(source.calls.load(Ordering::SeqCst), 6); + assert_eq!( + source.calls.load(Ordering::SeqCst), + if global.is_off() { 0 } else { 6 } + ); } } #[tokio::test] async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { - for global in [NipFiMode::Off, NipFiMode::Shadow, NipFiMode::DenyProtected] { + 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(); @@ -341,11 +428,15 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { || Ok(Nip98Proof::new(key, ())), ) .await; - assert_eq!( - result.err().unwrap().status(), - StatusCode::SERVICE_UNAVAILABLE, - "{global:?} {missing}" - ); + 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)); @@ -363,7 +454,11 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { .unwrap(); assert_eq!( response.status(), - StatusCode::SERVICE_UNAVAILABLE, + if global.is_off() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }, "{global:?} {missing} {path}" ); assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); @@ -375,7 +470,7 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { #[tokio::test] async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries() { - let (state, source, key, _) = fixture(NipFiMode::Off).await; + 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(); @@ -384,7 +479,7 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries assert!(!earlier.required); assert!(newer.required); with_policy(earlier, async { - assert!(mode(&state, &h).await.is_off()); + assert!(mode(&state, &h).await.observes_only()); }) .await; // Finishing the old request cannot publish over the newer observation. @@ -409,12 +504,13 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries #[tokio::test] async fn unmapped_legacy_rejection_scope_never_yields_an_admission_proof() { - let (state, _, key, _) = fixture(NipFiMode::Off).await; + 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 { diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 135684aeca8..7303073a383 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -176,7 +176,6 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ /// 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. fn nip_fi_unavailable(slot: &crate::access::AccessSlot) -> axum::response::Response { slot.method(AuthMethod::NipFi); slot.finish(AuthResult::Denied( @@ -191,7 +190,7 @@ async fn nip_fi_assertion_guard( request: Request, next: middleware::Next, ) -> axum::response::Response { - if 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() { @@ -210,6 +209,7 @@ async fn nip_fi_assertion_guard( mode, required: mode.enforces(), stored_required: false, + mapped: false, }; return crate::nip_fi_policy::with_policy( diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index ccd40d57bfd..bdf9de0858d 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -170,8 +170,8 @@ 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 @@ -188,10 +188,14 @@ its unbound report still exposes them for explicit operator repair. ## Request enforcement and discovery (BUZZ-511) -Each protected request resolves the normalized Host's active policy from the -primary database, with a 500 ms budget. The effective requirement is the stored -`requires_nip_fi` OR global `enforce`. Global `deny_protected` always returns 503 -before evidence checks; neither `off` nor `shadow` exempts a required row. +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, each protected request reads its normalized Host's active policy +from the primary database with a 500 ms budget. Existing Nostr proof, key pairing, deny map, membership and resource checks remain. Discovery, pairing, enrollment and operator exceptions keep their own authentication. @@ -214,22 +218,25 @@ WebSocket upgrades also bind tenant identity and its policy from the same primar 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 Off or Shadow. This changes no discovery fields. +ran in Shadow. Off never promotes a stored requirement. This changes no discovery fields. -Database-sourced deployments construct the issuer resolver and verifier even in -`off`, because some rows may require FI. Configure finite +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 enabling any row. Missing required components return 503. An optional row +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; policy lookup failure returns -503. The top-level `requires_nip_fi` is a host-specific boolean. Existing +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. Thus shadow optional hosts still advertise capability, and required off hosts -advertise capability and requirement together. No issuer, audience or identity +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. @@ -240,11 +247,11 @@ activation; the operator receipt remains pending until that evidence is recorded ### Staging and containment -This changes every serving host, including free hosts: protected routes and -discovery now require a primary policy read. Unmapped discovery previously could +Outside Off, this changes every serving host, including free hosts: protected +routes and discovery require a primary policy read. 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 binding snapshot even in Off and fails startup -if that load fails. The local dependency gauge +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. @@ -268,7 +275,7 @@ 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 -Off and Shadow: unsigned/missing evidence denies, signed valid FI succeeds, +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 @@ -276,11 +283,13 @@ 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 is unsafe: it ignores that row. +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; -never change them to Off as a bypass or clear accepted policy. No automatic fleet +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. From cb970242e42a77c9f5c2e8812d69ae9f78811595 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 18:41:47 -0500 Subject: [PATCH 21/27] docs(auth): clarify Off suspension and policy recovery Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-upgrades.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index bdf9de0858d..da3d2d4cf71 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 @@ -173,10 +178,18 @@ through its existing operator procedure; never broaden trust to make a probe pas 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 From 7092ebf9ca2dd5f979bb69b03b12e8296ef9e9fb Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:13:52 -0500 Subject: [PATCH 22/27] fix(auth): skip FI components in Off Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/git/transport.rs | 4 +- crates/buzz-relay/src/router.rs | 21 +++++----- crates/buzz-relay/src/state.rs | 45 +++++++++++++++++----- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 8f45a321691..1119976ff7f 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -96,8 +96,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 - // tenant-binding work for syntactically bad requests. The policy read - // still comes first so a required row cannot take the legacy path. + // 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 diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 7303073a383..a7861a9b786 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,8 +72,8 @@ use crate::state::AppState; // // ## Relationship to Off and Shadow // -// Off and Shadow preserve optional-community admission after the policy read. -// A stored requirement enforces FI independently of the global mode. +// 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,15 +183,6 @@ 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`. -/// -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) -} - async fn nip_fi_assertion_guard( State(state): State>, slot: crate::access::AccessSlot, diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 190e4b8b8ff..45009d9ed7e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1601,8 +1601,8 @@ pub struct AppState { /// NIP-FI federated-identity assertion verifier, shared across all HTTP /// ingress and WebSocket upgrade checks. /// - /// `None` for unconfigured OSS deployments. Database-sourced deployments - /// construct it even in Off, for stored requirements. 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. @@ -2778,9 +2778,8 @@ impl AuditShutdownHandle { /// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. /// -/// Database-sourced deployments construct the verifier in Off too, because -/// a required community still evaluates assertions. Unconfigured OSS Off and -/// repair mode need no verifier. The JWKS source is shared via `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 = ( @@ -2791,10 +2790,7 @@ type NipFiComponents = ( fn build_nip_fi_components(config: &crate::config::Config) -> NipFiComponents { use buzz_auth::{FederatedAssertionVerifier, HttpJwksFetcher, ProductionJwksSource}; - if config.nip_fi.mode.denies_unconditionally() - || (!config.nip_fi.mode.evaluates() - && config.nip_fi.source != crate::nip_fi_config::CommunitySource::Database) - { + if !config.nip_fi.mode.evaluates() { // Off: no enforcement. DenyProtected: verifier never consulted (always 503). return (None, None); } @@ -2887,6 +2883,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). From b1e663d1dac3b30c92772b42428f6553d285663b Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:14:35 -0500 Subject: [PATCH 23/27] fix(auth): count required Shadow proof checks Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/nip_fi_http.rs | 116 +++++++++++++++---------- crates/buzz-relay/src/nip_fi_policy.rs | 7 ++ crates/buzz-relay/src/nip_fi_shadow.rs | 26 +++--- 3 files changed, 91 insertions(+), 58 deletions(-) diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 89d1356d54f..a7ef3620a98 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -1632,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 index 4c0f968bc23..5e8f9321006 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -69,6 +69,13 @@ pub(crate) fn request_tenant(raw_host: &str) -> Option 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 } diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index 59220e5f600..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. From 960882f9f444771a451c9cee2b2fd3370b271a41 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:15:22 -0500 Subject: [PATCH 24/27] fix(auth): share Git request policy and tenant Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/git/transport.rs | 33 +++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 1119976ff7f..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 @@ -106,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 = crate::nip_fi_policy::mode(state, &parts.headers).await; let slot = AccessSlot::of(parts); if !mode.restricts() { if let Err(rejection) = parse_git_auth_header(&parts.headers, method) { From bcc52c8dac50b5be4c1eab4a2f4bfde3ea6915ef Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:16:06 -0500 Subject: [PATCH 25/27] docs(auth): gate Shadow upgrades on revocation Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-upgrades.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index da3d2d4cf71..dc63b73ca4c 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -309,5 +309,8 @@ 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. Do not enable production -upgrades until this activation/control gap is verified closed. +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. From 2e1157442083f8a4cce3b1b20913f267ee514992 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:17:08 -0500 Subject: [PATCH 26/27] fix(auth): cache community policy with read leases Drain bounded optional leases before acceptance commits, independently of notification delivery. Preserve authoritative upgrade binding rechecks. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 71 +++++ crates/buzz-pubsub/src/cache_invalidation.rs | 8 + crates/buzz-pubsub/src/lib.rs | 48 ++-- crates/buzz-relay/src/api/operator.rs | 6 + .../src/handlers/community_provisioning.rs | 2 + crates/buzz-relay/src/host_cache.rs | 217 ++++++++++++++++ crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/nip_fi_policy.rs | 63 ++++- .../src/nip_fi_policy/postgres_tests.rs | 244 ++++++++++++++++++ crates/buzz-relay/src/nip_fi_policy/tests.rs | 103 +++++++- crates/buzz-relay/src/state.rs | 28 +- docs/community-auth-upgrades.md | 92 ++++++- 12 files changed, 823 insertions(+), 60 deletions(-) create mode 100644 crates/buzz-relay/src/host_cache.rs diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index 3481fcf8dd4..e9a71b4ebb4 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 { @@ -109,6 +118,49 @@ 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. @@ -235,6 +287,25 @@ impl Db { if let Err(code) = configured_trust { return Ok(UpgradeOutcome::Refused(code)); } + // Lease protocol: stop new fills before waiting out every existing + // optional lease on every pod. Only the advisory/idempotency locks are + // held during the wait; lifecycle, host, owner and issuer are validated + // afterwards under their existing locks. A remap during this wait must + // still fail the exact host precondition below. + let lease_host = buzz_core::tenant::normalize_host(&request.expected_host); + sqlx::query("SELECT pg_advisory_xact_lock($1, hashtext($2))") + .bind(POLICY_LEASE_NAMESPACE) + .bind(&lease_host) + .execute(&mut *tx) + .await?; + let was_required: Option = + sqlx::query_scalar("SELECT requires_nip_fi FROM communities WHERE id = $1") + .bind(request.community_id) + .fetch_optional(&mut *tx) + .await?; + if was_required == Some(false) { + tokio::time::sleep(POLICY_LEASE_DRAIN).await; + } let community = CommunityId::from_uuid(request.community_id); crate::deletion::lock_community_deletion_shared(&mut tx, community).await?; // Same order and lock strength as ownership transfer; archive/delete 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/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/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..796f1d01d9a --- /dev/null +++ b/crates/buzz-relay/src/host_cache.rs @@ -0,0 +1,217 @@ +//! 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() + } + + 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 b30bd1e414c..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. diff --git a/crates/buzz-relay/src/nip_fi_policy.rs b/crates/buzz-relay/src/nip_fi_policy.rs index 5e8f9321006..a4a7fff42b2 100644 --- a/crates/buzz-relay/src/nip_fi_policy.rs +++ b/crates/buzz-relay/src/nip_fi_policy.rs @@ -1,6 +1,6 @@ -//! Request-scoped community policy. Each request reads the writer once; no -//! cross-request policy cache can publish a stale optional generation over an -//! observed requirement. Existing requests/sessions are drained by BUZZ-512. +//! 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, @@ -26,7 +26,7 @@ impl PolicySource for buzz_db::Db { host: &'a str, ) -> BoxFuture<'a, Result, ()>> { async move { - self.community_auth_policy_for_host(host) + self.community_auth_policy_lease_for_host(host) .await .map_err(|_| ()) } @@ -85,6 +85,49 @@ 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, @@ -99,16 +142,12 @@ pub(crate) async fn resolve( .and_then(|h| h.to_str().ok()) .unwrap_or(""); let host = buzz_core::tenant::normalize_host(host); - if host.is_empty() { + if host.is_empty() || host.chars().count() > buzz_core::tenant::MAX_HOST_LEN { return Err(not_found()); } - let start = std::time::Instant::now(); - let read = tokio::time::timeout(POLICY_READ_BUDGET, state.nip_fi_policy.lookup(&host)).await; - metrics::histogram!("buzz_nip_fi_policy_read_seconds").record(start.elapsed().as_secs_f64()); - metrics::counter!("buzz_nip_fi_policy_reads_total").increment(1); - let policy = match read { - Ok(Ok(Some(policy))) => policy, - Ok(Ok(None)) => 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, diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index cfe38415851..0bf4887d10c 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -10,6 +10,250 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; use tower::ServiceExt; +#[tokio::test] +#[ignore = "requires Postgres"] +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 in [false, true] { + let host = format!("lease-{}.example:8443", uuid::Uuid::new_v4().simple()); + 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 creation"); + }; + let mut pod_a = (*base).clone(); + Arc::make_mut(&mut pod_a.config).nip_fi.mode = NipFiMode::Shadow; + Arc::make_mut(&mut pod_a.config) + .nip_fi + .max_connection_lifetime_secs = 3600; + Arc::make_mut(&mut pod_a.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(); + // One connection proves rollback after a refused lease leaves THAT + // connection reusable, rather than checking an unrelated pool slot. + 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 mut h = HeaderMap::new(); + h.insert(header::HOST, host.to_uppercase().parse().unwrap()); + assert!(!resolve(&pod_b, &h).await.unwrap().stored_required); + assert!(pod_b.tenant_host_cache.policy(&host).is_some()); + 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 must retain legacy admission" + ); + // The deletion lock is a deterministic barrier AFTER the lease wait + // but BEFORE the row lock. It also lets this test remap the host while + // acceptance is pending, without racing a timer. + let mut barrier = base.db.pool().begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(row.id.as_uuid()) + .execute(&mut *barrier) + .await + .unwrap(); + 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 acceptance = { + let db = pod_a.db.clone(); + let request = request.clone(); + tokio::spawn(async move { db.enable_community_nip_fi(&request, Ok(())).await }) + }; + // Observe the production exclusive host lock. This condition is held + // until our deletion barrier is released, independent of scheduling. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let held: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' + AND classid = $1::int::oid AND objid = hashtext($2)::oid + AND objsubid = 2 AND mode = 'ExclusiveLock' AND granted)", + ) + .bind(0x4649504ci32) + .bind(&host) + .fetch_one(base.db.pool()) + .await + .unwrap(); + if held { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // A new fill through another pod cannot obtain an optional lease while + // acceptance owns the host lock. Its connection remains reusable. + 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 + ); + if notify { + // The normal mutation invalidates locally and the cross-pod + // consumer applies exactly this message. No Redis is required for + // consistency: the false branch deliberately drops it entirely. + 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(), + }, + ); + } + barrier.commit().await.unwrap(); + let UpgradeOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { + panic!("acceptance"); + }; + assert!( + pod_b.tenant_host_cache.policy(&host).is_none(), + "optional lease survived commit" + ); + assert!(resolve(&pod_b, &h).await.unwrap().stored_required); + assert_eq!( + 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 + .err() + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "a post-commit admission on another pod must require FI" + ); + + // A distinct optional target remapped while acceptance holds only the + // host lease lock must refuse, rather than commit under the wrong key. + let host2 = format!("remap-{}.example", uuid::Uuid::new_v4().simple()); + let created = base + .db + .create_community_with_owner_and_nip_fi_issuers( + &host2, + &owner, + &[issuer.into()], + &Keys::generate(), + ) + .await + .unwrap(); + let buzz_db::CreateCommunityWithOwnerResult::Created(row2) = created.outcome else { + panic!("fixture"); + }; + let mut barrier = base.db.pool().begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(row2.id.as_uuid()) + .execute(&mut *barrier) + .await + .unwrap(); + let barrier_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *barrier) + .await + .unwrap(); + let request2 = UpgradeRequest { + operation_id: uuid::Uuid::new_v4(), + community_id: *row2.id.as_uuid(), + expected_host: host2.clone(), + ..request.clone() + }; + let remapped = format!("new-{host2}"); + let acceptance = { + let db = pod_a.db.clone(); + tokio::spawn(async move { db.enable_community_nip_fi(&request2, Ok(())).await }) + }; + // Reaching the deletion-lock wait proves the exclusive lease lock was + // acquired and the full lease interval elapsed, before mutable checks. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' + AND NOT granted AND $1 = ANY(pg_blocking_pids(pid)))", + ) + .bind(barrier_pid) + .fetch_one(base.db.pool()) + .await + .unwrap(); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + sqlx::query("UPDATE communities SET host = $2 WHERE id = $1") + .bind(row2.id.as_uuid()) + .bind(&remapped) + .execute(base.db.pool()) + .await + .unwrap(); + barrier.commit().await.unwrap(); + assert_eq!( + acceptance.await.unwrap().unwrap(), + UpgradeOutcome::Refused("host_conflict") + ); + assert!( + !pod_b + .db + .community_auth_policy_for_host(&remapped) + .await + .unwrap() + .unwrap() + .requires_nip_fi + ); + } +} + struct Replay(Mutex>); impl buzz_auth::Nip98ReplayGuard for Replay { fn try_mark_in_scope<'a>( diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 1ee16b9f2a4..5685f8416ac 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -386,10 +386,10 @@ async fn discovery_is_host_specific_compatible_uncached_and_consistent() { assert_eq!(docs[0], docs[1]); } } - // One primary policy query per discovery request, no cross-host policy reuse. + // Serving hosts share a policy snapshot; unmapped results are not cached. assert_eq!( source.calls.load(Ordering::SeqCst), - if global.is_off() { 0 } else { 6 } + if global.is_off() { 0 } else { 4 } ); } } @@ -414,6 +414,11 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { .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), @@ -469,12 +474,13 @@ async fn missing_dependencies_fail_closed_and_repair_precedes_evidence() { } #[tokio::test] -async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries() { +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); @@ -502,6 +508,97 @@ async fn request_scope_reuses_one_read_without_publishing_optional_cache_entries 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 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"); + 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; diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 45009d9ed7e..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>, @@ -1889,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, @@ -2209,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); } @@ -2224,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. /// @@ -2503,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 diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index dc63b73ca4c..655df6882f1 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -207,22 +207,75 @@ Relay mode controls the effective requirement without changing durable storage: 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, each protected request reads its normalized Host's active policy -from the primary database with a 500 ms budget. +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 the policy read with HTTP handlers for that request. Root and -audio WebSocket admission use the same policy source. There is no cross-request -policy cache, positive or negative: an older read cannot overwrite a newer cached -generation. This adds one writer query per protected request or discovery request; -`buzz_nip_fi_policy_reads_total` and `buzz_nip_fi_policy_read_seconds` measure it. -Protected HTTP handlers use the immutable tenant ID from the same primary row -that supplied the request's policy. They do not resolve tenant-scoped data from -another host-cache snapshot. NIP-11 uses that policy tenant for its descriptors -and optional decorations too, so a remapped host cannot combine one community's -optional policy with another community's data. Exempt and unguarded routes keep -their existing host-cache behavior. +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 corresponding exclusive host lock and waits 1.1 seconds +before locking and checking lifecycle, host, owner and issuer. 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. A host remapped during the wait fails the subsequent exact host +precondition; the transaction cannot accept under a different lock key. + +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 wait blocks only new policy fills and competing acceptance for +the host. Ordinary routing, archive and remap writers remain unblocked during +the wait. Cold policy requests during that transition may return 503; upgrade +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. @@ -261,7 +314,7 @@ 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 primary policy read. Off retains ordinary routing. Unmapped discovery previously could +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 @@ -269,6 +322,17 @@ Off does not load it. A failed load still fails startup in Shadow or Enforce. Th 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. + 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 From 39dfe79b1fc04f10ee3eb94d6ea42cf686bafe1e Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 20:53:44 -0500 Subject: [PATCH 27/27] fix(auth): hold community binding stable through policy lease drain Lock and validate the community row before deriving the host advisory key. Preserve that binding until commit so single and double host remaps cannot leave an optional lease alive after acceptance. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 34 +- crates/buzz-relay/src/host_cache.rs | 8 + .../src/nip_fi_policy/postgres_tests.rs | 324 +++++++++--------- crates/buzz-relay/src/nip_fi_policy/tests.rs | 11 + docs/community-auth-upgrades.md | 41 ++- 5 files changed, 231 insertions(+), 187 deletions(-) diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index e9a71b4ebb4..88c6b93edfc 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -287,25 +287,6 @@ impl Db { if let Err(code) = configured_trust { return Ok(UpgradeOutcome::Refused(code)); } - // Lease protocol: stop new fills before waiting out every existing - // optional lease on every pod. Only the advisory/idempotency locks are - // held during the wait; lifecycle, host, owner and issuer are validated - // afterwards under their existing locks. A remap during this wait must - // still fail the exact host precondition below. - let lease_host = buzz_core::tenant::normalize_host(&request.expected_host); - sqlx::query("SELECT pg_advisory_xact_lock($1, hashtext($2))") - .bind(POLICY_LEASE_NAMESPACE) - .bind(&lease_host) - .execute(&mut *tx) - .await?; - let was_required: Option = - sqlx::query_scalar("SELECT requires_nip_fi FROM communities WHERE id = $1") - .bind(request.community_id) - .fetch_optional(&mut *tx) - .await?; - if was_required == Some(false) { - tokio::time::sleep(POLICY_LEASE_DRAIN).await; - } let community = CommunityId::from_uuid(request.community_id); crate::deletion::lock_community_deletion_shared(&mut tx, community).await?; // Same order and lock strength as ownership transfer; archive/delete @@ -324,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-relay/src/host_cache.rs b/crates/buzz-relay/src/host_cache.rs index 796f1d01d9a..b265948c498 100644 --- a/crates/buzz-relay/src/host_cache.rs +++ b/crates/buzz-relay/src/host_cache.rs @@ -79,6 +79,14 @@ impl HostCache { *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, diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 0bf4887d10c..6fc5d9b4cdb 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -10,8 +10,55 @@ 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() @@ -19,8 +66,9 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without .unwrap(); let owner = Keys::generate().public_key().to_hex(); let issuer = "https://lease-issuer.example"; - for notify in [false, true] { + 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( @@ -32,14 +80,13 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without .await .unwrap(); let buzz_db::CreateCommunityWithOwnerResult::Created(row) = created.outcome else { - panic!("fixture creation"); + panic!("fixture"); }; let mut pod_a = (*base).clone(); - Arc::make_mut(&mut pod_a.config).nip_fi.mode = NipFiMode::Shadow; - Arc::make_mut(&mut pod_a.config) - .nip_fi - .max_connection_lifetime_secs = 3600; - Arc::make_mut(&mut pod_a.config).nip_fi.communities = + 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( @@ -49,8 +96,7 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without 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(); - // One connection proves rollback after a refused lease leaves THAT - // connection reusable, rather than checking an unrelated pool slot. + // 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) @@ -64,10 +110,51 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without 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); - assert!(pod_b.tenant_host_cache.policy(&host).is_some()); + 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, @@ -77,55 +164,43 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without ) .await .is_ok(), - "optional community must retain legacy admission" + "optional community retains legacy admission" ); - // The deletion lock is a deterministic barrier AFTER the lease wait - // but BEFORE the row lock. It also lets this test remap the host while - // acceptance is pending, without racing a timer. - let mut barrier = base.db.pool().begin().await.unwrap(); - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(row.id.as_uuid()) - .execute(&mut *barrier) - .await - .unwrap(); - 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 acceptance = { let db = pod_a.db.clone(); let request = request.clone(); tokio::spawn(async move { db.enable_community_nip_fi(&request, Ok(())).await }) }; - // Observe the production exclusive host lock. This condition is held - // until our deletion barrier is released, independent of scheduling. - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let held: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' - AND classid = $1::int::oid AND objid = hashtext($2)::oid - AND objsubid = 2 AND mode = 'ExclusiveLock' AND granted)", - ) - .bind(0x4649504ci32) - .bind(&host) - .fetch_one(base.db.pool()) - .await - .unwrap(); - if held { - break; + 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(); } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - // A new fill through another pod cannot obtain an optional lease while - // acceptance owns the host lock. Its connection remains reusable. + }) + }; + 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()) @@ -138,10 +213,24 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without .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 { - // The normal mutation invalidates locally and the cross-pod - // consumer applies exactly this message. No Redis is required for - // consistency: the false branch deliberately drops it entirely. pod_a.invalidate_community_host(&buzz_core::TenantContext::resolved(row.id, &host)); pod_b.apply_cache_invalidation( row.id, @@ -150,107 +239,32 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without }, ); } - barrier.commit().await.unwrap(); - let UpgradeOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { - panic!("acceptance"); - }; - assert!( - pod_b.tenant_host_cache.policy(&host).is_none(), - "optional lease survived commit" - ); - assert!(resolve(&pod_b, &h).await.unwrap().stored_required); - assert_eq!( - 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 - .err() - .unwrap() - .status(), - StatusCode::UNAUTHORIZED, - "a post-commit admission on another pod must require FI" - ); - - // A distinct optional target remapped while acceptance holds only the - // host lease lock must refuse, rather than commit under the wrong key. - let host2 = format!("remap-{}.example", uuid::Uuid::new_v4().simple()); - let created = base - .db - .create_community_with_owner_and_nip_fi_issuers( - &host2, - &owner, - &[issuer.into()], - &Keys::generate(), - ) - .await - .unwrap(); - let buzz_db::CreateCommunityWithOwnerResult::Created(row2) = created.outcome else { - panic!("fixture"); - }; - let mut barrier = base.db.pool().begin().await.unwrap(); - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(row2.id.as_uuid()) - .execute(&mut *barrier) - .await - .unwrap(); - let barrier_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") - .fetch_one(&mut *barrier) - .await - .unwrap(); - let request2 = UpgradeRequest { - operation_id: uuid::Uuid::new_v4(), - community_id: *row2.id.as_uuid(), - expected_host: host2.clone(), - ..request.clone() - }; - let remapped = format!("new-{host2}"); - let acceptance = { - let db = pod_a.db.clone(); - tokio::spawn(async move { db.enable_community_nip_fi(&request2, Ok(())).await }) - }; - // Reaching the deletion-lock wait proves the exclusive lease lock was - // acquired and the full lease interval elapsed, before mutable checks. - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' - AND NOT granted AND $1 = ANY(pg_blocking_pids(pid)))", - ) - .bind(barrier_pid) - .fetch_one(base.db.pool()) - .await - .unwrap(); - if waiting { - break; - } - tokio::task::yield_now().await; + 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 + ); } - }) - .await - .unwrap(); - sqlx::query("UPDATE communities SET host = $2 WHERE id = $1") - .bind(row2.id.as_uuid()) - .bind(&remapped) - .execute(base.db.pool()) - .await - .unwrap(); - barrier.commit().await.unwrap(); - assert_eq!( - acceptance.await.unwrap().unwrap(), - UpgradeOutcome::Refused("host_conflict") - ); - assert!( - !pod_b - .db - .community_auth_policy_for_host(&remapped) - .await - .unwrap() - .unwrap() - .requires_nip_fi - ); + } } } diff --git a/crates/buzz-relay/src/nip_fi_policy/tests.rs b/crates/buzz-relay/src/nip_fi_policy/tests.rs index 5685f8416ac..30102051ab8 100644 --- a/crates/buzz-relay/src/nip_fi_policy/tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/tests.rs @@ -560,6 +560,7 @@ async fn concurrent_fills_share_one_read_and_a_drop_fences_publication_only() { 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)); @@ -577,6 +578,16 @@ async fn concurrent_fills_share_one_read_and_a_drop_fences_publication_only() { 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() diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 655df6882f1..c6f752c0b71 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -238,25 +238,36 @@ 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 corresponding exclusive host lock and waits 1.1 seconds -before locking and checking lifecycle, host, owner and issuer. 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. A host remapped during the wait fails the subsequent exact host -precondition; the transaction cannot accept under a different lock key. +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 wait blocks only new policy fills and competing acceptance for -the host. Ordinary routing, archive and remap writers remain unblocked during -the wait. Cold policy requests during that transition may return 503; upgrade -acceptance is rare and this avoids allowing stale optional leases after commit. +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 @@ -332,6 +343,10 @@ 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