diff --git a/Justfile b/Justfile index 369eb05680..ca89c5f35b 100644 --- a/Justfile +++ b/Justfile @@ -454,9 +454,9 @@ test-unit: -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)' # buzz-admin: storage-snapshot worker, the community archive command # parser, validation, and propagation-evidence tests, and the partition - # audit outcome contract. + # audit outcome contract, and the NIP-FI binding command's policy loading and backfill report. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/src/communities.rs b/crates/buzz-admin/src/communities.rs index d3232990ca..f8bcd7c015 100644 --- a/crates/buzz-admin/src/communities.rs +++ b/crates/buzz-admin/src/communities.rs @@ -264,7 +264,7 @@ fn ensure_not_deployment_host(host: &str, deployment_host: &str) -> Result<()> { Ok(()) } -fn normalize_host_authority(host: &str) -> Result { +pub(crate) fn normalize_host_authority(host: &str) -> Result { if host.is_empty() { return Err("host is empty".to_string()); } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 7fca365e6f..4421f47e1f 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -22,6 +22,7 @@ mod communities; mod deletions; +mod nip_fi; use std::future::Future; use std::sync::atomic::{AtomicU64, Ordering}; @@ -110,6 +111,11 @@ enum Command { #[command(subcommand)] command: communities::CommunitiesCommand, }, + /// NIP-FI community issuer bindings. + NipFi { + #[command(subcommand)] + command: nip_fi::NipFiCommand, + }, /// Durable CLI-only whole-community deletion control plane. Deletions { #[command(subcommand)] @@ -191,6 +197,7 @@ async fn run(cli: Cli) -> Result { } => cmd_list_product_feedback(limit).await, Command::Communities { command } => communities::run(command).await, Command::Deletions { command } => deletions::run(command).await, + Command::NipFi { command } => nip_fi::run(command).await, Command::ReconcileChannels { channel, relay_key } => { reconcile_channels(channel, relay_key).await?; Ok(0) diff --git a/crates/buzz-admin/src/nip_fi.rs b/crates/buzz-admin/src/nip_fi.rs new file mode 100644 index 0000000000..0fd62977bd --- /dev/null +++ b/crates/buzz-admin/src/nip_fi.rs @@ -0,0 +1,429 @@ +//! NIP-FI community issuer bindings: backfill and explicit operator edits. +//! +//! `backfill` and `bind` read `BUZZ_NIP_FI_PROVISION_BINDING`, +//! `BUZZ_NIP_FI_ISSUERS` and `BUZZ_NIP_FI_COMMUNITIES` with the relay's own +//! parser and overlap check, so the CLI binds exactly what relay provisioning +//! would. `unbind` works without provisioning config. See +//! `docs/operator-nip-fi-bindings.md`. + +use anyhow::{Context, Result}; +use buzz_auth::ProvisionBindingPolicy; +use buzz_db::{Db, NipFiUnbind}; +use clap::Subcommand; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::communities::normalize_host_authority; + +/// Communities read and bound per backfill page. +const BACKFILL_PAGE: i64 = 1_000; +/// Most unbound hosts listed in the backfill report. +const MAX_REPORTED_HOSTS: usize = 100; + +/// NIP-FI binding actions. +#[derive(Debug, Subcommand)] +pub enum NipFiCommand { + /// Bind every unbound one-label suffix community to the configured + /// issuers, then report how many remain unbound (must be 0 before enforce). + Backfill, + /// Bind one issuer to one community. + Bind { + /// Exact community hostname. + #[arg(long)] + host: String, + /// Issuer configured in `BUZZ_NIP_FI_ISSUERS`. + #[arg(long)] + issuer: String, + }, + /// Remove one issuer from one community. Refuses the last issuer. + Unbind { + /// Exact community hostname. + #[arg(long)] + host: String, + /// Bound issuer to remove. + #[arg(long)] + issuer: String, + }, +} + +/// Run a NIP-FI binding action. +pub async fn run(command: NipFiCommand) -> Result { + let policy = policy_for(&command, ProvisionBindingPolicy::from_env)?; + let db = crate::connect_db().await?; + let (output, code) = match (command, policy) { + (NipFiCommand::Backfill, Some(policy)) => { + let report = backfill(&db, &policy).await?; + (json!(report), report.exit_code()) + } + (NipFiCommand::Bind { host, issuer }, Some(policy)) => { + (bind(&db, &policy, &host, &issuer).await?, 0) + } + (NipFiCommand::Unbind { host, issuer }, policy) => { + (unbind(&db, policy.as_ref(), &host, &issuer).await?, 0) + } + (_, None) => unreachable!("policy_for requires a policy for backfill and bind"), + }; + println!("{output}"); + Ok(code) +} + +/// The provisioning policy `command` needs: required for `backfill` and +/// `bind`, optional for `unbind`, which runs without it. +fn policy_for( + command: &NipFiCommand, + load: impl FnOnce() -> Result, buzz_auth::ProvisionBindingError>, +) -> Result> { + if matches!(command, NipFiCommand::Unbind { .. }) { + return Ok(load().ok().flatten()); + } + let policy = load()?.context("BUZZ_NIP_FI_PROVISION_BINDING is required")?; + Ok(Some(policy)) +} + +/// What a backfill bound and what it left unbound. +#[derive(Debug, PartialEq, Eq, Serialize)] +pub(crate) struct BackfillReport { + rows_bound: u64, + unbound: usize, + unbound_hosts: Vec, + truncated: bool, +} + +impl BackfillReport { + fn new(rows_bound: u64) -> Self { + Self { + rows_bound, + unbound: 0, + unbound_hosts: Vec::new(), + truncated: false, + } + } + + /// Count every unbound host, listing at most `MAX_REPORTED_HOSTS`. + fn add_unbound(&mut self, host: String) { + self.unbound += 1; + if self.unbound_hosts.len() < MAX_REPORTED_HOSTS { + self.unbound_hosts.push(host); + } else { + self.truncated = true; + } + } + + /// 0 when every bindable community holds a binding, otherwise 1. + pub(crate) fn exit_code(&self) -> i32 { + i32::from(self.unbound > 0) + } +} + +/// Bind every unbound, active, bindable community under the policy's suffix, +/// one page of hosts at a time. A community whose deletion starts mid-run is +/// skipped, not an error; the closing recount reports anything left unbound. +pub(crate) async fn backfill(db: &Db, policy: &ProvisionBindingPolicy) -> Result { + let mut rows_bound = 0; + let mut after = String::new(); + loop { + let page = db + .list_unbound_nip_fi_communities(policy.host_suffix(), &after, BACKFILL_PAGE) + .await?; + let Some(last) = page.last() else { break }; + after.clone_from(&last.host); + let bindable: Vec<_> = page + .iter() + .filter(|community| policy.is_bindable(&community.host)) + .map(|community| community.id) + .collect(); + rows_bound += db + .bind_nip_fi_issuers_to_active(&bindable, policy.authorized_issuers()) + .await?; + } + + let mut report = BackfillReport::new(rows_bound); + let mut after = String::new(); + loop { + let page = db + .list_unbound_nip_fi_communities(policy.host_suffix(), &after, BACKFILL_PAGE) + .await?; + let Some(last) = page.last() else { break }; + after.clone_from(&last.host); + for community in page { + if policy.is_bindable(&community.host) { + report.add_unbound(community.host); + } + } + } + Ok(report) +} + +async fn bind(db: &Db, policy: &ProvisionBindingPolicy, host: &str, issuer: &str) -> Result { + let host = normalize_host_authority(host).map_err(anyhow::Error::msg)?; + policy.check_bind(&host, issuer)?; + let community = db + .lookup_community_by_host_for_management(&host) + .await? + .context("no community has this host")?; + let inserted = db + .bind_nip_fi_issuers(community.id, &[issuer.to_string()]) + .await?; + Ok(json!({"host": community.host, "issuer": issuer, "bound": inserted > 0})) +} + +/// Remove one issuer. The last issuer of a host backfill would rebind is +/// kept; only a policy that proves the host unbindable (a reserved host, or +/// one outside the suffix) lets the last one go. +async fn unbind( + db: &Db, + policy: Option<&ProvisionBindingPolicy>, + host: &str, + issuer: &str, +) -> Result { + let host = normalize_host_authority(host).map_err(anyhow::Error::msg)?; + let community = db + .lookup_community_by_host_for_management(&host) + .await? + .context("no community has this host")?; + let allow_last = policy.is_some_and(|policy| !policy.is_bindable(&community.host)); + let removed = match db + .unbind_nip_fi_issuer(community.id, issuer, allow_last) + .await? + { + NipFiUnbind::Removed => true, + NipFiUnbind::NotBound => false, + NipFiUnbind::LastIssuer => anyhow::bail!( + "refusing to remove the community's last NIP-FI issuer: backfill would rebind it; \ + bind a replacement first, or archive or delete the community to stop it logging in" + ), + }; + Ok(json!({"host": community.host, "issuer": issuer, "removed": removed})) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unloadable() -> Result, buzz_auth::ProvisionBindingError> { + ProvisionBindingPolicy::parse("not json", "").map(Some) + } + + #[test] + fn unbind_runs_when_the_policy_is_missing_or_fails_to_load() { + let unbind = NipFiCommand::Unbind { + host: "acme.example".into(), + issuer: "https://issuer.example".into(), + }; + assert!(policy_for(&unbind, unloadable) + .expect("unbind needs no policy") + .is_none()); + assert!(policy_for(&unbind, || Ok(None)) + .expect("unbind needs no policy") + .is_none()); + let bind = NipFiCommand::Bind { + host: "acme.example".into(), + issuer: "https://issuer.example".into(), + }; + for command in [NipFiCommand::Backfill, bind] { + assert!(policy_for(&command, unloadable).is_err(), "{command:?}"); + assert!(policy_for(&command, || Ok(None)).is_err(), "{command:?}"); + } + } + + // Exit 1 whenever a bindable host stays unbound; the host list stops at + // `MAX_REPORTED_HOSTS` and says so; stdout is the report's JSON. + fn report(rows_bound: u64, hosts: impl IntoIterator) -> BackfillReport { + let mut report = BackfillReport::new(rows_bound); + hosts.into_iter().for_each(|host| report.add_unbound(host)); + report + } + + #[test] + fn backfill_report_exits_one_caps_its_host_list_and_prints_json() { + let clean = report(3, Vec::new()); + assert_eq!(clean.exit_code(), 0); + assert_eq!( + json!(clean).to_string(), + r#"{"rows_bound":3,"truncated":false,"unbound":0,"unbound_hosts":[]}"# + ); + + let some = report(0, ["a.example".to_owned()]); + assert_eq!(some.exit_code(), 1); + assert_eq!( + json!(some).to_string(), + r#"{"rows_bound":0,"truncated":false,"unbound":1,"unbound_hosts":["a.example"]}"# + ); + + let hosts = (0..=MAX_REPORTED_HOSTS).map(|i| format!("h{i}.example")); + let many = report(0, hosts); + assert_eq!(many.exit_code(), 1); + assert_eq!(many.unbound, MAX_REPORTED_HOSTS + 1); + assert_eq!(many.unbound_hosts.len(), MAX_REPORTED_HOSTS); + assert_eq!( + many.unbound_hosts.last().unwrap(), + &format!("h{}.example", MAX_REPORTED_HOSTS - 1) + ); + assert!(many.truncated); + let exact = report(0, (0..MAX_REPORTED_HOSTS).map(|i| format!("h{i}"))); + assert!(!exact.truncated, "a full list is not truncated"); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use buzz_db::DbConfig; + use uuid::Uuid; + + const ISS: &str = "https://issuer.example/api/goose"; + + async fn db() -> Db { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .unwrap_or_else(|_| DbConfig::default().database_url); + Db::new(&DbConfig { + database_url, + ..DbConfig::default() + }) + .await + .expect("connect to test DB") + } + + /// A policy under a fresh suffix whose bare suffix host is reserved. + fn policy(suffix: &str) -> ProvisionBindingPolicy { + ProvisionBindingPolicy::parse( + &json!({"host_suffix": suffix, "authorized_issuers": [ISS]}).to_string(), + &json!([{"issuer": ISS, "command_audiences": [format!("https://cmd.{suffix}")]}]) + .to_string(), + ) + .expect("valid policy") + } + + /// Move a community into `quiescing` the way the deletion executor does: + /// the lifecycle trigger accepts the change only under its GUCs. + async fn quiesce(id: buzz_core::CommunityId) { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .unwrap_or_else(|_| DbConfig::default().database_url); + let pool = sqlx::PgPool::connect(&url).await.expect("connect"); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query( + "SELECT set_config('buzz.deletion_executor_community', $1, true), \ + set_config('buzz.deletion_fence_generation', \ + (SELECT deletion_fence_generation::text FROM communities WHERE id = $2), true)", + ) + .bind(id.to_string()) + .bind(id.as_uuid()) + .execute(&mut *tx) + .await + .expect("authorize lifecycle change"); + sqlx::query("UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1") + .bind(id.as_uuid()) + .execute(&mut *tx) + .await + .expect("quiesce"); + tx.commit().await.expect("commit"); + } + + async fn issuers(db: &Db, host: &str) -> Vec { + db.lookup_nip_fi_binding_by_host(host) + .await + .expect("lookup") + .expect("community") + .issuers + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn backfill_binds_only_bindable_hosts_and_reports_the_rest() { + let db = db().await; + let suffix = format!("{}.backfill.example", Uuid::new_v4().simple()); + let policy = policy(&suffix); + let bindable = format!("acme.{suffix}"); + let reserved = format!("cmd.{suffix}"); + let nested = format!("a.b.{suffix}"); + for host in [&bindable, &reserved, &nested] { + db.ensure_configured_community(host).await.expect("seed"); + } + + let report = backfill(&db, &policy).await.expect("backfill"); + assert_eq!( + report, + BackfillReport { + rows_bound: 1, + unbound: 0, + unbound_hosts: Vec::new(), + truncated: false, + } + ); + assert_eq!(report.exit_code(), 0); + assert_eq!(issuers(&db, &bindable).await, [ISS]); + assert!(issuers(&db, &reserved).await.is_empty()); + assert!(issuers(&db, &nested).await.is_empty()); + + let again = backfill(&db, &policy).await.expect("rerun"); + assert_eq!(again.rows_bound, 0); + assert_eq!(again.exit_code(), 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn backfill_skips_a_community_being_deleted() { + let db = db().await; + let suffix = format!("{}.deleting.example", Uuid::new_v4().simple()); + let policy = policy(&suffix); + let deleting = format!("deleting.{suffix}"); + let control = format!("control.{suffix}"); + let deleting_id = db + .ensure_configured_community(&deleting) + .await + .expect("seed") + .id; + db.ensure_configured_community(&control) + .await + .expect("seed"); + quiesce(deleting_id).await; + + let report = backfill(&db, &policy) + .await + .expect("backfill must not abort"); + assert_eq!(report.rows_bound, 1); + assert_eq!(report.exit_code(), 0); + assert_eq!(issuers(&db, &control).await, [ISS]); + assert!(issuers(&db, &deleting).await.is_empty()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_refuses_hosts_outside_the_suffix_and_unbind_keeps_the_last_issuer() { + let db = db().await; + let suffix = format!("{}.bind.example", Uuid::new_v4().simple()); + let policy = policy(&suffix); + let nested = format!("a.b.{suffix}"); + let host = format!("acme.{suffix}"); + db.ensure_configured_community(&nested).await.expect("seed"); + db.ensure_configured_community(&host).await.expect("seed"); + + let refused = bind(&db, &policy, &nested, ISS) + .await + .expect_err("outside suffix"); + assert_eq!( + refused.downcast_ref::(), + Some(&buzz_auth::BindRefusal::OutsideSuffix) + ); + assert!(issuers(&db, &nested).await.is_empty()); + + bind(&db, &policy, &host, ISS).await.expect("bind"); + for policy in [None, Some(&policy)] { + let last = unbind(&db, policy, &host, ISS) + .await + .expect_err("last issuer"); + assert!(last.to_string().contains("archive or delete"), "{last}"); + assert_eq!(issuers(&db, &host).await, [ISS]); + } + + // A reserved host's binding must be removable, since startup refuses it. + let reserved = format!("cmd.{suffix}"); + db.ensure_configured_community_with_nip_fi_issuers(&reserved, &[ISS.to_string()]) + .await + .expect("seed raw reserved binding"); + unbind(&db, Some(&policy), &reserved, ISS) + .await + .expect("unbind a reserved host's last issuer"); + assert!(issuers(&db, &reserved).await.is_empty()); + } +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 69af6a8af8..c8432f093a 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,17 +46,18 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - command_replay_key, validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, + command_replay_key, validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, BindRefusal, CanonicalCapabilities, ClientSubjectPosture, CommandError, CommandIssuerPolicy, CommandPolicyError, CommandReplayGuard, CommandResult, CommandVerifier, CommunityBinding, CommunityBindingError, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull, FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, - NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, - SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, - VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, - MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, + NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, ProvisionBindingError, + ProvisionBindingPolicy, RevalidationDependencies, SubjectClass, SubjectClassContract, + TokenClass, TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, + CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, + OAUTH_CLIENT_ID_CLAIM, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index 5eea285adc..aeb9349b4b 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -17,6 +17,7 @@ pub mod denial; pub mod deny_map; pub mod discovery; pub mod jwks; +pub mod provision; pub mod startup; pub mod verifier; @@ -52,6 +53,7 @@ pub use jwks::{ #[cfg(any(test, feature = "test-utils"))] pub use jwks::ToggleJwksFetcher; +pub use provision::{BindRefusal, ProvisionBindingError, ProvisionBindingPolicy}; pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{ AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError, VerifyAssertion, diff --git a/crates/buzz-auth/src/nip_fi/provision.rs b/crates/buzz-auth/src/nip_fi/provision.rs new file mode 100644 index 0000000000..d987681e18 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/provision.rs @@ -0,0 +1,417 @@ +//! Issuer bindings written when a community is created. +//! +//! A deployment that provisions communities under one host suffix (for +//! example `.communities.buzz.xyz`) binds each new community to a fixed +//! issuer allowlist at creation time. Runtime authorization reads the stored +//! per-community rows, filtered by the same [`ProvisionBindingPolicy::is_bindable`] +//! rule, so a row outside the suffix or on a reserved host never authorizes. +//! +//! Hosts whose `https://` equals a configured issuer's command audience +//! are *reserved*. Their community row may exist, but they never hold an +//! issuer binding, so a command audience can never become a community +//! assertion audience. + +use std::collections::BTreeSet; + +use buzz_core::tenant::normalize_host; + +/// Environment variable carrying the provisioning binding JSON. +pub const PROVISION_BINDING_ENV: &str = "BUZZ_NIP_FI_PROVISION_BINDING"; +/// Environment variable carrying the relay's issuer configs. +pub const ISSUERS_ENV: &str = "BUZZ_NIP_FI_ISSUERS"; +/// Environment variable carrying the static enterprise community map. +pub const COMMUNITIES_ENV: &str = "BUZZ_NIP_FI_COMMUNITIES"; +/// Longest issuer accepted, matching the binding table's `CHECK`. +const MAX_ISSUER_BYTES: usize = 2_048; + +/// Invalid provisioning binding configuration. The message never echoes +/// configured values. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[error("{0}")] +pub struct ProvisionBindingError(String); + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BindingJson { + host_suffix: String, + authorized_issuers: Vec, +} + +/// The fields of one `BUZZ_NIP_FI_ISSUERS` entry this module needs. +#[derive(serde::Deserialize)] +struct IssuerJson { + issuer: String, + #[serde(default)] + command_audiences: Vec, +} + +/// The field of one `BUZZ_NIP_FI_COMMUNITIES` entry this module needs. +#[derive(serde::Deserialize)] +struct CommunityJson { + canonical_uri: String, +} + +/// Why a host cannot be bound. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum BindRefusal { + /// The host's audience equals a configured command audience. + #[error("host is reserved: it is a NIP-FI command audience")] + ReservedHost, + /// The host is not exactly one label under the provisioning suffix. + #[error("host is not one label under the NIP-FI provisioning host suffix")] + OutsideSuffix, + /// The issuer is not configured in `BUZZ_NIP_FI_ISSUERS`. + #[error("issuer is not configured in BUZZ_NIP_FI_ISSUERS")] + UnknownIssuer, +} + +/// The authority of a canonical community URI, or `None` unless the URI is +/// exactly `https://` and the URL parser reserializes that authority +/// byte-for-byte. The round trip rejects anything the parser would repair: +/// whitespace, control characters, backslashes, uppercase, userinfo, path, +/// query, fragment, a redundant default `:443`, and non-canonical IPv6. +/// The authority must also already be in `normalize_host()` form, which +/// rejects a final trailing dot and `:80`, so the Host map key equals the +/// `aud` authority exactly and is never empty. +pub fn canonical_authority(uri: &str) -> Option<&str> { + let authority = uri.strip_prefix("https://")?; + let parsed = url::Url::parse(uri).ok()?; + let host = parsed.host_str()?; + let reserialized = match parsed.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_owned(), + }; + (reserialized == authority && normalize_host(authority) == authority).then_some(authority) +} + +/// Parsed provisioning binding: host suffix, issuers bound to new communities +/// under it, the configured issuer set, and the reserved command audiences. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProvisionBindingPolicy { + host_suffix: String, + authorized_issuers: Vec, + configured_issuers: BTreeSet, + command_audiences: BTreeSet, +} + +impl ProvisionBindingPolicy { + /// Read the policy from the environment. `Ok(None)` when + /// `BUZZ_NIP_FI_PROVISION_BINDING` is unset or empty: no community gets a + /// binding at creation. Whatever the NIP-FI mode, the suffix must not + /// cover a `BUZZ_NIP_FI_COMMUNITIES` host. + pub fn from_env() -> Result, ProvisionBindingError> { + let binding = std::env::var(PROVISION_BINDING_ENV).unwrap_or_default(); + if binding.trim().is_empty() { + return Ok(None); + } + let issuers = std::env::var(ISSUERS_ENV).unwrap_or_default(); + let policy = Self::parse(&binding, &issuers)?; + policy.reject_overlap(&std::env::var(COMMUNITIES_ENV).unwrap_or_default())?; + Ok(Some(policy)) + } + + /// Refuse a suffix that covers any host in the `BUZZ_NIP_FI_COMMUNITIES` + /// JSON, so the static enterprise map and database-bound communities + /// never overlap. Only each entry's `canonical_uri` is read; an unreadable + /// declaration fails closed because the check could not be trusted. + pub fn reject_overlap(&self, communities_json: &str) -> Result<(), ProvisionBindingError> { + if communities_json.trim().is_empty() { + return Ok(()); + } + let fail = + |message: &str| ProvisionBindingError(format!("{PROVISION_BINDING_ENV}: {message}")); + let unreadable = || fail(&format!("{COMMUNITIES_ENV} hosts are unreadable")); + let entries: Vec = + serde_json::from_str(communities_json).map_err(|_| unreadable())?; + for entry in entries { + let host = canonical_authority(&entry.canonical_uri).ok_or_else(unreadable)?; + if host == self.host_suffix || host.ends_with(&format!(".{}", self.host_suffix)) { + return Err(fail(&format!( + "host_suffix covers a {COMMUNITIES_ENV} host" + ))); + } + } + Ok(()) + } + + /// Parse the binding JSON against the issuer config JSON. + pub fn parse(binding_json: &str, issuers_json: &str) -> Result { + let fail = + |message: &str| ProvisionBindingError(format!("{PROVISION_BINDING_ENV}: {message}")); + let binding: BindingJson = serde_json::from_str(binding_json) + .map_err(|e| fail(&format!("invalid JSON ({:?})", e.classify())))?; + if issuers_json.trim().is_empty() { + return Err(fail(&format!("requires {ISSUERS_ENV}"))); + } + let issuers: Vec = serde_json::from_str(issuers_json).map_err(|e| { + fail(&format!( + "{ISSUERS_ENV} is not valid JSON ({:?})", + e.classify() + )) + })?; + + let host_suffix = binding.host_suffix; + if host_suffix.is_empty() + || normalize_host(&host_suffix) != host_suffix + || host_suffix.contains(':') + || host_suffix.split('.').any(str::is_empty) + { + return Err(fail( + "host_suffix must be a normalized domain name without a port", + )); + } + let configured_issuers: BTreeSet = + issuers.iter().map(|entry| entry.issuer.clone()).collect(); + if binding + .authorized_issuers + .iter() + .chain(&configured_issuers) + .any(|issuer| issuer.is_empty() || issuer.len() > MAX_ISSUER_BYTES) + { + return Err(fail(&format!( + "every issuer must be 1 to {MAX_ISSUER_BYTES} bytes" + ))); + } + let command_audiences: BTreeSet = issuers + .into_iter() + .flat_map(|entry| entry.command_audiences) + .collect(); + let mut authorized_issuers = binding.authorized_issuers; + authorized_issuers.sort(); + authorized_issuers.dedup(); + if authorized_issuers.is_empty() { + return Err(fail("authorized_issuers must not be empty")); + } + if authorized_issuers + .iter() + .any(|issuer| !configured_issuers.contains(issuer)) + { + return Err(fail(&format!( + "every authorized issuer must be configured in {ISSUERS_ENV}" + ))); + } + Ok(Self { + host_suffix, + authorized_issuers, + configured_issuers, + command_audiences, + }) + } + + /// Issuers bound to every bindable new community. + pub fn authorized_issuers(&self) -> &[String] { + &self.authorized_issuers + } + + /// The configured host suffix. + pub fn host_suffix(&self) -> &str { + &self.host_suffix + } + + /// Whether `host` is reserved: its `https://` is a command audience. + pub fn is_reserved(&self, host: &str) -> bool { + self.command_audiences.contains(&format!("https://{host}")) + } + + /// Hosts whose `https://` is a configured command audience. + pub fn reserved_hosts(&self) -> Vec { + self.command_audiences + .iter() + .filter_map(|aud| aud.strip_prefix("https://")) + .map(str::to_owned) + .collect() + } + + /// Whether `host` is exactly one DNS label under the suffix, with no port. + pub fn is_suffix_host(&self, host: &str) -> bool { + host.strip_suffix(self.host_suffix.as_str()) + .and_then(|prefix| prefix.strip_suffix('.')) + .is_some_and(|label| !label.is_empty() && !label.contains('.') && !label.contains(':')) + } + + /// Whether `host` may hold an issuer binding: one label under the suffix + /// and not reserved. Creation, operator binds and the runtime read all + /// apply this one rule. + pub fn is_bindable(&self, host: &str) -> bool { + self.is_suffix_host(host) && !self.is_reserved(host) + } + + /// Issuers to bind to a newly created community at `host`: the authorized + /// issuers for a bindable host, otherwise none. + pub fn issuers_for_new_host(&self, host: &str) -> &[String] { + if self.is_bindable(host) { + &self.authorized_issuers + } else { + &[] + } + } + + /// The stored `issuers` of `host` that may authorize: none unless the host + /// is bindable, and only issuers configured in `BUZZ_NIP_FI_ISSUERS`. + pub fn effective_issuers(&self, host: &str, mut issuers: Vec) -> Vec { + if !self.is_bindable(host) { + return Vec::new(); + } + issuers.retain(|issuer| self.configured_issuers.contains(issuer)); + issuers + } + + /// Check an explicit operator bind of `issuer` to `host`. + pub fn check_bind(&self, host: &str, issuer: &str) -> Result<(), BindRefusal> { + if self.is_reserved(host) { + return Err(BindRefusal::ReservedHost); + } + if !self.is_suffix_host(host) { + return Err(BindRefusal::OutsideSuffix); + } + if !self.configured_issuers.contains(issuer) { + return Err(BindRefusal::UnknownIssuer); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ISS: &str = "https://app.builderlab.xyz/api/goose"; + const OTHER: &str = "https://other.example"; + + fn issuers() -> String { + serde_json::json!([ + {"issuer": ISS, "command_audiences": ["https://communities.buzz.xyz"], "token_class": "nip-fi+jwt"}, + {"issuer": OTHER, "command_audiences": ["https://relay.other.example"]} + ]) + .to_string() + } + + fn policy() -> ProvisionBindingPolicy { + ProvisionBindingPolicy::parse( + &serde_json::json!({"host_suffix": "communities.buzz.xyz", "authorized_issuers": [ISS]}) + .to_string(), + &issuers(), + ) + .expect("valid policy") + } + + #[test] + fn provision_binding_binds_only_one_label_suffix_hosts() { + let policy = policy(); + assert_eq!( + policy.issuers_for_new_host("acme.communities.buzz.xyz"), + [ISS] + ); + for host in [ + "communities.buzz.xyz", + "a.b.communities.buzz.xyz", + "acme.communities.buzz.xyz:8443", + "acmecommunities.buzz.xyz", + ".communities.buzz.xyz", + "acme.example", + ] { + assert!(policy.issuers_for_new_host(host).is_empty(), "{host}"); + } + } + + #[test] + fn provision_binding_reserves_command_audience_hosts() { + let policy = policy(); + assert!(policy.is_reserved("communities.buzz.xyz")); + assert!(policy.is_reserved("relay.other.example")); + assert!(!policy.is_reserved("acme.communities.buzz.xyz")); + assert_eq!( + policy.reserved_hosts(), + ["communities.buzz.xyz", "relay.other.example"] + ); + assert_eq!( + policy.check_bind("communities.buzz.xyz", ISS), + Err(BindRefusal::ReservedHost) + ); + assert_eq!( + policy.check_bind("acme.communities.buzz.xyz", "https://unknown.example"), + Err(BindRefusal::UnknownIssuer) + ); + assert_eq!( + policy.check_bind("acme.communities.buzz.xyz", OTHER), + Ok(()) + ); + for host in ["a.b.communities.buzz.xyz", "acme.example"] { + assert_eq!( + policy.check_bind(host, ISS), + Err(BindRefusal::OutsideSuffix), + "{host}" + ); + } + } + + #[test] + fn provision_binding_effective_issuers_need_a_bindable_host_and_configured_issuer() { + let policy = policy(); + let stored = vec![ISS.to_string(), "https://unconfigured.example".to_string()]; + assert_eq!( + policy.effective_issuers("acme.communities.buzz.xyz", stored.clone()), + [ISS] + ); + for host in [ + "communities.buzz.xyz", + "a.b.communities.buzz.xyz", + "acme.example", + ] { + assert!( + policy.effective_issuers(host, stored.clone()).is_empty(), + "{host}" + ); + } + } + + #[test] + fn provision_binding_rejects_a_suffix_covering_an_enterprise_host() { + let policy = policy(); + let communities = |uri: &str| { + serde_json::json!([{"canonical_uri": uri, "authorized_issuers": [ISS]}]).to_string() + }; + assert!(policy.reject_overlap("").is_ok()); + assert!(policy + .reject_overlap(&communities("https://acme.example")) + .is_ok()); + for json in [ + communities("https://communities.buzz.xyz"), + communities("https://a.b.communities.buzz.xyz"), + communities("https://Acme.example"), + "not json".to_string(), + ] { + assert!(policy.reject_overlap(&json).is_err(), "{json}"); + } + } + + #[test] + fn provision_binding_rejects_invalid_config() { + let binding = |suffix: &str, issuers: &[&str]| { + serde_json::json!({"host_suffix": suffix, "authorized_issuers": issuers}).to_string() + }; + for (binding, issuers) in [ + (binding("communities.buzz.xyz", &[]), issuers()), + ( + binding("communities.buzz.xyz", &["https://unknown.example"]), + issuers(), + ), + (binding("Communities.buzz.xyz", &[ISS]), issuers()), + (binding("communities.buzz.xyz:443", &[ISS]), issuers()), + (binding("communities..xyz", &[ISS]), issuers()), + (binding("", &[ISS]), issuers()), + (binding("communities.buzz.xyz", &[ISS]), String::new()), + (binding("communities.buzz.xyz", &[""]), issuers()), + ( + binding("communities.buzz.xyz", &[ISS]), + serde_json::json!([{"issuer": ISS}, {"issuer": "x".repeat(2049)}]).to_string(), + ), + ("{\"host_suffix\": \"x\"}".to_string(), issuers()), + ] { + assert!( + ProvisionBindingPolicy::parse(&binding, &issuers).is_err(), + "{binding}" + ); + } + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 529034b46f..459ddd48ed 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -66,10 +66,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, channel, - channel_members, community, deletion, dm, event, feed, git_repo, moderation, operator_listener, - partition, personal_read, product_feedback, push, reaction, read_state, relay_admin_actions, - relay_invite, relay_members, relay_operators, reminder, replaceable, storage_accounting, - thread, thread_window, usage, user, workflow, + channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding, + operator_listener, partition, personal_read, product_feedback, push, reaction, read_state, + relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable, + storage_accounting, thread, thread_window, usage, user, workflow, }; pub use allowlist::AllowlistEntry; @@ -82,6 +82,7 @@ pub use community::{ pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use nip_fi_binding::{NipFiBindingRecord, NipFiUnbind}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index b373d37b4d..7cb0879ff8 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 65); + assert_eq!(migrations.len(), 66); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -741,6 +741,11 @@ mod postgres_tests { .sql .as_str() .contains("CREATE FUNCTION profile_name_fields")); + assert_eq!(migrations[65].version, 66); + assert!(migrations[65] + .sql + .as_str() + .contains("CREATE TABLE community_nip_fi_issuers")); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql @@ -2303,6 +2308,15 @@ mod postgres_tests { } expected_fences.extend(personal.fence_attachments); expected_fences.extend(["artifact_heads", "artifact_revisions"].map(str::to_owned)); + // 0066 fences the NIP-FI issuer bindings table. + assert!(MIGRATOR + .iter() + .find(|m| m.version == 66) + .expect("NIP-FI binding migration") + .sql + .as_str() + .contains("attach_community_write_fence('community_nip_fi_issuers')")); + expected_fences.insert("community_nip_fi_issuers".to_owned()); assert_eq!( expected_fences, schema.fence_attachments, "write-fence attachment targets differ after recovery policy" diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index e084d8ed17..46d7db6487 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -449,15 +449,33 @@ impl Db { pub async fn ensure_configured_community( &self, normalized_host: &str, + ) -> Result { + self.ensure_configured_community_with_nip_fi_issuers(normalized_host, &[]) + .await + } + + /// 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. + #[datastore_span( + name = "ensure_configured_community_with_nip_fi_issuers", + system = "postgresql" + )] + pub async fn ensure_configured_community_with_nip_fi_issuers( + &self, + normalized_host: &str, + nip_fi_issuers: &[String], ) -> Result { self.ensure_configured_community_with_operation( normalized_host, + nip_fi_issuers, crate::observability::WriterOperation::Authorization, ) .await } - /// Ensure the deployment-configured community during process bootstrap. + /// Ensure the deployment-configured community during process bootstrap, + /// binding `nip_fi_issuers` when this call creates its row. #[datastore_span( name = "ensure_configured_community_for_bootstrap", system = "postgresql" @@ -465,9 +483,11 @@ impl Db { pub async fn ensure_configured_community_for_bootstrap( &self, normalized_host: &str, + nip_fi_issuers: &[String], ) -> Result { self.ensure_configured_community_with_operation( normalized_host, + nip_fi_issuers, crate::observability::WriterOperation::Bootstrap, ) .await @@ -476,9 +496,11 @@ impl Db { async fn ensure_configured_community_with_operation( &self, normalized_host: &str, + nip_fi_issuers: &[String], operation: crate::observability::WriterOperation, ) -> Result { - let mut connection = crate::observability::acquire_writer(&self.pool, operation).await?; + 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) @@ -498,15 +520,16 @@ impl Db { )) })?; - let id: Uuid = row.try_get("id")?; + let id = CommunityId::from_uuid(row.try_get("id")?); let host: String = row.try_get("host")?; let created: bool = row.try_get("created")?; + if created { + self.insert_nip_fi_issuers_in_transaction(&mut connection, id, nip_fi_issuers) + .await?; + } + connection.commit().await?; - Ok(EnsuredCommunityRecord { - id: CommunityId::from_uuid(id), - host, - created, - }) + Ok(EnsuredCommunityRecord { id, host, created }) } /// Atomically creates a community and its initial owner. @@ -519,6 +542,23 @@ impl Db { &self, normalized_host: &str, owner_pubkey: &str, + ) -> Result { + self.create_community_with_owner_and_nip_fi_issuers(normalized_host, owner_pubkey, &[]) + .await + } + + /// 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. + #[datastore_span( + name = "create_community_with_owner_and_nip_fi_issuers", + system = "postgresql" + )] + pub async fn create_community_with_owner_and_nip_fi_issuers( + &self, + normalized_host: &str, + owner_pubkey: &str, + nip_fi_issuers: &[String], ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); let connection = crate::observability::acquire_writer( @@ -576,6 +616,12 @@ impl Db { .bind(&owner_pubkey) .execute(&mut *tx) .await?; + self.insert_nip_fi_issuers_in_transaction( + &mut tx, + CommunityId::from_uuid(id), + nip_fi_issuers, + ) + .await?; (id, host) } else { let existing = sqlx::query( diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 09235ca4a1..1e1e68cb05 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -84,6 +84,7 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ "channel_members", "channels", "community_bans", + "community_nip_fi_issuers", "delivery_log", "event_mentions", "events", @@ -154,6 +155,7 @@ pub const PURGE_SCOPED_TABLES: &[&str] = &[ "archived_identities", "audit_log", "community_bans", + "community_nip_fi_issuers", "pubkey_allowlist", "relay_members", "users", @@ -4393,7 +4395,7 @@ mod postgres_tests { }) .await .expect("connect isolated migration DB"); - crate::migration::run_migrations_through(&db.pool, 60) + crate::migration::run_migrations_through(&db.pool, 66) .await .expect("migrate with fence objects present"); let store = db.deletion_store(); @@ -4415,6 +4417,7 @@ DROP TRIGGER IF EXISTS community_write_fence_audit_log ON audit_log; DROP TRIGGER IF EXISTS community_write_fence_channel_members ON channel_members; DROP TRIGGER IF EXISTS community_write_fence_channels ON channels; DROP TRIGGER IF EXISTS community_write_fence_community_bans ON community_bans; +DROP TRIGGER IF EXISTS community_write_fence_community_nip_fi_issuers ON community_nip_fi_issuers; DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log; DROP TRIGGER IF EXISTS community_write_fence_event_mentions ON event_mentions; DROP TRIGGER IF EXISTS community_write_fence_events ON events; diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index efa46f1748..f759a770a2 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -22,6 +22,8 @@ pub mod dm; pub mod event; /// App-owned push enqueue and channel TTL refresh for inserted events. pub(crate) mod event_follow_up; +/// Per-community NIP-FI issuer bindings. +pub mod nip_fi_binding; #[cfg(test)] mod event_follow_up_postgres_tests; diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs new file mode 100644 index 0000000000..3f49f5c875 --- /dev/null +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -0,0 +1,646 @@ +//! Per-community NIP-FI issuer bindings (`community_nip_fi_issuers`). +//! +//! Operator-plane persistence only: creation paths, the backfill, and the +//! operator lookup route write and read these rows. The assertion audience of +//! a bound community is `https://`. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use sqlx::{Postgres, Row, Transaction}; + +use crate::{CommunityRecord, Db, Result}; + +/// Outcome of [`Db::unbind_nip_fi_issuer`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiUnbind { + /// The issuer was removed. + Removed, + /// The issuer was not bound to the community. + NotBound, + /// The issuer is the community's last one and was kept. + LastIssuer, +} + +/// A community's stored NIP-FI binding, as returned to the operator route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NipFiBindingRecord { + /// Canonical host stored on the community row. + pub host: String, + /// True when the community is not archived and its deletion has not + /// started quiescing. A community whose deletion was requested but not yet + /// quiesced still reports true. + pub active: bool, + /// Bound issuers in byte order. Empty means no binding. + pub issuers: Vec, +} + +impl Db { + /// Insert `issuers` for `community` inside the caller's transaction. + /// Existing rows are left as they are. No-op for an empty list. + pub(crate) async fn insert_nip_fi_issuers_in_transaction( + &self, + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + issuers: &[String], + ) -> Result { + if issuers.is_empty() { + return Ok(0); + } + self.deletion_store() + .guard_transaction(tx, community) + .await?; + let inserted = sqlx::query( + r#" + INSERT INTO community_nip_fi_issuers (community_id, issuer) + SELECT $1, issuer FROM unnest($2::text[]) AS issuer + ON CONFLICT DO NOTHING + "#, + ) + .bind(community.as_uuid()) + .bind(issuers) + .execute(&mut **tx) + .await? + .rows_affected(); + Ok(inserted) + } + + /// Bind `issuers` to an existing community. Returns the number of new rows. + #[datastore_span(name = "bind_nip_fi_issuers", system = "postgresql")] + pub async fn bind_nip_fi_issuers( + &self, + community: CommunityId, + issuers: &[String], + ) -> Result { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = Transaction::begin(connection, None).await?; + // Deletion lock before the row lock, the order deletion takes them. + self.deletion_store() + .guard_transaction(&mut tx, community) + .await?; + // Serializes with the backfill, which locks the same row. + sqlx::query("SELECT 1 FROM communities WHERE id = $1 FOR NO KEY UPDATE") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await?; + let inserted = self + .insert_nip_fi_issuers_in_transaction(&mut tx, community, issuers) + .await?; + tx.commit().await?; + Ok(inserted) + } + + /// Remove one issuer from a community, refusing to remove its last one + /// unless `allow_last`. + /// + /// The bound rows are locked first, so two concurrent unbinds of a + /// community's two issuers cannot both succeed and leave it empty. An + /// empty community looks never-provisioned and the next backfill would + /// rebind it; archive or delete the community to stop it logging in. + #[datastore_span(name = "unbind_nip_fi_issuer", system = "postgresql")] + pub async fn unbind_nip_fi_issuer( + &self, + community: CommunityId, + issuer: &str, + allow_last: bool, + ) -> Result { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = Transaction::begin(connection, None).await?; + self.deletion_store() + .guard_transaction(&mut tx, community) + .await?; + let bound: Vec = sqlx::query_scalar( + "SELECT issuer FROM community_nip_fi_issuers WHERE community_id = $1 FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_all(&mut *tx) + .await?; + let outcome = if !bound.iter().any(|bound| bound == issuer) { + NipFiUnbind::NotBound + } else if bound.len() == 1 && !allow_last { + NipFiUnbind::LastIssuer + } else { + sqlx::query( + "DELETE FROM community_nip_fi_issuers WHERE community_id = $1 AND issuer = $2", + ) + .bind(community.as_uuid()) + .bind(issuer) + .execute(&mut *tx) + .await?; + NipFiUnbind::Removed + }; + tx.commit().await?; + Ok(outcome) + } + + /// Read a community's binding by host, regardless of lifecycle state. + /// `None` when no community has this host. + #[datastore_span(name = "lookup_nip_fi_binding_by_host", system = "postgresql")] + pub async fn lookup_nip_fi_binding_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let row = sqlx::query( + r#" + SELECT c.host, + (c.archived_at IS NULL + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL) AS active, + COALESCE( + array_agg(b.issuer ORDER BY b.issuer COLLATE "C") + FILTER (WHERE b.issuer IS NOT NULL), + '{}' + ) AS issuers + FROM communities c + LEFT JOIN community_nip_fi_issuers b ON b.community_id = c.id + WHERE lower(c.host) = lower($1) + GROUP BY c.id + "#, + ) + .bind(normalized_host) + .fetch_optional(&mut *connection) + .await?; + row.map(|row| { + Ok(NipFiBindingRecord { + host: row.try_get("host")?, + active: row.try_get("active")?, + issuers: row.try_get("issuers")?, + }) + }) + .transpose() + } + + /// Up to `limit` active communities with no binding whose host ends in + /// `.` and sorts after `after`, in host order. Callers apply + /// the exact one-label rule. Archived communities are included on purpose: + /// unarchiving must not leave a community that cannot log in. + #[datastore_span(name = "list_unbound_nip_fi_communities", system = "postgresql")] + pub async fn list_unbound_nip_fi_communities( + &self, + host_suffix: &str, + after: &str, + limit: i64, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + WHERE c.host COLLATE "C" > $2 + AND right(lower(c.host), length($1) + 1) = '.' || lower($1) + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM community_nip_fi_issuers b WHERE b.community_id = c.id + ) + ORDER BY c.host COLLATE "C" + LIMIT $3 + "#, + ) + .bind(host_suffix) + .bind(after) + .bind(limit) + .fetch_all(&mut *connection) + .await?; + rows.into_iter() + .map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .collect() + } + + /// Bind `issuers` to each of `communities` that is still active and still + /// has no binding, in one statement. Takes each community's shared deletion + /// lock first, so a community whose deletion has started is skipped rather + /// than aborting the batch. The community rows are then locked, as + /// [`Db::bind_nip_fi_issuers`] does, so an operator bind that commits after + /// the caller listed the community wins and the default is not added + /// beside it. Returns the number of new rows. + #[datastore_span(name = "bind_nip_fi_issuers_to_active", system = "postgresql")] + pub async fn bind_nip_fi_issuers_to_active( + &self, + communities: &[CommunityId], + issuers: &[String], + ) -> Result { + if communities.is_empty() || issuers.is_empty() { + return Ok(0); + } + let ids: Vec = communities.iter().map(|id| *id.as_uuid()).collect(); + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = Transaction::begin(connection, None).await?; + sqlx::query( + "SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key(id)) \ + FROM unnest($1::uuid[]) AS id ORDER BY id", + ) + .bind(&ids) + .execute(&mut *tx) + .await?; + sqlx::query("SELECT 1 FROM communities WHERE id = ANY($1) ORDER BY id FOR NO KEY UPDATE") + .bind(&ids) + .execute(&mut *tx) + .await?; + let inserted = sqlx::query( + r#" + INSERT INTO community_nip_fi_issuers (community_id, issuer) + SELECT c.id, issuer + FROM communities c CROSS JOIN unnest($2::text[]) AS issuer + WHERE c.id = ANY($1) + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM community_nip_fi_issuers b WHERE b.community_id = c.id + ) + ON CONFLICT DO NOTHING + "#, + ) + .bind(&ids) + .bind(issuers) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + Ok(inserted) + } + + /// The subset of `hosts` whose community holds at least one binding. + #[datastore_span(name = "nip_fi_bound_hosts", system = "postgresql")] + pub async fn nip_fi_bound_hosts(&self, hosts: &[String]) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + let bound = sqlx::query_scalar( + r#" + SELECT c.host + FROM communities c + WHERE lower(c.host) = ANY(SELECT lower(h) FROM unnest($1::text[]) AS h) + AND EXISTS ( + SELECT 1 FROM community_nip_fi_issuers b WHERE b.community_id = c.id + ) + ORDER BY c.host + "#, + ) + .bind(hosts) + .fetch_all(&mut *connection) + .await?; + Ok(bound) + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use crate::CreateCommunityWithOwnerResult; + use sqlx::PgPool; + use uuid::Uuid; + + const ISS: &str = "https://issuer.example/api/goose"; + const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + async fn setup_db() -> Db { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + fn unique_host(prefix: &str) -> String { + format!("{prefix}-{}.bind.example", Uuid::new_v4().simple()) + } + + fn issuers() -> Vec { + vec![ISS.to_string()] + } + + async fn bound(db: &Db, host: &str) -> Option> { + db.lookup_nip_fi_binding_by_host(host) + .await + .expect("lookup binding") + .map(|record| record.issuers) + } + + async fn community_exists(db: &Db, host: &str) -> bool { + db.lookup_community_by_host_for_management(host) + .await + .expect("lookup community") + .is_some() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_to_active_skips_a_community_whose_deletion_started() { + let db = setup_db().await; + let deleting = db + .ensure_configured_community(&unique_host("deleting")) + .await + .expect("seed") + .id; + let control_host = unique_host("control"); + let control = db + .ensure_configured_community(&control_host) + .await + .expect("seed") + .id; + crate::test_support::quiesce_community_for_tests(&db.pool, deleting).await; + assert_eq!( + db.bind_nip_fi_issuers_to_active(&[deleting, control], &issuers()) + .await + .expect("a deleting community is skipped, not an error"), + 1 + ); + assert_eq!(bound(&db, &control_host).await, Some(issuers())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unbind_refuses_the_last_issuer() { + let db = setup_db().await; + let host = unique_host("unbind"); + let other = "https://other.example".to_string(); + let community = db + .ensure_configured_community_with_nip_fi_issuers( + &host, + &[ISS.to_string(), other.clone()], + ) + .await + .expect("seed") + .id; + assert_eq!( + db.unbind_nip_fi_issuer(community, "https://unbound.example", false) + .await + .expect("unbind unknown"), + NipFiUnbind::NotBound + ); + assert_eq!( + db.unbind_nip_fi_issuer(community, &other, false) + .await + .expect("unbind"), + NipFiUnbind::Removed + ); + assert_eq!( + db.unbind_nip_fi_issuer(community, ISS, false) + .await + .expect("unbind last"), + NipFiUnbind::LastIssuer + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + assert_eq!( + db.unbind_nip_fi_issuer(community, ISS, true) + .await + .expect("unbind last when allowed"), + NipFiUnbind::Removed + ); + assert_eq!(bound(&db, &host).await, Some(Vec::new())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_with_owner_binds_in_the_insert_transaction() { + let db = setup_db().await; + let host = unique_host("create"); + let created = db + .create_community_with_owner_and_nip_fi_issuers(&host, OWNER, &issuers()) + .await + .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()], + ) + .await + .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 bootstrap = unique_host("bootstrap"); + db.ensure_configured_community_for_bootstrap(&bootstrap, &issuers()) + .await + .expect("bootstrap"); + 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] + #[ignore = "requires Postgres"] + async fn binding_insert_failure_rolls_back_the_community_on_every_path() { + let db = setup_db().await; + // An empty issuer violates the table's CHECK, failing the binding insert. + let invalid = vec![String::new()]; + + let create = unique_host("fail-create"); + assert!(db + .create_community_with_owner_and_nip_fi_issuers(&create, OWNER, &invalid) + .await + .is_err()); + assert!(!community_exists(&db, &create).await); + + let ensure = unique_host("fail-ensure"); + assert!(db + .ensure_configured_community_with_nip_fi_issuers(&ensure, &invalid) + .await + .is_err()); + assert!(!community_exists(&db, &ensure).await); + + let bootstrap = unique_host("fail-bootstrap"); + assert!(db + .ensure_configured_community_for_bootstrap(&bootstrap, &invalid) + .await + .is_err()); + assert!(!community_exists(&db, &bootstrap).await); + } + + // Deletion takes its exclusive lock, then the community row. A bind that + // held the row while waiting for that lock would deadlock with it. + // Mutation: locking the row before `guard_transaction` in + // `bind_nip_fi_issuers` aborts one side with a deadlock. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn operator_bind_takes_locks_in_deletion_order() { + let db = setup_db().await; + let id = db + .ensure_configured_community(&unique_host("lock-order")) + .await + .expect("seed") + .id; + let mut deletion = db.pool.begin().await.expect("begin deletion"); + let deletion_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *deletion) + .await + .expect("pid"); + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(id.as_uuid()) + .execute(&mut *deletion) + .await + .expect("exclusive deletion lock"); + + let bind = tokio::spawn({ + let db = db.clone(); + async move { db.bind_nip_fi_issuers(id, &issuers()).await } + }); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while !sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE $1 = ANY(pg_blocking_pids(pid)))", + ) + .bind(deletion_pid) + .fetch_one(&db.pool) + .await + .expect("observe bind blocked") + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("bind must wait for the deletion lock"); + + sqlx::query("SELECT 1 FROM communities WHERE id = $1 FOR UPDATE") + .bind(id.as_uuid()) + .execute(&mut *deletion) + .await + .expect("deletion takes the community row"); + deletion.commit().await.expect("commit deletion"); + assert_eq!(bind.await.expect("join").expect("bind"), 1); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn backfill_keeps_only_an_operator_bind_made_after_it_listed() { + let db = setup_db().await; + let suffix = format!("{}.suffix.example", Uuid::new_v4().simple()); + let host = format!("acme.{suffix}"); + db.ensure_configured_community(&host).await.expect("seed"); + let listed = db + .list_unbound_nip_fi_communities(&suffix, "", 1000) + .await + .expect("list unbound"); + assert_eq!(listed.len(), 1); + + let operator = "https://operator.example".to_string(); + db.bind_nip_fi_issuers(listed[0].id, std::slice::from_ref(&operator)) + .await + .expect("operator bind between list and insert"); + assert_eq!( + db.bind_nip_fi_issuers_to_active(&[listed[0].id], &issuers()) + .await + .expect("backfill insert"), + 0 + ); + assert_eq!(bound(&db, &host).await, Some(vec![operator])); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn backfill_queries_find_and_bind_unbound_suffix_hosts() { + let db = setup_db().await; + let suffix = format!("{}.suffix.example", Uuid::new_v4().simple()); + let unbound = format!("acme.{suffix}"); + let already = format!("bound.{suffix}"); + let elsewhere = unique_host("elsewhere"); + for (host, issuers) in [ + (&unbound, Vec::new()), + (&already, issuers()), + (&elsewhere, Vec::new()), + ] { + db.ensure_configured_community_with_nip_fi_issuers(host, &issuers) + .await + .expect("seed"); + } + + let found = db + .list_unbound_nip_fi_communities(&suffix, "", 1000) + .await + .expect("list unbound"); + assert_eq!( + found.iter().map(|c| c.host.as_str()).collect::>(), + [unbound.as_str()] + ); + assert!(db + .list_unbound_nip_fi_communities(&suffix, &unbound, 1000) + .await + .expect("page after the last host") + .is_empty()); + let ids = [found[0].id]; + assert_eq!( + db.bind_nip_fi_issuers_to_active(&ids, &issuers()) + .await + .expect("bind"), + 1 + ); + assert_eq!( + db.bind_nip_fi_issuers_to_active(&ids, &issuers()) + .await + .expect("rebind"), + 0 + ); + assert!(db + .list_unbound_nip_fi_communities(&suffix, "", 1000) + .await + .expect("relist") + .is_empty()); + + assert_eq!( + db.nip_fi_bound_hosts(&[unbound.to_uppercase(), elsewhere.clone()]) + .await + .expect("bound hosts"), + std::slice::from_ref(&unbound) + ); + assert_eq!(bound(&db, &unique_host("missing")).await, None); + } +} diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 60917166dd..2b505e01ec 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -439,7 +439,9 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { let relay_main = include_str!("../../buzz-relay/src/main.rs"); assert!(relay_main.contains("pool_state.db.refresh_pool_waiter_metrics();")); - assert!(relay_main.contains(".ensure_configured_community_for_bootstrap(")); + assert!(relay_main.contains("ensure_deployment_community(&db,")); + let provisioning = include_str!("../../buzz-relay/src/handlers/community_provisioning.rs"); + assert!(provisioning.contains(".ensure_configured_community_for_bootstrap(")); let runtime = include_str!("../src/runtime/mod.rs"); assert!(runtime.contains("observability::refresh_pool_waiters(self.read_pool.is_some())")); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index ea5a4e65c1..fff7699c52 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -402,6 +402,9 @@ pub async fn provision_community( tracing::error!(error = %msg, "operator community persistence failed"); Err(internal_error("operator community persistence failed")) } + Err(msg) if msg == buzz_auth::BindRefusal::ReservedHost.to_string() => Err( + coded_api_error(StatusCode::BAD_REQUEST, "reserved_host", &msg), + ), Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), } } @@ -892,12 +895,78 @@ pub async fn community_availability( .lookup_community_by_host_for_management(&normalized_host) .await .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + // A reserved host (a NIP-FI command audience) is refused by create. + let reserved = state + .config + .nip_fi_provision_binding + .as_ref() + .is_some_and(|policy| policy.is_reserved(&normalized_host)); - Ok(Json(serde_json::json!({ + let mut body = serde_json::json!({ "host": query.host, "normalized_host": normalized_host, - "available": existing.is_none(), + "available": existing.is_none() && !reserved, "community_id": existing.map(|record| record.id.to_string()), + }); + if reserved { + body["error"] = "reserved_host".into(); + } + Ok(Json(body)) +} + +/// Query parameters for `GET /operator/communities/nip-fi-binding`. +#[derive(Debug, Deserialize)] +pub struct CommunityNipFiBindingQuery { + host: String, +} + +/// Read a community's NIP-FI issuer binding from the database. Only issuers +/// that could authorize are reported: none unless the host is bindable under +/// the provisioning policy (one label under the suffix, not reserved), and +/// only issuers configured in `BUZZ_NIP_FI_ISSUERS`. +/// +/// Contract relied on by kgoose: +/// - `host` is the host stored on the community row, not the queried text. +/// - A community in any lifecycle state answers 200. Archived or deletion +/// quiesced communities, including tombstones, report `active: false`. +/// - An unset provisioning policy, or no binding, reports `issuers: []`. +/// - 404 means no community row has this host. +pub async fn community_nip_fi_binding( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities/nip-fi-binding", + raw_query.as_deref(), + None, + ) + .await?; + + let normalized_host = normalize_candidate_host(&query.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let record = state + .db + .lookup_nip_fi_binding_by_host(&normalized_host) + .await + .map_err(|e| internal_error(&format!("look up NIP-FI binding: {e}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + let issuers = state + .config + .nip_fi_provision_binding + .as_ref() + .map_or_else(Vec::new, |policy| { + policy.effective_issuers(&record.host, record.issuers) + }); + + Ok(Json(serde_json::json!({ + "host": record.host, + "active": record.active, + "issuers": issuers, }))) } @@ -1119,7 +1188,15 @@ mod postgres_tests { } async fn operator_test_state(operator_keys: &[Keys]) -> Option> { + operator_test_state_with_policy(operator_keys, None).await + } + + async fn operator_test_state_with_policy( + operator_keys: &[Keys], + nip_fi_provision_binding: Option, + ) -> Option> { let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] + config.nip_fi_provision_binding = nip_fi_provision_binding; config.database_url = crate::test_support::database_url(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = "wss://tenant.example".to_string(); @@ -2572,6 +2649,303 @@ mod postgres_tests { .expect("clean up listener registrations"); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_binding_route_reads_bound_issuers_and_404s_unknown_hosts() { + let operator = Keys::generate(); + let (policy, _, host) = reserved_host_policy(); + let Some(state) = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy)).await + else { + return; + }; + let issuer = NIP_FI_ISSUER.to_string(); + state + .db + .ensure_configured_community_with_nip_fi_issuers( + &host, + &[issuer.clone(), "https://unconfigured.example".to_string()], + ) + .await + .expect("seed bound community"); + let router = build_router(state); + + for (query_host, expected) in [ + (host.to_uppercase(), StatusCode::OK), + (format!("missing-{host}"), StatusCode::NOT_FOUND), + ] { + let query = format!("host={query_host}"); + let url = format!("http://{INGRESS_HOST}/operator/communities/nip-fi-binding?{query}"); + let auth = nip98_auth_header(&operator, &url, "GET", None); + let response = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/operator/communities/nip-fi-binding?{query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), expected, "{query_host}"); + if expected == StatusCode::OK { + let json = read_json(response).await; + assert_eq!(json["host"], host); + assert_eq!(json["active"], true); + // The unconfigured issuer is stored but never reported. + assert_eq!(json["issuers"], serde_json::json!([issuer])); + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_binding_route_reports_no_issuers_outside_the_suffix() { + let operator = Keys::generate(); + let (policy, _, control) = reserved_host_policy(); + let Some(state) = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy)).await + else { + return; + }; + let nested = format!("a.{control}"); + let elsewhere = format!("community-{}.example", Uuid::new_v4().simple()); + for host in [&nested, &elsewhere] { + state + .db + .ensure_configured_community_with_nip_fi_issuers(host, &[NIP_FI_ISSUER.to_string()]) + .await + .expect("seed raw binding"); + let lookup = nip_fi_lookup(Arc::clone(&state), &operator, host).await; + assert_eq!(lookup["issuers"], serde_json::json!([]), "{host}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn availability_reports_a_reserved_host_unavailable() { + let operator = Keys::generate(); + let (policy, reserved, control) = reserved_host_policy(); + let Some(state) = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy)).await + else { + return; + }; + for (host, available) in [(&reserved, false), (&control, true)] { + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "GET", + &format!("/operator/communities/availability?host={host}"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{host}"); + let json = read_json(response).await; + assert_eq!(json["available"], available, "{host}"); + let error = (!available).then_some("reserved_host"); + assert_eq!(json["error"].as_str(), error, "{host}"); + } + } + + const NIP_FI_ISSUER: &str = "https://issuer.example/api/goose"; + + /// A provisioning policy under a fresh suffix whose `cmd.` host is + /// a command audience. Returns the policy, the reserved host and a normal + /// one-label control host. + fn reserved_host_policy() -> (buzz_auth::ProvisionBindingPolicy, String, String) { + let suffix = format!("{}.example", Uuid::new_v4().simple()); + let reserved = format!("cmd.{suffix}"); + let policy = buzz_auth::ProvisionBindingPolicy::parse( + &serde_json::json!({"host_suffix": suffix, "authorized_issuers": [NIP_FI_ISSUER]}) + .to_string(), + &serde_json::json!([{ + "issuer": NIP_FI_ISSUER, + "command_audiences": [format!("https://{reserved}")], + }]) + .to_string(), + ) + .expect("valid policy"); + (policy, reserved, format!("acme.{suffix}")) + } + + async fn nip_fi_lookup(state: Arc, operator: &Keys, host: &str) -> Value { + let response = signed_operator_request( + state, + operator, + "GET", + &format!("/operator/communities/nip-fi-binding?host={host}"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{host}"); + read_json(response).await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_provisioning_never_binds_a_reserved_host() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let (policy, reserved, control) = reserved_host_policy(); + let Some(state) = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy)).await + else { + return; + }; + + for create_only in [true, false] { + let control = control.replacen("acme.", &format!("acme-{create_only}."), 1); + // create_only refuses a reserved host; the legacy branch creates + // its row but binds nothing. + for (host, bindable) in [(reserved.as_str(), false), (control.as_str(), true)] { + let created = bindable || !create_only; + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": owner.public_key().to_hex(), + "create_only": create_only, + }) + .to_string(); + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities", + Some(body), + ) + .await; + assert_eq!( + response.status().is_success(), + created, + "create_only={create_only} {host}" + ); + if !created { + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(response).await["code"], "reserved_host"); + } + let row = state + .db + .lookup_community_by_host(host) + .await + .expect("lookup"); + assert_eq!(row.is_some(), created, "create_only={create_only} {host}"); + let bound = state + .db + .nip_fi_bound_hosts(&[host.to_string()]) + .await + .expect("bound hosts"); + assert_eq!( + !bound.is_empty(), + bindable, + "create_only={create_only} {host}" + ); + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_bootstrap_leaves_reserved_host_unbound_across_restarts() { + let (policy, reserved, control) = reserved_host_policy(); + let Ok(pool) = sqlx::PgPool::connect(&crate::test_support::database_url()).await else { + return; + }; + let db = buzz_db::Db::from_pool(pool); + for start in 0..2 { + for (host, bound) in [(&reserved, false), (&control, true)] { + let record = crate::handlers::community_provisioning::ensure_deployment_community( + &db, + Some(&policy), + host, + ) + .await + .expect("startup") + .expect("community"); + assert_eq!(&record.host, host); + let issuers = db + .nip_fi_bound_hosts(std::slice::from_ref(host)) + .await + .expect("bound hosts"); + assert_eq!(!issuers.is_empty(), bound, "start {start}: {host}"); + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_raw_reserved_binding_refuses_startup_and_reports_no_issuers() { + let operator = Keys::generate(); + let (policy, reserved, control) = reserved_host_policy(); + let Some(state) = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(policy.clone())) + .await + else { + return; + }; + let startup = || { + crate::handlers::community_provisioning::verify_reserved_hosts_unbound( + &state.db, &policy, + ) + }; + state + .db + .ensure_configured_community_with_nip_fi_issuers(&control, &[NIP_FI_ISSUER.to_string()]) + .await + .expect("bound control community"); + startup() + .await + .expect("control: startup with no reserved binding"); + + // Bind the reserved host with raw SQL, bypassing every policy check. + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); + state + .db + .ensure_configured_community_for_bootstrap(&reserved, &[]) + .await + .expect("reserved community"); + sqlx::query( + "INSERT INTO community_nip_fi_issuers (community_id, issuer) \ + SELECT id, $2 FROM communities WHERE host = $1", + ) + .bind(&reserved) + .bind(NIP_FI_ISSUER) + .execute(&pool) + .await + .expect("raw reserved binding"); + + let refusal = startup() + .await + .expect_err("reserved binding refuses startup"); + assert!(matches!( + refusal, + crate::handlers::community_provisioning::ReservedHostsError::Bound(ref hosts) + if hosts == std::slice::from_ref(&reserved) + )); + assert!( + refusal + .to_string() + .contains("buzz-admin nip-fi unbind --host"), + "{refusal}" + ); + let lookup = nip_fi_lookup(Arc::clone(&state), &operator, &reserved).await; + assert_eq!(lookup["issuers"], serde_json::json!([])); + let lookup = nip_fi_lookup(Arc::clone(&state), &operator, &control).await; + assert_eq!(lookup["issuers"], serde_json::json!([NIP_FI_ISSUER])); + + sqlx::query( + "DELETE FROM community_nip_fi_issuers \ + WHERE community_id = (SELECT id FROM communities WHERE host = $1)", + ) + .bind(&reserved) + .execute(&pool) + .await + .expect("remove raw binding so later startups in this database pass"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_check_availability() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 45c8f5e35d..129d2792ed 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -398,6 +398,11 @@ pub struct Config { /// the enforce verdict but admits exactly as `off`. `Off` (the default) /// leaves all identity enforcement to NIP-42 alone. pub nip_fi: crate::nip_fi_config::NipFiRelayConfig, + + /// Issuer binding written to communities created under a host suffix + /// (`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, } fn parse_bind_addr(raw: &str) -> Result { @@ -1371,6 +1376,7 @@ impl Config { _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, }; + let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?; Ok(Self { bind_addr, database_url, @@ -1436,7 +1442,8 @@ impl Config { admin, web_dir, serve_git_web_gui, - nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?, + nip_fi_provision_binding: crate::nip_fi_config::parse_provision_binding()?, + nip_fi, }) } diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index e8a5ee90d1..b187b223ad 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -261,6 +261,10 @@ pub async fn provision_community( } validate_host(&request.host)?; + let nip_fi_policy = state.config.nip_fi_provision_binding.as_ref(); + // Unbound either way: `issuers_for_new_host` binds nothing on a reserved host. + let nip_fi_issuers = + nip_fi_policy.map_or(&[][..], |policy| policy.issuers_for_new_host(&request.host)); let initial_owner = request .initial_owner_pubkey @@ -273,12 +277,19 @@ pub async fn provision_community( .transpose()?; if request.create_only { + if nip_fi_policy.is_some_and(|policy| policy.is_reserved(&request.host)) { + return Err(buzz_auth::BindRefusal::ReservedHost.to_string()); + } let owner_hex = initial_owner.as_deref().ok_or_else(|| { "initial_owner_pubkey is required when create_only is true".to_string() })?; let record = match state .db - .create_community_with_owner(&request.host, owner_hex) + .create_community_with_owner_and_nip_fi_issuers( + &request.host, + owner_hex, + nip_fi_issuers, + ) .await .map_err(|e| format!("failed to create community: {e}"))? { @@ -312,7 +323,7 @@ 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(&request.host) + .ensure_configured_community_with_nip_fi_issuers(&request.host, nip_fi_issuers) .await .map_err(|e| format!("failed to create community: {e}"))?; @@ -353,10 +364,70 @@ pub async fn provision_community( }) } +/// Startup: ensure the deployment community at `host`, bound only as the +/// provisioning policy allows. `Ok(None)` when `host` is empty. +pub async fn ensure_deployment_community( + db: &buzz_db::Db, + policy: Option<&buzz_auth::ProvisionBindingPolicy>, + host: &str, +) -> Result, buzz_db::DbError> { + if host.is_empty() { + return Ok(None); + } + let issuers = policy.map_or(&[][..], |policy| policy.issuers_for_new_host(host)); + db.ensure_configured_community_for_bootstrap(host, issuers) + .await + .map(Some) +} + +/// Why the reserved-host startup check refused. +#[derive(Debug, thiserror::Error)] +pub enum ReservedHostsError { + /// A reserved host holds an issuer binding. + #[error( + "reserved hosts hold NIP-FI issuer bindings (a command audience must never be a \ + community audience): {0:?}; remove each with `buzz-admin nip-fi unbind --host \ + --issuer `, or revert the command audience" + )] + Bound(Vec), + /// The check could not read the database. + #[error("failed to check reserved NIP-FI hosts: {0}")] + Check(buzz_db::DbError), +} + +/// Refuse startup when a reserved host (a NIP-FI command audience) holds an +/// issuer binding. An unbound reserved community row is valid. +pub async fn verify_reserved_hosts_unbound( + db: &buzz_db::Db, + policy: &buzz_auth::ProvisionBindingPolicy, +) -> Result<(), ReservedHostsError> { + let bound = db + .nip_fi_bound_hosts(&policy.reserved_hosts()) + .await + .map_err(ReservedHostsError::Check)?; + if bound.is_empty() { + Ok(()) + } else { + Err(ReservedHostsError::Bound(bound)) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn startup_verifies_reserved_hosts_before_seeding_the_deployment_community() { + let main = include_str!("../main.rs"); + let verify = main + .find("verify_reserved_hosts_unbound(&db, policy).await?;") + .expect("startup must refuse a bound reserved host"); + let seed = main + .find("match ensure_deployment_community(&db,") + .expect("startup seeds the deployment community"); + assert!(verify < seed, "the reserved-host check must run first"); + } + #[test] fn host_valid_bare_domain() { assert!(validate_host("acme.communities.buzz.xyz").is_ok()); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 7c1b607840..d6a082affa 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -611,37 +611,44 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // seeding an empty-host community that no request can ever resolve to. let mut step = StepTimer::start(StartupStep::MembershipBootstrap); let deployment_community = { + use buzz_relay::handlers::community_provisioning::{ + ensure_deployment_community, verify_reserved_hosts_unbound, + }; + if let Some(policy) = config.nip_fi_provision_binding.as_ref() { + verify_reserved_hosts_unbound(&db, policy).await?; + } let host = buzz_relay::tenant::relay_url_authority(&config.relay_url); - if host.is_empty() { - if config.require_relay_membership { - return Err(anyhow::anyhow!( - "Cannot derive a community host from BUZZ_RELAY_URL ({:?}); a resolvable host is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true", - config.relay_url - )); + if host.is_empty() && config.require_relay_membership { + return Err(anyhow::anyhow!( + "Cannot derive a community host from BUZZ_RELAY_URL ({:?}); a resolvable host is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true", + config.relay_url + )); + } + match ensure_deployment_community(&db, config.nip_fi_provision_binding.as_ref(), &host) + .await + { + Ok(Some(record)) => { + info!(host = %record.host, community = %record.id, "Deployment community ensured"); + Some(record.id) } - step.degrade(); - error!( - relay_url = %config.relay_url, - "Could not derive a community host from relay_url; skipping membership backfill/bootstrap (non-fatal, membership not required)" - ); - None - } else { - match db.ensure_configured_community_for_bootstrap(&host).await { - Ok(record) => { - info!(host = %record.host, community = %record.id, "Deployment community ensured"); - Some(record.id) - } - Err(e) => { - if config.require_relay_membership { - error!("Fatal: failed to ensure deployment community with membership enforcement enabled: {e}"); - return Err(anyhow::anyhow!( - "Failed to ensure deployment community (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}" - )); - } - step.degrade(); - error!("Failed to ensure deployment community (non-fatal, membership not required): {e}"); - None + Ok(None) => { + step.degrade(); + error!( + relay_url = %config.relay_url, + "Could not derive a community host from relay_url; skipping membership backfill/bootstrap (non-fatal, membership not required)" + ); + None + } + Err(e) => { + if config.require_relay_membership { + error!("Fatal: failed to ensure deployment community with membership enforcement enabled: {e}"); + return Err(anyhow::anyhow!( + "Failed to ensure deployment community (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}" + )); } + step.degrade(); + error!("Failed to ensure deployment community (non-fatal, membership not required): {e}"); + None } } }; diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 519b2214a9..6dd846533f 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -13,6 +13,7 @@ //! | `BUZZ_NIP_FI_ISSUERS` | If enforce | JSON array of issuer configs (see [`IssuerEnvConfig`]). | //! | `BUZZ_NIP_FI_COMMUNITIES` | If enforce | JSON array mapping each community's canonical URI to its authorized issuers (see [`CommunityEnvConfig`]). | //! | `BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS` | If enforce | Per-partition limit on session lifetime. | +//! | `BUZZ_NIP_FI_PROVISION_BINDING` | No | JSON `{"host_suffix", "authorized_issuers"}` binding communities created one label under the suffix; read in every mode (see [`buzz_auth::ProvisionBindingPolicy`]). | //! //! Each `BUZZ_NIP_FI_ISSUERS` entry also carries the S4 command-API fields. //! Enforce-mode startup fails if any issuer lacks the required ones: @@ -33,6 +34,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; +use buzz_auth::nip_fi::provision::canonical_authority; use buzz_auth::{ validate_nip_fi_config, CommunityBinding, FreshnessClass, IssuerJwksConfig, IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksSourceContract, NipFiMode, NipFiStartupError, @@ -253,23 +255,12 @@ impl NipFiCommunities { } } -/// The authority of a canonical community URI, or `None` unless the URI is -/// exactly `https://` and the URL parser reserializes that authority -/// byte-for-byte. The round trip rejects anything the parser would repair: -/// whitespace, control characters, backslashes, uppercase, userinfo, path, -/// query, fragment, a redundant default `:443`, and non-canonical IPv6. -/// The authority must also already be in `normalize_host()` form, which -/// rejects a final trailing dot and `:80`, so the Host map key equals the -/// `aud` authority exactly and is never empty. -fn canonical_authority(uri: &str) -> Option<&str> { - let authority = uri.strip_prefix("https://")?; - let parsed = url::Url::parse(uri).ok()?; - let host = parsed.host_str()?; - let reserialized = match parsed.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_owned(), - }; - (reserialized == authority && normalize_host(authority) == authority).then_some(authority) +/// Parse `BUZZ_NIP_FI_PROVISION_BINDING`, independent of `BUZZ_NIP_FI_MODE`. +/// [`buzz_auth::ProvisionBindingPolicy::from_env`] also refuses a suffix that +/// covers a `BUZZ_NIP_FI_COMMUNITIES` host, for `buzz-admin` too. +pub fn parse_provision_binding() -> Result, ConfigError> { + buzz_auth::ProvisionBindingPolicy::from_env() + .map_err(|e| ConfigError::InvalidValue(e.to_string())) } // ── Relay-level NIP-FI config ───────────────────────────────────────────────── @@ -1510,6 +1501,75 @@ mod tests { serde_json::json!({ "canonical_uri": uri, "authorized_issuers": issuers }) } + /// Startup (`Config::from_env`) with `BUZZ_NIP_FI_COMMUNITIES` declaring + /// `a.relay.test` and a provisioning binding under `suffix`. + fn startup_with_provision_suffix(mode: &str, suffix: &str) -> Result<(), String> { + enforce_with_communities(serde_json::json!([community_entry( + "https://a.relay.test", + &["https://issuer.test", "https://issuer-b.test"] + )])) + .expect("valid enforce config"); + std::env::set_var("BUZZ_NIP_FI_MODE", mode); + std::env::set_var( + buzz_auth::nip_fi::provision::PROVISION_BINDING_ENV, + serde_json::json!({ + "host_suffix": suffix, + "authorized_issuers": ["https://issuer.test"], + }) + .to_string(), + ); + crate::config::Config::from_env() + .map(|cfg| assert!(cfg.nip_fi_provision_binding.is_some())) + .map_err(|e| e.to_string()) + } + + #[test] + fn provision_binding_overlap_rejects_startup_in_every_mode() { + let _guard = super::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvGuard::new(NIP_FI_VARS); + let _binding = EnvGuard::new(&[buzz_auth::nip_fi::provision::PROVISION_BINDING_ENV]); + for mode in ["off", "enforce"] { + let err = startup_with_provision_suffix(mode, "relay.test") + .expect_err("suffix covering an enterprise host"); + assert!( + err.contains("covers a BUZZ_NIP_FI_COMMUNITIES host"), + "{mode}: {err}" + ); + startup_with_provision_suffix(mode, "communities.buzz.xyz") + .unwrap_or_else(|e| panic!("{mode}: disjoint suffix must start: {e}")); + } + } + + #[test] + fn provision_binding_off_mode_refuses_unreadable_enterprise_hosts() { + let _guard = super::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvGuard::new(NIP_FI_VARS); + let _binding = EnvGuard::new(&[buzz_auth::nip_fi::provision::PROVISION_BINDING_ENV]); + startup_with_provision_suffix("off", "communities.buzz.xyz").expect("valid start"); + for communities in [ + "not json".to_string(), + serde_json::json!([community_entry( + "https://A.relay.test", + &["https://issuer.test"] + )]) + .to_string(), + ] { + std::env::set_var("BUZZ_NIP_FI_COMMUNITIES", &communities); + let err = crate::config::Config::from_env() + .map(|_| ()) + .expect_err("unreadable enterprise hosts"); + assert!( + err.to_string() + .contains("BUZZ_NIP_FI_COMMUNITIES hosts are unreadable"), + "{communities}: {err}" + ); + } + } + #[test] fn enforce_communities_map_hosts_to_bindings() { let _guard = super::NIP_FI_ENV_LOCK diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 53f810b7f3..e7194c8f72 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -364,6 +364,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/availability", get(api::operator::community_availability), ) + .route( + "/operator/communities/nip-fi-binding", + get(api::operator::community_nip_fi_binding), + ) .route( "/operator/communities/transfer", post(api::operator::transfer_community), diff --git a/docs/nips/NIP-FA.md b/docs/nips/NIP-FA.md index 0a552d469b..c07778d53a 100644 --- a/docs/nips/NIP-FA.md +++ b/docs/nips/NIP-FA.md @@ -113,7 +113,7 @@ Content-Type: application/json } ``` -- `relay_url` selects an adapter-configured relay; the adapter MUST NOT fetch it. Clients MUST send it in the canonical form `wss://host[:port]`, with the scheme in lowercase, a DNS host as its lowercase ASCII (A-label) form with no trailing dot, an IPv4 host in dotted-decimal form, an IPv6 host in brackets in its [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) text form (for example `wss://[2001:db8::1]:8443`), the default port omitted, and no trailing slash, path, query, or fragment. Relay authorities on port 80 are not supported: clients MUST NOT send, and adapters MUST NOT configure, a `relay_url` with port 80. Adapters MUST configure relays in this canonical form and MUST compare `relay_url` against it exactly. An unknown relay MUST be rejected with 403 `authorization_denied`. +- `relay_url` selects an adapter-configured relay; the adapter MUST NOT fetch it. An adapter MAY obtain its exact relay list from a configured, authenticated source; it MUST still compare `relay_url` exactly. Clients MUST send it in the canonical form `wss://host[:port]`, with the scheme in lowercase, a DNS host as its lowercase ASCII (A-label) form with no trailing dot, an IPv4 host in dotted-decimal form, an IPv6 host in brackets in its [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) text form (for example `wss://[2001:db8::1]:8443`), the default port omitted, and no trailing slash, path, query, or fragment. Relay authorities on port 80 are not supported: clients MUST NOT send, and adapters MUST NOT configure, a `relay_url` with port 80. Adapters MUST configure relays in this canonical form and MUST compare `relay_url` against it exactly. An unknown relay MUST be rejected with 403 `authorization_denied`. - `nostr_pubkey` is the lowercase hex key the client will authenticate to the relay with. The NIP-98 event MUST be signed by this key, use kind `27235`, carry exactly one `u` tag equal to the absolute URL of this endpoint, exactly one `method` tag of `POST`, and exactly one `payload` tag equal to the lowercase hex SHA-256 of the exact request body bytes. Its `created_at` MUST be no more than 60 seconds old and MUST NOT be more than 5 seconds in the future. - The request body is limited to 4096 bytes and MUST NOT be content-encoded. A content-encoded body, such as a gzipped body, MUST be rejected with 400 `invalid_request`. Unknown fields MUST be rejected with 400 `invalid_request`. - Credential handling is covered in [Security Considerations](#security-considerations). diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md new file mode 100644 index 0000000000..f5c77be0a0 --- /dev/null +++ b/docs/operator-nip-fi-bindings.md @@ -0,0 +1,97 @@ +# NIP-FI Community Issuer Bindings + +A deployment that provisions communities under one host suffix, for example +`.communities.buzz.xyz`, stores the NIP-FI issuers allowed to vouch for +each community in `community_nip_fi_issuers`. The relay writes these rows when +it creates a community; `buzz-admin nip-fi` backfills and edits them. + +A host may hold a binding only when it is exactly one DNS label under the +suffix, has no port, and is not *reserved*. A host is reserved when +`https://` is a `command_audiences` entry of any issuer in +`BUZZ_NIP_FI_ISSUERS`, so a command audience can never become a community +assertion audience. The relay refuses to start while a reserved host holds a +binding, and the operator lookup route reports no issuers for a host that is not +bindable or for an issuer that is not configured. + +## Configuration + +`BUZZ_NIP_FI_PROVISION_BINDING` is read in every NIP-FI mode. Unset or empty +binds nothing. + +```json +{ + "host_suffix": "communities.buzz.xyz", + "authorized_issuers": ["https://app.builderlab.xyz/api/goose"] +} +``` + +- `host_suffix` is a lowercase domain name without a port or trailing dot. +- `authorized_issuers` is non-empty, and every entry must be an `issuer` in + `BUZZ_NIP_FI_ISSUERS`. Each issuer is 1 to 2048 bytes. +- The suffix must not cover any `BUZZ_NIP_FI_COMMUNITIES` host, and every + `canonical_uri` there must be readable. The relay and `buzz-admin` both + refuse to start otherwise. + +`buzz-admin` parses the same variables with the relay's parser. Run it with the +relay's environment: `BUZZ_NIP_FI_PROVISION_BINDING`, `BUZZ_NIP_FI_ISSUERS`, +`BUZZ_NIP_FI_COMMUNITIES`, and `DATABASE_URL`. + +## Commands + +```sh +buzz-admin nip-fi backfill +buzz-admin nip-fi bind --host acme.communities.buzz.xyz --issuer https://issuer.example +buzz-admin nip-fi unbind --host acme.communities.buzz.xyz --issuer https://issuer.example +``` + +`backfill` binds the authorized issuers to every active, unbound, bindable +community, a page of 1000 hosts at a time. Archived communities are bound on +purpose, so unarchiving leaves a community that can log in. A community whose +deletion has started is skipped. It always prints its counts and exits 1 while +any bindable community remains unbound: + +```json +{"rows_bound": 12, "unbound": 0, "unbound_hosts": [], "truncated": false} +``` + +`unbound_hosts` lists at most 100 hosts; `truncated` is true when there are +more. + +`bind` refuses a reserved host, a host that is not one label under the suffix, +and an issuer not in `BUZZ_NIP_FI_ISSUERS`. + +`unbind` works without `BUZZ_NIP_FI_PROVISION_BINDING`. It refuses to remove a +community's last issuer, because an empty community looks never-provisioned and +the next `backfill` would bind it again. To swap issuers, bind the new one and +then unbind the old one. To stop a community logging in, archive or delete it. +When the provisioning config is present and shows the host is not bindable (a +reserved host or one outside the suffix), `unbind` removes the last issuer too; +this is how to clear a reserved-host binding that blocks relay startup. + +`backfill` never adds the default issuers beside an operator `bind`: both lock +the community row, and the backfill only inserts while the community still has +no binding. + +## Lookup route + +`GET /operator/communities/nip-fi-binding?host=` is operator-signed and +returns `{"host": ..., "active": ..., "issuers": [...]}`. kgoose relies on this +contract: + +- `host` is the host stored on the community row. +- Every lifecycle state answers 200. `active` is `false` once the community is + archived or its deletion has started quiescing, including a tombstoned + community; it stays `true` while a deletion is only requested. +- `issuers` is `[]` when `BUZZ_NIP_FI_PROVISION_BINDING` is unset, the host is + not bindable, or nothing is bound. Only issuers in `BUZZ_NIP_FI_ISSUERS` are + reported, in byte order. +- 404 means no community has this host; 400 means the host is malformed. + +## Rollout + +1. Deploy the relay with `BUZZ_NIP_FI_PROVISION_BINDING` set. New communities + are bound at creation from then on. +2. Run `buzz-admin nip-fi backfill` with the relay's environment. Repeat it until + it exits 0. +3. Do not turn on the database binding source in enforce mode while any + bindable community is unbound: an unbound community fails closed. diff --git a/migrations/0066_community_nip_fi_issuers.sql b/migrations/0066_community_nip_fi_issuers.sql new file mode 100644 index 0000000000..518c451661 --- /dev/null +++ b/migrations/0066_community_nip_fi_issuers.sql @@ -0,0 +1,14 @@ +-- NIP-FI community issuer bindings: the explicit issuer allowlist for each +-- community, written when a community is created and by the operator +-- backfill. The assertion audience is `https://`. Zero rows +-- means no binding, so NIP-FI admission for that community fails closed. +-- +-- Additive. Nothing reads this table at admission time yet. +CREATE TABLE community_nip_fi_issuers ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL CHECK (length(issuer) BETWEEN 1 AND 2048), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, issuer) +); + +SELECT attach_community_write_fence('community_nip_fi_issuers'); diff --git a/schema/schema.sql b/schema/schema.sql index f65943a362..d252cbdb7e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -106,6 +106,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i tables/public/moderation_actions.sql \i tables/public/moderation_reports.sql \i tables/public/community_bans.sql +\i tables/public/community_nip_fi_issuers.sql \i tables/public/_operator_global_tables.sql \i tables/public/git_repo_names.sql \i tables/public/parameterized_event_watermarks.sql @@ -153,6 +154,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i triggers/audit_log.sql \i triggers/channel_members.sql \i triggers/community_bans.sql +\i triggers/community_nip_fi_issuers.sql \i triggers/delivery_log.sql \i triggers/event_mentions.sql \i triggers/git_repo_names.sql diff --git a/schema/tables/public/community_nip_fi_issuers.sql b/schema/tables/public/community_nip_fi_issuers.sql new file mode 100644 index 0000000000..d44482a18e --- /dev/null +++ b/schema/tables/public/community_nip_fi_issuers.sql @@ -0,0 +1,12 @@ +-- ── NIP-FI community issuer bindings ────────────────────────────────────────── +-- The explicit per-community issuer allowlist for NIP-FI assertions. The +-- assertion audience is `https://`, so it needs no column of +-- its own. Zero rows means the community has no binding and NIP-FI admission +-- for it fails closed. + +CREATE TABLE community_nip_fi_issuers ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL CHECK (length(issuer) BETWEEN 1 AND 2048), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, issuer) +); diff --git a/schema/triggers/community_nip_fi_issuers.sql b/schema/triggers/community_nip_fi_issuers.sql new file mode 100644 index 0000000000..7634ca372a --- /dev/null +++ b/schema/triggers/community_nip_fi_issuers.sql @@ -0,0 +1 @@ +SELECT attach_community_write_fence('community_nip_fi_issuers'); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f6aab81f24..13b5c73f7c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -144,6 +144,9 @@ run_unit_tests() { run_test_step "buzz-admin partition audit outcome contract test" \ cargo test -p buzz-admin tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes -- --exact --nocapture + run_test_step "buzz-admin NIP-FI binding command tests" \ + cargo test -p buzz-admin nip_fi::tests -- --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \