diff --git a/.env.example b/.env.example index 9d821ab559..98916ec279 100644 --- a/.env.example +++ b/.env.example @@ -470,9 +470,9 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # key and NIP-FI enforcement. Profiles are historical records and are never # retracted. -# New irreversible community FI acceptances stay disabled until the fleet's -# enforcement and activation baseline is verified. Disabling preserves receipts. -BUZZ_NIP_FI_UPGRADES_ENABLED=false +# Permit community FI policy enables and disables only after the whole fleet +# supports policy management. A closed gate preserves receipt recovery/status. +BUZZ_NIP_FI_CHANGES=false # NIP-CL compact membership discovery. Deploy fleet-wide dark before enabling. BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED=false diff --git a/Cargo.lock b/Cargo.lock index 8ffabd2da6..41a068ff88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,6 +969,8 @@ name = "buzz-admin" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-core", @@ -981,10 +983,13 @@ dependencies = [ "clap", "hex", "nostr 0.44.7", + "reqwest 0.13.4", "rustls", "serde", "serde_json", + "sha2 0.11.0", "sqlx", + "tempfile", "tokio", "tracing", "url", diff --git a/Justfile b/Justfile index 62c022136d..f595fab05b 100644 --- a/Justfile +++ b/Justfile @@ -468,7 +468,7 @@ test-unit: # parser, validation, and propagation-evidence tests, and the partition # audit outcome contract, NIP-FI policy/backfill, and user-deletion intake. 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) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)' + -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::/) + test(/^nip_fi_policy::tests::/) + test(/^user_deletions::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/Cargo.toml b/crates/buzz-admin/Cargo.toml index 10354c2f92..4d38d25582 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -27,6 +27,9 @@ serde_json = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } hex = { workspace = true } +base64 = { workspace = true } +reqwest = { workspace = true } +sha2 = { workspace = true } # Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process # CryptoProvider installed at startup. The workspace redis TLS feature compiles # both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we @@ -39,4 +42,6 @@ uuid = { workspace = true } clap = { version = "4", features = ["derive"] } [dev-dependencies] +axum = { workspace = true } +tempfile = "3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 36af8df338..86551425fa 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; +mod nip_fi_policy; mod user_deletions; use std::future::Future; @@ -124,6 +125,8 @@ enum Command { #[command(subcommand)] command: nip_fi::NipFiCommand, }, + /// Change or inspect a community stored NIP-FI requirement. + NipFiPolicy(nip_fi_policy::Args), /// Durable CLI-only whole-community deletion control plane. Deletions { #[command(subcommand)] @@ -213,6 +216,11 @@ async fn run(cli: Cli) -> Result { Command::Deletions { command } => deletions::run(command).await, Command::UserDeletions { command } => user_deletions::run(command).await, Command::NipFi { command } => nip_fi::run(command).await, + // Retain transport/file causes for this operator command without changing + // the diagnostics of other admin commands. Key parse errors are redacted. + Command::NipFiPolicy(args) => nip_fi_policy::run(args) + .await + .map_err(|e| anyhow::anyhow!("{e:#}")), Command::ReconcileChannels { channel, relay_key } => { reconcile_channels(channel, relay_key).await?; Ok(0) diff --git a/crates/buzz-admin/src/nip_fi_policy.rs b/crates/buzz-admin/src/nip_fi_policy.rs new file mode 100644 index 0000000000..6b7e18276b --- /dev/null +++ b/crates/buzz-admin/src/nip_fi_policy.rs @@ -0,0 +1,666 @@ +//! General stored community policy management with immutable saved intent. +use std::{ + io::Read, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use clap::{Args as ClapArgs, Subcommand}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use reqwest::{redirect::Policy, Method}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::Url; +use uuid::Uuid; + +const PATH: &str = "/operator/communities/nip-fi-policy"; +const MAX_BYTES: usize = 64 * 1024; + +/// Explicit operator destination and signing key for community policy management. +#[derive(Debug, ClapArgs)] +pub struct Args { + #[arg(long)] + origin: String, + #[arg(long)] + key_file: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Apply a saved requires_nip_fi=true intent, or recover its acceptance. + Enable { + #[arg(long)] + request: PathBuf, + }, + /// Apply a saved requires_nip_fi=false intent, or recover its acceptance. + Disable { + #[arg(long)] + request: PathBuf, + }, + /// Read current preconditions by community/host, or a historical operation. + Status { + #[arg(long, conflicts_with_all = ["community_id", "host"])] + operation_id: Option, + #[arg(long, requires = "host")] + community_id: Option, + #[arg(long, requires = "community_id")] + host: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + operation_id: Uuid, + community_id: Uuid, + expected_host: String, + actor_pubkey: String, + expected_owner_pubkey: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + expected_issuer: Option, + requires_nip_fi: bool, + expected_policy_generation: i64, + expected_requires_nip_fi: bool, +} + +impl Request { + fn validate(&self, desired: bool) -> Result<()> { + ensure!( + self.requires_nip_fi == desired, + "saved requires_nip_fi does not match the command" + ); + ensure!( + self.expected_policy_generation >= 0, + "generation must be nonnegative" + ); + ensure!( + if desired { + self.expected_issuer.as_ref().is_some_and(|s| !s.is_empty()) + } else { + self.expected_issuer.is_none() + }, + "enable requires expected_issuer; disable must omit it" + ); + ensure!( + !self.operation_id.is_nil() && !self.community_id.is_nil(), + "UUIDs must not be nil" + ); + let host = crate::communities::normalize_host_authority(&self.expected_host) + .map_err(anyhow::Error::msg)?; + ensure!( + host == self.expected_host && !host.contains(':'), + "expected_host must be canonical lowercase without a port or trailing dot" + ); + for key in [&self.actor_pubkey, &self.expected_owner_pubkey] { + ensure!( + key.len() == 64 + && key + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), + "owner public keys must be 64 lowercase hex characters" + ); + } + Ok(()) + } +} + +fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + std::fs::File::open(path) + .with_context(|| format!("cannot open {}", path.display()))? + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("cannot read {}", path.display()))?; + ensure!(bytes.len() <= limit, "input file exceeds size limit"); + Ok(bytes) +} + +fn endpoint(origin: &str) -> Result { + let base = Url::parse(origin).context("invalid operator origin")?; + ensure!( + matches!(base.scheme(), "http" | "https") + && base.host_str().is_some() + && base.username().is_empty() + && base.password().is_none() + && base.path() == "/" + && base.query().is_none() + && base.fragment().is_none(), + "operator origin must be an HTTP(S) origin without credentials, path, query or fragment" + ); + Ok(base.join(PATH)?) +} + +fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result { + let mut tags = vec![ + Tag::parse(["u", url.as_str()])?, + Tag::parse(["method", method.as_str()])?, + Tag::parse(["nonce", &Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys)?; + Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) +} + +/// Bounded, signed single call shared by the operator policy commands. +async fn signed_call(keys: &Keys, url: Url, body: Option>) -> Result { + let method = if body.is_some() { + Method::POST + } else { + Method::GET + }; + let auth = authorization(keys, &method, &url, body.as_deref())?; + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(30)) + .build()?; + let mut call = client.request(method, url).header("Authorization", auth); + if let Some(body) = body { + call = call.header("Content-Type", "application/json").body(body); + } + let mut response = call + .send() + .await + .context("unconfirmed: request failed; recover the same operation_id")?; + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("unconfirmed: response interrupted; recover the same operation_id")? + { + ensure!( + bytes.len() + chunk.len() <= MAX_BYTES, + "unconfirmed: response exceeds size limit" + ); + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + // Preserve known machine-readable codes, without echoing arbitrary response bodies. + let error = serde_json::from_slice::(&bytes).ok(); + let code = error.as_ref().and_then(|value| value["code"].as_str()); + let detail = match code { + Some( + code @ ("invalid_request" + | "community_not_found" + | "operation_not_found" + | "idempotency_conflict" + | "policy_conflict" + | "policy_generation_exhausted" + | "host_conflict" + | "owner_conflict" + | "lifecycle_conflict" + | "issuer_conflict" + | "upgrades_disabled" + | "authorization_unavailable"), + ) => code, + _ if error.is_none() => "non-JSON error response", + _ => "unrecognized error code", + }; + anyhow::bail!("operator API returned HTTP {} ({detail}); on policy_conflict re-read policy and use a new UUID; after response loss recover the same saved request and UUID", status.as_u16()); + } + // Print only structured relay output, never transport headers or signing material. + let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "unconfirmed: invalid JSON response (HTTP {})", + status.as_u16() + ) + })?; + Ok(receipt) +} + +fn receipt_exit_code( + value: &Value, + id: Uuid, + operator: &str, + request: Option<&Request>, +) -> Result { + let receipt = &value["receipt"]; + ensure!( + receipt["operation_id"].as_str() == Some(id.to_string().as_str()) + && receipt["operator_pubkey"].as_str() == Some(operator) + && receipt["policy_generation"].as_i64().is_some_and(|g| g > 0) + && receipt["requires_nip_fi"].is_boolean(), + "unconfirmed: invalid policy receipt" + ); + if let Some(request) = request { + let expected = serde_json::to_value(request)?; + ensure!( + expected + .as_object() + .context("invalid saved request")? + .iter() + .all(|(key, v)| receipt[key] == *v), + "unconfirmed: receipt differs from saved intent" + ); + } + match ( + receipt["state"].as_str(), + receipt["requires_nip_fi"].as_bool(), + ) { + (Some("applied"), Some(false)) => Ok(0), + (Some("pending"), Some(true)) => Ok(10), + (Some("superseded"), _) => Ok(11), + (Some("unavailable"), _) => Ok(12), + _ => anyhow::bail!("unconfirmed: invalid policy state"), + } +} + +/// Execute one signed policy call; never change or automatically retry saved intent. +pub async fn run(args: Args) -> Result { + let request = match &args.command { + Command::Enable { request } | Command::Disable { request } => { + let saved: Request = serde_json::from_slice(&read_bounded(request, 64 * 1024)?)?; + saved.validate(matches!(args.command, Command::Enable { .. }))?; + Some(saved) + } + Command::Status { .. } => None, + }; + let mut url = endpoint(&args.origin)?; + if let Command::Status { + operation_id, + community_id, + host, + } = &args.command + { + match (operation_id, community_id, host) { + (Some(id), None, None) if !id.is_nil() => { + url.query_pairs_mut() + .append_pair("operation_id", &id.to_string()); + } + (None, Some(id), Some(host)) if !id.is_nil() => { + let canonical = crate::communities::normalize_host_authority(host) + .map_err(anyhow::Error::msg)?; + ensure!( + canonical == *host && !host.contains(':'), + "host must be canonical lowercase without a port" + ); + url.query_pairs_mut() + .append_pair("community_id", &id.to_string()) + .append_pair("expected_host", host); + } + _ => anyhow::bail!("status requires operation-id or community-id and host"), + } + } + // Validate the entire saved intent, including action, before touching the key. + let secret = read_bounded(&args.key_file, 1024)?; + let keys = std::str::from_utf8(&secret) + .ok() + .and_then(|s| Keys::parse(s.trim()).ok()) + .context("invalid operator key file")?; + let value = signed_call( + &keys, + url, + request.as_ref().map(serde_json::to_vec).transpose()?, + ) + .await?; + let code = if let Some(saved) = request.as_ref() { + receipt_exit_code( + &value, + saved.operation_id, + &keys.public_key().to_hex(), + Some(saved), + )? + } else if let Command::Status { + operation_id: Some(id), + .. + } = args.command + { + receipt_exit_code(&value, id, &keys.public_key().to_hex(), None)? + } else { + if let Command::Status { + community_id: Some(id), + host: Some(host), + .. + } = &args.command + { + ensure!( + value["community_id"].as_str() == Some(id.to_string().as_str()) + && value["expected_host"].as_str() == Some(host.as_str()) + && value["expected_policy_generation"] + .as_i64() + .is_some_and(|g| g >= 0) + && value["expected_requires_nip_fi"].is_boolean(), + "unconfirmed: invalid current policy" + ); + } + 0 + }; + let stored = value + .get("receipt") + .map_or(&value["expected_requires_nip_fi"], |r| { + &r["current_requires_nip_fi"] + }); + if stored.is_boolean() + && value["effective_required"].is_boolean() + && *stored != value["effective_required"] + { + eprintln!("Global mode makes effective_required differ from the stored requirement; inspect mode in the response."); + } + println!("{}", serde_json::to_string(&value)?); + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::any, Json, Router}; + + fn saved(desired: bool) -> Request { + Request { + operation_id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + expected_host: "team.communities.example.org".into(), + actor_pubkey: "a".repeat(64), + expected_owner_pubkey: "a".repeat(64), + expected_issuer: desired.then(|| "https://issuer.example.org".into()), + requires_nip_fi: desired, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + } + } + + #[tokio::test] + async fn immutable_action_and_preconditions_are_checked_before_key_read() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("request.json"); + for (mut request, change, expected) in [ + (saved(true), 0, "does not match"), + (saved(false), 1, "generation"), + (saved(false), 2, "omit"), + (saved(false), 3, "canonical"), + ] { + match change { + 1 => request.expected_policy_generation = -1, + 2 => request.expected_issuer = Some("https://issuer.example.org".into()), + 3 => request.expected_host = "TEAM.communities.example.org".into(), + _ => (), + } + std::fs::write(&file, serde_json::to_vec(&request).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Disable { + request: file.clone(), + }, + }) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + } + } + + #[derive(Clone)] + struct TestPolicy { + origin: String, + response: Value, + expected_body: Value, + host: String, + community_id: Uuid, + operation_id: Uuid, + desired: bool, + } + + async fn policy_test_handler( + axum::extract::State(policy): axum::extract::State, + method: Method, + uri: axum::http::Uri, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, + ) -> Json { + let encoded = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap(); + let url = format!("{}{uri}", policy.origin); + buzz_auth::verify_nip98_event( + &event, + &url, + method.as_str(), + (method == Method::POST).then_some(body.as_ref()), + ) + .unwrap(); + if method == Method::POST { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + policy.expected_body + ); + assert!(buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err()); + } else if uri.query().unwrap().starts_with("community_id=") { + assert_eq!( + uri.query(), + Some( + format!( + "community_id={}&expected_host={}", + policy.community_id, policy.host + ) + .as_str() + ) + ); + return Json(serde_json::json!({ + "community_id": policy.community_id, "expected_host": policy.host, + "expected_policy_generation": 1, "expected_requires_nip_fi": policy.desired, + "mode": "shadow", "effective_required": policy.desired, + })); + } else { + assert_eq!( + uri.query(), + Some(format!("operation_id={}", policy.operation_id).as_str()) + ); + assert!(buzz_auth::verify_nip98_event( + &event, + &format!("{}{PATH}", policy.origin), + "GET", + None + ) + .is_err()); + } + Json(policy.response) + } + + #[tokio::test] + async fn policy_commands_send_saved_action_and_recover_current_and_history() { + let keys = Keys::generate(); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let file = dir.path().join("request.json"); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + for desired in [true, false] { + let saved = saved(desired); + let id = saved.operation_id; + let community_id = saved.community_id; + let host = saved.expected_host.clone(); + let mut receipt = serde_json::to_value(&saved).unwrap(); + receipt["operator_pubkey"] = keys.public_key().to_hex().into(); + receipt["policy_generation"] = 1.into(); + receipt["current_requires_nip_fi"] = desired.into(); + receipt["state"] = if desired { "pending" } else { "applied" }.into(); + let response = serde_json::json!({"receipt": receipt, "mode": "shadow", "effective_required": desired}); + std::fs::write(&file, serde_json::to_vec(&saved).unwrap()).unwrap(); + let expected_body = serde_json::to_value(&saved).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route(PATH, any(policy_test_handler)) + .with_state(TestPolicy { + origin: origin.clone(), + response, + expected_body, + host, + community_id, + operation_id: id, + desired, + }); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let command = if desired { + Command::Enable { + request: file.clone(), + } + } else { + Command::Disable { + request: file.clone(), + } + }; + for command in [ + command, + Command::Status { + operation_id: Some(id), + community_id: None, + host: None, + }, + ] { + assert_eq!( + run(Args { + origin: origin.clone(), + key_file: key_file.clone(), + command + }) + .await + .unwrap(), + if desired { 10 } else { 0 } + ); + } + assert_eq!( + run(Args { + origin, + key_file: key_file.clone(), + command: Command::Status { + operation_id: None, + community_id: Some(community_id), + host: Some(saved.expected_host.clone()), + } + }) + .await + .unwrap(), + 0 + ); + server.abort(); + for (state, code) in [("superseded", 11), ("unavailable", 12)] { + let value = serde_json::json!({"receipt": {"operation_id":id,"operator_pubkey":keys.public_key().to_hex(),"policy_generation":1,"requires_nip_fi":desired,"state":state}}); + assert_eq!( + receipt_exit_code(&value, id, &keys.public_key().to_hex(), None).unwrap(), + code + ); + } + } + } + #[tokio::test] + async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() { + for (field, value) in [ + ("expected_host", "TEST.communities.example.org"), + ("expected_host", "test.communities.example.org."), + ("expected_host", "test.communities.example.org:443"), + ("expected_host", "https://test.communities.example.org"), + ("actor_pubkey", &"A".repeat(64)), + ("expected_owner_pubkey", &"A".repeat(64)), + ("actor_pubkey", &"a".repeat(63)), + ("expected_owner_pubkey", &"g".repeat(64)), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("request.json"); + let mut input = serde_json::to_value(saved(true)).unwrap(); + input[field] = value.into(); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Enable { request: path }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("host") || message.contains("public keys"), + "{message}" + ); + assert!( + !message.contains("absent-key"), + "key read preceded validation: {message}" + ); + } + } + + #[tokio::test] + async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() { + use axum::http::StatusCode; + + for (status, body, expected) in [ + ( + 409, + r#"{"code":"owner_conflict","error":"private detail"}"#, + "owner_conflict", + ), + (409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"), + (409, r#"{"code":"host_conflict"}"#, "host_conflict"), + ( + 409, + r#"{"code":"idempotency_conflict"}"#, + "idempotency_conflict", + ), + (503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"), + ( + 502, + "private detail", + "non-JSON error response", + ), + ( + 409, + r#"{"code":"private detail"}"#, + "unrecognized error code", + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + PATH, + any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + let keys = Keys::generate(); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&saved(true)).unwrap()).unwrap(); + let error = run(Args { + origin, + key_file, + command: Command::Enable { + request: request_file, + }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains(&format!("HTTP {status}")), "{message}"); + assert!(message.contains(expected), "{message}"); + assert!(!message.contains("private detail"), "{message}"); + assert!(!message.contains(&keys.secret_key().display_secret().to_string())); + server.abort(); + } + } + + #[test] + fn origin_cannot_select_a_different_path_or_embed_credentials() { + for origin in [ + "https://user:password@example.org", + "https://example.org/wrong", + "https://example.org/?q=x", + "https://example.org/#fragment", + "file:///tmp/key", + ] { + assert!(endpoint(origin).is_err(), "accepted {origin}"); + } + } +} diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ba864271ad..1264ea9e8f 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -898,11 +898,15 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 84); + assert_eq!(migrations.len(), 85); assert!(migrations[83] .sql .as_str() .contains("ADD COLUMN auto_join_pending UUID")); + assert!(migrations[84] + .sql + .as_str() + .contains("ADD COLUMN requested_requires_nip_fi BOOLEAN NOT NULL DEFAULT true")); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -3239,6 +3243,45 @@ mod postgres_tests { .expect("second post-apply reconciliation"); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_receipt_expand_preserves_populated_rows_and_legacy_inserts() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations_through(&pool, 84).await.unwrap(); + let insert = "INSERT INTO community_auth_upgrades + (operation_id,target_community_id,expected_host,actor_pubkey,expected_owner_pubkey, + expected_issuer,operator_pubkey,policy_generation) + VALUES ($1,$2,'team.example.org','actor','owner','https://issuer.example.org','operator',1)"; + let first = uuid::Uuid::new_v4(); + sqlx::query(insert) + .bind(first) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + run_migrations(&pool).await.unwrap(); + let second = uuid::Uuid::new_v4(); + sqlx::query(insert) + .bind(second) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + for id in [first, second] { + let row: (bool, Option, Option, i64) = sqlx::query_as( + "SELECT requested_requires_nip_fi, expected_policy_generation, + expected_requires_nip_fi, policy_generation + FROM community_auth_upgrades WHERE operation_id=$1", + ) + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row, (true, None, None, 1)); + } + } + /// Migrations 0076 and 0078 refuse a populated table whose index was not /// prebuilt concurrently. A relay that auto-migrated through earlier /// releases and holds artifact data must still boot through every migration. When a diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index ec5a638798..21cee02ad2 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -1,4 +1,4 @@ -//! Monotonic community authentication requirements and durable operator receipts. +//! Community authentication policy management and durable operator receipts. //! //! Receipts are deployment-global and intentionally survive target deletion: //! an operation UUID must never acquire a new meaning after a lost response. @@ -39,6 +39,45 @@ pub struct UpgradeRequest { pub operator_pubkey: String, } +/// General policy intent; both preconditions are checked under the writer row lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyRequest { + /// Canonical operation tuple. Disable uses an empty issuer. + pub tuple: UpgradeRequest, + /// Desired stored requirement. + pub requires_nip_fi: bool, + /// Last observed generation. + pub expected_policy_generation: i64, + /// Last observed stored requirement. + pub expected_requires_nip_fi: bool, +} + +/// Durable accepted intent and the current primary-database snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, FromRow)] +pub struct PolicyRecord { + /// Original operation tuple and accepted generation, never current authority. + #[serde(flatten)] + #[sqlx(flatten)] + pub acceptance: UpgradeRecord, + /// Null only for legacy, unfenced requests. + pub expected_policy_generation: Option, + /// Null only for legacy, unfenced requests. + pub expected_requires_nip_fi: Option, + /// Current target generation, absent after target deletion. + pub current_policy_generation: Option, + /// Current stored flag, absent after target deletion. + pub current_requires_nip_fi: Option, +} + +/// Refusal or an immutable acceptance with current status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyOutcome { + /// Durable acceptance or identical read-only replay. + Accepted(Box), + /// No mutation or receipt was committed. + Refused(&'static str), +} + /// Durable acceptance tuple plus the current availability of its target. #[derive(Debug, Clone, PartialEq, Eq, Serialize, FromRow)] pub struct UpgradeRecord { @@ -53,15 +92,16 @@ pub struct UpgradeRecord { pub actor_pubkey: String, /// Owner precondition at acceptance. pub expected_owner_pubkey: String, - /// Issuer precondition at acceptance. + /// Issuer precondition at acceptance; omitted for disable. + #[serde(skip_serializing_if = "String::is_empty")] pub expected_issuer: String, /// Operator authorized to recover this receipt. pub operator_pubkey: String, - /// Accepted generation; concurrent enables converge on this value. + /// Immutable generation accepted for this operation. pub policy_generation: i64, /// Receipt proves acceptance; availability does not undo this fact. pub requires_nip_fi: bool, - /// Pending until BUZZ-512 supplies applied-and-drained evidence. + /// Current availability, with no certification of fleet-wide socket closure. pub state: String, /// Extensible activation obligations; unknown reasons must remain pending. pub pending_reasons: Vec, @@ -79,40 +119,40 @@ impl UpgradeRecord { } } -/// A refused enable never mutates policy or creates a receipt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpgradeOutcome { - /// Committed or recovered acceptance, including current target state. - Accepted(UpgradeRecord), - /// Stable machine-readable precondition failure. - Refused(&'static str), -} - /// Authoritative policy for a community in the active lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq, FromRow)] pub struct CommunityAuthPolicy { /// Immutable identity from the same row as this policy. #[sqlx(rename = "id")] pub community_id: Uuid, - /// Durable monotonic requirement. Global enforce remains an additional floor. + /// Durable stored requirement. Global enforce remains an additional floor. pub requires_nip_fi: bool, - /// Zero for legacy optional communities; positive after the first enable. + /// Zero before the first operation; increases with every accepted CAS intent. pub policy_generation: i64, } // One statement gives status a single primary-database snapshot. Missing trust, // host/policy drift and terminal lifecycle never become a usable active result. -const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi, - CASE WHEN c.id IS NOT NULL AND c.host = u.expected_host - AND c.archived_at IS NULL AND c.deleted_at IS NULL - AND c.deletion_state = 'active' AND c.requires_nip_fi - AND c.policy_generation = u.policy_generation - AND NOT EXISTS (SELECT 1 FROM community_deletion_requests d - WHERE d.community_id = c.id AND d.stage <> 'aborted') - AND EXISTS (SELECT 1 FROM community_nip_fi_issuers i +const RECEIPT: &str = "SELECT u.operation_id, u.target_community_id, u.expected_host, + u.actor_pubkey, u.expected_owner_pubkey, u.expected_issuer, u.operator_pubkey, + u.policy_generation, u.requested_requires_nip_fi AS requires_nip_fi, + u.expected_policy_generation, u.expected_requires_nip_fi, + c.policy_generation AS current_policy_generation, + c.requires_nip_fi AS current_requires_nip_fi, + CASE WHEN c.id IS NULL OR c.host <> u.expected_host + OR c.archived_at IS NOT NULL OR c.deleted_at IS NOT NULL + OR c.deletion_state <> 'active' + OR EXISTS (SELECT 1 FROM community_deletion_requests d + WHERE d.community_id = c.id AND d.stage <> 'aborted') THEN 'unavailable' + WHEN c.policy_generation <> u.policy_generation + OR c.requires_nip_fi <> u.requested_requires_nip_fi THEN 'superseded' + WHEN NOT u.requested_requires_nip_fi THEN 'applied' + WHEN EXISTS (SELECT 1 FROM community_nip_fi_issuers i WHERE i.community_id = c.id AND i.issuer = u.expected_issuer) - THEN 'pending' ELSE 'unavailable' END AS state, - ARRAY['connection_closure_unverified']::text[] AS pending_reasons + THEN 'pending' ELSE 'unavailable' END AS state, + CASE WHEN u.requested_requires_nip_fi + THEN ARRAY['connection_closure_unverified']::text[] + ELSE ARRAY[]::text[] END AS pending_reasons FROM community_auth_upgrades u LEFT JOIN communities c ON c.id = u.target_community_id WHERE u.operation_id = $1"; @@ -227,37 +267,34 @@ impl Db { .bind(community.as_uuid()).fetch_optional(&mut *connection).await?) } - /// Recover a receipt only for its authenticated originating operator. - /// Ownership changes do not destroy the original caller's recovery authority. - #[datastore_span(name = "community_auth_upgrade_status", system = "postgresql")] - pub async fn community_auth_upgrade_status( + /// Recover any policy receipt for its original authenticated operator. + pub async fn community_auth_policy_status( &self, operation: Uuid, operator: &str, - ) -> Result> { + ) -> Result> { let mut connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, ) .await?; - let record: Option = sqlx::query_as(RECEIPT) + let record: Option = sqlx::query_as(RECEIPT) .bind(operation) .fetch_optional(&mut *connection) .await?; - Ok(record.filter(|record| record.operator_pubkey == operator)) + Ok(record.filter(|record| record.acceptance.operator_pubkey == operator)) } - /// Atomically enable FI and persist the full idempotency tuple. - /// - /// `configured_trust` is the relay's trusted configuration check. Stored - /// binding presence is rechecked under a row lock here. No issuer is added. - /// Retries recover acceptance before rechecking mutable preconditions. - #[datastore_span(name = "enable_community_nip_fi", system = "postgresql")] - pub async fn enable_community_nip_fi( + /// Apply a compare-and-set policy intent; identical committed replay is read-only. + pub async fn set_community_nip_fi_policy( &self, - request: &UpgradeRequest, + request: &PolicyRequest, configured_trust: std::result::Result<(), &'static str>, - ) -> Result { + ) -> Result { + let desired = request.requires_nip_fi; + let expected_generation = request.expected_policy_generation; + let expected_state = request.expected_requires_nip_fi; + let request = &request.tuple; let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, @@ -273,19 +310,25 @@ impl Db { .bind(request.operation_id.to_string()) .execute(&mut *tx) .await?; - let existing: Option = sqlx::query_as(RECEIPT) + let existing: Option = sqlx::query_as(RECEIPT) .bind(request.operation_id) .fetch_optional(&mut *tx) .await?; if let Some(record) = existing { - return Ok(if record.matches(request) { - UpgradeOutcome::Accepted(record) - } else { - UpgradeOutcome::Refused("idempotency_conflict") - }); + return Ok( + if record.acceptance.matches(request) + && record.acceptance.requires_nip_fi == desired + && record.expected_policy_generation == Some(expected_generation) + && record.expected_requires_nip_fi == Some(expected_state) + { + PolicyOutcome::Accepted(Box::new(record)) + } else { + PolicyOutcome::Refused("idempotency_conflict") + }, + ); } if let Err(code) = configured_trust { - return Ok(UpgradeOutcome::Refused(code)); + return Ok(PolicyOutcome::Refused(code)); } let community = CommunityId::from_uuid(request.community_id); crate::deletion::lock_community_deletion_shared(&mut tx, community).await?; @@ -300,10 +343,18 @@ impl Db { .fetch_optional(&mut *tx) .await?; let Some(row) = row else { - return Ok(UpgradeOutcome::Refused("community_not_found")); + return Ok(PolicyOutcome::Refused("community_not_found")); }; if row.try_get::("host")? != request.expected_host { - return Ok(UpgradeOutcome::Refused("host_conflict")); + return Ok(PolicyOutcome::Refused("host_conflict")); + } + let old_required: bool = row.try_get("requires_nip_fi")?; + let old_generation: i64 = row.try_get("policy_generation")?; + if expected_generation != old_generation || expected_state != old_required { + return Ok(PolicyOutcome::Refused("policy_conflict")); + } + if old_generation == i64::MAX { + return Ok(PolicyOutcome::Refused("policy_generation_exhausted")); } // Lock order: deletion-shared, community row, host lease. Fills take // only the shared host lease and never a community row lock. Derive @@ -317,7 +368,7 @@ impl Db { // Keep the row stable while ALL optional leases expire, including // leases under former host keys. No remap (single or double) can // refill another host's optional cache while this drain is running. - if !row.try_get::("requires_nip_fi")? { + if desired && !old_required { tokio::time::sleep(POLICY_LEASE_DRAIN).await; } let deletion_pending: bool = sqlx::query_scalar( @@ -328,7 +379,7 @@ impl Db { .fetch_one(&mut *tx) .await?; if !row.try_get::("active")? || deletion_pending { - return Ok(UpgradeOutcome::Refused("lifecycle_conflict")); + return Ok(PolicyOutcome::Refused("lifecycle_conflict")); } let owners: Vec = sqlx::query_scalar( "SELECT pubkey FROM relay_members @@ -340,31 +391,39 @@ impl Db { if owners != [request.expected_owner_pubkey.clone()] || request.actor_pubkey != request.expected_owner_pubkey { - return Ok(UpgradeOutcome::Refused("owner_conflict")); + return Ok(PolicyOutcome::Refused("owner_conflict")); } - let issuer: Option = sqlx::query_scalar( - "SELECT issuer FROM community_nip_fi_issuers + if desired { + let issuer: Option = sqlx::query_scalar( + "SELECT issuer FROM community_nip_fi_issuers WHERE community_id = $1 AND issuer = $2 FOR UPDATE", - ) - .bind(request.community_id) - .bind(&request.expected_issuer) - .fetch_optional(&mut *tx) - .await?; - if issuer.is_none() { - return Ok(UpgradeOutcome::Refused("issuer_conflict")); + ) + .bind(request.community_id) + .bind(&request.expected_issuer) + .fetch_optional(&mut *tx) + .await?; + if issuer.is_none() { + return Ok(PolicyOutcome::Refused("issuer_conflict")); + } } // Admission is also required for this communities-row mutation. self.deletion_store() .guard_transaction(&mut tx, community) .await?; - let generation: i64 = sqlx::query_scalar("UPDATE communities SET - policy_generation = CASE WHEN requires_nip_fi THEN policy_generation ELSE policy_generation + 1 END, - requires_nip_fi = true WHERE id = $1 RETURNING policy_generation") - .bind(request.community_id).fetch_one(&mut *tx).await?; + let generation = old_generation + 1; + sqlx::query( + "UPDATE communities SET policy_generation = $2, requires_nip_fi = $3 WHERE id = $1", + ) + .bind(request.community_id) + .bind(generation) + .bind(desired) + .execute(&mut *tx) + .await?; sqlx::query( "INSERT INTO community_auth_upgrades (operation_id, target_community_id, expected_host, actor_pubkey, expected_owner_pubkey, - expected_issuer, operator_pubkey, policy_generation) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + expected_issuer, operator_pubkey, policy_generation, requested_requires_nip_fi, + expected_policy_generation, expected_requires_nip_fi) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(request.operation_id) .bind(request.community_id) @@ -374,6 +433,9 @@ impl Db { .bind(&request.expected_issuer) .bind(&request.operator_pubkey) .bind(generation) + .bind(desired) + .bind(expected_generation) + .bind(expected_state) .execute(&mut *tx) .await?; let record = sqlx::query_as(RECEIPT) @@ -381,7 +443,7 @@ impl Db { .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok(UpgradeOutcome::Accepted(record)) + Ok(PolicyOutcome::Accepted(Box::new(record))) } } diff --git a/crates/buzz-db/src/store/community_auth/postgres_tests.rs b/crates/buzz-db/src/store/community_auth/postgres_tests.rs index e8ef0862c4..9f10184d4c 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -32,13 +32,24 @@ async fn fixture() -> (Db, UpgradeRequest, Keys) { (db, request, keys) } +fn initial_enable(tuple: &UpgradeRequest) -> PolicyRequest { + PolicyRequest { + tuple: tuple.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + } +} + async fn accepted(db: &Db, request: &UpgradeRequest) -> UpgradeRecord { - let UpgradeOutcome::Accepted(record) = - db.enable_community_nip_fi(request, Ok(())).await.unwrap() + let PolicyOutcome::Accepted(record) = db + .set_community_nip_fi_policy(&initial_enable(request), Ok(())) + .await + .unwrap() else { panic!("acceptance refused") }; - record + record.acceptance } async fn policy(db: &Db, request: &UpgradeRequest) -> CommunityAuthPolicy { @@ -58,16 +69,18 @@ async fn response_loss_recovery_and_payload_binding() { assert_eq!(first.policy_generation, 1); // Discard the POST result and recover by original operator/UUID. assert_eq!( - db.community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + db.community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await - .unwrap(), + .unwrap() + .map(|record| record.acceptance), Some(first.clone()) ); assert_eq!(accepted(&db, &request).await, first); assert!(db - .community_auth_upgrade_status(request.operation_id, "another operator") + .community_auth_policy_status(request.operation_id, "another operator") .await .unwrap() + .map(|record| record.acceptance) .is_none()); for field in 0..6 { let mut changed = request.clone(); @@ -80,8 +93,10 @@ async fn response_loss_recovery_and_payload_binding() { _ => changed.operator_pubkey = "d".repeat(64), } assert_eq!( - db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") + db.set_community_nip_fi_policy(&initial_enable(&changed), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") ); } assert_eq!(policy(&db, &request).await.policy_generation, 1); @@ -136,21 +151,29 @@ async fn response_loss_recovery_and_payload_binding() { #[ignore = "requires Postgres"] async fn concurrent_ids_and_duplicate_requests_converge() { let (db, request, _) = fixture().await; - let mut second = request.clone(); - second.operation_id = Uuid::new_v4(); - let (a, b, c) = tokio::join!( - accepted(&db, &request), - accepted(&db, &request), - accepted(&db, &second) + let first = initial_enable(&request); + let second = intent(&request, true, 0, false); + let (a, b) = tokio::join!( + db.set_community_nip_fi_policy(&first, Ok(())), + db.set_community_nip_fi_policy(&second, Ok(())) ); - assert_eq!(a, b); - assert_eq!(a.policy_generation, c.policy_generation); + let (winner, loser) = match (a.unwrap(), b.unwrap()) { + (PolicyOutcome::Accepted(record), other) => (&first, (record, other)), + (other, PolicyOutcome::Accepted(record)) => (&second, (record, other)), + outcomes => panic!("no CAS winner: {outcomes:?}"), + }; + assert_eq!(loser.1, PolicyOutcome::Refused("policy_conflict")); + assert_eq!(apply(&db, winner).await, *loser.0); + let (replay_a, replay_b) = tokio::join!(apply(&db, winner), apply(&db, winner)); + assert_eq!(replay_a, replay_b); assert_eq!(policy(&db, &request).await.policy_generation, 1); let (_, mut other, _) = fixture().await; - other.operation_id = request.operation_id; + other.operation_id = winner.tuple.operation_id; assert_eq!( - db.enable_community_nip_fi(&other, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") + db.set_community_nip_fi_policy(&initial_enable(&other), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") ); } @@ -171,22 +194,26 @@ async fn failed_preconditions_leave_free_policy_and_no_receipt() { _ => changed.community_id = Uuid::new_v4(), } assert_eq!( - db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), - UpgradeOutcome::Refused(code) + db.set_community_nip_fi_policy(&initial_enable(&changed), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused(code) ); } assert_eq!( - db.enable_community_nip_fi(&request, Err("authorization_unavailable")) + db.set_community_nip_fi_policy(&initial_enable(&request), Err("authorization_unavailable")) .await .unwrap(), - UpgradeOutcome::Refused("authorization_unavailable") + PolicyOutcome::Refused("authorization_unavailable") ); db.unbind_nip_fi_issuer(CommunityId::from_uuid(request.community_id), ISSUER, true) .await .unwrap(); assert_eq!( - db.enable_community_nip_fi(&request, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("issuer_conflict") + db.set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("issuer_conflict") ); assert_eq!( policy(&db, &request).await, @@ -197,9 +224,10 @@ async fn failed_preconditions_leave_free_policy_and_no_receipt() { } ); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); } @@ -210,7 +238,7 @@ async fn blocked_upgrade( request: &UpgradeRequest, locker_pid: i32, statement: &str, -) -> tokio::task::JoinHandle> { +) -> tokio::task::JoinHandle> { use std::str::FromStr; let application = format!("upgrade-lock-{}", request.operation_id.simple()); let options = sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) @@ -224,8 +252,11 @@ async fn blocked_upgrade( .unwrap(), ); let request = request.clone(); - let task = - tokio::spawn(async move { upgrade_db.enable_community_nip_fi(&request, Ok(())).await }); + let task = tokio::spawn(async move { + upgrade_db + .set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + }); tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { assert!( @@ -261,14 +292,15 @@ async fn blocked_upgrade( async fn assert_refused_without_mutation( db: &Db, request: &UpgradeRequest, - task: tokio::task::JoinHandle>, + task: tokio::task::JoinHandle>, code: &'static str, ) { - assert_eq!(task.await.unwrap().unwrap(), UpgradeOutcome::Refused(code)); + assert_eq!(task.await.unwrap().unwrap(), PolicyOutcome::Refused(code)); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); let policy: (bool, i64) = sqlx::query_as("SELECT requires_nip_fi, policy_generation FROM communities WHERE id = $1") @@ -363,8 +395,18 @@ async fn accepted_receipt_survives_transfer_without_granting_fresh_owner_authori let mut fresh = request.clone(); fresh.operation_id = Uuid::new_v4(); assert_eq!( - db.enable_community_nip_fi(&fresh, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("owner_conflict") + db.set_community_nip_fi_policy( + &PolicyRequest { + tuple: fresh.clone(), + requires_nip_fi: true, + expected_policy_generation: 1, + expected_requires_nip_fi: true + }, + Ok(()) + ) + .await + .unwrap(), + PolicyOutcome::Refused("owner_conflict") ); } @@ -378,9 +420,12 @@ async fn authoritative_lookup_failure_is_error_not_fabricated_acceptance() { .unwrap(), ); let (_, request, _) = fixture().await; - assert!(db.enable_community_nip_fi(&request, Ok(())).await.is_err()); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .is_err()); + assert!(db + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .is_err()); } @@ -399,14 +444,17 @@ async fn deletion_intent_refuses_enable_before_quiescing() { .await .unwrap(); assert_eq!( - db.enable_community_nip_fi(&request, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("lifecycle_conflict") + db.set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("lifecycle_conflict") ); assert!(!policy(&db, &request).await.requires_nip_fi); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); } @@ -482,7 +530,21 @@ async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrain let first = accepted(&db, &request).await; assert_eq!(first.policy_generation, 1); request.operation_id = Uuid::new_v4(); - assert_eq!(accepted(&db, &request).await.policy_generation, 1); + assert_eq!( + apply( + &db, + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 1, + expected_requires_nip_fi: true + } + ) + .await + .acceptance + .policy_generation, + 2 + ); let invalid_receipt = sqlx::query( "UPDATE community_auth_upgrades SET policy_generation = 0 WHERE operation_id = $1", ) @@ -498,7 +560,7 @@ async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrain .as_deref(), Some("23514") ); - assert_eq!(policy(&db, &request).await.policy_generation, 1); + assert_eq!(policy(&db, &request).await.policy_generation, 2); } #[tokio::test] @@ -558,3 +620,290 @@ async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { .unwrap() .is_none()); } + +fn intent(tuple: &UpgradeRequest, desired: bool, generation: i64, required: bool) -> PolicyRequest { + let mut tuple = tuple.clone(); + tuple.operation_id = Uuid::new_v4(); + if !desired { + tuple.expected_issuer.clear(); + } + PolicyRequest { + tuple, + requires_nip_fi: desired, + expected_policy_generation: generation, + expected_requires_nip_fi: required, + } +} + +async fn apply(db: &Db, request: &PolicyRequest) -> PolicyRecord { + let PolicyOutcome::Accepted(record) = db + .set_community_nip_fi_policy(request, Ok(())) + .await + .unwrap() + else { + panic!("policy refused") + }; + *record +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_enable_disable_reenable_history_and_identity() { + let (db, tuple, _) = fixture().await; + let enable = intent(&tuple, true, 0, false); + let first = apply(&db, &enable).await; + assert_eq!(first.acceptance.policy_generation, 1); + assert_eq!(first.acceptance.state, "pending"); + let disable = intent(&tuple, false, 1, true); + let second = apply(&db, &disable).await; + assert_eq!(second.acceptance.policy_generation, 2); + assert_eq!(second.acceptance.state, "applied"); + let old = db + .community_auth_policy_status(enable.tuple.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(old.acceptance.state, "superseded"); + assert_eq!(old.current_policy_generation, Some(2)); + assert_eq!(old.current_requires_nip_fi, Some(false)); + // A committed retry is history even with the mutation gate disabled. + assert_eq!( + db.set_community_nip_fi_policy(&enable, Err("upgrades_disabled")) + .await + .unwrap(), + PolicyOutcome::Accepted(Box::new(old)) + ); + let reenable = intent(&tuple, true, 2, false); + let third = apply(&db, &reenable).await; + assert_eq!(third.acceptance.policy_generation, 3); + assert_eq!(third.current_requires_nip_fi, Some(true)); + for request in [&enable, &disable, &reenable] { + let record = db + .community_auth_policy_status(request.tuple.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(record.current_policy_generation, Some(3)); + assert_eq!( + record.acceptance.state, + if request == &reenable { + "pending" + } else { + "superseded" + } + ); + } + for change in 0..5 { + let mut changed = enable.clone(); + match change { + 0 => changed.requires_nip_fi = false, + 1 => changed.expected_policy_generation = 2, + 2 => changed.expected_requires_nip_fi = true, + 3 => changed.tuple.operator_pubkey = "f".repeat(64), + _ => changed.tuple.expected_issuer = "https://different.example".into(), + } + assert_eq!( + db.set_community_nip_fi_policy(&changed, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") + ); + } + assert_eq!(policy(&db, &tuple).await.policy_generation, 3); + // Reasserting required policy fences an earlier uncommitted disable too. + let stale_disable = intent(&tuple, false, 3, true); + let reassert = intent(&tuple, true, 3, true); + assert_eq!(apply(&db, &reassert).await.acceptance.policy_generation, 4); + assert_eq!( + db.set_community_nip_fi_policy(&stale_disable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + assert!(policy(&db, &tuple).await.requires_nip_fi); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_same_state_barrier_and_retained_legacy_receipt_identity() { + let (db, tuple, _) = fixture().await; + let stale = intent(&tuple, true, 0, false); + let barrier = intent(&tuple, false, 0, false); + let record = apply(&db, &barrier).await; + assert_eq!(record.acceptance.policy_generation, 1); + assert!(!record.acceptance.requires_nip_fi); + assert_eq!( + db.set_community_nip_fi_policy(&stale, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + let wrong_state = intent(&tuple, true, 1, true); + assert_eq!( + db.set_community_nip_fi_policy(&wrong_state, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + assert_eq!(apply(&db, &barrier).await, record); + let enable = intent(&tuple, true, 1, false); + apply(&db, &enable).await; + // Emulate an acceptance written before migration 0085, without restoring + // an unfenced writer just to create test data. Old INSERTs remain valid. + let legacy_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO community_auth_upgrades + (operation_id,target_community_id,expected_host,actor_pubkey,expected_owner_pubkey, + expected_issuer,operator_pubkey,policy_generation) VALUES ($1,$2,$3,$4,$4,$5,$6,2)", + ) + .bind(legacy_id) + .bind(tuple.community_id) + .bind(&tuple.expected_host) + .bind(&tuple.actor_pubkey) + .bind(&tuple.expected_issuer) + .bind(&tuple.operator_pubkey) + .execute(db.pool()) + .await + .unwrap(); + let retained = db + .community_auth_policy_status(legacy_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(retained.expected_policy_generation, None); + assert_eq!(retained.expected_requires_nip_fi, None); + assert_eq!(retained.acceptance.state, "pending"); + let mut reuse = intent(&tuple, true, 2, true); + reuse.tuple.operation_id = legacy_id; + assert_eq!( + db.set_community_nip_fi_policy(&reuse, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") + ); + apply(&db, &intent(&tuple, false, 2, true)).await; + let retained = db + .community_auth_policy_status(legacy_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(retained.acceptance.state, "superseded"); + assert_eq!(retained.current_policy_generation, Some(3)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_disable_guards_and_issuer_removal_then_reenable() { + let (db, tuple, _) = fixture().await; + accepted(&db, &tuple).await; + let disable = intent(&tuple, false, 1, true); + for kind in 0..4 { + let mut changed = disable.clone(); + let code = match kind { + 0 => { + changed.tuple.actor_pubkey = "a".repeat(64); + "owner_conflict" + } + 1 => { + changed.tuple.expected_host = "wrong.example".into(); + "host_conflict" + } + 2 => { + changed.tuple.expected_owner_pubkey = "b".repeat(64); + "owner_conflict" + } + _ => { + changed.tuple.community_id = Uuid::new_v4(); + "community_not_found" + } + }; + assert_eq!( + db.set_community_nip_fi_policy(&changed, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused(code) + ); + } + assert_eq!( + db.set_community_nip_fi_policy(&disable, Err("upgrades_disabled")) + .await + .unwrap(), + PolicyOutcome::Refused("upgrades_disabled") + ); + apply(&db, &disable).await; + let community = CommunityId::from_uuid(tuple.community_id); + assert_eq!( + db.unbind_nip_fi_issuer(community, ISSUER, true) + .await + .unwrap(), + crate::NipFiUnbind::Removed + ); + let enable = intent(&tuple, true, 2, false); + assert_eq!( + db.set_community_nip_fi_policy(&enable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("issuer_conflict") + ); + // Disable works without a trusted/bound issuer and still advances its barrier. + let next = apply(&db, &intent(&tuple, false, 2, false)).await; + assert_eq!(next.acceptance.policy_generation, 3); + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(tuple.community_id) + .execute(&db.pool) + .await + .unwrap(); + assert_eq!( + db.set_community_nip_fi_policy(&intent(&tuple, false, 3, false), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("lifecycle_conflict") + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_disable_serializes_behind_enable_drain() { + use std::str::FromStr; + let (db, tuple, _) = fixture().await; + let application = format!("policy-drain-{}", Uuid::new_v4().simple()); + let options = sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) + .unwrap() + .application_name(&application); + let writer = Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(), + ); + let enable = intent(&tuple, true, 0, false); + let task = tokio::spawn(async move { apply(&writer, &enable).await }); + // The lease-lock statement followed by idle-in-transaction identifies the + // production drain while the writer still owns its community row lock. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + assert!(!task.is_finished(), "missed the production lease drain"); + let draining: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE application_name = $1 AND state = 'idle in transaction' AND query LIKE 'SELECT pg_advisory_xact_lock($1,%')") + .bind(&application).fetch_one(&db.pool).await.unwrap(); + if draining { break; } + tokio::task::yield_now().await; + } + }).await.unwrap(); + let disable = intent(&tuple, false, 0, false); + assert_eq!( + db.set_community_nip_fi_policy(&disable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + assert_eq!(task.await.unwrap().acceptance.policy_generation, 1); + assert!(policy(&db, &tuple).await.requires_nip_fi); + assert_eq!( + apply(&db, &intent(&tuple, false, 1, true)) + .await + .acceptance + .policy_generation, + 2 + ); +} diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 66754b45d4..c326fe81dc 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -8716,12 +8716,12 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); let deleted = store.purge_postgres(&token).await.expect("purge postgres"); let upgrade = db - .community_auth_upgrade_status(upgrade_id, "operator") + .community_auth_policy_status(upgrade_id, "operator") .await .unwrap() .expect("receipt survives purge"); - assert_eq!(upgrade.community_id, community); - assert_eq!(upgrade.state, "unavailable"); + assert_eq!(upgrade.acceptance.community_id, community); + assert_eq!(upgrade.acceptance.state, "unavailable"); let retained: Uuid = sqlx::query_scalar( "SELECT target_community_id FROM entity_deletion_stage_history WHERE id = $1", ) diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index 1db75ce39c..b012fdf65c 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -62,7 +62,9 @@ async fn require_localhost(req: Request, next: Next) -> Response { pub fn git_policy_router(state: Arc) -> Router { Router::new() .route("/internal/git/policy", post(policy::hook_policy_check)) - .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB - .layer(middleware::from_fn(require_localhost)) + // Scope both guards to this path; Router::layer also wraps the default + // fallback, which would reject unrelated unknown URLs after merging. + .route_layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB + .route_layer(middleware::from_fn(require_localhost)) .with_state(state) } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 92c57e188c..b9fa7a59f5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -4,6 +4,9 @@ //! request signing and replay protection, but they do not run through event //! ingest, relay membership, channel scoping, storage, or fan-out. +mod community_policy; +pub use community_policy::{community_nip_fi_policy_status, set_community_nip_fi_policy}; + use crate::access::{AccessSlot, AuthMethod, AuthResult}; use std::sync::Arc; @@ -1197,24 +1200,6 @@ mod tests { } } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CommunityAuthUpgradeRequest { - operation_id: Uuid, - community_id: Uuid, - expected_host: String, - actor_pubkey: String, - expected_owner_pubkey: String, - expected_issuer: String, -} - -/// Query for operator-scoped upgrade receipt recovery. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommunityAuthUpgradeQuery { - operation_id: Uuid, -} - fn upgrade_configured_trust( state: &AppState, host: &str, @@ -1243,152 +1228,14 @@ fn upgrade_configured_trust( Ok(()) } -fn upgrade_response( - state: &AppState, - mut record: buzz_db::community_auth::UpgradeRecord, -) -> axum::response::Response { - use axum::response::IntoResponse; - if upgrade_configured_trust(state, &record.expected_host, &record.expected_issuer).is_err() { - record.state = "unavailable".to_owned(); - } - Json(record).into_response() -} - -/// Accept or recover a monotonic FI upgrade through the existing operator plane. -/// The NIP-98 principal is the trusted mediator; the request actor is the owner -/// authenticated by that mediator. No public user credential can select it. -pub async fn enable_community_nip_fi( - State(state): State>, - slot: AccessSlot, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result)> { - let operator = authorize_operator_request( - &state, - &slot, - &headers, - "POST", - "/operator/communities/nip-fi-upgrade", - None, - Some(&body), - ) - .await?; - let request: CommunityAuthUpgradeRequest = serde_json::from_slice(&body).map_err(|_| { - coded_api_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "invalid upgrade request", - ) - })?; - let invalid = || { - coded_api_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "invalid upgrade request", - ) - }; - if request.operation_id.is_nil() || request.community_id.is_nil() { - return Err(invalid()); - } - let host = normalize_candidate_host(&request.expected_host).map_err(|_| invalid())?; - let actor = validate_pubkey_hex(&request.actor_pubkey).ok_or_else(invalid)?; - let owner = validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(invalid)?; - let trust = if state.config.nip_fi_upgrades_enabled { - upgrade_configured_trust(&state, &host, &request.expected_issuer) - } else { - Err("upgrades_disabled") - }; - let tuple = buzz_db::community_auth::UpgradeRequest { - operation_id: request.operation_id, - community_id: request.community_id, - expected_host: host, - actor_pubkey: actor, - expected_owner_pubkey: owner, - expected_issuer: request.expected_issuer, - operator_pubkey: operator.to_hex(), - }; - let outcome = state - .db - .enable_community_nip_fi(&tuple, trust) - .await - .map_err(|error| { - tracing::warn!(%error, "community auth upgrade unavailable"); - coded_api_error( - StatusCode::SERVICE_UNAVAILABLE, - "authorization_unavailable", - "authorization unavailable", - ) - })?; - match outcome { - buzz_db::community_auth::UpgradeOutcome::Accepted(record) => { - state.invalidate_community_host(&TenantContext::resolved( - buzz_core::CommunityId::from_uuid(record.community_id), - &record.expected_host, - )); - Ok(upgrade_response(&state, record)) - } - buzz_db::community_auth::UpgradeOutcome::Refused(code) => { - let status = match code { - "community_not_found" => StatusCode::NOT_FOUND, - "authorization_unavailable" | "upgrades_disabled" => { - StatusCode::SERVICE_UNAVAILABLE - } - _ => StatusCode::CONFLICT, - }; - Err(coded_api_error(status, code, code)) - } - } -} - -/// Recover the original operator's exact durable tuple with truthful state. -/// A fresh NIP-98 signature is required for every status read and POST retry. -pub async fn community_nip_fi_upgrade_status( - State(state): State>, - slot: AccessSlot, - headers: HeaderMap, - RawQuery(raw_query): RawQuery, - Query(query): Query, -) -> Result)> { - let operator = authorize_operator_request( - &state, - &slot, - &headers, - "GET", - "/operator/communities/nip-fi-upgrade", - raw_query.as_deref(), - None, - ) - .await?; - let record = state - .db - .community_auth_upgrade_status(query.operation_id, &operator.to_hex()) - .await - .map_err(|error| { - tracing::warn!(%error, "community auth upgrade status unavailable"); - coded_api_error( - StatusCode::SERVICE_UNAVAILABLE, - "authorization_unavailable", - "authorization unavailable", - ) - })? - .ok_or_else(|| { - coded_api_error( - StatusCode::NOT_FOUND, - "operation_not_found", - "operation not found", - ) - })?; - Ok(upgrade_response(&state, record)) -} - -/// Keep all upgrade responses, including auth and dependency errors, out of caches. -pub async fn no_store_upgrade_response( +/// Keep all policy responses, including auth and dependency errors, out of caches. +pub async fn no_store_policy_response( request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - let upgrade = request.uri().path() == "/operator/communities/nip-fi-upgrade"; + let policy = request.uri().path() == "/operator/communities/nip-fi-policy"; let mut response = next.run(request).await; - if upgrade { + if policy { response.headers_mut().insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-store"), @@ -3137,422 +2984,6 @@ mod postgres_tests { operator_request_with_auth(state, method, path, body, auth).await } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn upgrade_operator_http_contract_recovers_pending_and_scopes_auth() { - let operator = Keys::generate(); - let other_operator = Keys::generate(); - let owner = Keys::generate(); - let (policy, _, host) = reserved_host_policy(); - let Some(mut state) = operator_test_state_with_policy( - &[operator.clone(), other_operator.clone()], - Some(policy), - ) - .await - else { - panic!("test database unavailable"); - }; - Arc::get_mut(&mut state).unwrap().nip98_replay = - Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); - let issuer_policy = buzz_auth::IssuerPolicy::new( - NIP_FI_ISSUER.to_owned(), - vec!["https://commands.example".to_owned()], - buzz_auth::TokenClass::DedicatedNipFi, - buzz_auth::FreshnessClass::OfflineJwt, - vec![jsonwebtoken::Algorithm::ES256], - 30, - 3600, - None, - buzz_auth::JwksSourceContract::new(format!("{NIP_FI_ISSUER}/jwks"), 300, 86400) - .unwrap(), - ) - .unwrap(); - Arc::make_mut(&mut Arc::get_mut(&mut state).expect("unique test state").config) - .nip_fi - .registry - .insert(issuer_policy); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = - false; - assert!(!state.config.nip_fi_upgrades_enabled); - - assert_eq!( - provision_community(state.clone(), &operator, &host, &owner) - .await - .status(), - StatusCode::OK - ); - let community = state - .db - .lookup_community_by_host(&host) - .await - .unwrap() - .unwrap(); - let id = Uuid::new_v4(); - let body = serde_json::json!({ - "operation_id": id, "community_id": community.id.to_string(), - "expected_host": host.to_uppercase(), "actor_pubkey": owner.public_key().to_hex(), - "expected_owner_pubkey": owner.public_key().to_hex(), "expected_issuer": NIP_FI_ISSUER, - }) - .to_string(); - let path = "/operator/communities/nip-fi-upgrade"; - // The cache guard also surrounds router/middleware rejections, not only - // responses produced inside the authenticated upgrade handler. - let oversized = build_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .header(header::CONTENT_LENGTH, (2 * 1024 * 1024).to_string()) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!(oversized.headers()[header::CACHE_CONTROL], "no-store"); - let unsupported = build_router(state.clone()) - .oneshot( - Request::builder() - .method("PUT") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(unsupported.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(unsupported.headers()[header::CACHE_CONTROL], "no-store"); - let disabled = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(disabled.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); - assert!( - !state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .requires_nip_fi - ); - assert!(state - .db - .community_auth_upgrade_status(id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; - let response = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - let record = read_json(response).await; - assert_eq!(record["expected_host"], host); - assert_eq!(record["operator_pubkey"], operator.public_key().to_hex()); - assert_eq!(record["policy_generation"], 1); - assert_eq!(record["state"], "pending"); - assert_eq!( - record["pending_reasons"], - serde_json::json!(["connection_closure_unverified"]) - ); - // A fresh signed retry recovers the same tuple despite the lost response. - let retry = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(retry.status(), StatusCode::OK); - assert_eq!(read_json(retry).await, record); - let status_path = format!("{path}?operation_id={id}"); - let status = - fresh_upgrade_request(state.clone(), &operator, "GET", &status_path, None).await; - assert_eq!(status.status(), StatusCode::OK); - assert_eq!(read_json(status).await, record); - let other = - fresh_upgrade_request(state.clone(), &other_operator, "GET", &status_path, None).await; - assert_eq!(other.status(), StatusCode::NOT_FOUND); - assert_eq!(other.headers()[header::CACHE_CONTROL], "no-store"); - let reuse = fresh_upgrade_request( - state.clone(), - &other_operator, - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(reuse.status(), StatusCode::CONFLICT); - assert_eq!(reuse.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(reuse).await["code"], "idempotency_conflict"); - let outsider = fresh_upgrade_request( - state.clone(), - &Keys::generate(), - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(outsider.status(), StatusCode::FORBIDDEN); - assert_eq!(outsider.headers()[header::CACHE_CONTROL], "no-store"); - let url = format!("http://{INGRESS_HOST}{path}"); - let auth = fresh_upgrade_auth(&operator, &url, "POST", Some(body.as_bytes())); - let raw = || { - Request::builder() - .method("POST") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, &auth) - .body(Body::from(body.clone())) - .unwrap() - }; - let first = build_router(state.clone()).oneshot(raw()).await.unwrap(); - // Repeat the exact signature: transport replay protection remains independent of operation idempotency. - let second = build_router(state.clone()).oneshot(raw()).await.unwrap(); - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::UNAUTHORIZED); - assert_eq!(second.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .policy_generation, - 1 - ); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = - false; - let while_off = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(while_off.status(), StatusCode::OK); - assert_eq!(read_json(while_off).await, record); - let while_off = - fresh_upgrade_request(state.clone(), &operator, "GET", &status_path, None).await; - assert_eq!(while_off.status(), StatusCode::OK); - assert_eq!(read_json(while_off).await, record); - let denied = - fresh_upgrade_request(state.clone(), &other_operator, "GET", &status_path, None).await; - assert_eq!(denied.status(), StatusCode::NOT_FOUND); - let reuse_off = fresh_upgrade_request( - state.clone(), - &other_operator, - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(reuse_off.status(), StatusCode::CONFLICT); - assert_eq!(read_json(reuse_off).await["code"], "idempotency_conflict"); - let new_id = Uuid::new_v4(); - let new_body = body.replace(&id.to_string(), &new_id.to_string()); - let disabled_after_acceptance = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(new_body)).await; - assert_eq!( - disabled_after_acceptance.status(), - StatusCode::SERVICE_UNAVAILABLE - ); - assert_eq!( - disabled_after_acceptance.headers()[header::CACHE_CONTROL], - "no-store" - ); - assert_eq!( - read_json(disabled_after_acceptance).await["code"], - "upgrades_disabled" - ); - assert!(state - .db - .community_auth_upgrade_status(new_id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; - - // Exercise the real HTTP-to-config-to-transaction seam: no raw trust - // precondition can bypass the deployment's configured issuer selection. - for invalid in [ - "{".to_owned(), - "{}".to_owned(), - body.replace(&id.to_string(), "not-a-uuid"), - ] { - let response = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(invalid)).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(response).await["code"], "invalid_request"); - } - let mut changed: Value = serde_json::from_str(&body).unwrap(); - let mismatched_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(mismatched_id); - changed["expected_issuer"] = serde_json::json!("https://unapproved.example"); - let mismatch = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(mismatch.status(), StatusCode::CONFLICT); - assert_eq!(mismatch.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(mismatch).await["code"], "issuer_conflict"); - assert!(state - .db - .community_auth_upgrade_status(mismatched_id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - - for bad_host in [ - "outside.example".to_owned(), - host.replacen("acme.", "cmd.", 1), - ] { - changed["operation_id"] = serde_json::json!(Uuid::new_v4()); - changed["expected_host"] = serde_json::json!(bad_host); - changed["expected_issuer"] = serde_json::json!(NIP_FI_ISSUER); - let response = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(response.status(), StatusCode::CONFLICT); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(response).await["code"], "issuer_conflict"); - } - changed["expected_host"] = serde_json::json!(host); - let original_policy = state.config.nip_fi_provision_binding.clone(); - let suffix = host.split_once('.').unwrap().1; - let multiple = buzz_auth::ProvisionBindingPolicy::parse( - &serde_json::json!({"host_suffix": suffix, "authorized_issuers": [NIP_FI_ISSUER, "https://second.example"]}).to_string(), - &serde_json::json!([ - {"issuer": NIP_FI_ISSUER, "command_audiences": [format!("https://cmd.{suffix}")]}, - {"issuer": "https://second.example", "command_audiences": ["https://commands.example"]} - ]).to_string()).unwrap(); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - Some(multiple); - changed["operation_id"] = serde_json::json!(Uuid::new_v4()); - let ambiguous = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(ambiguous.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(ambiguous.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - read_json(ambiguous).await["code"], - "authorization_unavailable" - ); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - original_policy; - - assert_eq!( - state - .db - .unbind_nip_fi_issuer(community.id, NIP_FI_ISSUER, true) - .await - .unwrap(), - buzz_db::NipFiUnbind::RequiredIssuer - ); - // Simulate out-of-band trust loss to exercise receipt recovery and - // configured-trust refusal independently of the operator removal guard. - sqlx::query("DELETE FROM community_nip_fi_issuers WHERE community_id = $1") - .bind(community.id.as_uuid()) - .execute(state.db.pool()) - .await - .unwrap(); - let missing_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(missing_id); - changed["expected_issuer"] = serde_json::json!(NIP_FI_ISSUER); - let missing = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(missing.status(), StatusCode::CONFLICT); - assert_eq!(read_json(missing).await["code"], "issuer_conflict"); - state - .db - .bind_nip_fi_issuers(community.id, &[NIP_FI_ISSUER.to_owned()]) - .await - .unwrap(); - - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) - .nip_fi - .registry = buzz_auth::IssuerRegistry::new(); - let inactive_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(inactive_id); - let inactive = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(inactive.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(inactive.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - read_json(inactive).await["code"], - "authorization_unavailable" - ); - let recovered = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(recovered.status(), StatusCode::OK); - let recovered = read_json(recovered).await; - assert_eq!(recovered["operation_id"], record["operation_id"]); - assert_eq!(recovered["policy_generation"], record["policy_generation"]); - assert_eq!(recovered["state"], "unavailable"); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - None; - let unconfigured = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(unconfigured.status(), StatusCode::SERVICE_UNAVAILABLE); - for operation in [missing_id, inactive_id] { - assert!(state - .db - .community_auth_upgrade_status(operation, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - } - assert!( - state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .requires_nip_fi - ); - } - - 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() { @@ -4994,4 +4425,18 @@ mod postgres_tests { let body = read_json(response).await; assert_eq!(body["error"], "internal server error"); } + 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 + } + + mod policy_tests; } diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs new file mode 100644 index 0000000000..ed9d660b93 --- /dev/null +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -0,0 +1,209 @@ +//! General operator policy intents and truthful current/historical readback. + +use super::*; +use buzz_db::community_auth::{PolicyOutcome, PolicyRecord, PolicyRequest, UpgradeRequest}; + +const PATH: &str = "/operator/communities/nip-fi-policy"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + operation_id: Uuid, + community_id: Uuid, + expected_host: String, + actor_pubkey: String, + expected_owner_pubkey: String, + #[serde(default)] + expected_issuer: Option, + requires_nip_fi: bool, + expected_policy_generation: i64, + expected_requires_nip_fi: bool, +} + +/// Exactly one of an operation receipt or a community/host current-state read. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyQuery { + operation_id: Option, + community_id: Option, + expected_host: Option, +} + +type Error = (StatusCode, Json); + +fn invalid() -> Error { + coded_api_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "invalid policy request", + ) +} + +fn unavailable(error: buzz_db::DbError) -> Error { + tracing::warn!(%error, "community auth policy unavailable"); + coded_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "authorization_unavailable", + "authorization unavailable", + ) +} + +fn mode_fields(state: &AppState, required: Option) -> Value { + let mode = state.config.nip_fi.mode; + let name = crate::nip_fi_config::mode_name(mode); + serde_json::json!({ + "mode": name, + "effective_required": required.map(|r| mode.with_requirement(r).restricts()), + }) +} + +fn response(state: &AppState, mut record: PolicyRecord) -> Json { + // Supersession is history, not a trust failure. Disable needs no issuer. + if record.acceptance.state == "pending" + && upgrade_configured_trust( + state, + &record.acceptance.expected_host, + &record.acceptance.expected_issuer, + ) + .is_err() + { + record.acceptance.state = "unavailable".into(); + } + let fields = mode_fields(state, record.current_requires_nip_fi); + Json(serde_json::json!({ + "receipt": record, + "mode": fields["mode"], + "effective_required": fields["effective_required"], + })) +} + +/// Atomically accept an owner-authorized stored requirement with CAS and replay. +pub async fn set_community_nip_fi_policy( + State(state): State>, + slot: AccessSlot, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Error> { + let operator = + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)) + .await?; + let request: Request = serde_json::from_slice(&body).map_err(|_| invalid())?; + let host = normalize_candidate_host(&request.expected_host).map_err(|_| invalid())?; + if request.operation_id.is_nil() + || request.community_id.is_nil() + || request.expected_policy_generation < 0 + || host != request.expected_host + || (request.requires_nip_fi + && request + .expected_issuer + .as_ref() + .is_none_or(|s| s.is_empty())) + || (!request.requires_nip_fi && request.expected_issuer.is_some()) + { + return Err(invalid()); + } + let tuple = UpgradeRequest { + operation_id: request.operation_id, + community_id: request.community_id, + expected_host: host, + actor_pubkey: validate_pubkey_hex(&request.actor_pubkey).ok_or_else(invalid)?, + expected_owner_pubkey: validate_pubkey_hex(&request.expected_owner_pubkey) + .ok_or_else(invalid)?, + expected_issuer: request.expected_issuer.unwrap_or_default(), + operator_pubkey: operator.to_hex(), + }; + let preflight = if !state.config.nip_fi_changes { + Err("upgrades_disabled") + } else if request.requires_nip_fi { + upgrade_configured_trust(&state, &tuple.expected_host, &tuple.expected_issuer) + } else { + Ok(()) + }; + let intent = PolicyRequest { + tuple, + requires_nip_fi: request.requires_nip_fi, + expected_policy_generation: request.expected_policy_generation, + expected_requires_nip_fi: request.expected_requires_nip_fi, + }; + match state + .db + .set_community_nip_fi_policy(&intent, preflight) + .await + .map_err(unavailable)? + { + PolicyOutcome::Accepted(record) => { + state.invalidate_community_host(&TenantContext::resolved( + buzz_core::CommunityId::from_uuid(record.acceptance.community_id), + &record.acceptance.expected_host, + )); + Ok(response(&state, *record)) + } + PolicyOutcome::Refused(code) => Err(coded_api_error( + match code { + "community_not_found" => StatusCode::NOT_FOUND, + "upgrades_disabled" | "authorization_unavailable" => { + StatusCode::SERVICE_UNAVAILABLE + } + _ => StatusCode::CONFLICT, + }, + code, + code, + )), + } +} + +/// Read exact current preconditions or the originating operator's operation history. +pub async fn community_nip_fi_policy_status( + State(state): State>, + slot: AccessSlot, + headers: HeaderMap, + RawQuery(raw): RawQuery, + Query(query): Query, +) -> Result, Error> { + let operator = + authorize_operator_request(&state, &slot, &headers, "GET", PATH, raw.as_deref(), None) + .await?; + match (query.operation_id, query.community_id, query.expected_host) { + (Some(id), None, None) if !id.is_nil() => { + let record = state + .db + .community_auth_policy_status(id, &operator.to_hex()) + .await + .map_err(unavailable)? + .ok_or_else(|| { + coded_api_error( + StatusCode::NOT_FOUND, + "operation_not_found", + "operation not found", + ) + })?; + Ok(response(&state, record)) + } + (None, Some(id), Some(host)) if !id.is_nil() => { + if normalize_candidate_host(&host).map_err(|_| invalid())? != host { + return Err(invalid()); + } + let policy = state + .db + .community_auth_policy_for_host(&host) + .await + .map_err(unavailable)? + .filter(|p| p.community_id == id) + .ok_or_else(|| { + coded_api_error( + StatusCode::NOT_FOUND, + "community_not_found", + "community not found", + ) + })?; + let fields = mode_fields(&state, Some(policy.requires_nip_fi)); + Ok(Json(serde_json::json!({ + "community_id": id, "expected_host": host, + "expected_policy_generation": policy.policy_generation, + "expected_requires_nip_fi": policy.requires_nip_fi, + "mode": fields["mode"], "effective_required": fields["effective_required"], + }))) + } + _ => Err(invalid()), + } +} diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs index d0dc9ff1ba..e371de9186 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -34,7 +34,7 @@ mod postgres_tests { .unwrap(); let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); let owner = &owners[3]; - assert!(!state.config.nip_fi_upgrades_enabled); + assert!(!state.config.nip_fi_changes); for (index, policy) in [ None, Some(Value::Null), diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs new file mode 100644 index 0000000000..db0bbddbed --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -0,0 +1,429 @@ +mod postgres_tests { + use super::super::*; + // Included in operator::postgres_tests to reuse the real signed HTTP fixture. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_operator_http_transition_history_guards_and_modes() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let (binding, _, host) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); + Arc::get_mut(&mut state).unwrap().nip98_replay = + Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); + let issuer_policy = buzz_auth::IssuerPolicy::new( + NIP_FI_ISSUER.to_owned(), + vec!["https://commands.example".into()], + buzz_auth::TokenClass::DedicatedNipFi, + buzz_auth::FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + buzz_auth::JwksSourceContract::new(format!("{NIP_FI_ISSUER}/jwks"), 300, 86400) + .unwrap(), + ) + .unwrap(); + let config = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config); + config.nip_fi.registry.insert(issuer_policy); + config.nip_fi.mode = buzz_auth::NipFiMode::Shadow; + config.nip_fi_changes = false; + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + let path = "/operator/communities/nip-fi-policy"; + let current_path = format!("{path}?community_id={}&expected_host={host}", community.id); + let current = + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await; + assert_eq!(current.headers()[header::CACHE_CONTROL], "no-store"); + let current = read_json(current).await; + assert_eq!(current["expected_policy_generation"], 0); + assert_eq!(current["expected_requires_nip_fi"], false); + let mut body = serde_json::json!({ + "operation_id": Uuid::new_v4(), "community_id": community.id.to_string(), + "expected_host": host, "actor_pubkey": owner.public_key().to_hex(), + "expected_owner_pubkey": owner.public_key().to_hex(), "expected_issuer": NIP_FI_ISSUER, + "requires_nip_fi": true, "expected_policy_generation": 0, "expected_requires_nip_fi": false, + }); + let disabled = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await; + assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); + for (field, value) in [ + ("operation_id", serde_json::json!(Uuid::nil())), + ("community_id", serde_json::json!(Uuid::nil())), + ("expected_host", serde_json::json!(host.to_uppercase())), + ("actor_pubkey", serde_json::json!("not-a-pubkey")), + ("expected_policy_generation", serde_json::json!(-1)), + ("expected_issuer", serde_json::json!("")), + ("unknown", serde_json::json!(true)), + ] { + let mut invalid = body.clone(); + invalid[field] = value; + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(invalid.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{field}"); + assert_eq!(read_json(response).await["code"], "invalid_request"); + } + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; + for (field, value, code) in [ + ( + "expected_issuer", + "https://unapproved.example".to_owned(), + "issuer_conflict", + ), + ( + "expected_host", + "outside.example".to_owned(), + "issuer_conflict", + ), + ( + "actor_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ( + "expected_owner_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ] { + let mut refused = body.clone(); + refused[field] = value.into(); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(refused.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(read_json(response).await["code"], code); + } + let mut history = Vec::new(); + for (generation, desired) in [(1, true), (2, false), (3, true)] { + body["operation_id"] = Uuid::new_v4().to_string().into(); + body["requires_nip_fi"] = desired.into(); + body["expected_policy_generation"] = (generation - 1).into(); + body["expected_requires_nip_fi"] = (!desired).into(); + if desired { + body["expected_issuer"] = NIP_FI_ISSUER.into(); + } else { + body.as_object_mut().unwrap().remove("expected_issuer"); + } + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let accepted = read_json(response).await; + assert_eq!(accepted["receipt"]["policy_generation"], generation); + assert_eq!(accepted["receipt"]["requires_nip_fi"], desired); + assert_eq!(accepted["effective_required"], desired); + assert_eq!( + accepted["receipt"]["state"], + if desired { "pending" } else { "applied" } + ); + if generation == 1 { + let binding = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding + .take(); + let mut fresh = body.clone(); + let refused_id = Uuid::new_v4(); + fresh["operation_id"] = refused_id.to_string().into(); + fresh["expected_policy_generation"] = 1.into(); + fresh["expected_requires_nip_fi"] = true.into(); + let refused = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(fresh.to_string()), + ) + .await; + assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(refused).await["code"], + "authorization_unavailable" + ); + assert!(state + .db + .community_auth_policy_status(refused_id, &operator.public_key().to_hex()) + .await + .unwrap() + .is_none()); + let historical = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await, + ) + .await; + assert_eq!(historical["receipt"]["state"], "unavailable"); + assert_eq!(historical["receipt"]["policy_generation"], 1); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding = binding; + let text = body.to_string(); + let auth = fresh_upgrade_auth( + &operator, + &format!("http://{INGRESS_HOST}{path}"), + "POST", + Some(text.as_bytes()), + ); + let first = operator_request_with_auth( + state.clone(), + "POST", + path, + Some(text.clone()), + auth.clone(), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + let replayed_signature = + operator_request_with_auth(state.clone(), "POST", path, Some(text), auth).await; + assert_eq!(replayed_signature.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + replayed_signature.headers()[header::CACHE_CONTROL], + "no-store" + ); + } + history.push(body.clone()); + for old in &history { + let id = old["operation_id"].as_str().unwrap(); + let historical = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await; + assert_eq!(historical.status(), StatusCode::OK); + let historical = read_json(historical).await; + assert_eq!( + historical["receipt"]["current_policy_generation"], + generation + ); + assert_eq!( + historical["receipt"]["state"], + if old == &body { + if desired { + "pending" + } else { + "applied" + } + } else { + "superseded" + } + ); + let replay = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(old.to_string()), + ) + .await; + assert_eq!(read_json(replay).await, historical); + } + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert_eq!(current["expected_policy_generation"], generation); + assert_eq!(current["expected_requires_nip_fi"], desired); + } + let mut stale = history[0].clone(); + stale["operation_id"] = Uuid::new_v4().to_string().into(); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(stale.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(read_json(response).await["code"], "policy_conflict"); + let mut conflict = history[0].clone(); + conflict["requires_nip_fi"] = false.into(); + conflict.as_object_mut().unwrap().remove("expected_issuer"); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(conflict.to_string()), + ) + .await; + assert_eq!(read_json(response).await["code"], "idempotency_conflict"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = false; + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(history[0].to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + for mode in [buzz_auth::NipFiMode::Off, buzz_auth::NipFiMode::Enforce] { + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi + .mode = mode; + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert_eq!(current["effective_required"], mode.restricts()); + } + // Enforce remains a global floor even after a stored disable. + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; + body["operation_id"] = Uuid::new_v4().to_string().into(); + body["requires_nip_fi"] = false.into(); + body["expected_policy_generation"] = 3.into(); + body["expected_requires_nip_fi"] = true.into(); + body.as_object_mut().unwrap().remove("expected_issuer"); + let response = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await, + ) + .await; + assert_eq!(response["receipt"]["state"], "applied"); + assert_eq!(response["receipt"]["current_requires_nip_fi"], false); + assert_eq!(response["effective_required"], true); + let invalid = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{current_path}&operation_id={}", Uuid::new_v4()), + None, + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + // Old acceptance rows remain recoverable through the sole supported + // endpoint, including after their target has been deleted. Their null + // preconditions cannot match a new fenced request with the same UUID. + for target in [*community.id.as_uuid(), Uuid::new_v4()] { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO community_auth_upgrades + (operation_id,target_community_id,expected_host,actor_pubkey,expected_owner_pubkey, + expected_issuer,operator_pubkey,policy_generation) VALUES ($1,$2,$3,$4,$4,$5,$6,1)") + .bind(id).bind(target).bind(&host).bind(owner.public_key().to_hex()) + .bind(NIP_FI_ISSUER).bind(operator.public_key().to_hex()) + .execute(state.db.pool()).await.unwrap(); + let receipt = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await, + ) + .await; + assert!(receipt["receipt"]["expected_policy_generation"].is_null()); + assert!(receipt["receipt"]["expected_requires_nip_fi"].is_null()); + assert_eq!(receipt["receipt"]["requires_nip_fi"], true); + assert_eq!( + receipt["receipt"]["state"], + if target == *community.id.as_uuid() { + "superseded" + } else { + "unavailable" + } + ); + if target != *community.id.as_uuid() { + assert!(receipt["receipt"]["current_policy_generation"].is_null()); + assert!(receipt["receipt"]["current_requires_nip_fi"].is_null()); + } + let mut reuse = history[0].clone(); + reuse["operation_id"] = id.to_string().into(); + reuse["community_id"] = target.to_string().into(); + reuse["expected_policy_generation"] = 4.into(); + let conflict = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(reuse.to_string()), + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert_eq!(read_json(conflict).await["code"], "idempotency_conflict"); + let hidden = fresh_upgrade_request( + state.clone(), + &Keys::generate(), + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await; + assert_eq!(hidden.status(), StatusCode::FORBIDDEN); + } + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert!(current.get("requires_nip_fi").is_none()); + assert!(current.get("policy_generation").is_none()); + assert_eq!(current["expected_policy_generation"], 4); + assert_eq!(current["expected_requires_nip_fi"], false); + // The removed route has no operator-auth handler even for signed calls. + for method in ["GET", "POST"] { + let gone = fresh_upgrade_request( + state.clone(), + &operator, + method, + "/operator/communities/nip-fi-upgrade", + (method == "POST").then(|| "{}".into()), + ) + .await; + assert_eq!(gone.status(), StatusCode::NOT_FOUND); + } + // The control-plane signature and allowlist remain mandatory. + let outsider = Keys::generate(); + let response = fresh_upgrade_request(state, &outsider, "GET", ¤t_path, None).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 215c1472fc..3c2deba25f 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -408,9 +408,9 @@ pub struct Config { /// Parsed whatever the NIP-FI mode, since creation happens in every mode. pub nip_fi_provision_binding: Option, - /// Permit new irreversible FI upgrade acceptances. Default off; receipt - /// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`). - pub nip_fi_upgrades_enabled: bool, + /// Permit new community FI policy enables and disables. Default off; receipt + /// recovery/status remain available (`BUZZ_NIP_FI_CHANGES`). + pub nip_fi_changes: bool, /// Database resolver bounds (`BUZZ_NIP_FI_RESOLVER_*`, /// `BUZZ_NIP_FI_SNAPSHOT_*`). Validated in every mode, so a bad value @@ -1508,7 +1508,7 @@ impl Config { web_dir, serve_git_web_gui, nip_fi_provision_binding, - nip_fi_upgrades_enabled: parse_bool("BUZZ_NIP_FI_UPGRADES_ENABLED", false)?, + nip_fi_changes: parse_bool("BUZZ_NIP_FI_CHANGES", false)?, nip_fi_resolver_bounds, nip_fi, }) @@ -1582,33 +1582,27 @@ mod tests { } #[test] - fn nip_fi_upgrade_acceptance_is_explicitly_default_off() { + fn nip_fi_changes_are_explicitly_default_off() { let (_fi, _cfg) = env_guards(); - let before = std::env::var("BUZZ_NIP_FI_UPGRADES_ENABLED").ok(); - std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED"); - assert!(!Config::from_env().unwrap().nip_fi_upgrades_enabled); + let before = std::env::var("BUZZ_NIP_FI_CHANGES").ok(); + std::env::remove_var("BUZZ_NIP_FI_CHANGES"); + assert!(!Config::from_env().unwrap().nip_fi_changes); for value in ["true", "TRUE", "1", "on", " On "] { - std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value); - assert!( - Config::from_env().unwrap().nip_fi_upgrades_enabled, - "{value}" - ); + std::env::set_var("BUZZ_NIP_FI_CHANGES", value); + assert!(Config::from_env().unwrap().nip_fi_changes, "{value}"); } for value in ["false", "FALSE", "0", "off", "", " Off "] { - std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value); - assert!( - !Config::from_env().unwrap().nip_fi_upgrades_enabled, - "{value}" - ); + std::env::set_var("BUZZ_NIP_FI_CHANGES", value); + assert!(!Config::from_env().unwrap().nip_fi_changes, "{value}"); } - std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", "treu"); + std::env::set_var("BUZZ_NIP_FI_CHANGES", "treu"); assert!( matches!(Config::from_env(), Err(ConfigError::InvalidValue(message)) - if message.contains("BUZZ_NIP_FI_UPGRADES_ENABLED must be true or false")) + if message.contains("BUZZ_NIP_FI_CHANGES must be true or false")) ); match before { - Some(value) => std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value), - None => std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED"), + Some(value) => std::env::set_var("BUZZ_NIP_FI_CHANGES", value), + None => std::env::remove_var("BUZZ_NIP_FI_CHANGES"), } } diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index fa1b54f4d7..a6ed9c3280 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -620,14 +620,19 @@ fn parse_mode() -> Result { } } -/// A missing-setting startup error naming the configured mode. -fn mode_requires(mode: NipFiMode, missing: &str) -> ConfigError { - let value = match mode { +/// Stable configured mode name for operator diagnostics and status. +pub(crate) fn mode_name(mode: NipFiMode) -> &'static str { + match mode { NipFiMode::Enforce => "enforce", NipFiMode::Shadow => "shadow", NipFiMode::Off => "off", NipFiMode::DenyProtected => "deny_protected", - }; + } +} + +/// A missing-setting startup error naming the configured mode. +fn mode_requires(mode: NipFiMode, missing: &str) -> ConfigError { + let value = mode_name(mode); ConfigError::InvalidValue(format!("BUZZ_NIP_FI_MODE={value} but {missing}")) } diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 6fc5d9b4cd..a26063b4a5 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -60,7 +60,7 @@ async fn wait_for_blocker(db: &buzz_db::Db, waiter: i32, blocker: i32) { #[ignore = "requires Postgres"] #[allow(clippy::result_large_err)] // Exercise the real Axum admission response. async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without_invalidation() { - use buzz_db::community_auth::{UpgradeOutcome, UpgradeRequest}; + use buzz_db::community_auth::{PolicyOutcome, PolicyRequest, UpgradeRequest}; let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() .await .unwrap(); @@ -125,10 +125,18 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without assert_eq!( pod_a .db - .enable_community_nip_fi(&wrong_host, Ok(())) + .set_community_nip_fi_policy( + &PolicyRequest { + tuple: wrong_host.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false + }, + Ok(()) + ) .await .unwrap(), - UpgradeOutcome::Refused("host_conflict") + PolicyOutcome::Refused("host_conflict") ); // Keep acceptance paused AFTER it locks the community row, without @@ -169,7 +177,18 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without let acceptance = { let db = pod_a.db.clone(); let request = request.clone(); - tokio::spawn(async move { db.enable_community_nip_fi(&request, Ok(())).await }) + tokio::spawn(async move { + db.set_community_nip_fi_policy( + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + }, + Ok(()), + ) + .await + }) }; let acceptance_pid = host_lock_pid(&base.db, &host, false).await; let mut remap_connection = base.db.pool().acquire().await.unwrap(); @@ -218,7 +237,7 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without "single/double remap must remain blocked until acceptance commits" ); issuer_gate.commit().await.unwrap(); - let UpgradeOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { + let PolicyOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { panic!("acceptance"); }; let prime_to_commit = primed_at.elapsed(); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dcd58c5e91..9d42fd5ac6 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -514,9 +514,9 @@ pub fn build_router(state: Arc) -> Router { get(api::operator::community_nip_fi_binding), ) .route( - "/operator/communities/nip-fi-upgrade", - get(api::operator::community_nip_fi_upgrade_status) - .post(api::operator::enable_community_nip_fi), + "/operator/communities/nip-fi-policy", + get(api::operator::community_nip_fi_policy_status) + .post(api::operator::set_community_nip_fi_policy), ) .route( "/operator/communities/transfer", @@ -635,9 +635,7 @@ pub fn build_router(state: Arc) -> Router { .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) // Outside all rejection-producing layers, including the API body limit. - .layer(middleware::from_fn( - api::operator::no_store_upgrade_response, - )) + .layer(middleware::from_fn(api::operator::no_store_policy_response)) } type MakeHttpSpan = fn(&Request) -> tracing::Span; @@ -2395,6 +2393,92 @@ mod tests { nip_fi_state(buzz_auth::NipFiMode::Off).await } + #[tokio::test] + async fn git_policy_guards_preserve_unmatched_routes() { + use axum::{body::to_bytes, extract::ConnectInfo, http::Request}; + use std::net::SocketAddr; + use tower::ServiceExt; + + let app = build_router(nip_fi_off_state().await); + let localhost: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + let remote: SocketAddr = "192.0.2.1:12345".parse().unwrap(); + // Exercise the merged production router, including its default fallback. + for peer in [None, Some(remote), Some(localhost)] { + for method in ["GET", "POST"] { + for path in ["/operator/communities/nip-fi-upgrade", "/unknown-route"] { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("content-length", 1024 * 1024 + 1) + .body(Body::from(vec![b' '; 1024 * 1024 + 1])) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "{method} {path}, peer={peer:?}, body={body:?}" + ); + } + } + } + for (peer, size, expected) in [ + (None, 2, StatusCode::FORBIDDEN), + (Some(remote), 1024 * 1024 + 1, StatusCode::FORBIDDEN), + ( + Some(localhost), + 1024 * 1024 + 1, + StatusCode::PAYLOAD_TOO_LARGE, + ), + // A small JSON object reaches the real handler's JSON extractor. + (Some(localhost), 2, StatusCode::UNPROCESSABLE_ENTITY), + ] { + let mut payload = b"{}".to_vec(); + payload.resize(size, b' '); + let mut req = Request::builder() + .method("POST") + .uri("/internal/git/policy") + .header("content-type", "application/json") + .header("content-length", size) + .body(Body::from(payload)) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, expected, + "peer={peer:?}, size={size}, body={body:?}" + ); + if expected == StatusCode::FORBIDDEN { + assert_eq!(&body[..], b"internal endpoint: localhost only"); + } + } + // The path's method fallback remains protected, too. + for (peer, expected) in [ + (None, StatusCode::FORBIDDEN), + (Some(remote), StatusCode::FORBIDDEN), + (Some(localhost), StatusCode::METHOD_NOT_ALLOWED), + ] { + let mut req = Request::builder() + .uri("/internal/git/policy") + .body(Body::empty()) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), expected, "GET policy, peer={peer:?}"); + } + } + async fn nip_fi_state(mode: buzz_auth::NipFiMode) -> Arc { use crate::nip_fi_config::NipFiRelayConfig; use buzz_auth::IssuerRegistry; diff --git a/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs b/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs index 433c1f8164..32121467c7 100644 --- a/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs +++ b/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs @@ -116,12 +116,12 @@ mod postgres_tests { use crate::nip_fi_core::test_support::{ assertion_verifier, sign_assertion, TEST_COMMUNITY_URI, TEST_ISSUER, }; - use buzz_db::community_auth::{UpgradeOutcome, UpgradeRequest}; + use buzz_db::community_auth::{PolicyOutcome, PolicyRequest, UpgradeRequest}; let base = real_db_state().await.unwrap(); let mut configured = (*base).clone(); let config = Arc::make_mut(&mut configured.config); config.nip_fi.mode = buzz_auth::NipFiMode::Shadow; - config.nip_fi_upgrades_enabled = false; + config.nip_fi_changes = false; config.nip_fi.max_connection_lifetime_secs = 3600; config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test_any_host( TEST_COMMUNITY_URI, @@ -223,9 +223,17 @@ mod postgres_tests { expected_issuer: TEST_ISSUER.into(), operator_pubkey: owner, }; - let UpgradeOutcome::Accepted(record) = state + let PolicyOutcome::Accepted(record) = state .db - .enable_community_nip_fi(&request, Ok(())) + .set_community_nip_fi_policy( + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + }, + Ok(()), + ) .await .unwrap() else { @@ -311,13 +319,13 @@ mod postgres_tests { } let status = state .db - .community_auth_upgrade_status(record.operation_id, &request.operator_pubkey) + .community_auth_policy_status(record.acceptance.operation_id, &request.operator_pubkey) .await .unwrap() .unwrap(); - assert_eq!(status.state, "pending"); + assert_eq!(status.acceptance.state, "pending"); assert_eq!( - status.pending_reasons, + status.acceptance.pending_reasons, vec!["connection_closure_unverified"] ); drop((compliant, compliant_audio, free)); diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index ed5205b1af..3a98b2af2d 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -1,434 +1,151 @@ -# Community NIP-FI upgrade operator contract +# Community NIP-FI policy operator contract -The acceptance layer records durable policy. Admission enforces the effective -requirement, and the periodic community revalidator eventually closes existing -legacy root and audio WebSockets. Deploy and verify these behaviors on every -serving relay before enabling upgrade calls. The receipt proves acceptance; it -never reports `active` or certifies that all old work has finished. +The operator plane manages a stored community requirement independently of the +global FI mode. See [operator usage](operator-nip-fi-upgrade.md). Receipts prove +durable acceptance, never fleet-wide socket closure or current authorization. -The operator HTTP plane is separate from tenant event ingest. These management -operations extend the existing provisioning API; community clients cannot invoke -them with ordinary membership credentials. Both routes require an allowlisted -operator's NIP-98 signature bound to the configured operator API origin, exact -method and URL, including the query string on GET, and the POST body hash. -Operator POST verification does not bind query strings. Each attempt needs a fresh -signature; operation idempotency does not bypass transport replay protection. -Responses use `Cache-Control: no-store`. +## Operator boundary -`BUZZ_NIP_FI_UPGRADES_ENABLED` defaults to `false`. While disabled, new -acceptances return 503 `upgrades_disabled` with no policy mutation or -receipt. Authorized GET and identical POST receipt recovery remain available; -principal checks and payload conflicts are unchanged. Enabling this deployment -switch is an explicit operator action after admission and eventual socket closure -are verified on every serving relay; caller release discipline alone cannot enable acceptance. +POST/GET `/operator/communities/nip-fi-policy` require an allowlisted operator's +NIP-98 signature bound to `RELAY_OPERATOR_API_ORIGIN`, method and URL (including +the GET query), and POST body hash. Each attempt needs a fresh signature. These +routes extend provisioning outside tenant event ingest; ordinary membership +credentials cannot invoke them. All responses use `Cache-Control: no-store`. + +The operator mediates an authenticated actor's registered key. The relay verifies +the operator and stored owner; `actor_pubkey` is the mediator's claimed actor, +not an independent user credential. Never accept a caller-selected actor as identity. ## Generation storage -`communities.policy_generation` is `BIGINT NOT NULL DEFAULT 0`, without a -nonnegative database CHECK. Supported writers retain the current generation -when the community already requires FI, or increment it by one on acceptance. -Starting from zero, those writes preserve the nonnegative invariant. Direct SQL -can insert a negative community generation and is outside that writer contract. -Positive-generation CHECKs on acceptance receipts remain in place; receipt -matching still requires the exact accepted generation. +`communities.requires_nip_fi` defaults false; `policy_generation` defaults zero. +Every new general operation advances the generation, even a same-state intent. +Replay never increments. Generation exhaustion is refused. The historical +`community_auth_upgrades` table stores acceptance receipts for both directions. +Receipts survive target deletion and are not tenant serving data. -The community CHECK is omitted to avoid validating existing rows while adding -the column. The current schema planner folds a column and its CHECK together, -and can rebuild and validate a CHECK when its validation metadata differs. -Migration, fresh desired-state creation and later reconciliation therefore all -omit this community CHECK. No bootstrap or schema-tooling workaround is required. +## Enable and disable -## Enable +POST contains `operation_id`, `community_id`, canonical `expected_host`, +`actor_pubkey`, `expected_owner_pubkey`, desired `requires_nip_fi`, and mandatory +`expected_policy_generation` plus `expected_requires_nip_fi`. UUIDs must be non-nil, +keys lowercase hex, generations nonnegative; unknown fields are rejected. +Enable requires `expected_issuer`; disable must omit it. -`POST /operator/communities/nip-fi-upgrade` +Read current preconditions with GET `?community_id=&expected_host=`. +Both generation and stored state must still match under the writer row lock. +A mismatch returns 409 `policy_conflict` without mutation or receipt. Re-read +and use a new UUID/request for revised intent. On a lost response, preserve the +original complete file/UUID and recover or retry it with a fresh signature. -```json -{ - "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", - "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", - "expected_host": "team.communities.example", - "actor_pubkey": "", - "expected_owner_pubkey": "", - "expected_issuer": "https://identity.example/api/goose" -} -``` +Acceptance serializes UUID reuse, then locks deletion-shared → community +`FOR NO KEY UPDATE` → exclusive host lease, preserving lifecycle/deletion-intent, +exact host, single owner and actor==owner checks and community write admission. +Acceptance and receipt commit atomically. UUID identity binds the full tuple, +action, both preconditions and authenticated operator. Different +meaning under a committed UUID returns 409 `idempotency_conflict`. Identical +committed replay recovers history before mutable preflight and never writes policy. -The trusted operator mediates the authenticated actor. Kgoose must resolve that -actor's usable registered key from its session; it must never accept a user-supplied -actor key as identity. The actor must equal the expected and current owner. -`actor_pubkey` records the mediator's claimed authenticated actor for audit and -idempotency; it is not an independently verified end-user credential. The relay -authenticates the operator and checks the stored current owner. -Host and keys normalize before comparison; issuer bytes match exactly. UUIDs -must be non-nil. Unknown JSON fields are rejected. - -The deployment selects the expected issuer from the **single** authorized issuer -in `BUZZ_NIP_FI_PROVISION_BINDING`. An absent or ambiguous selection is unavailable. -The target must be bindable under that policy and the issuer must have a configured -`BUZZ_NIP_FI_ISSUERS` policy. The same issuer must already be bound to the community -in `community_nip_fi_issuers`. The call never adds or broadens trust. - -A transaction serializes UUID reuse across targets, then community lifecycle, -host and owner checks against transfer/archive/delete. It locks the exact issuer -row against unbind. Only a first optional-to-required transition increments -`policy_generation`; concurrent distinct operations converge on that generation. -Acceptance and its receipt commit together. No timeout or retry clears the policy. +Enable requires a single deployment-selected authorized issuer in +`BUZZ_NIP_FI_PROVISION_BINDING`, an applicable registry policy and exact existing +community binding. It locks the binding against unbind and adds no trust. +Disable does not depend on issuer availability and does not alter bindings. +Every optional→required transition, including re-enable, excludes new host leases +and drains prior optional leases for 1.1 seconds before committing. ## Readback and response -`GET /operator/communities/nip-fi-upgrade?operation_id=` +GET `?operation_id=` returns only the originating operator's receipt. +Mixed query forms are invalid. The response separates `receipt` (original tuple, +accepted generation/action/preconditions and current target generation/flag) from +`mode` and `effective_required`. Current-state reads also print exact expected +`expected_policy_generation` and `expected_requires_nip_fi` fields, without a +duplicate unprefixed pair. Missing/deleted targets have null current fields. -Only the original authenticated operator can recover the receipt. Readback -survives owner transfer, archive and deletion; the recorded owner is historical -acceptance evidence, not authority for a fresh enable. - -Enable and readback return HTTP 200 for an accepted receipt: - -```json -{ - "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", - "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", - "expected_host": "team.communities.example", - "actor_pubkey": "", - "expected_owner_pubkey": "", - "expected_issuer": "https://identity.example/api/goose", - "operator_pubkey": "", - "policy_generation": 1, - "requires_nip_fi": true, - "state": "pending", - "pending_reasons": ["connection_closure_unverified"] -} -``` - -`requires_nip_fi: true` records durable acceptance. `state` reports availability -and connection closure separately. The acceptance layer returns `pending` while the target's policy, -lifecycle and issuer binding still match, and `unavailable` if they do not or -trusted issuer configuration is no longer applicable. A missing database response -is HTTP 503, never a fabricated receipt. A receipt is not proof of current service -health. No response in this version reports `active`. - -`pending_reasons` applies only while `state` is `pending`; clients must ignore -those obligations when state is `unavailable`, which always takes precedence. -`pending_reasons` is extensible. Clients must preserve pending on unknown values. -`connection_closure_unverified` means that this version does not certify socket -closure. It remains present after successful sweeps: neither a timer nor local -socket counts prove fleet-wide completion. Clients must treat `pending` as -accepted policy whose connection closure is unverified, rather than wait for an -activation ACK that this version does not produce. - -The UUID binds the normalized community/host/actor/owner/issuer/operator tuple. -Identical retries recover that tuple and generation before rechecking mutable -preconditions. Changed payload or principal conflicts. After a lost POST response, -read by UUID or retry the same payload with a fresh NIP-98 signature. Do not generate -a new UUID to hide an ambiguous response. No rollback endpoint exists. - -| HTTP | `code` | Meaning | +| Receipt state | Meaning | CLI exit | |---|---|---| -| 400 | `invalid_request` | Invalid enable JSON, UUID, host or key | -| 401/403 | Existing operator auth errors | Missing/invalid credentials or operator not allowlisted | -| 404 | `community_not_found` | No target for a first acceptance | -| 404 | `operation_not_found` | No receipt owned by this operator | -| 409 | `idempotency_conflict` | UUID already bound to a different tuple/principal | -| 409 | `host_conflict` | ID does not map to expected host | -| 409 | `owner_conflict` | Actor/expected owner/current owner disagree | -| 409 | `lifecycle_conflict` | Archive/deletion or durable deletion intent | -| 409 | `issuer_conflict` | Expected issuer/host not approved, or exact stored binding absent | -| 503 | `upgrades_disabled` | New acceptances disabled; wait for operator enablement, preserving the same UUID | -| 503 | `authorization_unavailable` | Trusted config or authoritative DB unavailable; retry/read back same UUID | +| `applied` | Current stored disable | 0 | +| `pending` | Current accepted enable; closure uncertified | 10 | +| `superseded` | Later generation or state differs | 11 | +| `unavailable` | Target/lifecycle/host or required issuer trust unavailable | 12 | -Malformed GET queries use Axum's existing query-extractor rejection. Transport -signature replay uses the existing operator replay error independently of this table. +`pending_reasons` includes `connection_closure_unverified` for enable. Neither +time, local counters nor a successful sweep certify fleet completion. No response +reports `active`. History is not a current authorization credential; ownership +changes do not destroy the original operator's recovery authority. + +Invalid requests return 400; operator auth uses existing 401/403 errors. Missing +community/operation returns 404. Host, owner, lifecycle, issuer and policy conflicts +return 409 with corresponding `*_conflict` codes; generation exhaustion returns +409 `policy_generation_exhausted`. New mutations behind a closed gate return +503 `upgrades_disabled`; unavailable authoritative DB/trust returns +503 `authorization_unavailable`. Request/transport/response errors exit 5. ## Schema and rollout -Migration 0082 is additive: legacy rows keep `requires_nip_fi=false` and generation -zero. It adds no trigger, function, procedure, rule or foreign key and rewrites no -existing rows. Lock and statement budgets are 5 and 30 seconds. This migration -changes no data-plane gate. Relay mode determines whether stored requirements -are enforced, as described below. -Do not infer requirements from issuer rows, backfill free communities, or remove -the enterprise enforce floor as part of this migration. +Migration 0085 adds desired action and nullable CAS preconditions to existing +receipts. True/default and null preconditions preserve old receipt meaning and +old INSERT compatibility. No data backfill, trigger, function or foreign key is +added. Historical rows with null CAS preconditions remain readable through +policy status, including after target deletion. A new fenced request cannot reuse +such a row's UUID: null historical preconditions differ from the mandatory new +preconditions, so it returns `idempotency_conflict` without mutation. -Apply schema before new code. Desired-state and migration definitions match; -The registry entry is static lint metadata, so it is present in the migration -and `schema/seeds/`, not the pgschema reconciliation script (database.md Rule 3). -This follows the other operator-global registry entries. Receipts intentionally survive community purge: -the target ID/host/owner tuple prevents reusing a lost-response UUID for another -community, including after deletion. They are operator-only recovery evidence. -No tenant serving writer owns this table and no fence trigger is attached. +The old `/operator/communities/nip-fi-upgrade` endpoint and the `nip-fi-upgrade` +admin command have been removed. Its GET and POST requests receive 404; use +`nip-fi-policy` for both changes and receipt recovery. Upgrade existing clients +for the [policy client contract](operator-nip-fi-policy-client.md) before this +relay version reaches their fleet, with their mutations paused until rollout ends. +No client may guess CAS fields or refresh a saved request during recovery. -The active-community primary accessor is -`Db::community_auth_policy(CommunityId) -> Result>`. -`None` means no active target. Repair mode denies protected traffic first. In -`off`, no community requires FI, including a stored-required community; FI lookup -or dependency failures must not deny ordinary admission. In `shadow`, enforce -stored requirements and observe optional communities. In `enforce`, require FI -for every community. Evaluating policy lookups fail closed on errors. Mode -changes do not clear the stored requirement, generation, issuer trust or receipts. +Before rolling, close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay and keep +`BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first, roll the whole +fleet to the supported policy writer, then explicitly enable the new gate and +resume migrated clients. Old binaries with their old gate open can still accept +unfenced enables and reverse a disable; a new-name setting alone cannot close +that old gate. Retire the old setting after the last old binary is gone. +Reads and committed replay remain available on the supported API with its gate +closed. Do not roll back to a writer that exposes unfenced enable after any +policy operation. This removal does not erase receipts or rewrite schema. -## Mistaken acceptance and containment +## Request enforcement and discovery -Stop the initiating caller and set `BUZZ_NIP_FI_UPGRADES_ENABLED=false` to prevent -new acceptances. Keep authenticated status/readback available. Record the exact -operation, target and generation for operator-led incident assessment and recovery. -Preserve the durable requirement, issuer trust and receipts while restoring a -working FI path or containing the affected community through the established -archive/lifecycle controls. Coordinate any necessary issuer/configuration repair -through its existing operator procedure; never broaden trust to make a probe pass. +`off` ignores stored requirements; `shadow` enforces stored true; `enforce` is a +global requirement floor; `deny_protected` denies protected traffic. Report +`effective_required` alongside stored state so disable under enforce and enable +under off cannot be mistaken for effective changes. Evaluating policy lookups +fail closed on errors; off does not require an FI policy read. -Disabling calls or rolling back code does not undo acceptance. In Shadow, never -restore a serving binary that ignores the durable requirement; retain effective enforcement -or contain the community while repairing the FI path. There is no -clear-policy API or routine SQL reset. A timeout or mistaken call does not -authorize clearing the requirement or discarding operation evidence. - -An explicit operator change to `off` intentionally suspends FI enforcement for -all communities while preserving durable requirements and receipts. This is a -relay-wide mode choice, not an undo of acceptance or a binary rollback. Readback -must distinguish stored policy from effective enforcement and must not report -active enforcement in Off. Returning to `shadow` resumes enforcement for stored -required communities; `enforce` requires FI everywhere. Off suspends FI for -every community; it is not per-community containment. Choose relay-wide Off -suspension or archive/lifecycle containment explicitly; neither a fleet-wide -drain nor a deployment is an automatic fallback. - -Last-issuer removal is refused once the community requires NIP-FI, including -when the operator binding path requests `allow_last`. Bind a replacement before -removing an issuer, or use archive/lifecycle controls for containment. Removing -the issuer recorded by a receipt while another remains makes that receipt -`unavailable`; it never clears the requirement or destroys the receipt. -Bulk backfill does not recreate missing bindings for required communities; -its unbound report still exposes them for explicit operator repair. - -## Request enforcement and discovery (BUZZ-511) - -Relay mode controls the effective requirement without changing durable storage: -`off` disables FI for all communities, including stored `requires_nip_fi=true`; -`shadow` enforces FI only for stored required communities and observes optional -communities; `enforce` requires FI everywhere. `deny_protected` remains an -unconditional repair refusal. Off ignores missing FI policy, verifier, deny map -and issuer dependencies; ordinary tenant routing and Nostr authorization remain. -Outside Off, protected requests resolve their normalized Host's active policy -through a bounded primary-read lease cache, with a 500 ms budget on misses. -Existing Nostr proof, key pairing, deny map, membership and resource checks remain. -Discovery, pairing, enrollment and operator exceptions keep their own authentication. - -The router shares one policy snapshot with HTTP handlers for that request. Git -also scopes its extractor to one policy and tenant snapshot, retaining its -existing header, signed-URL and denial ordering. Routing and policy share one -host cache capped at 10,000 entries. A serving entry carries immutable tenant ID, -requirement, generation and original read time from the same row. It is usable -for strictly less than one second from before the read's connection checkout; -neither a slow read nor a cache hit renews its lifetime. Concurrent fills for a -host share a successful read. Misses, errors and timeouts are never cached. A late result -after invalidation is usable once by its in-flight request but never published, -and an older generation cannot replace a newer -observed generation for the same community. Off retains ordinary routing-only -cache population and does no FI-policy fills. - -Cache fills take a shared transaction-scoped advisory lock keyed by normalized -host in a dedicated namespace. Lock acquisition and the serving-row SELECT are -separate statements in an explicit READ COMMITTED transaction. A contended fill -refuses immediately; a statement has a 400 ms server-side timeout inside the -500 ms overall budget. Waiters on the per-host fill share its successful result; -when no result was cached, each waiter makes at most one lease read of its own -inside that same budget, with no retry loop. Unmapped hosts remain 404. -Each cold policy fill uses six database statements: BEGIN, isolation setup, -statement-timeout setup, try-lock, row SELECT and COMMIT. Ordinary routing keeps -its single plain serving-row query and a routing-only snapshot in the same -bounded host cache. That snapshot cannot supply FI policy. A routing refresh -preserves a fresh policy for the same tenant without extending its lease. - -Acceptance takes the shared deletion lock and locks the target community row -`FOR NO KEY UPDATE`. It checks the row's host against `expected_host`, then takes -the exclusive advisory lock derived from that locked host. An optional row waits -1.1 seconds before the remaining owner/issuer checks and commit. The row stays -locked throughout, preventing single or double host remaps during the drain. -This also expires leases issued under any former host before row-lock acquisition. -An initially wrong host precondition is refused before waiting. - -This drains every optional one-second lease across all pods BEFORE commit, -without relying on Redis delivery or synchronized wall clocks. The extra 100 ms -is a clock-rate margin. Lease duration and acceptance wait are compile-time fleet -protocol constants; increasing the lease requires a coordinated writer rollout -first. Existing receipt recovery skips this wait. Already-required targets need -no lease drain. Fills take only a shared advisory host lock and never lock the -community row, preserving the deletion-shared → row → host-lock ordering. - -Policy resolutions that start after acceptance commits therefore cannot use an -optional lease for that community, even on a pod that missed invalidation. -Admissions that resolved policy before commit can still be in flight, exactly -as with primary reads; existing sockets are caught by a later community sweep, and already-running -requests may finish. Cache expiry does not certify socket closure. -The acceptance drain blocks new policy fills and other acceptances for the -host. Its community row lock also blocks archive/unarchive, deletion admission -and fences, ownership transfer/convergence and issuer-binding writers for that -community during the 1.1-second wait. These are operator actions; ordinary -serving reads and writes do not take a conflicting row lock. Foreign-key -`FOR KEY SHARE` locks do not conflict with `FOR NO KEY UPDATE`, and the shared -deletion lock remains compatible with ordinary tenant writers. Cold policy -requests during the transition may return 503; acceptance is rare and this -avoids allowing stale optional leases after commit. - -Relay acceptance, provisioning and archive/unarchive writes drop the local host -snapshot and publish a best-effort Redis invalidation to other pods. An epoch -fences fills that began before a local or received drop. Direct administrative -database writes and missed notifications retain the one-second routing bound. -Host remap, archive and deletion do not drain leases: cached tenant binding can -lag those changes by up to one second, as ordinary host routing already does. -Policy and tenant data always use the same cached row, including NIP-11 -descriptors and decorations. Issuer-binding and JWKS freshness keep their own -existing bounds and checks; cached status never substitutes for those checks. - -`buzz_nip_fi_policy_cache_hits_total` and `buzz_nip_fi_policy_cache_misses_total` -measure cache use; `buzz_nip_fi_policy_reads_total` counts fill attempts and -`buzz_nip_fi_policy_read_seconds` measures completed source reads. A warm ordinary -protected request or discovery request performs no policy writer read. Root and -audio upgrades intentionally keep a final uncached authoritative tenant/policy -binding after the early evidence gate: one read on a cache hit, two on a cold -successful upgrade. Its optional-to-required recheck preserves the unarchive and -host-remap race fence, as well as early refusal and deadline ordering. - -Issuer-binding/JWKS caches keep their existing bounds; unavailable dependencies -refuse required admission, rather than treating it as optional. - -WebSocket upgrades also bind tenant identity and its policy from the same primary -row. An earlier missing or optional policy result cannot authorize a socket for a -required row that becomes serving during lookup (for example, on unarchive). -Before upgrade, enforce that bound row's requirement even when the early check -ran in Shadow. Off never promotes a stored requirement. This changes no discovery fields. - -Database-sourced deployments load the FI binding snapshot and command components -only in evaluating modes. Off does not depend on that snapshot and cannot report -required-admission readiness. Configure finite -`BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS`, issuer JWKS and command configuration -before enforcing any row in Shadow or Enforce. Missing required components return -503 only when FI is enabled. An optional row -still follows global off/shadow behavior, including signed and unsigned legacy -proofs where the endpoint permits them. - -Both `GET /info` and root NIP-11 negotiation return `Cache-Control: no-store`. -An unknown or non-serving Host returns generic 404. Outside Off, policy lookup -failure returns 503. Off uses ordinary tenant routing and returns generic 404 on -a tenant lookup failure, without consulting FI policy. The top-level `requires_nip_fi` is a host-specific boolean. Existing -`limitation.federated_identity` and `federated_identity` descriptor fields retain -capability semantics: advertise when global mode evaluates OR the host requires -FI. Shadow optional hosts still advertise capability. Off hosts advertise neither -FI capability nor an effective requirement, even when stored policy is required. No issuer, audience or identity -provider address is added to discovery. Existing clients can keep reading their -current fields; new clients must use the requirement field to distinguish optional -capability from mandatory evidence. +Host policy comes from the writer, bound to community identity and lifecycle. +Optional cache leases last at most one second; enable excludes refills and drains +them. Fail-closed cache ordering may retain old required state for the remaining +lease after disable. It cannot renew that stale lease. `/info` advertises effective +`requires_nip_fi` without exposing operator receipts. ### Existing connections -The existing community revalidator also reads durable authentication policy from -the writer by immutable community ID for communities with local sockets. Each -policy read has a 500 ms budget. A failed read retains that community's sockets, -continues the sweep, and retries on a later pass. No Redis notification or client -request is needed. An upgrade callback that registers after a scan is caught on -the next pass. +The periodic revalidator reads stored policy without row-locking transactions. +After disable it stops requesting required-policy closes; a concurrent sweep may +request one last non-FI close from its earlier snapshot. That client reconnects +under optional policy. FI-admitted sockets retain their finite lifetime and then +reconnect under current policy. Disable does not reopen or force-logout sessions. +Re-enable repeats the optional-lease drain; non-FI root/audio sockets close through +existing revalidation. Valid FI-admitted sockets retain existing rules. -When effective mode requires FI, legacy and optional-shadow sockets receive -Close 1008 (`community authentication upgraded`) through the existing transport -cleanup paths. Only the enforced FI admission marker preserves a socket; later -NIP-42 AUTH does not turn a legacy socket into an FI admission. Valid FI sessions -retain their normal expiry and revocation rules. Other communities stay open. -Off and repair mode retain their existing behavior; Off suspends FI enforcement. +## Mistaken acceptance and containment -`BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` defaults to 30 seconds, clamped to 1–300 seconds. -This interval is not a deadline through database failure, sweep duration, -overload or a paused relay. Closure requires a successful sweep on each running -compatible relay. Already-running HTTP requests and descendant work may finish. -There is no boot inventory, drain ACK, or durable fleet-completion certification. -The operator receipt remains `pending` even after successful local closure. +Stop an uncertain initiating caller and preserve its operation evidence. Read +current policy before deciding whether to issue a new explicit CAS disable. +A timeout is not permission to discard the original request or edit SQL. +Disable is a supported operator action; acceptance is no longer monotonic. +Disable removes the required-policy block on last-issuer removal; the separate +last-binding guard still applies. The operator unbind command keeps the last +issuer for bindable hosts. Re-enable must recheck trust and the exact binding. +Removing trust never implicitly clears stored policy. -For each serving relay, observe -`buzz_community_auth_policy_checks_total{outcome="required"}` advancing after a -required policy change, with no sustained `error` or `timeout` outcomes. -Other bounded outcomes are `optional` and `inactive`; no community labels are -emitted. `buzz_community_auth_policy_disconnects_total` counts legacy socket -cancellation requests. Verify actual Close 1008 on idle root/audio probes and -successful FI reconnects on that relay; the counters alone do not certify -transport retirement or fleet completion. A relay without local sockets has no -policy checks to report. Account for that when probing each serving relay. - -There is no new feature flag. The first successful sweep after deployment can -close existing legacy sockets in already-required communities. Count required rows and assess concurrent -reconnect load before rollout; this change does not spread those reconnects. -Keep new upgrades disabled throughout mixed-version deployment. A binary rollback -needs no migration and restores the old receipt reason; clients must tolerate -unknown pending reasons. An older binary cannot provide this eventual-closure -behavior, so retain a compatible binary or contain affected communities instead -of assuming rollback undoes accepted policy. - -### Staging and containment - -Outside Off, this changes every serving host, including free hosts: protected -routes and discovery require a valid primary policy lease. Off retains ordinary routing. Unmapped discovery previously could -return a generic document; it now returns 404. Policy failures return 503. -Database-source startup loads its FI binding snapshot only in evaluating modes; -Off does not load it. A failed load still fails startup in Shadow or Enforce. The local dependency gauge -`buzz_nip_fi_required_admission_ready` must be 1 before accepting any upgrade; -it covers verifier, deny map and finite lifetime, not verification of socket closure. -Binding and JWKS readiness must also be verified through a signed request. - -Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` throughout a mixed-version rollout -until EVERY acceptance path implements the host-lock lease-drain protocol. -Uncached serving pods can coexist with caching pods, but an old acceptance -writer cannot safely coexist with cached optional leases. Before rolling an -acceptance writer back or removing this protocol, disable new acceptance on -every writer and verify that disablement; keep the lease-aware writers until -in-flight acceptance has finished. Disabling new calls does not undo existing -requirements. Existing required-community admission, eventual socket closure -and revocation prerequisites still apply. Never enable an older acceptance path -while any caching pod serves optional communities. -Older pods may log an unknown `CommunityHost` cache-invalidation message during -the mixed-version rollout. They keep serving through their existing uncached -policy reads; missing that best-effort notification does not weaken the lease -protocol. The acceptance-writer gate above still applies. - -Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` during the initial staging rollout. -Start with zero required rows and record a 30-minute baseline using representative -free signed/unsigned HTTP, root/audio WS and discovery traffic. Compare identical -traffic before and after this change. Record request rate, policy-read p95/p99, -writer pool acquisition p99 (`buzz_db_pool_acquire_duration_seconds`, -`pool_role=writer`, `operation=authorization`), policy timeout/error count, HTTP -503 rate and startup/restart success. A local PostgreSQL 16 probe measured 100 -serial reads in 13.36 ms; that is not a production capacity result. - -Initial staging abort criteria (validate before increasing traffic): any policy -timeout, policy-induced 503, unexpected free-admission denial or startup failure; -policy-read p99 above 50 ms; writer acquisition p99 above 20 ms or more than twice -its pre-change baseline; total 503 rate more than 0.1 percentage point above -baseline over five minutes. These are conservative rollout limits, not measured -production guarantees. Do not expand until the full 30-minute run stays within -them and the operator has reviewed measured results. Verify unknown-host 404 and -dependency-failure 503 with no-store explicitly. - -Then test a disposable required community with complete FI dependencies, in both -Shadow and Enforce: unsigned/missing evidence denies, signed valid FI succeeds, -root/audio upgrades agree, discovery agrees, and a pending deletion request does -not weaken the requirement. Before production acceptance, verify idle legacy root/audio closure after a -durable policy change, FI reconnects and isolation on every serving relay. No -production verification is established by local tests. - -Before any required row exists, an operator may stop the rollout and restore the -previous binary after verifying the primary contains zero required rows. Once a -requirement has been accepted, a pre-511 binary in Shadow is unsafe: it ignores -that row. Off explicitly suspends FI; global Enforce still requires all hosts. -Stop new upgrade calls, retain policy and receipt records, and keep an enforcing -binary while repairing dependencies. If admission cannot be restored, contain the -affected serving hosts with an operator-approved repair-mode/traffic action; -Off explicitly disables FI; use it only as an operator mode decision and never -clear accepted policy. Returning to Shadow restores stored requirements. No automatic fleet -drain or deployment is prescribed here. Existing receipt recovery remains usable -while new acceptances are disabled. - -Required sessions admitted under global Shadow are real enforced FI sessions. -The existing global-Shadow admin disconnect path still only observes would-close; -A separate revocation change must close these enforced sessions independently of global rollout mode, -while preserving the separate optional-shadow registry. Before enabling upgrade -acceptance for any serving community in Shadow, deploy and verify that local -and cross-pod disconnect behavior, including real Redis propagation. Keep upgrade -acceptance disabled until that prerequisite is met; admission tests alone do not -establish it. This socket sweep does not change that independent revocation prerequisite. +Close the mutation gate to stop new changes while keeping recovery available. +Use established archive/lifecycle containment or repair FI trust as appropriate; +never broaden trust to make a probe pass. Global off suspends FI everywhere while +preserving stored policy and receipts; it is not per-community disable. Binary +rollback does not undo acceptance and must not restore unfenced writers. No live +mutation or deployment is implied by this procedure. diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 683d82fef3..037bb727d6 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -5,6 +5,10 @@ A deployment that provisions communities under one host suffix, for example 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. +To make an existing community require federated sign-in, use the separate +[community policy management command](operator-nip-fi-upgrade.md). +An issuer binding alone does not change that community's required policy. + 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 @@ -83,7 +87,7 @@ an operator disable or a change in issuer configuration. A different owner retains the existing conflict. Legacy ensure and deployment bootstrap remain optional and do not accept an explicit initial policy. -This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +This policy is independent of `BUZZ_NIP_FI_CHANGES`. Mode `off` stores it but bypasses enforcement. The production creation paths are operator create-only, legacy operator ensure, and deployment bootstrap; only create-only accepts this choice. diff --git a/docs/operator-nip-fi-policy-client.md b/docs/operator-nip-fi-policy-client.md new file mode 100644 index 0000000000..0faadc3f1e --- /dev/null +++ b/docs/operator-nip-fi-policy-client.md @@ -0,0 +1,46 @@ +# Operator NIP-FI policy client contract + +Use signed POST and GET `/operator/communities/nip-fi-policy`. The former +`/operator/communities/nip-fi-upgrade` route is removed and returns 404 for GET +and POST; it no longer performs operator authentication. Deployment proxies +may independently reject a request before it reaches the relay. + +For a new intent, read GET `?community_id=&expected_host=`. Copy its +`expected_policy_generation` and `expected_requires_nip_fi` into the request. +These are the only current-state field pair; the response also includes +`community_id`, `expected_host`, `mode` and `effective_required`. + +Durably save the complete immutable request before its first possible dispatch: +operation UUID, community identity, expected host, authenticated actor and owner, +desired `requires_nip_fi`, both CAS preconditions, and the trusted issuer for an +enable. Disable omits the issuer. After a crash, timeout or lost response, read +GET `?operation_id=` or replay that exact saved request with a fresh NIP-98 +signature. Never reread current policy and overwrite its saved CAS fields under +the same UUID. A `policy_conflict` requires a new explicitly authorized intent +with a new UUID and freshly observed preconditions. + +POST and operation-status GET return `{ "receipt": { ... }, "mode": "...", +"effective_required": true }`. Validate the authenticated operator, saved tuple, +desired action, CAS preconditions and positive accepted generation inside +`receipt`. Its `current_policy_generation` and `current_requires_nip_fi` describe +the current target, not the original intent. They are null after target deletion. +`pending`, `applied`, `superseded` and `unavailable` have the meanings in the +[operator contract](community-auth-upgrades.md#readback-and-response). +Historical acceptance never certifies current policy or fleet-wide socket closure. +No response reports `active`. + +Previously accepted enable receipts remain readable through operation-status +GET, with desired=true and null CAS preconditions. Recover them as history; do +not invent preconditions or convert them into a new write. Reusing their UUID +for a new fenced request returns `idempotency_conflict`, even after deletion. +Treat only the structured 404 `operation_not_found` from the supported endpoint +as an absent receipt. A 404 from the removed route is not evidence that an +operation was never accepted. + +Ship migrated callers with mutation dispatch paused before rolling out the +route removal. Close the old gate on all old binaries and keep the new gate +closed on new binaries. Apply additive schema, complete the whole-fleet rollout, +then open `BUZZ_NIP_FI_CHANGES` and resume supported callers. See +[rollout](community-auth-upgrades.md#schema-and-rollout). An uncertain operation +from the old caller needs explicit reconciliation; a missing receipt is not +permission to synthesize a replacement policy write. diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md new file mode 100644 index 0000000000..95652d65fa --- /dev/null +++ b/docs/operator-nip-fi-upgrade.md @@ -0,0 +1,80 @@ +# Manage a community's NIP-FI requirement + +Use `buzz-admin nip-fi-policy` to enable, disable or inspect a community's +stored requirement through the deployment operator API. The command needs the +configured `RELAY_OPERATOR_API_ORIGIN` and an allowlisted operator signing key +in a protected file. It sends one signed request and never retries automatically. + +Before rolling, close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay and +keep `BUZZ_NIP_FI_CHANGES=false` on new relays. Pause callers and upgrade them +for the [supported API](operator-nip-fi-policy-client.md) before deploying this +relay version. Apply the schema and roll compatible management writers across +the whole fleet. Then explicitly +enable that gate, which controls both enable and disable. Enable additionally +requires the deployment-selected issuer to be trusted and already bound to the +community. The active community must have exactly one owner; the authenticated +actor mediated by the operator must be that owner. See the +[policy contract](community-auth-upgrades.md) and [binding guide](operator-nip-fi-bindings.md). + +Read current preconditions first: + +```sh +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + status --community-id c9acf9a6-37d5-4af1-8df9-92a165637b01 --host team.communities.example.org +``` + +Save a new request file with a fresh operation UUID and the exact +`expected_policy_generation` and `expected_requires_nip_fi` from that response: + +```json +{ + "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", + "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", + "expected_host": "team.communities.example.org", + "actor_pubkey": "", + "expected_owner_pubkey": "", + "expected_issuer": "https://identity.example.org", + "requires_nip_fi": true, + "expected_policy_generation": 0, + "expected_requires_nip_fi": false +} +``` + +```sh +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + enable --request policy.json +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + status --operation-id c2ffcc20-9505-48fd-b6c7-446aa833bcd1 +``` + +To disable, save a **new** file/UUID with `requires_nip_fi:false`, omit +`expected_issuer`, and copy freshly read preconditions. Run `disable --request` +with that file. The subcommand checks the saved action before reading the key +or sending anything. A same-state operation also advances the generation, +creating a barrier against earlier uncommitted requests. + +After `policy_conflict`, re-read current policy and use a new UUID for the revised +intent. After a timeout or lost response, keep the complete original file and UUID: +read its status or retry that exact file. A committed replay never reapplies policy. + +| Exit | Result | +|---|---| +| 0 | Current-state read, or applied stored disable | +| 10 | Enable accepted; connection closure remains unverified (`pending`) | +| 11 | Historical acceptance superseded by a later generation | +| 12 | Receipt target or required trust unavailable | +| 5 | Invalid input, API refusal or uncertain transport/response | + +Inspect `mode` and `effective_required` separately from the stored flag. +`enforce` keeps FI required after disable; `off` suspends FI after enable. +`shadow` follows the stored flag. An enable receipt remains pending even after +local sweeps, since it cannot certify fleet-wide connection closure. Required +host caches can over-restrict for their remaining one-second lease after disable. +A racing sweep may close one last non-FI socket; it can reconnect under optional +policy. FI-admitted sockets retain their finite deadline. Re-enable repeats the +optional-lease drain and existing periodic revalidation. + +The old `nip-fi-upgrade` command and endpoint are removed. Requests to that +route now return 404. Recover historical receipts with `nip-fi-policy status +--operation-id`; their original null CAS preconditions remain visible. Never +reuse a historical operation UUID for a new intent. diff --git a/migrations/0085_community_auth_policy.sql b/migrations/0085_community_auth_policy.sql new file mode 100644 index 0000000000..d92d8681a5 --- /dev/null +++ b/migrations/0085_community_auth_policy.sql @@ -0,0 +1,8 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +-- Retain old receipt intent and INSERT compatibility without a data rewrite. +ALTER TABLE community_auth_upgrades + ADD COLUMN requested_requires_nip_fi BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN expected_policy_generation BIGINT, + ADD COLUMN expected_requires_nip_fi BOOLEAN; diff --git a/schema/tables/public/community_auth_upgrades.sql b/schema/tables/public/community_auth_upgrades.sql index cd4785adcc..89e2604bfb 100644 --- a/schema/tables/public/community_auth_upgrades.sql +++ b/schema/tables/public/community_auth_upgrades.sql @@ -10,5 +10,8 @@ CREATE TABLE community_auth_upgrades ( expected_issuer TEXT NOT NULL, operator_pubkey TEXT NOT NULL, policy_generation BIGINT NOT NULL CHECK (policy_generation > 0), + requested_requires_nip_fi BOOLEAN NOT NULL DEFAULT true, + expected_policy_generation BIGINT, + expected_requires_nip_fi BOOLEAN, accepted_at TIMESTAMPTZ NOT NULL DEFAULT now() ); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f9127fa596..b49a0ca444 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -165,6 +165,9 @@ run_unit_tests() { run_test_step "buzz-admin NIP-FI binding command tests" \ cargo test -p buzz-admin nip_fi::tests -- --nocapture + run_test_step "buzz-admin NIP-FI policy command tests" \ + cargo test -p buzz-admin nip_fi_policy::tests -- --nocapture + run_test_step "buzz-admin user-deletion intake command tests" \ cargo test -p buzz-admin user_deletions::tests -- --nocapture