mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
Manage community NIP-FI policy with fenced enable and disable
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
2
Justfile
2
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
|
||||
|
||||
@@ -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<i32> {
|
||||
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:#}")),
|
||||
|
||||
368
crates/buzz-admin/src/nip_fi_policy.rs
Normal file
368
crates/buzz-admin/src/nip_fi_policy.rs
Normal file
@@ -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<Uuid>,
|
||||
#[arg(long, requires = "host")]
|
||||
community_id: Option<Uuid>,
|
||||
#[arg(long, requires = "community_id")]
|
||||
host: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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::<Value>(&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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>> {
|
||||
pub(super) fn read_bounded(path: &Path, limit: usize) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
|
||||
pub(super) fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
|
||||
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<i32> {
|
||||
.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<Vec<u8>>) -> Result<Value> {
|
||||
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<i32> {
|
||||
| "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<i32> {
|
||||
_ 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<i32> {
|
||||
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)]
|
||||
|
||||
@@ -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<i64>, Option<bool>, 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() {
|
||||
|
||||
@@ -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<i64>,
|
||||
/// Null only for legacy, unfenced requests.
|
||||
pub expected_requires_nip_fi: Option<bool>,
|
||||
/// Current target generation, absent after target deletion.
|
||||
pub current_policy_generation: Option<i64>,
|
||||
/// Current stored flag, absent after target deletion.
|
||||
pub current_requires_nip_fi: Option<bool>,
|
||||
}
|
||||
|
||||
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<Option<UpgradeRecord>> {
|
||||
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<Option<PolicyRecord>> {
|
||||
let mut connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let record: Option<UpgradeRecord> = sqlx::query_as(RECEIPT)
|
||||
let record: Option<PolicyRecord> = 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<PolicyOutcome> {
|
||||
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<UpgradeOutcome> {
|
||||
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<PolicyOutcome> {
|
||||
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<UpgradeRecord> = sqlx::query_as(RECEIPT)
|
||||
let existing: Option<PolicyRecord> = 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::<String, _>("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::<bool, _>("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::<bool, _>("active")? || deletion_pending {
|
||||
return Ok(UpgradeOutcome::Refused("lifecycle_conflict"));
|
||||
return Ok(PolicyOutcome::Refused("lifecycle_conflict"));
|
||||
}
|
||||
let owners: Vec<String> = 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<String> = sqlx::query_scalar(
|
||||
"SELECT issuer FROM community_nip_fi_issuers
|
||||
if desired {
|
||||
let issuer: Option<String> = 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
216
crates/buzz-relay/src/api/operator/community_policy.rs
Normal file
216
crates/buzz-relay/src/api/operator/community_policy.rs
Normal file
@@ -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<String>,
|
||||
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<Uuid>,
|
||||
community_id: Option<Uuid>,
|
||||
expected_host: Option<String>,
|
||||
}
|
||||
|
||||
type Error = (StatusCode, Json<Value>);
|
||||
|
||||
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<bool>) -> 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<Value> {
|
||||
// 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<Arc<AppState>>,
|
||||
slot: AccessSlot,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Json<Value>, 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<Arc<AppState>>,
|
||||
slot: AccessSlot,
|
||||
headers: HeaderMap,
|
||||
RawQuery(raw): RawQuery,
|
||||
Query(query): Query<PolicyQuery>,
|
||||
) -> Result<Json<Value>, 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()),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -513,6 +513,11 @@ pub fn build_router(state: Arc<AppState>) -> 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)
|
||||
|
||||
@@ -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=<UUID>&expected_host=<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": "<owner's registered 64-character hex public key>",
|
||||
"expected_owner_pubkey": "<expected current owner's hex public key>",
|
||||
"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=<UUID>`
|
||||
GET `?operation_id=<UUID>` 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": "<owner at acceptance>",
|
||||
"expected_owner_pubkey": "<expected owner at acceptance>",
|
||||
"expected_issuer": "https://identity.example/api/goose",
|
||||
"operator_pubkey": "<authenticated operator>",
|
||||
"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<Option<CommunityAuthPolicy>>`.
|
||||
`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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<verified owner public key: 64 lowercase hex characters>",
|
||||
"expected_owner_pubkey": "<same verified owner public key>",
|
||||
"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": "<owner's registered lowercase hex public key>",
|
||||
"expected_owner_pubkey": "<same current owner's public key>",
|
||||
"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:<verified-digest>
|
||||
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.
|
||||
|
||||
8
migrations/0084_community_auth_policy.sql
Normal file
8
migrations/0084_community_auth_policy.sql
Normal file
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user