diff --git a/Cargo.lock b/Cargo.lock index d19342367f..ec6e180659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,6 +969,8 @@ name = "buzz-admin" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-core", @@ -981,10 +983,13 @@ dependencies = [ "clap", "hex", "nostr 0.44.7", + "reqwest 0.13.4", "rustls", "serde", "serde_json", + "sha2 0.11.0", "sqlx", + "tempfile", "tokio", "tracing", "url", diff --git a/Justfile b/Justfile index 80a6a56af7..9170ebb053 100644 --- a/Justfile +++ b/Justfile @@ -468,7 +468,7 @@ test-unit: # parser, validation, and propagation-evidence tests, and the partition # audit outcome contract, NIP-FI policy/backfill, and user-deletion intake. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^user_deletions::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index 10354c2f92..4d38d25582 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -27,6 +27,9 @@ serde_json = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } hex = { workspace = true } +base64 = { workspace = true } +reqwest = { workspace = true } +sha2 = { workspace = true } # Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process # CryptoProvider installed at startup. The workspace redis TLS feature compiles # both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we @@ -39,4 +42,6 @@ uuid = { workspace = true } clap = { version = "4", features = ["derive"] } [dev-dependencies] +axum = { workspace = true } +tempfile = "3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 36af8df338..992514cd0b 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_upgrade; mod user_deletions; use std::future::Future; @@ -124,6 +125,8 @@ enum Command { #[command(subcommand)] command: nip_fi::NipFiCommand, }, + /// Require NIP-FI through the relay's existing signed operator API. + NipFiUpgrade(nip_fi_upgrade::Args), /// Durable CLI-only whole-community deletion control plane. Deletions { #[command(subcommand)] @@ -213,6 +216,11 @@ async fn run(cli: Cli) -> Result { Command::Deletions { command } => deletions::run(command).await, Command::UserDeletions { command } => user_deletions::run(command).await, Command::NipFi { command } => nip_fi::run(command).await, + // Retain transport/file causes for this operator command without changing + // the diagnostics of other admin commands. Key parse errors are redacted. + Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args) + .await + .map_err(|error| anyhow::anyhow!("{error:#}")), Command::ReconcileChannels { channel, relay_key } => { reconcile_channels(channel, relay_key).await?; Ok(0) diff --git a/crates/buzz-admin/src/nip_fi_upgrade.rs b/crates/buzz-admin/src/nip_fi_upgrade.rs new file mode 100644 index 0000000000..be64a45749 --- /dev/null +++ b/crates/buzz-admin/src/nip_fi_upgrade.rs @@ -0,0 +1,526 @@ +//! One-shot calls to the existing operator upgrade API; no database or worker startup. + +use std::{io::Read, path::Path, path::PathBuf, time::Duration}; + +use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use clap::{Args as ClapArgs, Subcommand}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use reqwest::{redirect::Policy, Method}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::Url; +use uuid::Uuid; + +const PATH: &str = "/operator/communities/nip-fi-upgrade"; +const MAX_BYTES: usize = 64 * 1024; + +/// Explicit operator destination and protected signing material. +#[derive(Debug, ClapArgs)] +pub struct Args { + /// Must match the relay's RELAY_OPERATOR_API_ORIGIN (HTTP for a trusted mesh, or HTTPS). + #[arg(long)] + origin: String, + /// File containing the authorized deployment operator's hex secret key or nsec. + #[arg(long)] + key_file: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Accept or recover an upgrade using the exact saved request tuple. + Enable { + /// JSON with operation_id, community_id, expected_host, actor_pubkey, + /// expected_owner_pubkey and expected_issuer. Reuse after response loss. + #[arg(long)] + request: PathBuf, + }, + /// Read the original operator's durable receipt without sending a mutation. + Status { + #[arg(long)] + operation_id: Uuid, + }, +} + +#[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, +} + +impl UpgradeRequest { + fn validate(&self) -> Result<()> { + ensure!( + !self.operation_id.is_nil() && !self.community_id.is_nil(), + "UUIDs must not be nil" + ); + let host = crate::communities::normalize_host_authority(&self.expected_host) + .map_err(anyhow::Error::msg)?; + ensure!( + host == self.expected_host && !host.contains(':'), + "expected_host must be canonical lowercase without a port or trailing dot" + ); + for key in [&self.actor_pubkey, &self.expected_owner_pubkey] { + ensure!( + key.len() == 64 + && key + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), + "owner public keys must be 64 lowercase hex characters" + ); + } + Ok(()) + } +} + +fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + std::fs::File::open(path) + .with_context(|| format!("cannot open {}", path.display()))? + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("cannot read {}", path.display()))?; + ensure!(bytes.len() <= limit, "input file exceeds size limit"); + Ok(bytes) +} + +fn endpoint(origin: &str, operation: Option) -> Result { + let base = Url::parse(origin).context("invalid operator origin")?; + ensure!( + matches!(base.scheme(), "http" | "https") + && base.host_str().is_some() + && base.username().is_empty() + && base.password().is_none() + && base.path() == "/" + && base.query().is_none() + && base.fragment().is_none(), + "operator origin must be an HTTP(S) origin without credentials, path, query or fragment" + ); + let mut url = base.join(PATH)?; + if let Some(id) = operation { + ensure!(!id.is_nil(), "operation_id must not be nil"); + url.query_pairs_mut() + .append_pair("operation_id", &id.to_string()); + } + Ok(url) +} + +fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result { + let mut tags = vec![ + Tag::parse(["u", url.as_str()])?, + Tag::parse(["method", method.as_str()])?, + Tag::parse(["nonce", &Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys)?; + Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) +} + +fn receipt_exit_code( + receipt: &Value, + operation_id: Uuid, + operator: &str, + request: Option<&UpgradeRequest>, +) -> Result { + ensure!( + receipt["operation_id"].as_str() == Some(operation_id.to_string().as_str()) + && receipt["operator_pubkey"].as_str() == Some(operator) + && receipt["requires_nip_fi"].as_bool() == Some(true) + && receipt["policy_generation"].as_i64().is_some_and(|g| g > 0), + "unconfirmed: response is not the expected durable acceptance receipt" + ); + if let Some(request) = request { + let expected = serde_json::to_value(request)?; + ensure!( + expected + .as_object() + .context("request must be an object")? + .iter() + .all(|(k, v)| receipt[k] == *v), + "unconfirmed: receipt tuple does not match the saved request" + ); + } + let reasons = receipt["pending_reasons"] + .as_array() + .context("unconfirmed: missing pending reasons")?; + ensure!( + reasons.iter().all(Value::is_string), + "unconfirmed: invalid pending reasons" + ); + match receipt["state"].as_str() { + Some("active") if reasons.is_empty() => Ok(0), + Some("pending") => Ok(10), + Some("unavailable") => Ok(12), + _ => anyhow::bail!("unconfirmed: invalid upgrade state"), + } +} + +/// Send exactly one signed request. A timeout never means the upgrade was rejected. +pub async fn run(args: Args) -> Result { + let request = match &args.command { + Command::Enable { request } => { + let request: UpgradeRequest = + serde_json::from_slice(&read_bounded(request, MAX_BYTES)?)?; + request.validate()?; + Some(request) + } + Command::Status { .. } => None, + }; + let operation_id = match &args.command { + Command::Enable { .. } => request.as_ref().context("missing request")?.operation_id, + Command::Status { operation_id } => *operation_id, + }; + let url = endpoint(&args.origin, request.is_none().then_some(operation_id))?; + // Do not include the secret or its parse error in diagnostics. + 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 body = request.as_ref().map(serde_json::to_vec).transpose()?; + let method = if body.is_some() { + Method::POST + } else { + Method::GET + }; + let auth = authorization(&keys, &method, &url, body.as_deref())?; + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(30)) + .build()?; + let mut call = client.request(method, url).header("Authorization", auth); + if let Some(body) = body { + call = call.header("Content-Type", "application/json").body(body); + } + let mut response = call + .send() + .await + .context("unconfirmed: request failed; recover the same operation_id")?; + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("unconfirmed: response interrupted; recover the same operation_id")? + { + ensure!( + bytes.len() + chunk.len() <= MAX_BYTES, + "unconfirmed: response exceeds size limit" + ); + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + // Preserve known machine-readable codes, without echoing arbitrary response bodies. + let error = serde_json::from_slice::(&bytes).ok(); + let code = error.as_ref().and_then(|value| value["code"].as_str()); + let detail = match code { + Some( + code @ ("invalid_request" + | "community_not_found" + | "operation_not_found" + | "idempotency_conflict" + | "host_conflict" + | "owner_conflict" + | "lifecycle_conflict" + | "issuer_conflict" + | "upgrades_disabled" + | "authorization_unavailable"), + ) => code, + _ if error.is_none() => "non-JSON error response", + _ => "unrecognized error code", + }; + anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16()); + } + // Print only structured relay output, never transport headers or signing material. + let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "unconfirmed: invalid JSON response (HTTP {})", + status.as_u16() + ) + })?; + let code = receipt_exit_code( + &receipt, + operation_id, + &keys.public_key().to_hex(), + request.as_ref(), + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, http::HeaderMap, routing::any, Json, Router}; + use tokio::sync::mpsc; + + fn tuple() -> UpgradeRequest { + let owner = Keys::generate().public_key().to_hex(); + UpgradeRequest { + operation_id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + expected_host: "test.communities.example.org".into(), + actor_pubkey: owner.clone(), + expected_owner_pubkey: owner, + expected_issuer: "https://issuer.example.org".into(), + } + } + + #[tokio::test] + async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() { + for (field, value) in [ + ("expected_host", "TEST.communities.example.org"), + ("expected_host", "test.communities.example.org."), + ("expected_host", "test.communities.example.org:443"), + ("expected_host", "https://test.communities.example.org"), + ("actor_pubkey", &"A".repeat(64)), + ("expected_owner_pubkey", &"A".repeat(64)), + ("actor_pubkey", &"a".repeat(63)), + ("expected_owner_pubkey", &"g".repeat(64)), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("request.json"); + let mut input = serde_json::to_value(tuple()).unwrap(); + input[field] = value.into(); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Enable { request: path }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("host") || message.contains("public keys"), + "{message}" + ); + assert!( + !message.contains("absent-key"), + "key read preceded validation: {message}" + ); + } + } + + #[tokio::test] + async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() { + use axum::http::StatusCode; + + for (status, body, expected) in [ + ( + 409, + r#"{"code":"owner_conflict","error":"private detail"}"#, + "owner_conflict", + ), + (409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"), + (409, r#"{"code":"host_conflict"}"#, "host_conflict"), + ( + 409, + r#"{"code":"idempotency_conflict"}"#, + "idempotency_conflict", + ), + (503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"), + ( + 502, + "private detail", + "non-JSON error response", + ), + ( + 409, + r#"{"code":"private detail"}"#, + "unrecognized error code", + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + PATH, + any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + let keys = Keys::generate(); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&tuple()).unwrap()).unwrap(); + let error = run(Args { + origin, + key_file, + command: Command::Enable { + request: request_file, + }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains(&format!("HTTP {status}")), "{message}"); + assert!(message.contains(expected), "{message}"); + assert!(!message.contains("private detail"), "{message}"); + assert!(!message.contains(&keys.secret_key().display_secret().to_string())); + server.abort(); + } + } + + fn receipt(request: &UpgradeRequest, keys: &Keys, state: &str) -> Value { + let mut value = serde_json::to_value(request).unwrap(); + value["operator_pubkey"] = keys.public_key().to_hex().into(); + value["policy_generation"] = 1.into(); + value["requires_nip_fi"] = true.into(); + value["state"] = state.into(); + value["pending_reasons"] = serde_json::json!([]); + value + } + + #[tokio::test] + async fn command_signs_exact_post_body_and_get_query_and_preserves_operation() { + let keys = Keys::generate(); + let tuple = tuple(); + let response = receipt(&tuple, &keys, "pending"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (sender, mut requests) = mpsc::channel(4); + let app = Router::new().route( + PATH, + any( + move |method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Bytes| { + let response = response.clone(); + let sender = sender.clone(); + async move { + sender.send((method, uri, headers, body)).await.unwrap(); + Json(response) + } + }, + ), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&tuple).unwrap()).unwrap(); + + for command in [ + Command::Enable { + request: request_file, + }, + Command::Status { + operation_id: tuple.operation_id, + }, + ] { + assert_eq!( + run(Args { + origin: origin.clone(), + key_file: key_file.clone(), + command + }) + .await + .unwrap(), + 10 + ); + let (method, uri, headers, body) = requests.recv().await.unwrap(); + let encoded = headers["Authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap(); + let url = format!("{origin}{uri}"); + let pubkey = buzz_auth::verify_nip98_event( + &event, + &url, + method.as_str(), + (method == Method::POST).then_some(body.as_ref()), + ) + .unwrap(); + assert_eq!(pubkey, keys.public_key()); + if method == Method::POST { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + serde_json::to_value(&tuple).unwrap() + ); + assert!( + buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err() + ); + } else { + assert_eq!( + uri.query(), + Some(format!("operation_id={}", tuple.operation_id).as_str()) + ); + assert!(buzz_auth::verify_nip98_event( + &event, + &format!("{origin}{PATH}"), + "GET", + None + ) + .is_err()); + } + } + assert!(requests.try_recv().is_err()); + server.abort(); + } + + #[test] + fn only_matching_required_receipts_can_report_success() { + let keys = Keys::generate(); + let request = tuple(); + let operator = keys.public_key().to_hex(); + for (state, code) in [("active", 0), ("pending", 10), ("unavailable", 12)] { + assert_eq!( + receipt_exit_code( + &receipt(&request, &keys, state), + request.operation_id, + &operator, + Some(&request) + ) + .unwrap(), + code + ); + } + for (field, bad) in [ + ("operation_id", Value::from(Uuid::new_v4().to_string())), + ( + "operator_pubkey", + Value::from(Keys::generate().public_key().to_hex()), + ), + ("community_id", Value::from(Uuid::new_v4().to_string())), + ("requires_nip_fi", Value::from(false)), + ("policy_generation", Value::from(0)), + ("state", Value::from("unknown")), + ("pending_reasons", serde_json::json!(["not_drained"])), + ] { + let mut value = receipt(&request, &keys, "active"); + value[field] = bad; + assert!( + receipt_exit_code(&value, request.operation_id, &operator, Some(&request)).is_err(), + "accepted invalid {field}" + ); + } + } + + #[test] + fn origin_cannot_select_a_different_path_or_embed_credentials() { + for origin in [ + "https://user:password@example.org", + "https://example.org/wrong", + "https://example.org/?q=x", + "https://example.org/#fragment", + "file:///tmp/key", + ] { + assert!(endpoint(origin, None).is_err(), "accepted {origin}"); + } + } +} diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a10..4bc0b500c0 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -5,6 +5,10 @@ A deployment that provisions communities under one host suffix, for example each community in `community_nip_fi_issuers`. The relay writes these rows when it creates a community; `buzz-admin nip-fi` backfills and edits them. +To make an existing community require federated sign-in, use the separate +[community upgrade command and manual test](operator-nip-fi-upgrade.md). +An issuer binding alone does not change that community's required policy. + A host may hold a binding only when it is exactly one DNS label under the suffix, has no port, and is not *reserved*. A host is reserved when `https://` is a `command_audiences` entry of any issuer in diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md new file mode 100644 index 0000000000..40fb517e75 --- /dev/null +++ b/docs/operator-nip-fi-upgrade.md @@ -0,0 +1,213 @@ +# Require NIP-FI on an existing community + +`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. + +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 the test + +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. + +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: + +```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" +} +``` + +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 +``` + +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. + +```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 +``` + +```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 +``` + +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. + +| 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.