diff --git a/.env.example b/.env.example index 9d821ab559e..d51c7d7ee49 100644 --- a/.env.example +++ b/.env.example @@ -451,6 +451,15 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_NIP_FI_PROVISION_BINDING and must not be combined with # BUZZ_NIP_FI_COMMUNITIES). See docs/operator-nip-fi-bindings.md. # BUZZ_NIP_FI_COMMUNITY_SOURCE=env +# Require FI for newly provisioned tenant communities (both operator creation +# paths). Needs source=database and BUZZ_NIP_FI_PROVISION_BINDING; new hosts +# outside that binding policy are refused. Deployment/control-host startup +# bootstrap is excluded and keeps its existing policy. Independent of +# BUZZ_NIP_FI_UPGRADES_ENABLED, which gates upgrades of existing communities. +# Disabling this default affects future creations only: born-required rows +# stay required, with no per-community rollback endpoint. MODE=off remains +# the explicit relay-wide FI bypass; Shadow enforces stored requirements. +# BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES=false # Most community-issuer rows one snapshot may hold (1..=10000000). # BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS=1000000 # Snapshot load timeout in ms; must be under the 30s refresh interval. diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 56cf748c342..06733597321 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -42,6 +42,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// A new required community has no issuer binding in its creation policy. + #[error("required community creation needs NIP-FI issuer bindings")] + NipFiBindingRequired, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, @@ -176,6 +180,7 @@ impl DbError { E::InvalidData(_) => "invalid_data", E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found", E::AccessDenied(_) => "access_denied", + E::NipFiBindingRequired => "nip_fi_binding_required", _ => "other", }; kind.to_owned() diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index b0c35890dfb..b46e336120d 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -512,7 +512,9 @@ impl Db { /// Like [`Db::ensure_configured_community`], binding `nip_fi_issuers` to /// the community in the same transaction when this call creates its row. - /// An existing row keeps its bindings unchanged. + /// An existing row keeps its bindings unchanged. Operator tenant creation + /// must use [`Db::ensure_configured_community_with_nip_fi_policy`] to apply + /// the configured creation requirement. #[datastore_span( name = "ensure_configured_community_with_nip_fi_issuers", system = "postgresql" @@ -521,10 +523,28 @@ impl Db { &self, normalized_host: &str, nip_fi_issuers: &[String], + ) -> Result { + self.ensure_configured_community_with_nip_fi_policy(normalized_host, nip_fi_issuers, false) + .await + } + + /// Ensure a community, setting its initial NIP-FI requirement and generation + /// atomically with its issuer bindings. Existing rows retain their policy. + /// Required creation without issuers is refused inside the transaction. + #[datastore_span( + name = "ensure_configured_community_with_nip_fi_policy", + system = "postgresql" + )] + pub async fn ensure_configured_community_with_nip_fi_policy( + &self, + normalized_host: &str, + nip_fi_issuers: &[String], + requires_nip_fi: bool, ) -> Result { self.ensure_configured_community_with_operation( normalized_host, nip_fi_issuers, + requires_nip_fi, crate::observability::WriterOperation::Authorization, ) .await @@ -544,6 +564,7 @@ impl Db { self.ensure_configured_community_with_operation( normalized_host, nip_fi_issuers, + false, crate::observability::WriterOperation::Bootstrap, ) .await @@ -553,14 +574,15 @@ impl Db { &self, normalized_host: &str, nip_fi_issuers: &[String], + requires_nip_fi: bool, operation: crate::observability::WriterOperation, ) -> Result { let connection = crate::observability::acquire_writer(&self.pool, operation).await?; let mut connection = sqlx::Transaction::begin(connection, None).await?; let row = sqlx::query( r#" - INSERT INTO communities (host) - VALUES ($1) + INSERT INTO communities (host, requires_nip_fi, policy_generation) + VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END) ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host WHERE communities.deletion_state = 'active' AND communities.deleted_at IS NULL @@ -568,6 +590,7 @@ impl Db { "#, ) .bind(normalized_host) + .bind(requires_nip_fi) .fetch_optional(&mut *connection) .await? .ok_or_else(|| { @@ -580,6 +603,9 @@ impl Db { let host: String = row.try_get("host")?; let created: bool = row.try_get("created")?; if created { + if requires_nip_fi && nip_fi_issuers.is_empty() { + return Err(DbError::NipFiBindingRequired); + } self.insert_nip_fi_issuers_in_transaction(&mut connection, id, nip_fi_issuers) .await?; } @@ -611,7 +637,9 @@ impl Db { /// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to /// the new community in the same transaction. Identical retries return the - /// original record without touching its bindings. + /// original record without touching its bindings. Operator tenant creation + /// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply + /// the configured creation requirement. #[datastore_span( name = "create_community_with_owner_and_nip_fi_issuers", system = "postgresql" @@ -622,6 +650,31 @@ impl Db { owner_pubkey: &str, nip_fi_issuers: &[String], relay_keypair: &nostr::Keys, + ) -> Result> { + self.create_community_with_owner_and_nip_fi_policy( + normalized_host, + owner_pubkey, + nip_fi_issuers, + false, + relay_keypair, + ) + .await + } + + /// Atomically create a community, owner, issuer bindings and initial NIP-FI + /// policy (generation 1 when required, otherwise 0). Identical retries keep + /// the original policy and bindings. Required creation needs issuers. + #[datastore_span( + name = "create_community_with_owner_and_nip_fi_policy", + system = "postgresql" + )] + pub async fn create_community_with_owner_and_nip_fi_policy( + &self, + normalized_host: &str, + owner_pubkey: &str, + nip_fi_issuers: &[String], + requires_nip_fi: bool, + relay_keypair: &nostr::Keys, ) -> Result> { use relay_members::MembershipWrite; let owner_pubkey = owner_pubkey.to_ascii_lowercase(); @@ -644,17 +697,21 @@ impl Db { let row = sqlx::query( r#" - INSERT INTO communities (host) - VALUES ($1) + INSERT INTO communities (host, requires_nip_fi, policy_generation) + VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END) ON CONFLICT (lower(host)) DO NOTHING RETURNING id, host "#, ) .bind(normalized_host) + .bind(requires_nip_fi) .fetch_optional(&mut *tx) .await?; if let Some(row) = row { + if requires_nip_fi && nip_fi_issuers.is_empty() { + return Err(DbError::NipFiBindingRequired); + } let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; 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 e8ef0862c4c..36e116b7775 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -420,19 +420,22 @@ async fn migration_schema_expansion_preserves_existing_optional_rows_with_and_wi .await .unwrap(); let db = Db::from_pool(pool.clone()); - let free = db - .ensure_configured_community("free-before.example") + // Model rows written by the pre-expansion relay. Today's creation writer + // also sets policy columns that this historical schema does not have yet. + let free = CommunityId::from_uuid(Uuid::new_v4()); + let bound = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, 'free-before.example'), ($2, 'bound-before.example')") + .bind(free.as_uuid()) + .bind(bound.as_uuid()) + .execute(&pool) .await - .unwrap() - .id; - let bound = db - .ensure_configured_community_with_nip_fi_issuers( - "bound-before.example", - &[ISSUER.to_owned()], - ) + .unwrap(); + sqlx::query("INSERT INTO community_nip_fi_issuers (community_id, issuer) VALUES ($1, $2)") + .bind(bound.as_uuid()) + .bind(ISSUER) + .execute(&pool) .await - .unwrap() - .id; + .unwrap(); crate::migration::run_migrations(&pool).await.unwrap(); for community in [free, bound] { assert_eq!( diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index ffd25cba963..42d4edca10e 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -512,70 +512,100 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn create_with_owner_binds_in_the_insert_transaction() { + async fn creation_policy_is_atomic_and_create_only_on_both_paths() { let db = setup_db().await; - let host = unique_host("create"); - let created = db - .create_community_with_owner_and_nip_fi_issuers( - &host, - OWNER, - &issuers(), - &nostr::Keys::generate(), - ) - .await - .map(|write| write.outcome) - .expect("create"); - assert!(matches!( - created, - CreateCommunityWithOwnerResult::Created(_) - )); - assert_eq!(bound(&db, &host).await, Some(issuers())); - - // An identical retry returns the record without new rows. - db.create_community_with_owner_and_nip_fi_issuers( - &host, - OWNER, - &["https://other.example".to_string()], - &nostr::Keys::generate(), - ) - .await - .map(|write| write.outcome) - .expect("retry"); - assert_eq!(bound(&db, &host).await, Some(issuers())); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn ensure_binds_only_when_it_creates_the_row() { - let db = setup_db().await; - let host = unique_host("ensure"); - let first = db - .ensure_configured_community_with_nip_fi_issuers(&host, &issuers()) - .await - .expect("ensure"); - assert!(first.created); - assert_eq!(bound(&db, &host).await, Some(issuers())); - - let again = db - .ensure_configured_community_with_nip_fi_issuers( - &host, - &["https://other.example".to_string()], - ) - .await - .expect("ensure existing"); - assert!(!again.created); - assert_eq!(bound(&db, &host).await, Some(issuers())); + let keys = nostr::Keys::generate(); + for create_only in [true, false] { + for required in [false, true] { + let host = unique_host("policy"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let id = if create_only { + let write = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &owner, + &issuers(), + required, + &keys, + ) + .await + .expect("create"); + let CreateCommunityWithOwnerResult::Created(record) = write.outcome else { + panic!("not created") + }; + record.id + } else { + db.ensure_configured_community_with_nip_fi_policy(&host, &issuers(), required) + .await + .expect("ensure") + .id + }; + let before = db.community_auth_policy(id).await.unwrap().unwrap(); + assert_eq!(before.requires_nip_fi, required); + assert_eq!(before.policy_generation, i64::from(required)); + assert_eq!(bound(&db, &host).await, Some(issuers())); + + // Both enabling and disabling the default leave existing rows + // intact. Missing current bindings must not refuse a retry. + let changed_issuers = ["https://other.example".to_owned()]; + for retry_issuers in [&[][..], changed_issuers.as_slice()] { + if create_only { + let _write = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &owner, + retry_issuers, + !required, + &keys, + ) + .await + .expect("retry"); + } else { + let again = db + .ensure_configured_community_with_nip_fi_policy( + &host, + retry_issuers, + !required, + ) + .await + .expect("converge"); + assert!(!again.created); + } + } + assert_eq!(db.community_auth_policy(id).await.unwrap().unwrap(), before); + assert_eq!(bound(&db, &host).await, Some(issuers())); + if required { + assert_eq!( + db.unbind_nip_fi_issuer(id, ISS, true).await.unwrap(), + NipFiUnbind::RequiredIssuer + ); + } + } + // No row, owner or bindings survive a refused required creation. + let host = unique_host("unbound-required"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let refused = if create_only { + db.create_community_with_owner_and_nip_fi_policy(&host, &owner, &[], true, &keys) + .await + .map(|_| ()) + } else { + db.ensure_configured_community_with_nip_fi_policy(&host, &[], true) + .await + .map(|_| ()) + }; + assert!(matches!(refused, Err(crate::DbError::NipFiBindingRequired))); + assert!(!community_exists(&db, &host).await); + } let bootstrap = unique_host("bootstrap"); - db.ensure_configured_community_for_bootstrap(&bootstrap, &issuers()) + let record = db + .ensure_configured_community_for_bootstrap(&bootstrap, &issuers()) .await .expect("bootstrap"); + let policy = db.community_auth_policy(record.id).await.unwrap().unwrap(); + assert!(!policy.requires_nip_fi); + assert_eq!(policy.policy_generation, 0); assert_eq!(bound(&db, &bootstrap).await, Some(issuers())); - let unbound = unique_host("bootstrap-unbound"); - db.ensure_configured_community_for_bootstrap(&unbound, &[]) - .await - .expect("bootstrap unbound"); - assert_eq!(bound(&db, &unbound).await, Some(Vec::new())); } #[tokio::test] @@ -587,10 +617,11 @@ mod postgres_tests { let create = unique_host("fail-create"); assert!(db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &create, OWNER, &invalid, + true, &nostr::Keys::generate() ) .await @@ -600,7 +631,7 @@ mod postgres_tests { let ensure = unique_host("fail-ensure"); assert!(db - .ensure_configured_community_with_nip_fi_issuers(&ensure, &invalid) + .ensure_configured_community_with_nip_fi_policy(&ensure, &invalid, true) .await .is_err()); assert!(!community_exists(&db, &ensure).await); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f2402..16fbba9f879 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -423,6 +423,11 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } + Err(msg) if msg.starts_with("nip_fi_binding_required:") => Err(coded_api_error( + StatusCode::BAD_REQUEST, + "nip_fi_binding_required", + &msg, + )), Err(msg) if msg.starts_with("limit_reached:") => { Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg)) } @@ -1409,6 +1414,179 @@ mod postgres_tests { use crate::router::build_router; use crate::state::AppState; + fn set_default(state: &mut Arc, required: bool) { + Arc::make_mut(&mut Arc::get_mut(state).unwrap().config).nip_fi_require_new_communities = + required; + } + + async fn stored_policy( + state: &Arc, + host: &str, + ) -> buzz_db::community_auth::CommunityAuthPolicy { + let record = state + .db + .lookup_community_by_host(host) + .await + .unwrap() + .unwrap(); + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap() + } + + async fn provision( + state: &Arc, + operator: &Keys, + owner: &Keys, + host: &str, + create_only: bool, + ) -> axum::response::Response { + signed_operator_request(state.clone(), operator, "POST", "/operator/communities", Some(serde_json::json!({ + "host": host, "initial_owner_pubkey": owner.public_key().to_hex(), "create_only": create_only, + }).to_string())).await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn new_community_requirement_preserves_retries_and_bootstrap() { + let operator = Keys::generate(); + let (policy, reserved, control) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy.clone())) + .await + .expect("Postgres test state"); + assert!(!state.config.nip_fi_require_new_communities); + assert!(!state.config.nip_fi_upgrades_enabled); + for create_only in [true, false] { + let owner = Keys::generate(); + let legacy = control.replacen("acme.", &format!("legacy-{create_only}."), 1); + let required = control.replacen("acme.", &format!("required-{create_only}."), 1); + set_default(&mut state, false); + assert!(provision(&state, &operator, &owner, &legacy, create_only) + .await + .status() + .is_success()); + let before = stored_policy(&state, &legacy).await; + assert!(!before.requires_nip_fi); + assert_eq!(before.policy_generation, 0); + + set_default(&mut state, true); + assert!(provision(&state, &operator, &owner, &legacy, create_only) + .await + .status() + .is_success()); + assert_eq!(stored_policy(&state, &legacy).await, before); + assert!(provision(&state, &operator, &owner, &required, create_only) + .await + .status() + .is_success()); + let born = stored_policy(&state, &required).await; + assert!(born.requires_nip_fi); + assert_eq!(born.policy_generation, 1); + assert_eq!( + nip_fi_lookup(state.clone(), &operator, &required).await["issuers"], + serde_json::json!([NIP_FI_ISSUER]) + ); + + // Rollback changes future creations only. It cannot clear durable policy. + set_default(&mut state, false); + assert!(provision(&state, &operator, &owner, &required, create_only) + .await + .status() + .is_success()); + assert_eq!(stored_policy(&state, &required).await, born); + + // Removing the binding selection makes both hosts unbindable. Retrying or + // converging existing rows still succeeds without changing bindings. + set_default(&mut state, true); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + None; + for host in [&legacy, &required] { + assert!(provision(&state, &operator, &owner, host, create_only) + .await + .status() + .is_success()); + } + assert_eq!(stored_policy(&state, &legacy).await, before); + assert_eq!(stored_policy(&state, &required).await, born); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + Some(policy.clone()); + } + + // New reserved hosts are refused by both operator paths, before + // startup separately creates the control row under its exception. + let owner = Keys::generate(); + for create_only in [true, false] { + let refusal = provision(&state, &operator, &owner, &reserved, create_only).await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_json(refusal).await["code"], + if create_only { + "reserved_host" + } else { + "nip_fi_binding_required" + } + ); + assert!(state + .db + .lookup_community_by_host(&reserved) + .await + .unwrap() + .is_none()); + } + + // The flag is on while the reserved deployment host is first bootstrapped. + assert!(state.config.nip_fi_require_new_communities); + for _ in 0..2 { + crate::handlers::community_provisioning::ensure_deployment_community( + &state.db, + state.config.nip_fi_provision_binding.as_ref(), + &reserved, + ) + .await + .unwrap() + .unwrap(); + let stored = stored_policy(&state, &reserved).await; + assert!(!stored.requires_nip_fi); + assert_eq!(stored.policy_generation, 0); + assert_eq!( + nip_fi_lookup(state.clone(), &operator, &reserved).await["issuers"], + serde_json::json!([]) + ); + } + + // Both operator entry paths refuse *new* unbindable hosts. Existing + // unbound rows still converge; bootstrap is never an operator fallback. + let owner = Keys::generate(); + for create_only in [true, false] { + let outside = format!("outside-{}.example", Uuid::new_v4().simple()); + let refusal = provision(&state, &operator, &owner, &outside, create_only).await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refusal).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + if !create_only { + state + .db + .ensure_configured_community(&outside) + .await + .unwrap(); + assert!(provision(&state, &operator, &owner, &outside, false) + .await + .status() + .is_success()); + assert!(!stored_policy(&state, &outside).await.requires_nip_fi); + } + } + } + struct AlwaysFreshReplayGuard; impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 215c1472fca..628aa5ec6df 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -407,6 +407,9 @@ pub struct Config { /// (`BUZZ_NIP_FI_PROVISION_BINDING`). `None` binds nothing at creation. /// Parsed whatever the NIP-FI mode, since creation happens in every mode. pub nip_fi_provision_binding: Option, + /// Require NIP-FI for newly provisioned tenant communities (default false). + /// Deployment bootstrap and existing rows retain their policy. + pub nip_fi_require_new_communities: bool, /// Permit new irreversible FI upgrade acceptances. Default off; receipt /// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`). @@ -1426,6 +1429,17 @@ impl Config { let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?; let nip_fi_provision_binding = crate::nip_fi_config::parse_provision_binding()?; + let nip_fi_require_new_communities = + parse_bool("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", false)?; + if nip_fi_require_new_communities + && (nip_fi.source != crate::nip_fi_config::CommunitySource::Database + || nip_fi_provision_binding.is_none()) + { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES requires BUZZ_NIP_FI_COMMUNITY_SOURCE=database \ + and BUZZ_NIP_FI_PROVISION_BINDING".into(), + )); + } let nip_fi_resolver_bounds = crate::nip_fi_resolver::ResolverBounds::from_env(db_pool_size)?; if nip_fi.source == crate::nip_fi_config::CommunitySource::Database @@ -1508,6 +1522,7 @@ impl Config { web_dir, serve_git_web_gui, nip_fi_provision_binding, + nip_fi_require_new_communities, nip_fi_upgrades_enabled: parse_bool("BUZZ_NIP_FI_UPGRADES_ENABLED", false)?, nip_fi_resolver_bounds, nip_fi, @@ -1616,7 +1631,8 @@ mod tests { // lets that configuration start. #[test] fn database_source_config_guards_refuse_startup() { - const VARS: [&str; 6] = [ + const VARS: [&str; 7] = [ + "BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", "BUZZ_NIP_FI_COMMUNITY_SOURCE", "BUZZ_NIP_FI_COMMUNITIES", "BUZZ_NIP_FI_ISSUERS", @@ -1639,6 +1655,16 @@ mod tests { other => panic!("expected a refusal, got {:?}", other.map(|_| ())), }; + std::env::remove_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES"); + assert!(!Config::from_env().unwrap().nip_fi_require_new_communities); + std::env::set_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", "false"); + assert!(!Config::from_env().unwrap().nip_fi_require_new_communities); + std::env::set_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", "treu"); + assert!(refusal().contains("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES must be true or false")); + std::env::set_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", "true"); + assert!(refusal().contains("requires BUZZ_NIP_FI_COMMUNITY_SOURCE=database")); + std::env::remove_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES"); + std::env::set_var("BUZZ_NIP_FI_COMMUNITY_SOURCE", "ldap"); assert!(refusal().contains("must be \"env\" or \"database\"")); @@ -1665,6 +1691,8 @@ mod tests { ); std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); assert!(refusal().contains("requires BUZZ_NIP_FI_PROVISION_BINDING")); + std::env::set_var("BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES", "true"); + assert!(refusal().contains("BUZZ_NIP_FI_PROVISION_BINDING")); std::env::set_var( "BUZZ_NIP_FI_PROVISION_BINDING", @@ -1672,6 +1700,20 @@ mod tests { ); let config = Config::from_env().expect("complete database-source config"); assert!(config.nip_fi_provision_binding.is_some()); + assert!(config.nip_fi_require_new_communities); + assert!(!config.nip_fi_upgrades_enabled); + // This creation default never changes mode or enables global enforcement. + for (mode, expected) in [ + ("off", buzz_auth::NipFiMode::Off), + ("shadow", buzz_auth::NipFiMode::Shadow), + ("enforce", buzz_auth::NipFiMode::Enforce), + ("deny_protected", buzz_auth::NipFiMode::DenyProtected), + ] { + std::env::set_var("BUZZ_NIP_FI_MODE", mode); + let config = Config::from_env().unwrap(); + assert!(config.nip_fi_require_new_communities); + assert_eq!(config.nip_fi.mode, expected); + } } // Mutation: parsing the resolver bounds only when the resolver is built diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 377783abec1..4e9152680db 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -222,6 +222,13 @@ pub(crate) fn record_binding_creation( } } +fn creation_error(error: buzz_db::DbError) -> String { + match error { + buzz_db::DbError::NipFiBindingRequired => format!("nip_fi_binding_required: {error}"), + other => format!("failed to create community: {other}"), + } +} + /// Validate and execute a relay-operator community provisioning request. /// /// The caller is an HTTP operator endpoint, not the Nostr event ingest path. @@ -278,14 +285,15 @@ pub async fn provision_community( })?; let write = state .db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &request.host, owner_hex, nip_fi_issuers, + state.config.nip_fi_require_new_communities, &state.relay_keypair, ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(creation_error)?; let record = match write.outcome { buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, buzz_db::CreateCommunityWithOwnerResult::HostExists => { @@ -323,9 +331,13 @@ pub async fn provision_community( // create_only so an existing owner can never be rotated by a create race. let record = state .db - .ensure_configured_community_with_nip_fi_issuers(&request.host, nip_fi_issuers) + .ensure_configured_community_with_nip_fi_policy( + &request.host, + nip_fi_issuers, + state.config.nip_fi_require_new_communities, + ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(creation_error)?; state.invalidate_community_host(&TenantContext::resolved(record.id, &record.host)); if record.created { record_binding_creation( diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a103..a83e6f1fbbb 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -42,6 +42,38 @@ binds nothing. relay's environment: `BUZZ_NIP_FI_PROVISION_BINDING`, `BUZZ_NIP_FI_ISSUERS`, `BUZZ_NIP_FI_COMMUNITIES`, and `DATABASE_URL`. +### Require FI for new tenant communities + +`BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES` defaults to `false`. Setting it to +`true` requires `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` and +`BUZZ_NIP_FI_PROVISION_BINDING`; incomplete configuration refuses startup. +Both operator provisioning modes create new tenant rows with +`requires_nip_fi=true` and `policy_generation=1`, in the same transaction as +their issuer bindings (and the initial owner for `create_only`). A new host +outside the binding policy, including a reserved host, is refused without +leaving a community row, including in `off` mode. The refusal returns HTTP 400 +with code `nip_fi_binding_required` (`reserved_host` for a reserved `create_only` +request). Existing rows and idempotent retries retain their +policy, generation and bindings, even if the creation default changes. + +Deployment-host startup bootstrap is excluded: it keeps the existing behavior, +including an unrequired, unbound reserved control host. Operator creation never +uses this bootstrap exception. This switch is independent of +`BUZZ_NIP_FI_UPGRADES_ENABLED`, which gates upgrades of existing communities. +It does not create upgrade operations or retrofit existing rows. + +Stage the database source and issuer configuration first using the rollout +below. Enable this creation default only when the issuer can sign users into +new communities. Verify a new community's NIP-11 `requires_nip_fi` and issuer +binding, and verify an existing community retains its policy. Mode semantics +are unchanged: `off` bypasses FI relay-wide, `shadow` enforces stored required +communities while observing others, and `enforce` enforces all communities. + +**Disabling this default affects future creations only.** Born-required rows +remain required; no per-community rollback endpoint exists. `BUZZ_NIP_FI_MODE=off` +is an explicit relay-wide bypass, not an undo of durable policy. See +[community auth containment](community-auth-upgrades.md#mistaken-acceptance-and-containment). + ### Database community source `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves communities from these