diff --git a/Justfile b/Justfile index c87f04e599..f96c847785 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(/^nip_fi_upgrade::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_upgrade::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/src/main.rs b/crates/buzz-admin/src/main.rs index 992514cd0b..5249978130 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 nip_fi_upgrade; mod user_deletions; @@ -127,6 +128,8 @@ enum Command { }, /// Require NIP-FI through the relay's existing signed operator API. NipFiUpgrade(nip_fi_upgrade::Args), + /// 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)] @@ -218,6 +221,9 @@ async fn run(cli: Cli) -> Result { 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::NipFiUpgrade(args) => nip_fi_upgrade::run(args) .await .map_err(|error| anyhow::anyhow!("{error:#}")), 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..21131c6209 --- /dev/null +++ b/crates/buzz-admin/src/nip_fi_policy.rs @@ -0,0 +1,368 @@ +//! General stored community policy management with immutable saved intent. +use std::path::PathBuf; + +use anyhow::{ensure, Context, Result}; +use clap::{Args as ClapArgs, Subcommand}; +use nostr::Keys; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::nip_fi_upgrade::{endpoint, read_bounded, signed_call, UpgradeRequest}; + +/// 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" + ); + UpgradeRequest { + operation_id: self.operation_id, + community_id: self.community_id, + expected_host: self.expected_host.clone(), + actor_pubkey: self.actor_pubkey.clone(), + expected_owner_pubkey: self.expected_owner_pubkey.clone(), + expected_issuer: self.expected_issuer.clone().unwrap_or_default(), + } + .validate() + } +} + +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, None)?; + url.set_path("/operator/communities/nip-fi-policy"); + 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["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::*; + + 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:#}"); + } + } + + #[tokio::test] + async fn policy_commands_send_saved_action_and_recover_current_and_history() { + use axum::{ + body::Bytes, + extract::RawQuery, + http::{HeaderMap, Method}, + routing::any, + Json, Router, + }; + 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("/operator/communities/nip-fi-policy", any( + move |method: Method, RawQuery(query): RawQuery, headers: HeaderMap, body: Bytes| { + let response = response.clone(); let expected_body = expected_body.clone(); let host = host.clone(); + async move { + assert!(headers["authorization"].to_str().unwrap().starts_with("Nostr ")); + if method == Method::POST { + assert_eq!(serde_json::from_slice::(&body).unwrap(), expected_body); + } else if query.as_ref().unwrap().starts_with("community_id=") { + return Json(serde_json::json!({"community_id":community_id,"expected_host":host, + "expected_policy_generation":1,"expected_requires_nip_fi":desired, + "requires_nip_fi":desired,"mode":"shadow","effective_required":desired})); + } else { assert_eq!(query, Some(format!("operation_id={id}"))); } + Json(response) + } + })); + 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 + ); + } + } + } +} diff --git a/crates/buzz-admin/src/nip_fi_upgrade.rs b/crates/buzz-admin/src/nip_fi_upgrade.rs index be64a45749..842e3fcc08 100644 --- a/crates/buzz-admin/src/nip_fi_upgrade.rs +++ b/crates/buzz-admin/src/nip_fi_upgrade.rs @@ -47,17 +47,17 @@ enum Command { #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -struct UpgradeRequest { - operation_id: Uuid, - community_id: Uuid, - expected_host: String, - actor_pubkey: String, - expected_owner_pubkey: String, - expected_issuer: String, +pub(super) struct UpgradeRequest { + pub(super) operation_id: Uuid, + pub(super) community_id: Uuid, + pub(super) expected_host: String, + pub(super) actor_pubkey: String, + pub(super) expected_owner_pubkey: String, + pub(super) expected_issuer: String, } impl UpgradeRequest { - fn validate(&self) -> Result<()> { + pub(super) fn validate(&self) -> Result<()> { ensure!( !self.operation_id.is_nil() && !self.community_id.is_nil(), "UUIDs must not be nil" @@ -81,7 +81,7 @@ impl UpgradeRequest { } } -fn read_bounded(path: &Path, limit: usize) -> Result> { +pub(super) 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()))? @@ -92,7 +92,7 @@ fn read_bounded(path: &Path, limit: usize) -> Result> { Ok(bytes) } -fn endpoint(origin: &str, operation: Option) -> Result { +pub(super) fn endpoint(origin: &str, operation: Option) -> Result { let base = Url::parse(origin).context("invalid operator origin")?; ensure!( matches!(base.scheme(), "http" | "https") @@ -190,12 +190,25 @@ pub async fn run(args: Args) -> Result { .and_then(|s| Keys::parse(s.trim()).ok()) .context("invalid operator key file")?; let body = request.as_ref().map(serde_json::to_vec).transpose()?; + let receipt = signed_call(&keys, url, body).await?; + let code = receipt_exit_code( + &receipt, + operation_id, + &keys.public_key().to_hex(), + request.as_ref(), + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(code) +} + +/// Bounded, signed single call shared by the operator policy commands. +pub(super) 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 auth = authorization(keys, &method, &url, body.as_deref())?; let client = reqwest::Client::builder() .redirect(Policy::none()) .retry(reqwest::retry::never()) @@ -232,6 +245,8 @@ pub async fn run(args: Args) -> Result { | "community_not_found" | "operation_not_found" | "idempotency_conflict" + | "policy_conflict" + | "policy_generation_exhausted" | "host_conflict" | "owner_conflict" | "lifecycle_conflict" @@ -242,7 +257,7 @@ pub async fn run(args: Args) -> Result { _ if error.is_none() => "non-JSON error response", _ => "unrecognized error code", }; - anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16()); + 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(|| { @@ -251,14 +266,7 @@ pub async fn run(args: Args) -> Result { status.as_u16() ) })?; - let code = receipt_exit_code( - &receipt, - operation_id, - &keys.public_key().to_hex(), - request.as_ref(), - )?; - println!("{}", serde_json::to_string(&receipt)?); - Ok(code) + Ok(receipt) } #[cfg(test)] diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ef2259dde3..2f0154ba9c 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -898,7 +898,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 83); + assert_eq!(migrations.len(), 84); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -3240,6 +3240,45 @@ mod postgres_tests { /// bounded inline build cannot take its lock it must fail closed without /// recording that migration, keeping earlier migrations and each index /// already built in its own transaction. + #[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, 83).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)); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn populated_artifact_tables_auto_migrate_through_every_migration() { diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index ec5a638798..64ef7c2364 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,55 @@ 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, +} + +impl PolicyRecord { + fn legacy(mut self) -> UpgradeRecord { + if self.acceptance.state == "superseded" { + self.acceptance.state = "unavailable".into(); + } + self.acceptance.pending_reasons = vec!["connection_closure_unverified".into()]; + self.acceptance + } +} + +/// Refusal or an immutable acceptance with current status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyOutcome { + /// Durable acceptance or identical read-only replay. + Accepted(PolicyRecord), + /// 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 { @@ -94,25 +143,34 @@ 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"; @@ -235,16 +293,47 @@ impl Db { operation: Uuid, operator: &str, ) -> Result> { + Ok(self + .community_auth_policy_status(operation, operator) + .await? + .filter(|record| record.expected_policy_generation.is_none()) + .map(PolicyRecord::legacy)) + } + + /// Recover any policy receipt for its original authenticated operator. + pub async fn community_auth_policy_status( + &self, + operation: Uuid, + operator: &str, + ) -> 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)) + } + + /// Apply a compare-and-set policy intent; identical committed replay is read-only. + pub async fn set_community_nip_fi_policy( + &self, + request: &PolicyRequest, + configured_trust: std::result::Result<(), &'static str>, + ) -> Result { + self.write_community_nip_fi_policy( + &request.tuple, + Some(( + request.requires_nip_fi, + request.expected_policy_generation, + request.expected_requires_nip_fi, + )), + configured_trust, + ) + .await } /// Atomically enable FI and persist the full idempotency tuple. @@ -258,6 +347,26 @@ impl Db { request: &UpgradeRequest, configured_trust: std::result::Result<(), &'static str>, ) -> Result { + Ok( + match self + .write_community_nip_fi_policy(request, None, configured_trust) + .await? + { + PolicyOutcome::Accepted(record) => UpgradeOutcome::Accepted(record.legacy()), + PolicyOutcome::Refused(code) => UpgradeOutcome::Refused(code), + }, + ) + } + + async fn write_community_nip_fi_policy( + &self, + request: &UpgradeRequest, + intent: Option<(bool, i64, bool)>, + configured_trust: std::result::Result<(), &'static str>, + ) -> Result { + let desired = intent.map_or(true, |i| i.0); + let expected_generation = intent.map(|i| i.1); + let expected_state = intent.map(|i| i.2); let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, @@ -273,19 +382,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 == expected_generation + && record.expected_requires_nip_fi == expected_state + { + PolicyOutcome::Accepted(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 +415,24 @@ 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 let Some((_, generation, required)) = intent { + if generation < 0 || generation != old_generation || required != old_required { + return Ok(PolicyOutcome::Refused("policy_conflict")); + } + } else if !old_required && old_generation != 0 { + // An uncommitted legacy retry cannot reverse a later disable. + return Ok(PolicyOutcome::Refused("policy_conflict")); + } + let bump = intent.is_some() || !old_required; + if bump && 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 +446,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 +457,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 +469,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 + i64::from(bump); + 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 +511,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 +521,7 @@ impl Db { .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok(UpgradeOutcome::Accepted(record)) + Ok(PolicyOutcome::Accepted(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..8f38806a1e 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -558,3 +558,307 @@ 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(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" + } + ); + assert!(db + .community_auth_upgrade_status(request.tuple.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .is_none()); + assert_eq!( + db.enable_community_nip_fi(&request.tuple, Ok(())) + .await + .unwrap(), + UpgradeOutcome::Refused("idempotency_conflict") + ); + } + 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); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_same_state_barrier_refuses_stale_cas_and_legacy_retry() { + 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") + ); + assert_eq!( + db.enable_community_nip_fi(&tuple, Ok(())).await.unwrap(), + UpgradeOutcome::Refused("policy_conflict") + ); + // Generation equality alone is insufficient: the state precondition matters too. + 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") + ); + for id in [ + stale.tuple.operation_id, + tuple.operation_id, + wrong_state.tuple.operation_id, + ] { + assert!(db + .community_auth_policy_status(id, &tuple.operator_pubkey) + .await + .unwrap() + .is_none()); + } + assert_eq!(apply(&db, &barrier).await, record); + let enable = intent(&tuple, true, 1, false); + assert_eq!(apply(&db, &enable).await.acceptance.policy_generation, 2); + assert_eq!( + db.enable_community_nip_fi(&enable.tuple, Ok(())) + .await + .unwrap(), + UpgradeOutcome::Refused("idempotency_conflict") + ); + // Legacy receipt identity includes absence of CAS preconditions too. + let mut legacy = tuple.clone(); + legacy.operation_id = Uuid::new_v4(); + let old = accepted(&db, &legacy).await; + let mut generic = intent(&legacy, true, 2, true); + generic.tuple.operation_id = legacy.operation_id; + assert_eq!( + db.set_community_nip_fi_policy(&generic, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") + ); + assert_eq!( + db.community_auth_upgrade_status(legacy.operation_id, &tuple.operator_pubkey) + .await + .unwrap(), + Some(old) + ); + apply(&db, &intent(&tuple, false, 2, true)).await; + assert_eq!( + db.community_auth_upgrade_status(legacy.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap() + .state, + "unavailable" + ); + assert_eq!( + db.community_auth_policy_status(legacy.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap() + .acceptance + .state, + "superseded" + ); +} + +#[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-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f240..8672aed591 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; @@ -1374,7 +1377,10 @@ pub async fn no_store_upgrade_response( request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - let upgrade = request.uri().path() == "/operator/communities/nip-fi-upgrade"; + let upgrade = matches!( + request.uri().path(), + "/operator/communities/nip-fi-upgrade" | "/operator/communities/nip-fi-policy" + ); let mut response = next.run(request).await; if upgrade { response.headers_mut().insert( @@ -4981,4 +4987,5 @@ mod postgres_tests { let body = read_json(response).await; assert_eq!(body["error"], "internal server error"); } + 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..df55a0c1d4 --- /dev/null +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -0,0 +1,216 @@ +//! 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 { + use buzz_auth::NipFiMode; + let mode = state.config.nip_fi.mode; + let name = match mode { + NipFiMode::Off => "off", + NipFiMode::Shadow => "shadow", + NipFiMode::Enforce => "enforce", + NipFiMode::DenyProtected => "deny-protected", + }; + 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_upgrades_enabled { + 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, + "requires_nip_fi": policy.requires_nip_fi, "policy_generation": policy.policy_generation, + "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/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs new file mode 100644 index 0000000000..163ad67ca4 --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -0,0 +1,212 @@ +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(&[operator.clone()], 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_upgrades_enabled = 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"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + 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" } + ); + 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 legacy = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("/operator/communities/nip-fi-upgrade?operation_id={id}"), + None, + ) + .await; + assert_eq!(legacy.status(), StatusCode::NOT_FOUND); + 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_upgrades_enabled = + 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()); + } + 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); + // 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/router.rs b/crates/buzz-relay/src/router.rs index dcd58c5e91..172895d0ca 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -513,6 +513,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/nip-fi-binding", get(api::operator::community_nip_fi_binding), ) + .route( + "/operator/communities/nip-fi-policy", + get(api::operator::community_nip_fi_policy_status) + .post(api::operator::set_community_nip_fi_policy), + ) .route( "/operator/communities/nip-fi-upgrade", get(api::operator::community_nip_fi_upgrade_status) diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index ed5205b1af..0d246da120 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -1,434 +1,138 @@ -# 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, request form 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 +precondition fields. 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 0084 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. Apply schema first, then compatible management writers across the **whole +fleet** with `BUZZ_NIP_FI_UPGRADES_ENABLED=false`, then explicitly enable mutations. +The gate controls both directions; reads and committed replay remain available. +Do not roll back to an unfenced management writer after the first general operation. -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. +Legacy `/nip-fi-upgrade` remains enable-only. Fresh enables on optional/nonzero +generation are refused, so an old uncommitted retry cannot reverse disable. +Already-required legacy no-ops retain their generation. Legacy GET hides general +CAS receipts with 404, and POST cannot reuse their UUIDs. Old binaries cannot hide +those receipts or fence retries; the gate-off whole-fleet rollout is mandatory. +General status can read legacy receipts, with null CAS preconditions. -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. +## Request enforcement and discovery -## Mistaken acceptance and containment +`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. -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. - -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. +Last-issuer removal is allowed after disable, and 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 4bc0b500c0..2fc21a1b50 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -6,7 +6,7 @@ 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 upgrade command and manual test](operator-nip-fi-upgrade.md). +[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 diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md index 40fb517e75..db3b0d1603 100644 --- a/docs/operator-nip-fi-upgrade.md +++ b/docs/operator-nip-fi-upgrade.md @@ -1,213 +1,75 @@ -# Require NIP-FI on an existing community +# Manage a community's NIP-FI requirement -`buzz-admin nip-fi-upgrade` makes one signed HTTP call to the existing relay -operator API. It needs no database access, application startup, sponsorship or -billing workflow. `enable` accepts the saved upgrade tuple; `status` reads its -durable receipt. Both require an authorized deployment operator key. -The [upgrade API contract](community-auth-upgrades.md) defines the receipt and -policy semantics used below. +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. -This command is a trusted operator surface. The operator must independently -authenticate the community owner and verify the owner's registered public key -before putting it in `actor_pubkey`. A caller-supplied public key is not proof -of the owner's identity. The relay still checks current ownership, community -identity, issuer trust and the upgrade admission gate. This command does not -replace an application's owner authentication or admission policy. +Before changing policy, apply the schema and roll compatible management writers +across the whole fleet with `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. 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). -## Before the test +Read current preconditions first: -Use a disposable staging community whose policy is not yet required. Verify its -UUID, canonical lowercase host, current owner and active issuer binding against -authoritative records. Use the current owner's key for both public-key fields. -The issuer must match the relay's provisioning configuration and active trust. +```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 +``` -Verify `BUZZ_NIP_FI_MODE=shadow` on **every serving replica** before connecting -the test client. Shadow observes optional communities and enforces their stored -required policy after an upgrade. `off` ignores stored requirements; `enforce` -already requires sign-in everywhere. Either mode invalidates this transition -test. Keep shadow mode unchanged throughout the test and record it in evidence. - -All serving relay replicas must support persisted required policy and closing -existing sessions after an upgrade. Apply their required migrations and finish -the rollout before the test. The deployment must already have authorized -`BUZZ_NIP_FI_UPGRADES_ENABLED=true`; this command does not change that gate. -Only the deployment's authorized operator may enable that gate, after verifying -the fleet prerequisites in the API contract. Record that operator's approval. -Use a client that supports required-policy discovery and federated sign-in. - -Identify the configured `RELAY_OPERATOR_API_ORIGIN` and a key in -`RELAY_OPERATOR_PUBKEYS`. The origin is the operator API origin, not the target -community URL. Use HTTPS outside a trusted internal mesh. Provision the key as -a protected file/Secret through the deployment's existing secret process; never -paste it into arguments, logs or a committed manifest. Do not borrow a client -or managed agent key. - -## Save the operation, then leave the client connected - -Save `request.json` with one newly generated operation UUID and the verified -values. Keep this file unchanged for retries. The command refuses noncanonical -hosts or public keys before sending a POST. These are example values only: +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": "deecfc7a-9ec2-4ec5-b15b-9e567ad59740", - "community_id": "00fb4822-7930-40fc-9eed-315926f83ca7", - "expected_host": "test.communities.example.org", - "actor_pubkey": "", - "expected_owner_pubkey": "", - "expected_issuer": "https://issuer.example.org" + "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 } ``` -Open the test community in an existing client and confirm it can read and send -a message without federated sign-in. Keep that session connected. Record the -client version, relay image digests and time, plus the community's `/info` -response showing `requires_nip_fi: false`. Do not sign out, restart the client, -or change relay configuration during the acceptance test. - -## Run one pod - -Use a relay image containing this command, pinned to its verified digest. The -normal relay image includes `/usr/local/bin/buzz-admin`. Check the command's -`--help` before the test if using a previously built image. - -Select the staging context and namespace explicitly. Create the input ConfigMap -from the saved request, and use an existing Secret with a `key` entry: - ```sh -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create configmap nip-fi-upgrade-input \ - --from-file=request.json +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 ``` -Replace the image, origin and Secret name below with reviewed deployment -values. Save as `upgrade-pod.yaml`. This is a single Pod, with no Job controller, -retry policy or application/service labels. It mounts only the input and key. -Use the deployment's approved network/mesh settings if its operator API needs -them; confirm the pod can reach that API without adding it to serving workloads. -If a mesh sidecar is injected, use the platform's approved one-shot completion -mechanism (for example, native sidecars). An ordinary sidecar can keep this Pod -running after the command exits; retain the upgrade container's exit status and -logs, and arrange sidecar shutdown without weakening required network policy. +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. -```yaml -apiVersion: v1 -kind: Pod -metadata: - name: nip-fi-upgrade-enable -spec: - restartPolicy: Never - activeDeadlineSeconds: 60 - automountServiceAccountToken: false - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - containers: - - name: upgrade - image: ghcr.io/block/buzz@sha256: - command: [/usr/local/bin/buzz-admin] - args: - - nip-fi-upgrade - - --origin - - https://operator.example.org - - --key-file - - /operator/key - - enable - - --request - - /input/request.json - volumeMounts: - - {name: operator, mountPath: /operator, readOnly: true} - - {name: input, mountPath: /input, readOnly: true} - volumes: - - name: operator - secret: - secretName: reviewed-upgrade-operator - defaultMode: 0440 - - name: input - configMap: - name: nip-fi-upgrade-input -``` +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. -```sh -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create -f upgrade-pod.yaml -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" logs -f pod/nip-fi-upgrade-enable -c upgrade -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" get pod nip-fi-upgrade-enable -o json -``` +| 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 | -The last command records the pod UID, observed image ID and container exit code. -Do not use Pod phase alone: the expected accepted outcome in this relay version -is exit **10**, which Kubernetes labels Failed. This is acceptance, not a command -failure or a certified fleet-wide connection drain. +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. -| Exit | Meaning | -| --- | --- | -| 10 | Expected success: valid matching receipt, `requires_nip_fi: true`, state `pending` | -| 12 | Valid acceptance receipt, target/trust currently `unavailable` | -| 5 | Request error or unconfirmed result; acceptance may already have happened | - -This relay version never reports `active`. The command reserves exit 0 for a -future valid `active` receipt with no pending reasons; do not wait for it here. -`connection_closure_unverified` is expected even after successful socket sweeps: -the receipt does not certify fleet-wide closure. Continue with the client test. -Retain and investigate other/unknown pending reasons; they remain unverified. - -Each HTTP call has a 30-second timeout, no automatic retry and no redirects. -The command verifies the returned operation, operator, required policy and -positive generation. For enable it also verifies every saved tuple field. - -## Confirm policy and recover the same operation - -Create another pod from the same manifest with a new pod name and replace the -`enable --request /input/request.json` arguments with: - -```yaml - - status - - --operation-id - - deecfc7a-9ec2-4ec5-b15b-9e567ad59740 -``` - -Use the actual saved operation UUID, same API origin and same operator key. -Compare the receipt's complete tuple and generation with the saved evidence. -A successful receipt must report `requires_nip_fi: true`. Also read the target -community's `/info` and confirm it advertises required policy. - -After timeout, response loss or pod termination, status may recover an accepted -receipt. If it cannot confirm acceptance, repeat enable with the **same saved -request** and a new pod name. Never create a new operation UUID to escape an -uncertain result. `pending` and `unavailable` do not undo required policy; retain -their state and reasons. A pending receipt with `connection_closure_unverified` -does not prevent the manual client test from passing on observed evidence. -An unavailable target or unknown obligations require investigation. -Turning off admission, deleting the pod or rolling back the command cannot undo -an accepted policy change. - -## Observe the existing client - -Record `BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` on every serving replica (default -30 seconds, clamped to 1–300). Before the test, choose and record a finite client -reconnect/sign-in budget from that client's configured reconnect behavior. Wait -at least the largest replica interval plus that client budget after acceptance. -Record the resulting observation deadline. If sign-in has not appeared by then, -record the test as failed or blocked with logs; do not wait indefinitely. - -1. After the upgrade, verify the already-connected client demands federated - sign-in before continuing to read or send in the community. Let the client - handle the disconnect/reconnect. Do not manually sign out or restart it. -2. Complete sign-in with the test user's allowed issuer, then verify reading and - sending work again in that same community. -3. Record a failure if access continues without sign-in, sign-in never appears, - or access does not resume. A changed `/info` response alone is not a pass. - -Record the exact invocation/manifest, saved request, operator public key, -community UUID/host, pod UID/observed image ID, all serving relay versions and -shadow modes, revalidator intervals, client reconnect budget/deadline, -client version, pre/post `/info`, receipt/generation/state, timestamps and observed -client behavior. Keep secret key bytes and authorization headers out of evidence. -Mark unexecuted steps as **not run**, and keep policy confirmation separate from -the client result. Export logs and pod metadata before deleting the temporary -pods and input ConfigMap. Use a fresh staging community for another first-upgrade -test; do not clear persisted policy to reset this one. -For a mistaken acceptance, stop the caller and follow the API contract's -containment procedure. Archive or isolate the affected community through the -deployment's authorized operator process; there is no policy rollback command. +The legacy `nip-fi-upgrade enable/status` command remains for old saved requests. +Use the general command for new operations; legacy GET hides general CAS receipts. diff --git a/migrations/0084_community_auth_policy.sql b/migrations/0084_community_auth_policy.sql new file mode 100644 index 0000000000..d92d8681a5 --- /dev/null +++ b/migrations/0084_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() );