From 2cd40badb85de059a6916206e133da7a0a88fb2e Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 17:07:31 -0500 Subject: [PATCH] Manage community NIP-FI policy with enable, disable and status (#9176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Operators can enable, disable and inspect a community's stored NIP-FI requirement through signed POST/GET `/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`. This replaces the old enable-only endpoint and drops the `nip-fi-upgrade` command added earlier in this PR, along with obsolete compatibility code. Each saved intent binds its desired state and observed generation/state. Every accepted new intent advances the generation, including same-state changes. Committed retries recover history without reapplying policy; stale requests are refused. Current status exposes one pair of reusable `expected_*` preconditions. Historical receipts remain readable after policy changes or target deletion, including old rows with null preconditions. Their UUIDs cannot be reused for new requests. Migration 0085 stays additive; main’s default-channel recovery migration retains version 0084. It preserves old receipt rows and INSERT compatibility. Operator instructions cover use, recovery and client migration: [usage guide](https://github.com/block/buzz/blob/33a1c0bb87a6fa10212eb4ec60788e24189627e2/docs/operator-nip-fi-upgrade.md), [client contract](https://github.com/block/buzz/blob/33a1c0bb87a6fa10212eb4ec60788e24189627e2/docs/operator-nip-fi-policy-client.md). ### Rollout and containment The old `/operator/communities/nip-fi-upgrade` route is removed; GET and POST return 404 without its old auth handler. A pending sponsorship client still targets that route. Migrate that client to the supported API and deploy it with mutation dispatch paused before this relay version reaches its fleet. Its complete CAS preconditions must be durably saved before first dispatch and reused unchanged after a crash or lost response. This is a pending integration dependency, not a claim that the caller is deployed. Close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay. Keep `BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first and finish the whole-fleet rollout before opening the new gate and resuming migrated clients. Before opening the new gate, verify the deployed artifact SHA on every pod and probe each pod directly: the old route must return 404, and a fresh, well-formed, signed policy request with a new UUID must return 503 `upgrades_disabled`. A committed retry is not a gate probe. Old pods with the old gate open can still accept unfenced enables. Do not roll back to those writers after a policy operation. The old variable is no longer read by this version; the new gate defaults off and controls both directions. Read accepted intent in `receipt`, current preconditions/effective policy through status, and effective discovery through `/info`. Enable stays `pending` because receipts cannot certify fleet-wide socket closure. Under global `enforce`, stored disable leaves FI effectively required. Closing the gate preserves receipt recovery; use a fresh CAS disable or existing archive controls for containment. Preserve uncertain requests and their UUIDs. This PR performs no live state change or deployment. ### Related issue N/A ### Testing Published repair head: `33a1c0bb87a6fa10212eb4ec60788e24189627e2`, incorporating main `39d5159caf`. The Git policy guards now apply only to matched paths. Unknown GET/POST paths on relays without a web directory return 404 instead of inheriting the internal endpoint’s 403. The matched internal endpoint retains its localhost restriction and 1 MB body limit, including its method fallback. The signed removed-route 404 assertion is unchanged. Main’s migration 0084 is unchanged; the additive policy migration is now 0085 with identical SQL. Its populated-receipt upgrade test starts at schema 84. Formatting and commit hooks pass; independent source review has no findings. At this exact head, formatting, all-target Clippy (`-D warnings`, relay/db/admin/pubsub), and test compilation (relay/db/admin, `--lib --tests --no-run`) pass with exit 0. The full native PostgreSQL lane passes: 1,373/1,373 tests, exit 0, including the unchanged signed removed-route 404 assertion, fresh-database migration and populated receipt upgrade from main schema 84 to 85. The first full package run used an uninitialized private database and stopped with two missing-table errors; that failed attempt is retained. After schema initialization and verification, the corrected full package rerun passes: 2,041/2,041 tests, exit 0 (1,396 skipped by unit selection). The separate PostgreSQL lane skips 2,064 tests by its selection. Both full runs use the exact published repair head. Hosted CI remains pending. The new router test reproduced the old failure with the exact localhost-only response body; the new production-router regression also passes in the corrected package run. Four individual guard mutations are running serially; their assertion-failure receipts and the restored final green run remain pending. Brad authorized one `git push --no-verify` of this repair conditional on passing lint and compilation. Those gates passed; the push exited 0 and both the remote branch and GitHub PR SHA were verified as `33a1c0bb87a6fa10212eb4ec60788e24189627e2`. The normal publication attempt was stopped before completion and its log is retained. This exception leaves remaining tests and acceptance requirements outstanding. Historical evidence below applies to the earlier published head, not the published repair. Published head: `d31793ccbcdfd748361bcd559358915862b5ed57`. This integrates main's missing `ChannelEvent.received_at` fixture correction and corrects the newly integrated initial-policy test to use `nip_fi_changes`, with matching documentation. Independent source review has no open findings. At this exact head, all-target Clippy with `-D warnings` passes for buzz-relay, buzz-db, buzz-admin and buzz-pubsub; independent Clippy and formatting checks also passed. The normal push hooks failed (push exit 1): - Rust: `buzz-acp` recovery test hit its 15-second test deadline. Nextest completed 844/1101 tests: 843 passed, 1 failed, 3 skipped; 257 were not run. - Tauri: `buzz-terminal` login-shell test received no child output within its 10-second test deadline; 26 passed, 1 failed. - Mobile: gallery test `2 trailing images match carousel at 208.0, scale 1.0` expected no loading placeholders and found two; 4,524 passed, 60 skipped, 1 failed. These are two test-level deadline failures and one assertion failure, not compiler errors. The failing source paths are unchanged from main `6c3ffd160b`; no baseline reproduction or flaky classification is claimed. Desktop JS checks/tests/typecheck, branch-skew and file-size checks passed. Full hooks are not reported as passing. The first hosted PostgreSQL run at d31793cc failed the removed-route assertion (expected 404, got 403); the router repair above addresses that failure. That hosted run’s Rust unit, Tauri and mobile jobs passed. Historical d31793cc publication exception: Brad explicitly authorized a single `git push --no-verify` for this head on 2026-11-06 after the failed normal-hook run. That push exited 0 and its remote SHA was verified. Failed-hook evidence is retained. The earlier `cf2c9ba599` publication also used an explicit exception. Neither exception waives outstanding validation, review or human acceptance before merge. Historical evidence at `5b2eb6f09366b8a8f480a5fc61e8e5368f62302d`: 2,006 affected-package tests and 32 disposable-relay checks passed. These predate the latest main merge and are not final-head evidence. Earlier native PostgreSQL and separate `just ci` / `just test` attempts were incomplete, interrupted or failed and are not passing results. No staging run or human runtime confirmation is claimed. This PR is not a draft and is ready for review; readiness does not attest human runtime confirmation. Generated with Codex --------- Signed-off-by: Brad Seiler Co-authored-by: Honor --- .env.example | 6 +- Cargo.lock | 5 + Justfile | 2 +- crates/buzz-admin/Cargo.toml | 5 + crates/buzz-admin/src/main.rs | 8 + crates/buzz-admin/src/nip_fi_policy.rs | 666 ++++++++++++++++++ crates/buzz-db/src/runtime/migration.rs | 45 +- crates/buzz-db/src/store/community_auth.rs | 198 ++++-- .../store/community_auth/postgres_tests.rs | 431 ++++++++++-- crates/buzz-db/src/store/deletion.rs | 6 +- crates/buzz-relay/src/api/git/mod.rs | 6 +- crates/buzz-relay/src/api/operator.rs | 597 +--------------- .../src/api/operator/community_policy.rs | 209 ++++++ .../initial_policy_postgres_tests.rs | 2 +- .../operator/postgres_tests/policy_tests.rs | 429 +++++++++++ crates/buzz-relay/src/config.rs | 38 +- crates/buzz-relay/src/nip_fi_config.rs | 13 +- .../src/nip_fi_policy/postgres_tests.rs | 29 +- crates/buzz-relay/src/router.rs | 96 ++- .../tests/policy_sweep_postgres_tests.rs | 22 +- docs/community-auth-upgrades.md | 523 ++++---------- docs/operator-nip-fi-bindings.md | 6 +- docs/operator-nip-fi-policy-client.md | 46 ++ docs/operator-nip-fi-upgrade.md | 80 +++ migrations/0085_community_auth_policy.sql | 8 + .../tables/public/community_auth_upgrades.sql | 3 + scripts/run-tests.sh | 3 + 27 files changed, 2338 insertions(+), 1144 deletions(-) create mode 100644 crates/buzz-admin/src/nip_fi_policy.rs create mode 100644 crates/buzz-relay/src/api/operator/community_policy.rs create mode 100644 crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs create mode 100644 docs/operator-nip-fi-policy-client.md create mode 100644 docs/operator-nip-fi-upgrade.md create mode 100644 migrations/0085_community_auth_policy.sql 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