From b0d006618e0f04db254b9ccdd27dff72b1f93733 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:27:03 -0500 Subject: [PATCH] Expose one set of current policy preconditions Exercise retained historical receipts and deleted targets through the supported status API; refuse UUID reuse without mutating policy. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/api/operator/community_policy.rs | 1 - .../operator/postgres_tests/policy_tests.rs | 214 +++++++++++++++++- 2 files changed, 205 insertions(+), 10 deletions(-) diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs index 622aec2b3e..ed9d660b93 100644 --- a/crates/buzz-relay/src/api/operator/community_policy.rs +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -199,7 +199,6 @@ pub async fn community_nip_fi_policy_status( 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"], 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 index 19b1e0823d..db0bbddbed 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -66,7 +66,64 @@ mod postgres_tests { .await; assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); + for (field, value) in [ + ("operation_id", serde_json::json!(Uuid::nil())), + ("community_id", serde_json::json!(Uuid::nil())), + ("expected_host", serde_json::json!(host.to_uppercase())), + ("actor_pubkey", serde_json::json!("not-a-pubkey")), + ("expected_policy_generation", serde_json::json!(-1)), + ("expected_issuer", serde_json::json!("")), + ("unknown", serde_json::json!(true)), + ] { + let mut invalid = body.clone(); + invalid[field] = value; + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(invalid.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{field}"); + assert_eq!(read_json(response).await["code"], "invalid_request"); + } Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; + for (field, value, code) in [ + ( + "expected_issuer", + "https://unapproved.example".to_owned(), + "issuer_conflict", + ), + ( + "expected_host", + "outside.example".to_owned(), + "issuer_conflict", + ), + ( + "actor_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ( + "expected_owner_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ] { + let mut refused = body.clone(); + refused[field] = value.into(); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(refused.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(read_json(response).await["code"], code); + } let mut history = Vec::new(); for (generation, desired) in [(1, true), (2, false), (3, true)] { body["operation_id"] = Uuid::new_v4().to_string().into(); @@ -96,6 +153,73 @@ mod postgres_tests { accepted["receipt"]["state"], if desired { "pending" } else { "applied" } ); + if generation == 1 { + let binding = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding + .take(); + let mut fresh = body.clone(); + let refused_id = Uuid::new_v4(); + fresh["operation_id"] = refused_id.to_string().into(); + fresh["expected_policy_generation"] = 1.into(); + fresh["expected_requires_nip_fi"] = true.into(); + let refused = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(fresh.to_string()), + ) + .await; + assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(refused).await["code"], + "authorization_unavailable" + ); + assert!(state + .db + .community_auth_policy_status(refused_id, &operator.public_key().to_hex()) + .await + .unwrap() + .is_none()); + let historical = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await, + ) + .await; + assert_eq!(historical["receipt"]["state"], "unavailable"); + assert_eq!(historical["receipt"]["policy_generation"], 1); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding = binding; + let text = body.to_string(); + let auth = fresh_upgrade_auth( + &operator, + &format!("http://{INGRESS_HOST}{path}"), + "POST", + Some(text.as_bytes()), + ); + let first = operator_request_with_auth( + state.clone(), + "POST", + path, + Some(text.clone()), + auth.clone(), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + let replayed_signature = + operator_request_with_auth(state.clone(), "POST", path, Some(text), auth).await; + assert_eq!(replayed_signature.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + replayed_signature.headers()[header::CACHE_CONTROL], + "no-store" + ); + } history.push(body.clone()); for old in &history { let id = old["operation_id"].as_str().unwrap(); @@ -125,15 +249,6 @@ mod postgres_tests { "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, @@ -225,6 +340,87 @@ mod postgres_tests { ) .await; assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + // Old acceptance rows remain recoverable through the sole supported + // endpoint, including after their target has been deleted. Their null + // preconditions cannot match a new fenced request with the same UUID. + for target in [*community.id.as_uuid(), Uuid::new_v4()] { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO community_auth_upgrades + (operation_id,target_community_id,expected_host,actor_pubkey,expected_owner_pubkey, + expected_issuer,operator_pubkey,policy_generation) VALUES ($1,$2,$3,$4,$4,$5,$6,1)") + .bind(id).bind(target).bind(&host).bind(owner.public_key().to_hex()) + .bind(NIP_FI_ISSUER).bind(operator.public_key().to_hex()) + .execute(state.db.pool()).await.unwrap(); + let receipt = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await, + ) + .await; + assert!(receipt["receipt"]["expected_policy_generation"].is_null()); + assert!(receipt["receipt"]["expected_requires_nip_fi"].is_null()); + assert_eq!(receipt["receipt"]["requires_nip_fi"], true); + assert_eq!( + receipt["receipt"]["state"], + if target == *community.id.as_uuid() { + "superseded" + } else { + "unavailable" + } + ); + if target != *community.id.as_uuid() { + assert!(receipt["receipt"]["current_policy_generation"].is_null()); + assert!(receipt["receipt"]["current_requires_nip_fi"].is_null()); + } + let mut reuse = history[0].clone(); + reuse["operation_id"] = id.to_string().into(); + reuse["community_id"] = target.to_string().into(); + reuse["expected_policy_generation"] = 4.into(); + let conflict = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(reuse.to_string()), + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert_eq!(read_json(conflict).await["code"], "idempotency_conflict"); + let hidden = fresh_upgrade_request( + state.clone(), + &Keys::generate(), + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await; + assert_eq!(hidden.status(), StatusCode::FORBIDDEN); + } + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert!(current.get("requires_nip_fi").is_none()); + assert!(current.get("policy_generation").is_none()); + assert_eq!(current["expected_policy_generation"], 4); + assert_eq!(current["expected_requires_nip_fi"], false); + // The removed route has no operator-auth handler even for signed calls. + for method in ["GET", "POST"] { + let gone = fresh_upgrade_request( + state.clone(), + &operator, + method, + "/operator/communities/nip-fi-upgrade", + (method == "POST").then(|| "{}".into()), + ) + .await; + assert_eq!(gone.status(), StatusCode::NOT_FOUND); + } // The control-plane signature and allowlist remain mandatory. let outsider = Keys::generate(); let response = fresh_upgrade_request(state, &outsider, "GET", ¤t_path, None).await;