From 5bc998705a35e37e437f60b2364d4edfc3053610 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:01:06 -0500 Subject: [PATCH 01/18] feat(auth): add disabled operator runtime Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> (cherry picked from commit a5c28eaedb3616e74fc7604f00756d0cf896cd4b) --- crates/buzz-relay/src/api/operator.rs | 167 ++++ crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/operator_runtime.rs | 758 ++++++++++++++++++ .../buzz-relay/tests/o5_operator_surface.rs | 340 ++++++++ 4 files changed, 1267 insertions(+) create mode 100644 crates/buzz-relay/src/operator_runtime.rs create mode 100644 crates/buzz-relay/tests/o5_operator_surface.rs diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..8148156af8 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -20,6 +20,10 @@ use buzz_core::{CommunityId, TenantContext}; use crate::handlers::community_provisioning::{ normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, }; +use crate::operator_runtime::{ + OpaqueOperatorReference, OperatorIntent, OperatorInvocation, OperatorInvocationContext, + OperatorOutcome, OperatorReasonCode, OperatorRuntime, OperatorRuntimeError, +}; use crate::state::AppState; use super::{api_error, bridge, internal_error}; @@ -497,6 +501,169 @@ pub async fn community_availability( }))) } +#[derive(Debug, Deserialize)] +struct LifecycleRequestContext { + domain_id: Uuid, + operation_id: Uuid, + correlation_id: Uuid, + reason: OperatorReasonCode, + expected_revision: u64, + #[serde(default)] + approval_references: Vec, +} + +impl LifecycleRequestContext { + fn into_runtime(self) -> Result { + OperatorInvocationContext::new( + self.domain_id, + self.operation_id, + self.correlation_id, + self.reason, + self.expected_revision, + self.approval_references, + ) + } +} + +#[derive(Debug, Deserialize)] +struct ListLifecycleRequest { + #[serde(flatten)] + context: LifecycleRequestContext, + limit: u16, + after: Option, +} + +#[derive(Debug, Deserialize)] +struct ReplaceLifecycleRequest { + #[serde(flatten)] + context: LifecycleRequestContext, + target: OpaqueOperatorReference, + replacement: OpaqueOperatorReference, +} + +#[derive(Debug, Deserialize)] +struct RevokeLifecycleRequest { + #[serde(flatten)] + context: LifecycleRequestContext, + target: OpaqueOperatorReference, +} + +type LifecycleResponse = Result, (StatusCode, Json)>; + +/// Construct the provider-neutral lifecycle router from complete external +/// authentication, authorization, durable-executor, and clock dependencies. +/// +/// The stock relay router does not call this function or register these paths. +/// A deployment composition root must explicitly construct [`OperatorRuntime`] +/// and merge this returned router. +pub fn lifecycle_router(runtime: Arc) -> axum::Router { + use axum::routing::post; + + axum::Router::new() + .route("/operator/v1/lifecycle/list", post(list_lifecycle)) + .route("/operator/v1/lifecycle/preview", post(preview_lifecycle)) + .route("/operator/v1/lifecycle/revoke", post(revoke_lifecycle)) + .route("/operator/v1/lifecycle/rotate", post(rotate_lifecycle)) + .with_state(runtime) +} + +async fn list_lifecycle( + State(runtime): State>, + headers: HeaderMap, + Json(request): Json, +) -> LifecycleResponse { + let invocation = OperatorInvocation::new( + request.context.into_runtime().map_err(lifecycle_error)?, + OperatorIntent::List { + limit: request.limit, + after: request.after, + }, + ) + .map_err(lifecycle_error)?; + invoke_lifecycle(runtime, &headers, invocation).await +} + +async fn preview_lifecycle( + State(runtime): State>, + headers: HeaderMap, + Json(request): Json, +) -> LifecycleResponse { + let invocation = OperatorInvocation::new( + request.context.into_runtime().map_err(lifecycle_error)?, + OperatorIntent::Preview { + target: request.target, + replacement: request.replacement, + }, + ) + .map_err(lifecycle_error)?; + invoke_lifecycle(runtime, &headers, invocation).await +} + +async fn revoke_lifecycle( + State(runtime): State>, + headers: HeaderMap, + Json(request): Json, +) -> LifecycleResponse { + let invocation = OperatorInvocation::new( + request.context.into_runtime().map_err(lifecycle_error)?, + OperatorIntent::Revoke { + target: request.target, + }, + ) + .map_err(lifecycle_error)?; + invoke_lifecycle(runtime, &headers, invocation).await +} + +async fn rotate_lifecycle( + State(runtime): State>, + headers: HeaderMap, + Json(request): Json, +) -> LifecycleResponse { + let invocation = OperatorInvocation::new( + request.context.into_runtime().map_err(lifecycle_error)?, + OperatorIntent::Rotate { + target: request.target, + replacement: request.replacement, + }, + ) + .map_err(lifecycle_error)?; + invoke_lifecycle(runtime, &headers, invocation).await +} + +async fn invoke_lifecycle( + runtime: Arc, + headers: &HeaderMap, + invocation: OperatorInvocation, +) -> LifecycleResponse { + let header = headers + .get(axum::http::header::AUTHORIZATION) + .ok_or_else(|| lifecycle_error(OperatorRuntimeError::MissingCredential))?; + let credential = crate::operator_runtime::OperatorCredential::from_authorization_header(header) + .map_err(lifecycle_error)?; + runtime + .invoke(&credential, invocation) + .await + .map(Json) + .map_err(lifecycle_error) +} + +fn lifecycle_error(error: OperatorRuntimeError) -> (StatusCode, Json) { + let status = match error { + OperatorRuntimeError::MissingCredential + | OperatorRuntimeError::InvalidCredential + | OperatorRuntimeError::Unauthenticated => StatusCode::UNAUTHORIZED, + OperatorRuntimeError::CrossDomain + | OperatorRuntimeError::StaleAuthority + | OperatorRuntimeError::MissingCapability + | OperatorRuntimeError::InvalidAuthority => StatusCode::FORBIDDEN, + OperatorRuntimeError::InvalidRequest => StatusCode::BAD_REQUEST, + OperatorRuntimeError::IdempotencyConflict => StatusCode::CONFLICT, + OperatorRuntimeError::StorageUnavailable => StatusCode::SERVICE_UNAVAILABLE, + OperatorRuntimeError::ExecutorContract => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(serde_json::json!({ "error": error.code() }))) +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 3bdd3d8b4e..a590737869 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -34,6 +34,8 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// Disabled-by-default provider-neutral operator lifecycle composition root. +pub mod operator_runtime; /// Provider-neutral inventory of every protected relay surface. pub mod protected_surface; /// NIP-01 client/relay message parsing. diff --git a/crates/buzz-relay/src/operator_runtime.rs b/crates/buzz-relay/src/operator_runtime.rs new file mode 100644 index 0000000000..67ddab3a79 --- /dev/null +++ b/crates/buzz-relay/src/operator_runtime.rs @@ -0,0 +1,758 @@ +//! Provider-neutral composition boundary for privileged lifecycle operations. +//! +//! The stock relay never constructs this runtime. A deployment must explicitly +//! supply an authenticator, a durable idempotent executor, and a trusted clock +//! before [`crate::api::operator::lifecycle_router`] can be built. + +use std::{fmt, sync::Arc}; + +use async_trait::async_trait; +use axum::http::HeaderValue; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +const MAX_CREDENTIAL_BYTES: usize = 8 * 1024; +const MAX_APPROVALS: usize = 4; +const MAX_LIST_LIMIT: u16 = 100; + +/// Redaction-safe opaque reference to an actor, target, approval, or cursor. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OpaqueOperatorReference([u8; 32]); + +impl OpaqueOperatorReference { + /// Construct a reference from an already-derived pseudonymous digest. + pub const fn from_digest(digest: [u8; 32]) -> Self { + Self(digest) + } + + /// Return the pseudonymous digest for durable comparison and encoding. + pub const fn digest(self) -> [u8; 32] { + self.0 + } + + fn is_zero(self) -> bool { + self.0 == [0; 32] + } +} + +impl fmt::Debug for OpaqueOperatorReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("OpaqueOperatorReference([redacted])") + } +} + +impl Serialize for OpaqueOperatorReference { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&hex::encode(self.0)) + } +} + +impl<'de> Deserialize<'de> for OpaqueOperatorReference { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + if encoded.len() != 64 { + return Err(D::Error::custom("operator reference must be 32-byte hex")); + } + let mut digest = [0; 32]; + hex::decode_to_slice(encoded, &mut digest) + .map_err(|_| D::Error::custom("operator reference must be 32-byte hex"))?; + if digest == [0; 32] { + return Err(D::Error::custom("operator reference must be non-zero")); + } + Ok(Self(digest)) + } +} + +/// Sensitive transport credential passed only to the installed authenticator. +pub struct OperatorCredential(Box<[u8]>); + +impl OperatorCredential { + /// Copy one bounded authorization header without parsing or logging it. + pub fn from_authorization_header(value: &HeaderValue) -> Result { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > MAX_CREDENTIAL_BYTES { + return Err(OperatorRuntimeError::InvalidCredential); + } + Ok(Self(bytes.to_vec().into_boxed_slice())) + } + + /// Expose the credential only at the explicit authentication boundary. + pub fn expose_to_authenticator(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for OperatorCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("OperatorCredential([redacted])") + } +} + +impl Drop for OperatorCredential { + fn drop(&mut self) { + self.0.fill(0); + } +} + +/// Closed lifecycle capabilities understood by the provider-neutral runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OperatorCapability { + /// Inspect active and historical lifecycle records. + Inspect, + /// Preview an exact lifecycle transition. + Preview, + /// Revoke an exact lifecycle target. + Revoke, + /// Rotate an exact binding to a proven replacement. + Rotate, +} + +/// Stable provider-neutral purpose for a privileged operation. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum OperatorReasonCode { + /// Routine account offboarding. + Offboarding, + /// Credential or key compromise containment. + CompromiseContainment, + /// Planned key rotation. + PlannedRotation, + /// Account recovery after independent verification. + VerifiedRecovery, + /// Data-integrity repair of an already committed effect. + IntegrityRepair, + /// Emergency deny-only local containment. + EmergencyContainment, + /// Retention archive of an inactive record. + RetentionArchive, +} + +impl OperatorReasonCode { + fn discriminant(self) -> u16 { + match self { + Self::Offboarding => 1, + Self::CompromiseContainment => 2, + Self::PlannedRotation => 3, + Self::VerifiedRecovery => 4, + Self::IntegrityRepair => 5, + Self::EmergencyContainment => 6, + Self::RetentionArchive => 7, + } + } +} + +/// Lifecycle operations exposed by the initial reachable operator surface. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum OperatorAction { + /// List active and historical lifecycle records. + List, + /// Preview an exact rotation. + Preview, + /// Revoke an exact target. + Revoke, + /// Rotate an exact binding. + Rotate, +} + +impl OperatorAction { + /// Required authenticated capability. + pub const fn capability(self) -> OperatorCapability { + match self { + Self::List => OperatorCapability::Inspect, + Self::Preview => OperatorCapability::Preview, + Self::Revoke => OperatorCapability::Revoke, + Self::Rotate => OperatorCapability::Rotate, + } + } + + fn discriminant(self) -> u16 { + match self { + Self::List => 1, + Self::Preview => 2, + Self::Revoke => 3, + Self::Rotate => 4, + } + } +} + +/// Common stable identities and fences for one lifecycle invocation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperatorInvocationContext { + domain_id: Uuid, + operation_id: Uuid, + correlation_id: Uuid, + reason: OperatorReasonCode, + expected_revision: u64, + approval_references: Box<[OpaqueOperatorReference]>, +} + +impl OperatorInvocationContext { + /// Validate the domain, operation, correlation, revision, and approvals. + pub fn new( + domain_id: Uuid, + operation_id: Uuid, + correlation_id: Uuid, + reason: OperatorReasonCode, + expected_revision: u64, + mut approval_references: Vec, + ) -> Result { + if domain_id.is_nil() + || operation_id.is_nil() + || correlation_id.is_nil() + || expected_revision == 0 + || approval_references.len() > MAX_APPROVALS + || approval_references.iter().any(|value| value.is_zero()) + { + return Err(OperatorRuntimeError::InvalidRequest); + } + approval_references.sort_unstable_by_key(|value| value.digest()); + if approval_references + .windows(2) + .any(|pair| pair[0] == pair[1]) + { + return Err(OperatorRuntimeError::InvalidRequest); + } + Ok(Self { + domain_id, + operation_id, + correlation_id, + reason, + expected_revision, + approval_references: approval_references.into_boxed_slice(), + }) + } + + /// Server-resolved authorization domain. + pub const fn domain_id(&self) -> Uuid { + self.domain_id + } + + /// Stable idempotency identity. + pub const fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Request-correlation identity, separate from operation identity. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable operator reason. + pub const fn reason(&self) -> OperatorReasonCode { + self.reason + } + + /// Exact lifecycle revision fence supplied by the caller. + pub const fn expected_revision(&self) -> u64 { + self.expected_revision + } + + /// Bounded opaque approval references. + pub fn approval_references(&self) -> &[OpaqueOperatorReference] { + &self.approval_references + } +} + +/// Action-specific bounded operator intent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperatorIntent { + /// Bounded list with an optional opaque cursor. + List { + /// Maximum records to return. + limit: u16, + /// Opaque pagination cursor. + after: Option, + }, + /// Preview one exact rotation. + Preview { + /// Existing binding reference. + target: OpaqueOperatorReference, + /// Proposed replacement reference. + replacement: OpaqueOperatorReference, + }, + /// Revoke one exact target. + Revoke { + /// Exact target reference. + target: OpaqueOperatorReference, + }, + /// Rotate one exact target to one exact replacement. + Rotate { + /// Existing binding reference. + target: OpaqueOperatorReference, + /// Proven replacement reference. + replacement: OpaqueOperatorReference, + }, +} + +impl OperatorIntent { + /// Operation class represented by this intent. + pub const fn action(&self) -> OperatorAction { + match self { + Self::List { .. } => OperatorAction::List, + Self::Preview { .. } => OperatorAction::Preview, + Self::Revoke { .. } => OperatorAction::Revoke, + Self::Rotate { .. } => OperatorAction::Rotate, + } + } +} + +/// Fully shaped, still-unauthenticated operator invocation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperatorInvocation { + context: OperatorInvocationContext, + intent: OperatorIntent, + fingerprint: [u8; 32], +} + +impl OperatorInvocation { + /// Construct a bounded invocation and derive its stable semantic digest. + pub fn new( + context: OperatorInvocationContext, + intent: OperatorIntent, + ) -> Result { + match &intent { + OperatorIntent::List { limit, after } => { + if *limit == 0 + || *limit > MAX_LIST_LIMIT + || after.is_some_and(OpaqueOperatorReference::is_zero) + { + return Err(OperatorRuntimeError::InvalidRequest); + } + } + OperatorIntent::Preview { + target, + replacement, + } + | OperatorIntent::Rotate { + target, + replacement, + } => { + if target.is_zero() || replacement.is_zero() || target == replacement { + return Err(OperatorRuntimeError::InvalidRequest); + } + } + OperatorIntent::Revoke { target } if target.is_zero() => { + return Err(OperatorRuntimeError::InvalidRequest); + } + OperatorIntent::Revoke { .. } => {} + } + let fingerprint = semantic_fingerprint(&context, &intent); + Ok(Self { + context, + intent, + fingerprint, + }) + } + + /// Invocation context. + pub const fn context(&self) -> &OperatorInvocationContext { + &self.context + } + + /// Action-specific intent. + pub const fn intent(&self) -> &OperatorIntent { + &self.intent + } + + /// Stable semantic digest used for idempotency conflict detection. + pub const fn fingerprint(&self) -> [u8; 32] { + self.fingerprint + } +} + +fn semantic_fingerprint(context: &OperatorInvocationContext, intent: &OperatorIntent) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-operator-runtime-intent-v1"); + hasher.update(context.domain_id.as_bytes()); + hasher.update(context.operation_id.as_bytes()); + hasher.update(intent.action().discriminant().to_be_bytes()); + hasher.update(context.reason.discriminant().to_be_bytes()); + hasher.update(context.expected_revision.to_be_bytes()); + for approval in context.approval_references.iter() { + hasher.update(approval.digest()); + } + match intent { + OperatorIntent::List { limit, after } => { + hasher.update(limit.to_be_bytes()); + if let Some(after) = after { + hasher.update(after.digest()); + } + } + OperatorIntent::Preview { + target, + replacement, + } + | OperatorIntent::Rotate { + target, + replacement, + } => { + hasher.update(target.digest()); + hasher.update(replacement.digest()); + } + OperatorIntent::Revoke { target } => hasher.update(target.digest()), + } + hasher.finalize().into() +} + +/// Redaction-safe facts supplied to the installed authenticator. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperatorAuthorizationRequest { + domain_id: Uuid, + operation_id: Uuid, + capability: OperatorCapability, + intent_fingerprint: [u8; 32], +} + +impl OperatorAuthorizationRequest { + fn from_invocation(invocation: &OperatorInvocation) -> Self { + Self { + domain_id: invocation.context.domain_id, + operation_id: invocation.context.operation_id, + capability: invocation.intent.action().capability(), + intent_fingerprint: invocation.fingerprint, + } + } + + /// Server-resolved authorization domain. + pub const fn domain_id(self) -> Uuid { + self.domain_id + } + + /// Stable operation identity. + pub const fn operation_id(self) -> Uuid { + self.operation_id + } + + /// Required capability. + pub const fn capability(self) -> OperatorCapability { + self.capability + } + + /// Stable semantic fingerprint. + pub const fn intent_fingerprint(self) -> [u8; 32] { + self.intent_fingerprint + } +} + +/// Authenticated capability grant returned by a deployment-owned verifier. +pub trait GrantedOperatorCapability: Send + Sync { + /// Authorization domain bound by the grant. + fn domain_id(&self) -> Uuid; + /// Stable operation identity bound by the grant. + fn operation_id(&self) -> Uuid; + /// Exact semantic intent fingerprint bound by the grant. + fn intent_fingerprint(&self) -> [u8; 32]; + /// Pseudonymous actor reference stored in durable evidence. + fn actor_reference(&self) -> OpaqueOperatorReference; + /// Pseudonymous credential/provenance reference stored in durable evidence. + fn provenance_reference(&self) -> OpaqueOperatorReference; + /// Exclusive trusted expiry in Unix seconds. + fn expires_at_unix_seconds(&self) -> u64; + /// Whether this grant permits the exact closed capability. + fn permits(&self, capability: OperatorCapability) -> bool; +} + +/// Deployment-provided credential authenticator and capability source. +#[async_trait] +pub trait OperatorAuthenticator: Send + Sync { + /// Authenticate sensitive credential material and return an intent-bound grant. + async fn authenticate( + &self, + credential: &OperatorCredential, + request: OperatorAuthorizationRequest, + ) -> Result, OperatorRuntimeError>; +} + +/// Trusted time source used to reject authority stale at invocation time. +pub trait OperatorClock: Send + Sync { + /// Current Unix time in seconds. + fn now_unix_seconds(&self) -> Result; +} + +/// Fully authenticated operation passed to the durable executor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorizedOperatorOperation { + invocation: OperatorInvocation, + actor_reference: OpaqueOperatorReference, + provenance_reference: OpaqueOperatorReference, +} + +impl AuthorizedOperatorOperation { + /// Authenticated invocation. + pub const fn invocation(&self) -> &OperatorInvocation { + &self.invocation + } + + /// Pseudonymous actor reference. + pub const fn actor_reference(&self) -> OpaqueOperatorReference { + self.actor_reference + } + + /// Pseudonymous credential/provenance reference. + pub const fn provenance_reference(&self) -> OpaqueOperatorReference { + self.provenance_reference + } +} + +/// Redacted lifecycle state returned by listing operations. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OperatorRecordState { + /// Currently active. + Active, + /// Revoked but retained for attribution. + Revoked, + /// Replaced by a later binding. + Rotated, + /// Archived while lineage remains retained. + Archived, +} + +/// One redacted active or historical lifecycle record. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct OperatorRecord { + /// Pseudonymous record reference. + pub reference: OpaqueOperatorReference, + /// Redacted lifecycle state. + pub state: OperatorRecordState, + /// Monotonic record revision. + pub revision: u64, +} + +/// Stable result status for an idempotent operator operation. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OperatorOutcomeStatus { + /// Read-only list completed. + Listed, + /// Read-only preview completed. + Previewed, + /// Exact target was revoked. + Revoked, + /// Exact target was rotated. + Rotated, +} + +/// Redacted result returned by the durable executor. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct OperatorOutcome { + operation_id: Uuid, + correlation_id: Uuid, + action: OperatorAction, + status: OperatorOutcomeStatus, + affected_count: u32, + lifecycle_revision: u64, + records: Vec, +} + +impl OperatorOutcome { + /// Construct a bounded result for one committed or read-only operation. + pub fn new( + operation_id: Uuid, + correlation_id: Uuid, + action: OperatorAction, + status: OperatorOutcomeStatus, + affected_count: u32, + lifecycle_revision: u64, + records: Vec, + ) -> Result { + if operation_id.is_nil() + || correlation_id.is_nil() + || lifecycle_revision == 0 + || affected_count > u32::from(MAX_LIST_LIMIT) + || records.len() > usize::from(MAX_LIST_LIMIT) + || records + .iter() + .any(|record| record.reference.is_zero() || record.revision == 0) + || !matches!( + (action, status), + (OperatorAction::List, OperatorOutcomeStatus::Listed) + | (OperatorAction::Preview, OperatorOutcomeStatus::Previewed) + | (OperatorAction::Revoke, OperatorOutcomeStatus::Revoked) + | (OperatorAction::Rotate, OperatorOutcomeStatus::Rotated) + ) + || (action != OperatorAction::List && !records.is_empty()) + { + return Err(OperatorRuntimeError::ExecutorContract); + } + Ok(Self { + operation_id, + correlation_id, + action, + status, + affected_count, + lifecycle_revision, + records, + }) + } + + /// Stable operation identity. + pub const fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Separate request-correlation identity. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Completed action. + pub const fn action(&self) -> OperatorAction { + self.action + } +} + +/// Durable, atomically audited, idempotent lifecycle executor. +#[async_trait] +pub trait DurableOperatorExecutor: Send + Sync { + /// Execute or replay one operation by `(domain, operation_id, fingerprint)`. + /// + /// The implementation must return the original result for an identical + /// retry and reject an operation ID reused with a different fingerprint. + /// Mutations, receipt, audit outbox, invalidation, and post-commit effects + /// must share one database transaction. + async fn execute_idempotent( + &self, + operation: AuthorizedOperatorOperation, + ) -> Result; +} + +/// Explicit composition root for the disabled operator lifecycle surface. +pub struct OperatorRuntime { + authenticator: Arc, + executor: Arc, + clock: Arc, +} + +impl OperatorRuntime { + /// Construct a runtime from complete deployment-provided dependencies. + pub fn new( + authenticator: Arc, + executor: Arc, + clock: Arc, + ) -> Self { + Self { + authenticator, + executor, + clock, + } + } + + /// Authenticate, capability-check, and invoke the durable idempotent executor. + pub async fn invoke( + &self, + credential: &OperatorCredential, + invocation: OperatorInvocation, + ) -> Result { + let request = OperatorAuthorizationRequest::from_invocation(&invocation); + let grant = self.authenticator.authenticate(credential, request).await?; + let required = invocation.intent.action().capability(); + let now = self.clock.now_unix_seconds()?; + if grant.domain_id() != invocation.context.domain_id { + return Err(OperatorRuntimeError::CrossDomain); + } + if grant.operation_id() != invocation.context.operation_id + || grant.intent_fingerprint() != invocation.fingerprint + { + return Err(OperatorRuntimeError::InvalidAuthority); + } + if grant.expires_at_unix_seconds() <= now { + return Err(OperatorRuntimeError::StaleAuthority); + } + if !grant.permits(required) { + return Err(OperatorRuntimeError::MissingCapability); + } + let actor_reference = grant.actor_reference(); + let provenance_reference = grant.provenance_reference(); + if actor_reference.is_zero() + || provenance_reference.is_zero() + || actor_reference == provenance_reference + { + return Err(OperatorRuntimeError::InvalidAuthority); + } + let expected_operation_id = invocation.context.operation_id; + let expected_correlation_id = invocation.context.correlation_id; + let expected_action = invocation.intent.action(); + let outcome = self + .executor + .execute_idempotent(AuthorizedOperatorOperation { + invocation, + actor_reference, + provenance_reference, + }) + .await?; + if outcome.operation_id() != expected_operation_id + || outcome.correlation_id() != expected_correlation_id + || outcome.action() != expected_action + { + return Err(OperatorRuntimeError::ExecutorContract); + } + Ok(outcome) + } +} + +/// Closed, redaction-safe failure returned by the operator runtime. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum OperatorRuntimeError { + /// Authorization header is absent. + #[error("operator credential is required")] + MissingCredential, + /// Authorization header is empty or exceeds its bound. + #[error("operator credential is invalid")] + InvalidCredential, + /// Request identifiers, bounds, or intent shape are invalid. + #[error("operator request is invalid")] + InvalidRequest, + /// Credential authentication failed. + #[error("operator authentication failed")] + Unauthenticated, + /// Authenticated evidence crosses the requested domain. + #[error("operator authority crosses the requested domain")] + CrossDomain, + /// Authenticated authority is stale. + #[error("operator authority is stale")] + StaleAuthority, + /// Authenticated authority lacks the exact capability. + #[error("operator capability is missing")] + MissingCapability, + /// Authenticated grant contains invalid evidence references. + #[error("operator authority is invalid")] + InvalidAuthority, + /// Operation ID was replayed with a different semantic intent. + #[error("operator operation conflicts with an existing intent")] + IdempotencyConflict, + /// Durable storage could not accept the operation or denial evidence. + #[error("operator durable storage is unavailable")] + StorageUnavailable, + /// Executor returned an outcome inconsistent with the request. + #[error("operator executor contract failed")] + ExecutorContract, +} + +impl OperatorRuntimeError { + /// Stable client-safe error code. + pub const fn code(self) -> &'static str { + match self { + Self::MissingCredential => "operator_credential_required", + Self::InvalidCredential => "operator_credential_invalid", + Self::InvalidRequest => "operator_request_invalid", + Self::Unauthenticated => "operator_authentication_failed", + Self::CrossDomain => "operator_cross_domain_denied", + Self::StaleAuthority => "operator_authority_stale", + Self::MissingCapability => "operator_capability_missing", + Self::InvalidAuthority => "operator_authority_invalid", + Self::IdempotencyConflict => "operator_idempotency_conflict", + Self::StorageUnavailable => "operator_storage_unavailable", + Self::ExecutorContract => "operator_executor_contract_failed", + } + } +} diff --git a/crates/buzz-relay/tests/o5_operator_surface.rs b/crates/buzz-relay/tests/o5_operator_surface.rs new file mode 100644 index 0000000000..4031027919 --- /dev/null +++ b/crates/buzz-relay/tests/o5_operator_surface.rs @@ -0,0 +1,340 @@ +//! Focused scaffold for the disabled-by-default O5 operator composition path. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, +}; +use buzz_relay::{ + api::operator::lifecycle_router, + operator_runtime::{ + AuthorizedOperatorOperation, DurableOperatorExecutor, GrantedOperatorCapability, + OpaqueOperatorReference, OperatorAction, OperatorAuthenticator, + OperatorAuthorizationRequest, OperatorCapability, OperatorClock, OperatorCredential, + OperatorOutcome, OperatorOutcomeStatus, OperatorRecord, OperatorRecordState, + OperatorRuntime, OperatorRuntimeError, + }, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; +use uuid::Uuid; + +const CREDENTIAL_CANARY: &str = "Synthetic operator credential must never escape"; +const PRIVATE_CLAIM_CANARY: &str = "Synthetic private claim must never escape"; + +struct FixedClock; + +impl OperatorClock for FixedClock { + fn now_unix_seconds(&self) -> Result { + Ok(100) + } +} + +struct TestGrant { + domain_id: Uuid, + operation_id: Uuid, + intent_fingerprint: [u8; 32], + allow: bool, +} + +impl GrantedOperatorCapability for TestGrant { + fn domain_id(&self) -> Uuid { + self.domain_id + } + + fn operation_id(&self) -> Uuid { + self.operation_id + } + + fn intent_fingerprint(&self) -> [u8; 32] { + self.intent_fingerprint + } + + fn actor_reference(&self) -> OpaqueOperatorReference { + OpaqueOperatorReference::from_digest([1; 32]) + } + + fn provenance_reference(&self) -> OpaqueOperatorReference { + OpaqueOperatorReference::from_digest([2; 32]) + } + + fn expires_at_unix_seconds(&self) -> u64 { + 200 + } + + fn permits(&self, _capability: OperatorCapability) -> bool { + self.allow + } +} + +struct TestAuthenticator { + allow: bool, + calls: Mutex>, +} + +#[async_trait] +impl OperatorAuthenticator for TestAuthenticator { + async fn authenticate( + &self, + credential: &OperatorCredential, + request: OperatorAuthorizationRequest, + ) -> Result, OperatorRuntimeError> { + assert_eq!( + credential.expose_to_authenticator(), + CREDENTIAL_CANARY.as_bytes() + ); + assert_ne!(request.intent_fingerprint(), [0; 32]); + self.calls.lock().expect("auth calls").push(request); + Ok(Box::new(TestGrant { + domain_id: request.domain_id(), + operation_id: request.operation_id(), + intent_fingerprint: request.intent_fingerprint(), + allow: self.allow, + })) + } +} + +#[derive(Default)] +struct TestExecutor { + receipts: Mutex>, + committed_actions: Mutex>, +} + +#[async_trait] +impl DurableOperatorExecutor for TestExecutor { + async fn execute_idempotent( + &self, + operation: AuthorizedOperatorOperation, + ) -> Result { + let invocation = operation.invocation(); + let context = invocation.context(); + let action = invocation.intent().action(); + let fingerprint = invocation.fingerprint(); + let receipt_key = (context.domain_id(), context.operation_id()); + let mut receipts = self.receipts.lock().expect("receipts"); + if let Some((existing_fingerprint, outcome)) = receipts.get(&receipt_key) { + return if *existing_fingerprint == fingerprint { + Ok(outcome.clone()) + } else { + Err(OperatorRuntimeError::IdempotencyConflict) + }; + } + + assert_ne!(operation.actor_reference().digest(), [0; 32]); + assert_ne!(operation.provenance_reference().digest(), [0; 32]); + let (status, affected_count, records) = match action { + OperatorAction::List => ( + OperatorOutcomeStatus::Listed, + 1, + vec![OperatorRecord { + reference: OpaqueOperatorReference::from_digest([7; 32]), + state: OperatorRecordState::Active, + revision: context.expected_revision(), + }], + ), + OperatorAction::Preview => (OperatorOutcomeStatus::Previewed, 1, Vec::new()), + OperatorAction::Revoke => (OperatorOutcomeStatus::Revoked, 1, Vec::new()), + OperatorAction::Rotate => (OperatorOutcomeStatus::Rotated, 1, Vec::new()), + }; + let outcome = OperatorOutcome::new( + context.operation_id(), + context.correlation_id(), + action, + status, + affected_count, + context.expected_revision() + 1, + records, + )?; + receipts.insert(receipt_key, (fingerprint, outcome.clone())); + self.committed_actions + .lock() + .expect("committed actions") + .push(action); + Ok(outcome) + } +} + +fn runtime() -> ( + Arc, + Arc, + Arc, +) { + runtime_with_capability(true) +} + +fn runtime_with_capability( + allow: bool, +) -> ( + Arc, + Arc, + Arc, +) { + let authenticator = Arc::new(TestAuthenticator { + allow, + calls: Mutex::new(Vec::new()), + }); + let executor = Arc::new(TestExecutor::default()); + let runtime = Arc::new(OperatorRuntime::new( + authenticator.clone(), + executor.clone(), + Arc::new(FixedClock), + )); + (runtime, authenticator, executor) +} + +fn reference(byte: u8) -> String { + hex::encode([byte; 32]) +} + +fn request_body(domain_id: Uuid, operation_id: Uuid, correlation_id: Uuid) -> Value { + json!({ + "domain_id": domain_id, + "operation_id": operation_id, + "correlation_id": correlation_id, + "reason": "planned_rotation", + "expected_revision": 7, + "approval_references": [reference(9)], + "private_claim_canary": PRIVATE_CLAIM_CANARY, + }) +} + +async fn post(runtime: Arc, path: &str, body: Value) -> (StatusCode, String) { + let response = lifecycle_router(runtime) + .oneshot( + Request::post(path) + .header(header::AUTHORIZATION, CREDENTIAL_CANARY) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("operator request"), + ) + .await + .expect("operator response"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("response body"); + ( + status, + String::from_utf8(bytes.to_vec()).expect("UTF-8 response"), + ) +} + +#[tokio::test] +async fn real_composition_path_reaches_list_preview_revoke_and_rotate() { + let (runtime, authenticator, executor) = runtime(); + let domain_id = Uuid::from_u128(0x501); + let mut results = Vec::new(); + + let mut list = request_body(domain_id, Uuid::from_u128(0x510), Uuid::from_u128(0x610)); + list["limit"] = json!(25); + results.push(post(runtime.clone(), "/operator/v1/lifecycle/list", list.clone()).await); + + let mut preview = request_body(domain_id, Uuid::from_u128(0x511), Uuid::from_u128(0x611)); + preview["target"] = json!(reference(3)); + preview["replacement"] = json!(reference(4)); + results.push(post(runtime.clone(), "/operator/v1/lifecycle/preview", preview).await); + + let mut revoke = request_body(domain_id, Uuid::from_u128(0x512), Uuid::from_u128(0x612)); + revoke["target"] = json!(reference(3)); + results.push(post(runtime.clone(), "/operator/v1/lifecycle/revoke", revoke).await); + + let mut rotate = request_body(domain_id, Uuid::from_u128(0x513), Uuid::from_u128(0x613)); + rotate["target"] = json!(reference(3)); + rotate["replacement"] = json!(reference(4)); + results.push(post(runtime.clone(), "/operator/v1/lifecycle/rotate", rotate).await); + + // The same semantic operation returns the original result. + results.push(post(runtime, "/operator/v1/lifecycle/list", list).await); + + for (status, body) in &results { + assert_eq!(*status, StatusCode::OK, "unexpected body: {body}"); + assert!(!body.contains(CREDENTIAL_CANARY)); + assert!(!body.contains(PRIVATE_CLAIM_CANARY)); + } + assert_eq!(authenticator.calls.lock().expect("auth calls").len(), 5); + + let actions = executor + .committed_actions + .lock() + .expect("committed actions") + .clone(); + assert_eq!(actions.len(), 4, "idempotent replay must not re-execute"); + for expected in [ + OperatorAction::List, + OperatorAction::Preview, + OperatorAction::Revoke, + OperatorAction::Rotate, + ] { + assert!(actions.contains(&expected), "missing {expected:?}"); + } +} + +#[tokio::test] +async fn conflicting_operation_replay_is_denied_without_second_execution() { + let (runtime, _authenticator, executor) = runtime(); + let domain_id = Uuid::from_u128(0x520); + let operation_id = Uuid::from_u128(0x521); + let correlation_id = Uuid::from_u128(0x522); + let mut first = request_body(domain_id, operation_id, correlation_id); + first["target"] = json!(reference(3)); + assert_eq!( + post(runtime.clone(), "/operator/v1/lifecycle/revoke", first,) + .await + .0, + StatusCode::OK + ); + + let mut conflicting = request_body(domain_id, operation_id, correlation_id); + conflicting["target"] = json!(reference(5)); + let (status, body) = post(runtime, "/operator/v1/lifecycle/revoke", conflicting).await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(body.contains("operator_idempotency_conflict")); + assert_eq!( + executor + .committed_actions + .lock() + .expect("committed actions") + .len(), + 1 + ); +} + +#[tokio::test] +async fn missing_credential_and_missing_capability_never_reach_executor() { + let (runtime, _authenticator, executor) = runtime_with_capability(false); + let domain_id = Uuid::from_u128(0x530); + let mut body = request_body(domain_id, Uuid::from_u128(0x531), Uuid::from_u128(0x532)); + body["target"] = json!(reference(3)); + + let missing = lifecycle_router(runtime.clone()) + .oneshot( + Request::post("/operator/v1/lifecycle/revoke") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("missing-credential request"), + ) + .await + .expect("missing-credential response"); + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED); + + let (status, response) = post(runtime, "/operator/v1/lifecycle/revoke", body).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(response.contains("operator_capability_missing")); + assert!(executor + .committed_actions + .lock() + .expect("committed actions") + .is_empty()); +} + +#[test] +fn stock_router_does_not_register_lifecycle_surface() { + let stock_router = include_str!("../src/router.rs"); + assert!(!stock_router.contains("lifecycle_router")); + assert!(!stock_router.contains("/operator/v1/lifecycle")); +} From 4509312239d36db14c06c13f3ba18db9584245f1 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:17:58 -0500 Subject: [PATCH 02/18] feat(auth): persist audit and lifecycle state Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- Cargo.lock | 5 + crates/buzz-audit/Cargo.toml | 2 + .../buzz-audit/src/authorization/encoding.rs | 447 ++++ crates/buzz-audit/src/authorization/event.rs | 504 ++++ crates/buzz-audit/src/authorization/export.rs | 379 +++ .../src/authorization/identifiers.rs | 244 ++ crates/buzz-audit/src/authorization/mod.rs | 52 + .../buzz-audit/src/authorization/registry.rs | 290 +++ crates/buzz-audit/src/lib.rs | 2 + crates/buzz-db/Cargo.toml | 3 + crates/buzz-db/src/authorization_evidence.rs | 1338 ++++++++++ crates/buzz-db/src/lib.rs | 7 + crates/buzz-db/src/migration.rs | 28 +- crates/buzz-db/src/operator_lifecycle.rs | 2313 +++++++++++++++++ crates/buzz-db/src/test_support.rs | 86 + .../0046_authorization_audit_outbox.sql | 79 + .../0047_authorization_decision_queue.sql | 37 + .../0048_authorization_evidence_delivery.sql | 154 ++ .../0049_authorization_operator_lifecycle.sql | 185 ++ .../0050_authorization_lifecycle_previews.sql | 30 + 20 files changed, 6184 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-audit/src/authorization/encoding.rs create mode 100644 crates/buzz-audit/src/authorization/event.rs create mode 100644 crates/buzz-audit/src/authorization/export.rs create mode 100644 crates/buzz-audit/src/authorization/identifiers.rs create mode 100644 crates/buzz-audit/src/authorization/mod.rs create mode 100644 crates/buzz-audit/src/authorization/registry.rs create mode 100644 crates/buzz-db/src/authorization_evidence.rs create mode 100644 crates/buzz-db/src/operator_lifecycle.rs create mode 100644 crates/buzz-db/src/test_support.rs create mode 100644 migrations/0046_authorization_audit_outbox.sql create mode 100644 migrations/0047_authorization_decision_queue.sql create mode 100644 migrations/0048_authorization_evidence_delivery.sql create mode 100644 migrations/0049_authorization_operator_lifecycle.sql create mode 100644 migrations/0050_authorization_lifecycle_previews.sql diff --git a/Cargo.lock b/Cargo.lock index 63ed5de962..bd276c5f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -888,6 +888,7 @@ dependencies = [ "chrono", "futures-util", "hex", + "hmac 0.13.0", "serde", "serde_json", "sha2 0.11.0", @@ -896,6 +897,7 @@ dependencies = [ "tokio", "tracing", "uuid", + "zeroize", ] [[package]] @@ -1003,10 +1005,12 @@ dependencies = [ name = "buzz-db" version = "0.1.0" dependencies = [ + "buzz-audit", "buzz-auth", "buzz-core", "chrono", "hex", + "hmac 0.13.0", "metrics", "metrics-util", "nostr", @@ -1019,6 +1023,7 @@ dependencies = [ "tokio", "tracing", "uuid", + "zeroize", ] [[package]] diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index ff7bafb379..bd89eaee4d 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -20,3 +20,5 @@ thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } futures-util = { workspace = true } +hmac = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/buzz-audit/src/authorization/encoding.rs b/crates/buzz-audit/src/authorization/encoding.rs new file mode 100644 index 0000000000..235cedcc98 --- /dev/null +++ b/crates/buzz-audit/src/authorization/encoding.rs @@ -0,0 +1,447 @@ +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; + +use super::{ + ActorReference, AuthorizationEventV1, AuthorizationEvidenceError, EventPayloadV1, StreamId, +}; + +/// Database-assigned acceptance and chain metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AcceptedEventMetadata { + stream_id: StreamId, + stream_position: u64, + previous_chain_digest: [u8; 32], + accepted_at: DateTime, +} + +impl AcceptedEventMetadata { + /// Construct metadata after the stream head is locked. + pub fn new( + stream_id: StreamId, + stream_position: u64, + previous_chain_digest: [u8; 32], + accepted_at: DateTime, + ) -> Result { + if stream_position == 0 { + return Err(AuthorizationEvidenceError::NilIdentifier); + } + Ok(Self { + stream_id, + stream_position, + previous_chain_digest, + accepted_at, + }) + } + + /// Durable stream identity. + pub const fn stream_id(self) -> StreamId { + self.stream_id + } + + /// Stream-local position. + pub const fn stream_position(self) -> u64 { + self.stream_position + } + + /// Digest at the preceding stream position. + pub const fn previous_chain_digest(self) -> [u8; 32] { + self.previous_chain_digest + } + + /// Database acceptance time. + pub const fn accepted_at(self) -> DateTime { + self.accepted_at + } +} + +/// Canonical bytes and digests for one durably accepted event. +#[derive(Clone, PartialEq, Eq)] +pub struct CanonicalEvent { + bytes: Vec, + content_digest: [u8; 32], + chain_digest: [u8; 32], +} + +impl std::fmt::Debug for CanonicalEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CanonicalEvent") + .field("bytes", &"[redacted]") + .field("content_digest", &"[redacted]") + .field("chain_digest", &"[redacted]") + .finish() + } +} + +impl CanonicalEvent { + /// Digest only trusted semantic fields for idempotency before stream allocation. + pub fn semantic_digest(event: &AuthorizationEventV1) -> [u8; 32] { + Sha256::digest(encode_semantic(event)).into() + } + + /// Encode V1 in fixed field order with explicit length and optional markers. + pub fn encode(event: &AuthorizationEventV1, accepted: AcceptedEventMetadata) -> Self { + let semantic = encode_semantic(event); + let content_digest: [u8; 32] = Sha256::digest(&semantic).into(); + let mut bytes = Vec::with_capacity(semantic.len() + 128); + push_bytes(&mut bytes, b"buzz-authorization-event-accepted-v1"); + push_bytes(&mut bytes, accepted.stream_id.as_uuid().as_bytes()); + push_u64(&mut bytes, accepted.stream_position); + push_bytes(&mut bytes, &accepted.previous_chain_digest); + push_time(&mut bytes, accepted.accepted_at); + push_bytes(&mut bytes, &semantic); + let chain_digest: [u8; 32] = Sha256::digest(&bytes).into(); + Self { + bytes, + content_digest, + chain_digest, + } + } + + /// Canonical accepted-event bytes. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Digest of trusted semantic fields, excluding database acceptance facts. + pub const fn content_digest(&self) -> [u8; 32] { + self.content_digest + } + + /// Digest chaining semantic fields to exact stream acceptance metadata. + pub const fn chain_digest(&self) -> [u8; 32] { + self.chain_digest + } + + /// Verify a stored accepted-event frame before it crosses the export boundary. + /// + /// This checks the immutable row's stream coordinates, semantic digest, and + /// full chain digest without decoding or exposing any event field. + pub fn verify_accepted_bytes( + bytes: &[u8], + stream_id: StreamId, + stream_position: u64, + expected_content_digest: [u8; 32], + expected_chain_digest: [u8; 32], + ) -> Result<(), AuthorizationEvidenceError> { + if stream_position == 0 || <[u8; 32]>::from(Sha256::digest(bytes)) != expected_chain_digest + { + return Err(AuthorizationEvidenceError::InvalidDeliveryLease); + } + + let mut cursor = 0_usize; + let label = take_bytes(bytes, &mut cursor)?; + let embedded_stream = take_bytes(bytes, &mut cursor)?; + let embedded_position = take_u64(bytes, &mut cursor)?; + let previous_digest = take_bytes(bytes, &mut cursor)?; + take_exact(bytes, &mut cursor, 12)?; + let semantic = take_bytes(bytes, &mut cursor)?; + if cursor != bytes.len() + || label != b"buzz-authorization-event-accepted-v1" + || embedded_stream != stream_id.as_uuid().as_bytes() + || embedded_position != stream_position + || previous_digest.len() != 32 + || <[u8; 32]>::from(Sha256::digest(semantic)) != expected_content_digest + { + return Err(AuthorizationEvidenceError::InvalidDeliveryLease); + } + Ok(()) + } +} + +fn take_u64(source: &[u8], cursor: &mut usize) -> Result { + let value: [u8; 8] = take_exact(source, cursor, 8)? + .try_into() + .map_err(|_| AuthorizationEvidenceError::InvalidDeliveryLease)?; + Ok(u64::from_be_bytes(value)) +} + +fn take_bytes<'a>( + source: &'a [u8], + cursor: &mut usize, +) -> Result<&'a [u8], AuthorizationEvidenceError> { + let length = take_u64(source, cursor)?; + let length = + usize::try_from(length).map_err(|_| AuthorizationEvidenceError::InvalidDeliveryLease)?; + take_exact(source, cursor, length) +} + +fn take_exact<'a>( + source: &'a [u8], + cursor: &mut usize, + length: usize, +) -> Result<&'a [u8], AuthorizationEvidenceError> { + let end = cursor + .checked_add(length) + .filter(|end| *end <= source.len()) + .ok_or(AuthorizationEvidenceError::InvalidDeliveryLease)?; + let value = &source[*cursor..end]; + *cursor = end; + Ok(value) +} + +fn encode_semantic(event: &AuthorizationEventV1) -> Vec { + let mut bytes = Vec::with_capacity(512); + push_bytes(&mut bytes, b"buzz-authorization-event-semantic-v1"); + push_u16(&mut bytes, 1); + push_bytes(&mut bytes, event.event_id().as_uuid().as_bytes()); + push_bytes(&mut bytes, event.domain().as_uuid().as_bytes()); + push_time(&mut bytes, event.occurred_at()); + push_optional_uuid( + &mut bytes, + event.operation_id().map(|value| value.as_uuid()), + ); + push_bytes(&mut bytes, event.correlation_id().as_uuid().as_bytes()); + push_bytes(&mut bytes, event.attempt_id().as_uuid().as_bytes()); + push_optional_uuid( + &mut bytes, + event.causal_parent().map(|value| value.as_uuid()), + ); + encode_actor(&mut bytes, event.actor()); + push_optional_reference(&mut bytes, event.principal_reference()); + push_optional_reference(&mut bytes, event.key_reference()); + push_u16(&mut bytes, event.transport().discriminant()); + push_u16(&mut bytes, event.operation().discriminant()); + push_u16(&mut bytes, event.source().discriminant()); + push_u16(&mut bytes, event.kind().discriminant()); + push_u16(&mut bytes, event.result().discriminant()); + push_u16(&mut bytes, event.reason().discriminant()); + let versions = event.versions(); + push_optional_u64(&mut bytes, versions.binding); + push_optional_u64(&mut bytes, versions.lease); + push_optional_u64(&mut bytes, versions.lifecycle); + push_optional_u64(&mut bytes, versions.invalidation); + push_optional_digest(&mut bytes, versions.policy_digest); + encode_payload(&mut bytes, event.payload()); + bytes +} + +fn encode_actor(bytes: &mut Vec, actor: &ActorReference) { + push_u16(bytes, actor.class().discriminant()); + match actor { + ActorReference::NotApplicable + | ActorReference::Unresolved + | ActorReference::ControlPlane => {} + ActorReference::Direct(actor) => push_reference(bytes, *actor), + ActorReference::Delegated { + actor, + owner, + relationship_revision, + } => { + push_reference(bytes, *actor); + push_reference(bytes, *owner); + push_u64(bytes, *relationship_revision); + } + ActorReference::Operator { actor, approvers } => { + push_reference(bytes, *actor); + push_u16(bytes, approvers.len() as u16); + for approver in approvers { + push_reference(bytes, *approver); + } + } + } +} + +fn encode_payload(bytes: &mut Vec, payload: &EventPayloadV1) { + match payload { + EventPayloadV1::None => push_u16(bytes, 0), + EventPayloadV1::Lifecycle(value) => { + push_u16(bytes, 1); + push_reference(bytes, value.target()); + push_optional_u64(bytes, value.previous_version()); + push_optional_u64(bytes, value.current_version()); + push_optional_uuid(bytes, value.receipt_id().map(|id| id.as_uuid())); + push_optional_uuid(bytes, value.effect_id().map(|id| id.as_uuid())); + push_optional_u64(bytes, value.invalidation_generation()); + match value.lineage_binding() { + Some(reference) => { + bytes.push(1); + push_reference(bytes, reference); + } + None => bytes.push(0), + } + } + EventPayloadV1::BoundedSummary { + count, + snapshot_digest, + } => { + push_u16(bytes, 2); + push_u32(bytes, *count); + push_bytes(bytes, snapshot_digest); + } + EventPayloadV1::Delivery { + original_event_id, + delivery_attempt, + } => { + push_u16(bytes, 3); + push_bytes(bytes, original_event_id.as_uuid().as_bytes()); + push_u32(bytes, *delivery_attempt); + } + } +} + +fn push_reference(bytes: &mut Vec, reference: super::PseudonymousReference) { + bytes.push(reference.kind() as u8); + push_u32(bytes, reference.key_epoch()); + push_bytes(bytes, &reference.digest()); +} + +fn push_optional_reference(bytes: &mut Vec, reference: Option) { + match reference { + Some(reference) => { + bytes.push(1); + push_reference(bytes, reference); + } + None => bytes.push(0), + } +} + +fn push_optional_uuid(bytes: &mut Vec, value: Option) { + match value { + Some(value) => { + bytes.push(1); + push_bytes(bytes, value.as_bytes()); + } + None => bytes.push(0), + } +} + +fn push_optional_u64(bytes: &mut Vec, value: Option) { + match value { + Some(value) => { + bytes.push(1); + push_u64(bytes, value); + } + None => bytes.push(0), + } +} + +fn push_optional_digest(bytes: &mut Vec, value: Option<[u8; 32]>) { + match value { + Some(value) => { + bytes.push(1); + push_bytes(bytes, &value); + } + None => bytes.push(0), + } +} + +fn push_time(bytes: &mut Vec, value: DateTime) { + bytes.extend_from_slice(&value.timestamp().to_be_bytes()); + bytes.extend_from_slice(&value.timestamp_subsec_nanos().to_be_bytes()); +} + +fn push_bytes(target: &mut Vec, value: &[u8]) { + target.extend_from_slice(&(value.len() as u64).to_be_bytes()); + target.extend_from_slice(value); +} + +fn push_u16(target: &mut Vec, value: u16) { + target.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u32(target: &mut Vec, value: u32) { + target.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u64(target: &mut Vec, value: u64) { + target.extend_from_slice(&value.to_be_bytes()); +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + use crate::authorization::{ + ActorReference, AttemptId, CorrelationId, DecisionReason, EventId, EventKind, EventResult, + OperationClass, SourceClass, TransportClass, VersionVectorV1, + }; + use buzz_core::CommunityId; + + fn fixture_event() -> AuthorizationEventV1 { + let domain = CommunityId::from_uuid( + uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(), + ); + AuthorizationEventV1::new( + EventId::from_uuid( + uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(), + ) + .unwrap(), + domain, + Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 0).unwrap(), + None, + CorrelationId::from_uuid( + uuid::Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap(), + ) + .unwrap(), + AttemptId::from_uuid( + uuid::Uuid::parse_str("44444444-4444-4444-8444-444444444444").unwrap(), + ) + .unwrap(), + None, + ActorReference::Unresolved, + TransportClass::Internal, + OperationClass::Read, + SourceClass::Policy, + EventKind::AdmissionDenied, + EventResult::Denied, + DecisionReason::PolicyDenied, + VersionVectorV1::default(), + EventPayloadV1::None, + ) + } + + #[test] + fn canonical_encoding_is_deterministic_and_chain_sensitive() { + let event = fixture_event(); + let accepted = AcceptedEventMetadata::new( + StreamId::from_uuid( + uuid::Uuid::parse_str("55555555-5555-4555-8555-555555555555").unwrap(), + ) + .unwrap(), + 7, + [6; 32], + Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 1).unwrap(), + ) + .unwrap(); + let first = CanonicalEvent::encode(&event, accepted); + let second = CanonicalEvent::encode(&event, accepted); + assert_eq!(first, second); + CanonicalEvent::verify_accepted_bytes( + first.bytes(), + accepted.stream_id(), + accepted.stream_position(), + first.content_digest(), + first.chain_digest(), + ) + .expect("exact canonical frame verifies"); + let mut tampered = first.bytes().to_vec(); + let last = tampered.last_mut().expect("canonical frame is nonempty"); + *last ^= 1; + assert_eq!( + CanonicalEvent::verify_accepted_bytes( + &tampered, + accepted.stream_id(), + accepted.stream_position(), + first.content_digest(), + first.chain_digest(), + ), + Err(AuthorizationEvidenceError::InvalidDeliveryLease) + ); + let next = AcceptedEventMetadata::new( + accepted.stream_id(), + 8, + first.chain_digest(), + accepted.accepted_at(), + ) + .unwrap(); + assert_ne!( + first.chain_digest(), + CanonicalEvent::encode(&event, next).chain_digest() + ); + let debug = format!("{first:?}"); + assert!(debug.contains("[redacted]")); + assert!(!debug.contains(&hex::encode(first.bytes()))); + } +} diff --git a/crates/buzz-audit/src/authorization/event.rs b/crates/buzz-audit/src/authorization/event.rs new file mode 100644 index 0000000000..107366ae99 --- /dev/null +++ b/crates/buzz-audit/src/authorization/event.rs @@ -0,0 +1,504 @@ +use std::fmt; + +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; + +use super::{ + ActorClass, AttemptId, AuthorizationEvidenceError, CorrelationId, DecisionReason, EffectId, + EventId, EventKind, EventResult, OperationClass, OperationId, PseudonymousReference, ReceiptId, + ReferenceKind, SourceClass, TransportClass, +}; + +/// Maximum independently verified approvals represented in one evidence event. +pub const MAX_EVENT_APPROVERS: usize = 4; + +/// Redacted actor and approval facts for one event. +#[derive(Clone, PartialEq, Eq)] +pub enum ActorReference { + /// No actor applies to this observation. + NotApplicable, + /// Input did not reach authenticated actor resolution. + Unresolved, + /// Authenticated direct actor. + Direct(PseudonymousReference), + /// Authenticated delegate and independently verified owner relationship. + Delegated { + /// Domain-scoped actor pseudonym. + actor: PseudonymousReference, + /// Domain-scoped owner pseudonym. + owner: PseudonymousReference, + /// Exact persisted relationship revision. + relationship_revision: u64, + }, + /// Authenticated operator and independently authenticated approvers. + Operator { + /// Domain-scoped operator pseudonym. + actor: PseudonymousReference, + /// Distinct domain-scoped approver pseudonyms. + approvers: Box<[PseudonymousReference]>, + }, + /// Non-human control-plane action. + ControlPlane, +} + +impl ActorReference { + /// Construct a direct authenticated actor. + pub fn direct(actor: PseudonymousReference) -> Result { + if actor.kind() != ReferenceKind::Actor { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + Ok(Self::Direct(actor)) + } + + /// Construct exact delegated actor evidence. + pub fn delegated( + actor: PseudonymousReference, + owner: PseudonymousReference, + relationship_revision: u64, + ) -> Result { + if actor.kind() != ReferenceKind::Actor + || owner.kind() != ReferenceKind::Actor + || relationship_revision == 0 + { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + Ok(Self::Delegated { + actor, + owner, + relationship_revision, + }) + } + + /// Construct operator evidence with a bounded, distinct approval set. + pub fn operator( + actor: PseudonymousReference, + mut approvers: Vec, + ) -> Result { + if actor.kind() != ReferenceKind::Actor || approvers.len() > MAX_EVENT_APPROVERS { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + if approvers + .iter() + .any(|approver| approver.kind() != ReferenceKind::Approver) + { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + approvers.sort(); + approvers.dedup(); + Ok(Self::Operator { + actor, + approvers: approvers.into_boxed_slice(), + }) + } + + /// Closed actor class stored alongside the canonical payload. + pub const fn class(&self) -> ActorClass { + match self { + Self::NotApplicable => ActorClass::NotApplicable, + Self::Unresolved => ActorClass::Unresolved, + Self::Direct(_) => ActorClass::Direct, + Self::Delegated { .. } => ActorClass::Delegated, + Self::Operator { .. } => ActorClass::Operator, + Self::ControlPlane => ActorClass::ControlPlane, + } + } +} + +impl fmt::Debug for ActorReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActorReference") + .field("class", &self.class()) + .field("value", &"[redacted]") + .finish() + } +} + +/// Authorization-relevant versions available at a decision boundary. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct VersionVectorV1 { + /// Binding version. + pub binding: Option, + /// Lease version. + pub lease: Option, + /// Domain lifecycle revision. + pub lifecycle: Option, + /// Durable invalidation generation. + pub invalidation: Option, + /// Opaque policy-revision digest. + pub policy_digest: Option<[u8; 32]>, +} + +impl fmt::Debug for VersionVectorV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VersionVectorV1") + .field("binding", &self.binding) + .field("lease", &self.lease) + .field("lifecycle", &self.lifecycle) + .field("invalidation", &self.invalidation) + .field("policy_digest", &"[redacted]") + .finish() + } +} + +/// Closed lifecycle evidence payload. +#[derive(Clone, PartialEq, Eq)] +pub struct LifecycleEvidenceV1 { + target: PseudonymousReference, + previous_version: Option, + current_version: Option, + receipt_id: Option, + effect_id: Option, + invalidation_generation: Option, + lineage_binding: Option, +} + +impl LifecycleEvidenceV1 { + /// Construct a bounded lifecycle payload. + #[allow(clippy::too_many_arguments)] + pub fn new( + target: PseudonymousReference, + previous_version: Option, + current_version: Option, + receipt_id: Option, + effect_id: Option, + invalidation_generation: Option, + lineage_binding: Option, + ) -> Result { + if target.kind() == ReferenceKind::Approver + || lineage_binding.is_some_and(|value| value.kind() != ReferenceKind::Binding) + { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + Ok(Self { + target, + previous_version, + current_version, + receipt_id, + effect_id, + invalidation_generation, + lineage_binding, + }) + } + + pub(crate) const fn target(&self) -> PseudonymousReference { + self.target + } + + pub(crate) const fn previous_version(&self) -> Option { + self.previous_version + } + + pub(crate) const fn current_version(&self) -> Option { + self.current_version + } + + pub(crate) const fn receipt_id(&self) -> Option { + self.receipt_id + } + + pub(crate) const fn effect_id(&self) -> Option { + self.effect_id + } + + pub(crate) const fn invalidation_generation(&self) -> Option { + self.invalidation_generation + } + + pub(crate) const fn lineage_binding(&self) -> Option { + self.lineage_binding + } +} + +impl fmt::Debug for LifecycleEvidenceV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LifecycleEvidenceV1") + .field("target", &"[redacted]") + .field("previous_version", &self.previous_version) + .field("current_version", &self.current_version) + .field("receipt_id", &"[redacted]") + .field("effect_id", &"[redacted]") + .field("invalidation_generation", &self.invalidation_generation) + .field("lineage_binding", &"[redacted]") + .finish() + } +} + +/// Versioned closed event payload. +#[derive(Clone, PartialEq, Eq)] +pub enum EventPayloadV1 { + /// Event needs no additional fields. + None, + /// Authorization-relevant lifecycle facts. + Lifecycle(LifecycleEvidenceV1), + /// Bounded listing or preview summary. + BoundedSummary { + /// Number of records represented, already capped by the caller. + count: u32, + /// Digest of the snapshot or preview input. + snapshot_digest: [u8; 32], + }, + /// Delivery or quarantine observation for an immutable event. + Delivery { + /// Original immutable event identity. + original_event_id: EventId, + /// Delivery attempt ordinal, distinct from request attempt identity. + delivery_attempt: u32, + }, +} + +impl fmt::Debug for EventPayloadV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::None => formatter.write_str("EventPayloadV1::None"), + Self::Lifecycle(value) => formatter.debug_tuple("Lifecycle").field(value).finish(), + Self::BoundedSummary { count, .. } => formatter + .debug_struct("BoundedSummary") + .field("count", count) + .field("snapshot_digest", &"[redacted]") + .finish(), + Self::Delivery { + delivery_attempt, .. + } => formatter + .debug_struct("Delivery") + .field("original_event_id", &"[redacted]") + .field("delivery_attempt", delivery_attempt) + .finish(), + } + } +} + +/// Closed provider-neutral authorization evidence before durable acceptance. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationEventV1 { + event_id: EventId, + domain: CommunityId, + occurred_at: DateTime, + operation_id: Option, + correlation_id: CorrelationId, + attempt_id: AttemptId, + causal_parent: Option, + actor: ActorReference, + principal_reference: Option, + key_reference: Option, + transport: TransportClass, + operation: OperationClass, + source: SourceClass, + kind: EventKind, + result: EventResult, + reason: DecisionReason, + versions: VersionVectorV1, + payload: EventPayloadV1, +} + +impl AuthorizationEventV1 { + /// Construct schema-V1 evidence from closed trusted fields. + #[allow(clippy::too_many_arguments)] + pub fn new( + event_id: EventId, + domain: CommunityId, + occurred_at: DateTime, + operation_id: Option, + correlation_id: CorrelationId, + attempt_id: AttemptId, + causal_parent: Option, + actor: ActorReference, + transport: TransportClass, + operation: OperationClass, + source: SourceClass, + kind: EventKind, + result: EventResult, + reason: DecisionReason, + versions: VersionVectorV1, + payload: EventPayloadV1, + ) -> Self { + Self { + event_id, + domain, + occurred_at, + operation_id, + correlation_id, + attempt_id, + causal_parent, + actor, + principal_reference: None, + key_reference: None, + transport, + operation, + source, + kind, + result, + reason, + versions, + payload, + } + } + + /// Attach domain-separated principal and key pseudonyms without retaining + /// the raw issuer, subject, or key bytes. + pub fn with_subject_references( + mut self, + principal_reference: Option, + key_reference: Option, + ) -> Result { + if principal_reference.is_some_and(|value| value.kind() != ReferenceKind::Principal) + || key_reference.is_some_and(|value| value.kind() != ReferenceKind::Key) + { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + self.principal_reference = principal_reference; + self.key_reference = key_reference; + Ok(self) + } + + /// Event identity. + pub const fn event_id(&self) -> EventId { + self.event_id + } + + /// Authorization domain. + pub const fn domain(&self) -> CommunityId { + self.domain + } + + /// Occurrence time supplied by the trusted decision boundary. + pub const fn occurred_at(&self) -> DateTime { + self.occurred_at + } + + /// Semantic operation identity, when applicable. + pub const fn operation_id(&self) -> Option { + self.operation_id + } + + /// Cross-component attempt correlation. + pub const fn correlation_id(&self) -> CorrelationId { + self.correlation_id + } + + /// Invocation attempt identity. + pub const fn attempt_id(&self) -> AttemptId { + self.attempt_id + } + + /// Closed event kind. + pub const fn kind(&self) -> EventKind { + self.kind + } + + /// Closed result. + pub const fn result(&self) -> EventResult { + self.result + } + + /// Closed trusted reason. + pub const fn reason(&self) -> DecisionReason { + self.reason + } + + /// Closed actor classification, without its pseudonymous values. + pub const fn actor_class(&self) -> ActorClass { + self.actor.class() + } + + /// Pseudonymous issuer-qualified principal reference, when known. + pub const fn principal_reference(&self) -> Option { + self.principal_reference + } + + /// Pseudonymous authenticated-key fingerprint, when known. + pub const fn key_reference(&self) -> Option { + self.key_reference + } + + pub(crate) const fn causal_parent(&self) -> Option { + self.causal_parent + } + + pub(crate) const fn actor(&self) -> &ActorReference { + &self.actor + } + + pub(crate) const fn transport(&self) -> TransportClass { + self.transport + } + + pub(crate) const fn operation(&self) -> OperationClass { + self.operation + } + + pub(crate) const fn source(&self) -> SourceClass { + self.source + } + + pub(crate) const fn versions(&self) -> VersionVectorV1 { + self.versions + } + + pub(crate) const fn payload(&self) -> &EventPayloadV1 { + &self.payload + } +} + +impl fmt::Debug for AuthorizationEventV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationEventV1") + .field("event_id", &"[redacted]") + .field("domain", &"[redacted]") + .field("occurred_at", &self.occurred_at) + .field("operation_id", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("attempt_id", &"[redacted]") + .field("causal_parent", &"[redacted]") + .field("actor", &self.actor) + .field("principal_reference", &"[redacted]") + .field("key_reference", &"[redacted]") + .field("transport", &self.transport) + .field("operation", &self.operation) + .field("source", &self.source) + .field("kind", &self.kind) + .field("result", &self.result) + .field("reason", &self.reason) + .field("versions", &self.versions) + .field("payload", &self.payload) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authorization::{PseudonymKey, Pseudonymizer}; + + #[test] + fn operator_approvers_are_distinct_and_not_self() { + let domain = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let pseudonyms = Pseudonymizer::new(PseudonymKey::new([3; 32]).unwrap(), 1); + let actor = pseudonyms + .derive(domain, ReferenceKind::Actor, b"actor") + .unwrap(); + let approver = pseudonyms + .derive(domain, ReferenceKind::Approver, b"approver") + .unwrap(); + let value = ActorReference::operator(actor, vec![approver, approver]).unwrap(); + let ActorReference::Operator { approvers, .. } = value else { + panic!("expected operator actor"); + }; + assert_eq!(approvers.len(), 1); + } + + #[test] + fn debug_output_contains_no_reference_digest() { + let domain = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let pseudonyms = Pseudonymizer::new(PseudonymKey::new([8; 32]).unwrap(), 2); + let actor = pseudonyms + .derive(domain, ReferenceKind::Actor, b"synthetic-sensitive-value") + .unwrap(); + let rendered = format!("{:?}", ActorReference::direct(actor).unwrap()); + assert!(!rendered.contains(&hex::encode(actor.digest()))); + assert!(rendered.contains("[redacted]")); + } +} diff --git a/crates/buzz-audit/src/authorization/export.rs b/crates/buzz-audit/src/authorization/export.rs new file mode 100644 index 0000000000..9dbc053d08 --- /dev/null +++ b/crates/buzz-audit/src/authorization/export.rs @@ -0,0 +1,379 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use super::{ + AuthorizationEvidenceError, CanonicalEvent, DeliveryAttemptId, EventId, EvidenceStreamKind, + StreamId, +}; + +/// Evidence capacity priority. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum CapacityClass { + /// Denial, revocation, expiry, containment, and replay evidence. + RestrictiveReserve = 1, + /// A new allow or widening transition. + NewAllow = 2, + /// Nonessential inspection or preview evidence. + NonessentialRead = 3, +} + +/// Closed low-cardinality pipeline control signal. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u16)] +pub enum ControlCode { + /// Evidence storage could not durably accept a decision. + AcceptanceUnavailable = 1, + /// Reserved capacity was exhausted. + CapacityExhausted = 2, + /// Export sink was unavailable. + SinkUnavailable = 3, + /// Sink rejected the event as poison. + PoisonEvent = 4, + /// Event schema is not supported by the sink. + UnsupportedSchema = 5, + /// Stream or event digest did not verify. + IntegrityFailure = 6, + /// Delivery lease expired before acknowledgement. + LeaseExpired = 7, + /// Restore reconciliation found inconsistent evidence. + RestoreMismatch = 8, +} + +impl ControlCode { + /// Stable provider-neutral code. + pub const fn code(self) -> &'static str { + match self { + Self::AcceptanceUnavailable => "acceptance_unavailable", + Self::CapacityExhausted => "capacity_exhausted", + Self::SinkUnavailable => "sink_unavailable", + Self::PoisonEvent => "poison_event", + Self::UnsupportedSchema => "unsupported_schema", + Self::IntegrityFailure => "integrity_failure", + Self::LeaseExpired => "lease_expired", + Self::RestoreMismatch => "restore_mismatch", + } + } +} + +/// Immutable event lane selected for export. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeliveryKind { + /// Transactional outbox event. + AuditOutbox, + /// Non-mutating decision event. + Decision, +} + +impl DeliveryKind { + /// Corresponding durable stream lane. + pub const fn stream_kind(self) -> EvidenceStreamKind { + match self { + Self::AuditOutbox => EvidenceStreamKind::AuditOutbox, + Self::Decision => EvidenceStreamKind::Decision, + } + } +} + +/// Bounded retry policy for exporter delivery. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RetryPolicy { + initial_delay: Duration, + maximum_delay: Duration, + maximum_attempts: u32, +} + +impl RetryPolicy { + /// Validate retry bounds. + pub fn new( + initial_delay: Duration, + maximum_delay: Duration, + maximum_attempts: u32, + ) -> Result { + if initial_delay.is_zero() + || maximum_delay < initial_delay + || maximum_delay > Duration::from_secs(24 * 60 * 60) + || maximum_attempts == 0 + || maximum_attempts > 100 + { + return Err(AuthorizationEvidenceError::InvalidRetryPolicy); + } + Ok(Self { + initial_delay, + maximum_delay, + maximum_attempts, + }) + } + + /// Exponential delay capped at the configured maximum. + pub fn delay_for(self, attempt: u32) -> Option { + if attempt == 0 || attempt > self.maximum_attempts { + return None; + } + let multiplier = 1_u32.checked_shl(attempt.saturating_sub(1).min(31))?; + Some( + self.initial_delay + .checked_mul(multiplier) + .unwrap_or(self.maximum_delay) + .min(self.maximum_delay), + ) + } + + /// Maximum attempts before quarantine/dead-letter handling. + pub const fn maximum_attempts(self) -> u32 { + self.maximum_attempts + } +} + +/// Claimed immutable event plus mutable lease facts. +#[derive(Clone, PartialEq, Eq)] +pub struct DeliveryLease { + /// Event lane. + kind: DeliveryKind, + /// Event identity. + event_id: EventId, + /// Stream identity. + stream_id: StreamId, + /// Stream-local position. + stream_position: u64, + /// Unique identity for this exporter attempt. + delivery_attempt_id: DeliveryAttemptId, + /// Bounded attempt ordinal. + attempt: u32, + /// Lease expiry under database time. + lease_expires_at: DateTime, + /// Canonical immutable event bytes. + canonical_event: Vec, + /// Semantic content digest required for sink acknowledgement. + content_digest: [u8; 32], + /// Expected stream-chain digest. + chain_digest: [u8; 32], +} + +impl DeliveryLease { + /// Construct a lease from transactionally claimed storage facts. + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: DeliveryKind, + event_id: EventId, + stream_id: StreamId, + stream_position: u64, + delivery_attempt_id: DeliveryAttemptId, + attempt: u32, + lease_expires_at: DateTime, + canonical_event: Vec, + content_digest: [u8; 32], + chain_digest: [u8; 32], + ) -> Self { + Self { + kind, + event_id, + stream_id, + stream_position, + delivery_attempt_id, + attempt, + lease_expires_at, + canonical_event, + content_digest, + chain_digest, + } + } + + /// Event lane. + pub const fn kind(&self) -> DeliveryKind { + self.kind + } + /// Event identity. + pub const fn event_id(&self) -> EventId { + self.event_id + } + /// Stream identity. + pub const fn stream_id(&self) -> StreamId { + self.stream_id + } + /// Stream-local position. + pub const fn stream_position(&self) -> u64 { + self.stream_position + } + /// Unique identity for this exporter attempt. + pub const fn delivery_attempt_id(&self) -> DeliveryAttemptId { + self.delivery_attempt_id + } + /// Bounded attempt ordinal. + pub const fn attempt(&self) -> u32 { + self.attempt + } + /// Lease expiry under database time. + pub const fn lease_expires_at(&self) -> DateTime { + self.lease_expires_at + } + /// Canonical immutable bytes presented to the sink. + pub fn canonical_event(&self) -> &[u8] { + &self.canonical_event + } + /// Semantic content digest the sink must acknowledge. + pub const fn content_digest(&self) -> [u8; 32] { + self.content_digest + } + /// Expected stream-chain digest. + pub const fn chain_digest(&self) -> [u8; 32] { + self.chain_digest + } + + /// Validate lease and payload bounds before handing it to a sink. + pub fn validate(&self, now: DateTime) -> Result<(), AuthorizationEvidenceError> { + if self.stream_position == 0 + || self.attempt == 0 + || self.lease_expires_at <= now + || self.canonical_event.is_empty() + || self.canonical_event.len() > 64 * 1024 + { + return Err(AuthorizationEvidenceError::InvalidDeliveryLease); + } + CanonicalEvent::verify_accepted_bytes( + &self.canonical_event, + self.stream_id, + self.stream_position, + self.content_digest, + self.chain_digest, + ) + } +} + +impl std::fmt::Debug for DeliveryLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeliveryLease") + .field("kind", &self.kind) + .field("event_id", &"[redacted]") + .field("stream_id", &"[redacted]") + .field("stream_position", &self.stream_position) + .field("delivery_attempt_id", &"[redacted]") + .field("attempt", &self.attempt) + .field("lease_expires_at", &self.lease_expires_at) + .field("canonical_event", &"[redacted]") + .field("content_digest", &"[redacted]") + .field("chain_digest", &"[redacted]") + .finish() + } +} + +/// Sink outcome for one claimed delivery. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeliveryDisposition { + /// Sink durably accepted this exact event. + Accepted, + /// Retry according to bounded policy. + Retry(ControlCode), + /// Quarantine while preserving the immutable event. + Quarantine(ControlCode), +} + +/// Redaction-safe delivery failure returned by a sink adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeliveryFailure { + /// Closed control code; no raw upstream error is retained. + pub control_code: ControlCode, + /// Whether the adapter considers the failure retryable. + pub retryable: bool, +} + +/// Independent bounded counters for evidence-pipeline control gaps. +/// +/// Counters intentionally accept only [`ControlCode`]. They cannot retain an +/// identity, correlation, route, provider error, or credential fragment. +#[derive(Debug, Default)] +pub struct EvidenceHealthSignal { + acceptance_unavailable: AtomicU64, + capacity_exhausted: AtomicU64, + sink_unavailable: AtomicU64, + poison_event: AtomicU64, + unsupported_schema: AtomicU64, + integrity_failure: AtomicU64, + lease_expired: AtomicU64, + restore_mismatch: AtomicU64, +} + +impl EvidenceHealthSignal { + /// Increment one closed control category with saturation. + pub fn record(&self, code: ControlCode) { + let counter = match code { + ControlCode::AcceptanceUnavailable => &self.acceptance_unavailable, + ControlCode::CapacityExhausted => &self.capacity_exhausted, + ControlCode::SinkUnavailable => &self.sink_unavailable, + ControlCode::PoisonEvent => &self.poison_event, + ControlCode::UnsupportedSchema => &self.unsupported_schema, + ControlCode::IntegrityFailure => &self.integrity_failure, + ControlCode::LeaseExpired => &self.lease_expired, + ControlCode::RestoreMismatch => &self.restore_mismatch, + }; + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + Some(value.saturating_add(1)) + }); + } + + /// Read one counter without exposing labels or event data. + pub fn count(&self, code: ControlCode) -> u64 { + let counter = match code { + ControlCode::AcceptanceUnavailable => &self.acceptance_unavailable, + ControlCode::CapacityExhausted => &self.capacity_exhausted, + ControlCode::SinkUnavailable => &self.sink_unavailable, + ControlCode::PoisonEvent => &self.poison_event, + ControlCode::UnsupportedSchema => &self.unsupported_schema, + ControlCode::IntegrityFailure => &self.integrity_failure, + ControlCode::LeaseExpired => &self.lease_expired, + ControlCode::RestoreMismatch => &self.restore_mismatch, + }; + counter.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_is_bounded_and_capped() { + let policy = RetryPolicy::new(Duration::from_secs(2), Duration::from_secs(10), 5).unwrap(); + assert_eq!(policy.delay_for(1), Some(Duration::from_secs(2))); + assert_eq!(policy.delay_for(4), Some(Duration::from_secs(10))); + assert_eq!(policy.delay_for(6), None); + } + + #[test] + fn restrictive_evidence_has_highest_capacity_priority() { + assert!(CapacityClass::RestrictiveReserve < CapacityClass::NewAllow); + assert!(CapacityClass::NewAllow < CapacityClass::NonessentialRead); + } + + #[test] + fn health_signal_is_bounded_and_saturating() { + let signal = EvidenceHealthSignal::default(); + signal.record(ControlCode::AcceptanceUnavailable); + assert_eq!(signal.count(ControlCode::AcceptanceUnavailable), 1); + assert_eq!(signal.count(ControlCode::IntegrityFailure), 0); + } + + #[test] + fn delivery_debug_never_renders_canonical_bytes_or_digests() { + let lease = DeliveryLease::new( + DeliveryKind::AuditOutbox, + EventId::generate(), + StreamId::generate(), + 1, + DeliveryAttemptId::generate(), + 1, + Utc::now() + chrono::Duration::minutes(1), + b"planted-private-claim-canary".to_vec(), + [17; 32], + [23; 32], + ); + let rendered = format!("{lease:?}"); + assert!(rendered.contains("[redacted]")); + assert!(!rendered.contains("planted-private-claim-canary")); + assert!(!rendered.contains(&hex::encode([17; 32]))); + assert!(!rendered.contains(&hex::encode([23; 32]))); + } +} diff --git a/crates/buzz-audit/src/authorization/identifiers.rs b/crates/buzz-audit/src/authorization/identifiers.rs new file mode 100644 index 0000000000..ec3db0f9d6 --- /dev/null +++ b/crates/buzz-audit/src/authorization/identifiers.rs @@ -0,0 +1,244 @@ +use std::fmt; + +use buzz_core::CommunityId; +use hmac::digest::KeyInit; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use uuid::Uuid; +use zeroize::Zeroize; + +use super::AuthorizationEvidenceError; + +macro_rules! uuid_identifier { + ($name:ident, $description:literal) => { + #[doc = $description] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(Uuid); + + impl $name { + /// Validate and preserve an existing identifier. + pub fn from_uuid(value: Uuid) -> Result { + if value.is_nil() { + return Err(AuthorizationEvidenceError::NilIdentifier); + } + Ok(Self(value)) + } + + /// Allocate a fresh random identifier. + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Borrow the exact UUID for storage and comparison. + pub const fn as_uuid(self) -> Uuid { + self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple(stringify!($name)) + .field(&"[redacted]") + .finish() + } + } + }; +} + +uuid_identifier!(OperationId, "Stable identity for one semantic operation."); +uuid_identifier!( + CorrelationId, + "Cross-component correlation identity for one attempt." +); +uuid_identifier!(AttemptId, "Identity for one invocation attempt."); +uuid_identifier!(EventId, "Identity for one logical evidence event."); +uuid_identifier!(StreamId, "Identity for one durable evidence stream."); +uuid_identifier!(ReceiptId, "Identity for one immutable operation receipt."); +uuid_identifier!( + EffectId, + "Identity for one immutable post-commit effect intent." +); +uuid_identifier!( + AuthorityEvidenceId, + "Single-use identity for verified authority evidence." +); +uuid_identifier!( + ApprovalEvidenceId, + "Single-use identity for independently verified approval evidence." +); +uuid_identifier!( + DeliveryAttemptId, + "Identity for one exporter delivery attempt." +); +uuid_identifier!(ExporterId, "Identity for one exporter worker."); + +/// Closed identity classes accepted by authorization evidence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum ReferenceKind { + /// Authenticated operator or request actor. + Actor = 1, + /// Independently authenticated approver. + Approver = 2, + /// Issuer-qualified principal. + Principal = 3, + /// Nostr or other public key. + Key = 4, + /// Stable identity-binding record. + Binding = 5, + /// Authorization lease. + Lease = 6, + /// Runtime session. + Session = 7, + /// Exact delegated relationship. + DelegatedRelationship = 8, + /// Authorization policy revision. + Policy = 9, +} + +/// Secret key dedicated to the authorization-audit pseudonym namespace. +pub struct PseudonymKey([u8; 32]); + +impl PseudonymKey { + /// Construct a key from already generated secret bytes. + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + Ok(Self(bytes)) + } +} + +impl fmt::Debug for PseudonymKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PseudonymKey([redacted])") + } +} + +impl Drop for PseudonymKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// Domain- and kind-separated pseudonymous reference. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PseudonymousReference { + kind: ReferenceKind, + key_epoch: u32, + digest: [u8; 32], +} + +impl PseudonymousReference { + /// Reference class. + pub const fn kind(self) -> ReferenceKind { + self.kind + } + + /// Pseudonymization key epoch. + pub const fn key_epoch(self) -> u32 { + self.key_epoch + } + + /// Stable digest within this domain, class, and key epoch. + pub const fn digest(self) -> [u8; 32] { + self.digest + } +} + +impl fmt::Debug for PseudonymousReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PseudonymousReference") + .field("kind", &self.kind) + .field("key_epoch", &self.key_epoch) + .field("digest", &"[redacted]") + .finish() + } +} + +/// Derives audit-only pseudonyms without retaining raw identifiers. +pub struct Pseudonymizer { + key: PseudonymKey, + key_epoch: u32, +} + +impl Pseudonymizer { + /// Bind a dedicated audit key to its rotation epoch. + pub const fn new(key: PseudonymKey, key_epoch: u32) -> Self { + Self { key, key_epoch } + } + + /// Derive a domain- and reference-kind-separated pseudonym. + pub fn derive( + &self, + domain: CommunityId, + kind: ReferenceKind, + raw: &[u8], + ) -> Result { + if self.key_epoch == 0 || raw.is_empty() || raw.len() > 4096 { + return Err(AuthorizationEvidenceError::InvalidPseudonymInput); + } + let mut mac = as KeyInit>::new_from_slice(&self.key.0) + .map_err(|_| AuthorizationEvidenceError::InvalidPseudonymInput)?; + Mac::update(&mut mac, b"buzz-authorization-audit-pseudonym-v1"); + Mac::update(&mut mac, domain.as_uuid().as_bytes()); + Mac::update(&mut mac, &[kind as u8]); + Mac::update(&mut mac, &self.key_epoch.to_be_bytes()); + Mac::update(&mut mac, &(raw.len() as u64).to_be_bytes()); + Mac::update(&mut mac, raw); + Ok(PseudonymousReference { + kind, + key_epoch: self.key_epoch, + digest: mac.finalize().into_bytes().into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers_reject_nil_and_debug_redacts() { + assert_eq!( + EventId::from_uuid(Uuid::nil()), + Err(AuthorizationEvidenceError::NilIdentifier) + ); + assert!(format!("{:?}", EventId::generate()).contains("[redacted]")); + } + + #[test] + fn pseudonyms_are_domain_kind_and_epoch_separated() { + let domain_a = CommunityId::from_uuid(Uuid::new_v4()); + let domain_b = CommunityId::from_uuid(Uuid::new_v4()); + let input = b"synthetic-principal"; + let a = Pseudonymizer::new(PseudonymKey::new([7; 32]).unwrap(), 1); + let b = Pseudonymizer::new(PseudonymKey::new([7; 32]).unwrap(), 2); + assert_ne!( + a.derive(domain_a, ReferenceKind::Actor, input).unwrap(), + a.derive(domain_b, ReferenceKind::Actor, input).unwrap() + ); + assert_ne!( + a.derive(domain_a, ReferenceKind::Actor, input).unwrap(), + a.derive(domain_a, ReferenceKind::Principal, input).unwrap() + ); + assert_ne!( + a.derive(domain_a, ReferenceKind::Actor, input).unwrap(), + b.derive(domain_a, ReferenceKind::Actor, input).unwrap() + ); + assert_eq!( + Pseudonymizer::new(PseudonymKey::new([7; 32]).unwrap(), 0).derive( + domain_a, + ReferenceKind::Actor, + input + ), + Err(AuthorizationEvidenceError::InvalidPseudonymInput) + ); + assert_eq!( + PseudonymKey::new([0; 32]).unwrap_err(), + AuthorizationEvidenceError::InvalidPseudonymInput + ); + } +} diff --git a/crates/buzz-audit/src/authorization/mod.rs b/crates/buzz-audit/src/authorization/mod.rs new file mode 100644 index 0000000000..bf25761187 --- /dev/null +++ b/crates/buzz-audit/src/authorization/mod.rs @@ -0,0 +1,52 @@ +//! Closed contracts for durable authorization evidence. +//! +//! This module is deliberately separate from the legacy general-purpose audit +//! log. Authorization evidence has no arbitrary JSON or string payload and no +//! raw identity field. Database code assigns stream identity and position only +//! after the event is accepted durably. + +mod encoding; +mod event; +mod export; +mod identifiers; +mod registry; + +pub use encoding::{AcceptedEventMetadata, CanonicalEvent}; +pub use event::{ + ActorReference, AuthorizationEventV1, EventPayloadV1, LifecycleEvidenceV1, VersionVectorV1, +}; +pub use export::{ + CapacityClass, ControlCode, DeliveryDisposition, DeliveryFailure, DeliveryKind, DeliveryLease, + EvidenceHealthSignal, RetryPolicy, +}; +pub use identifiers::{ + ApprovalEvidenceId, AttemptId, AuthorityEvidenceId, CorrelationId, DeliveryAttemptId, EffectId, + EventId, ExporterId, OperationId, PseudonymKey, Pseudonymizer, PseudonymousReference, + ReceiptId, ReferenceKind, StreamId, +}; +pub use registry::{ + ActorClass, DecisionReason, EventKind, EventResult, EvidenceStreamKind, OperationClass, + SourceClass, TransportClass, +}; + +use thiserror::Error; + +/// Validation or canonical-encoding failure for an authorization event. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum AuthorizationEvidenceError { + /// A required UUID was nil. + #[error("authorization evidence identifier must not be nil")] + NilIdentifier, + /// A temporal bound was invalid. + #[error("authorization evidence time bounds are invalid")] + InvalidTime, + /// Pseudonym input was empty or exceeded its fixed bound. + #[error("authorization pseudonym input is invalid")] + InvalidPseudonymInput, + /// Retry configuration was invalid. + #[error("authorization evidence retry policy is invalid")] + InvalidRetryPolicy, + /// Delivery lease state was invalid. + #[error("authorization evidence delivery lease is invalid")] + InvalidDeliveryLease, +} diff --git a/crates/buzz-audit/src/authorization/registry.rs b/crates/buzz-audit/src/authorization/registry.rs new file mode 100644 index 0000000000..062dbb039e --- /dev/null +++ b/crates/buzz-audit/src/authorization/registry.rs @@ -0,0 +1,290 @@ +//! Stable closed registries used by authorization evidence. + +macro_rules! closed_registry { + ( + $(#[$meta:meta])* + $visibility:vis enum $name:ident { + $($variant:ident = $number:literal => $code:literal,)* + } + ) => { + $(#[$meta])* + #[allow(missing_docs)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[repr(u16)] + $visibility enum $name { + $($variant = $number,)* + } + + impl $name { + /// Stable provider-neutral registry code. + pub const fn code(self) -> &'static str { + match self { + $(Self::$variant => $code,)* + } + } + + /// Numeric representation frozen by evidence schema V1. + pub const fn discriminant(self) -> u16 { + self as u16 + } + + /// Complete registry in stable order. + pub const ALL: &'static [Self] = &[$(Self::$variant,)*]; + } + }; +} + +closed_registry! { + /// Semantic event names accepted by evidence schema V1. + pub enum EventKind { + AssertionAccepted = 1 => "assertion.accepted", + AssertionRejected = 2 => "assertion.rejected", + ProofAccepted = 3 => "proof.accepted", + ProofRejected = 4 => "proof.rejected", + AdmissionAllowed = 5 => "admission.allowed", + AdmissionDenied = 6 => "admission.denied", + BindingCreated = 7 => "binding.created", + BindingMatched = 8 => "binding.matched", + BindingConflict = 9 => "binding.conflict", + BindingRevoked = 10 => "binding.revoked", + BindingRotated = 11 => "binding.rotated", + BindingRecovered = 12 => "binding.recovered", + BindingArchived = 13 => "binding.archived", + LeaseIssued = 14 => "lease.issued", + LeaseRenewed = 15 => "lease.renewed", + LeaseExpired = 16 => "lease.expired", + LeaseInvalidated = 17 => "lease.invalidated", + LeaseRefreshDenied = 18 => "lease.refresh_denied", + DelegatedAllowed = 19 => "delegated.allowed", + DelegatedDenied = 20 => "delegated.denied", + OperatorDenied = 21 => "operator.denied", + OperatorInspected = 22 => "operator.inspected", + OperatorListed = 23 => "operator.listed", + OperatorPreviewed = 24 => "operator.previewed", + OperatorProvisioned = 25 => "operator.provisioned", + OperatorRetired = 26 => "operator.retired", + OperatorPrincipalDisabled = 27 => "operator.principal_disabled", + OperatorKeyRevoked = 28 => "operator.key_revoked", + OperatorBindingRevoked = 29 => "operator.binding_revoked", + OperatorSessionRevoked = 30 => "operator.session_revoked", + OperatorDomainContained = 31 => "operator.domain_contained", + OperatorRotated = 32 => "operator.rotated", + OperatorRecovered = 33 => "operator.recovered", + OperatorPrincipalEnabled = 34 => "operator.principal_enabled", + OperatorArchived = 35 => "operator.archived", + OperatorDelegationRetired = 36 => "operator.delegation_retired", + OperatorRequestReplayed = 37 => "operator.request_replayed", + OperatorReconciled = 38 => "operator.reconciled", + OperatorEffectRepaired = 39 => "operator.effect_repaired", + OperatorEmergencyRevoked = 40 => "operator.emergency_revoked", + InvalidationCommitted = 41 => "invalidation.committed", + InvalidationObserved = 42 => "invalidation.observed", + InvalidationReconciled = 43 => "invalidation.reconciled", + InvalidationFailed = 44 => "invalidation.failed", + PolicyStale = 45 => "policy.stale", + KeysetUnknownKey = 46 => "keyset.unknown_key", + KeysetUnavailable = 47 => "keyset.unavailable", + StorageUnavailable = 48 => "storage.unavailable", + ProvenanceAccepted = 49 => "provenance.accepted", + ProvenanceRejected = 50 => "provenance.rejected", + EvidenceBackpressure = 51 => "evidence.backpressure", + EvidenceExported = 52 => "evidence.exported", + EvidenceQuarantined = 53 => "evidence.quarantined", + EvidenceDeadLettered = 54 => "evidence.dead_lettered", + EvidenceRestored = 55 => "evidence.restored", + EvidenceTamperDetected = 56 => "evidence.tamper_detected", + BindingRepaired = 57 => "binding.repaired", + LeaseUseAfterDeniedRefresh = 58 => "lease.use_after_denied_refresh", + AdministrativeAction = 59 => "administrative.action", + BreakglassAction = 60 => "breakglass.action", + KeysetRefreshed = 61 => "keyset.refreshed", + IssuerUnavailable = 62 => "issuer.unavailable", + DirectOriginRejected = 63 => "direct_origin.rejected", + ProvenanceFailed = 64 => "provenance.failed", + } +} + +closed_registry! { + /// Closed evidence result classification. + pub enum EventResult { + Allowed = 1 => "allowed", + Denied = 2 => "denied", + Unavailable = 3 => "unavailable", + Applied = 4 => "applied", + NoChange = 5 => "no_change", + Previewed = 6 => "previewed", + Replayed = 7 => "replayed", + Quarantined = 8 => "quarantined", + } +} + +closed_registry! { + /// Trusted decision reason classification. + pub enum DecisionReason { + Verified = 1 => "verified", + PolicyAllowed = 2 => "policy_allowed", + PolicyDenied = 3 => "policy_denied", + EvidenceInvalid = 4 => "evidence_invalid", + EvidenceExpired = 5 => "evidence_expired", + EvidenceReplayed = 6 => "evidence_replayed", + EvidenceUnavailable = 7 => "evidence_unavailable", + DomainMismatch = 8 => "domain_mismatch", + OperationMismatch = 9 => "operation_mismatch", + TargetMismatch = 10 => "target_mismatch", + MissingReason = 11 => "missing_reason", + MissingApproval = 12 => "missing_approval", + StaleApproval = 13 => "stale_approval", + SelfApproval = 14 => "self_approval", + ReplayedApproval = 15 => "replayed_approval", + StaleExpectedState = 16 => "stale_expected_state", + IntentConflict = 17 => "intent_conflict", + LegacyOperationReserved = 18 => "legacy_operation_reserved", + StorageUnavailable = 19 => "storage_unavailable", + CapacityExhausted = 20 => "capacity_exhausted", + SchemaUnsupported = 21 => "schema_unsupported", + ExportPoisoned = 22 => "export_poisoned", + IntegrityFailure = 23 => "integrity_failure", + Applied = 24 => "applied", + AlreadyApplied = 25 => "already_applied", + PreviewOnly = 26 => "preview_only", + RepairNotAuthorized = 27 => "repair_not_authorized", + EmergencyScopeDenied = 28 => "emergency_scope_denied", + UnsupportedExactTarget = 29 => "unsupported_exact_target", + DirectOriginRejected = 30 => "direct_origin_rejected", + ProvenanceFailed = 31 => "provenance_failed", + UnknownKey = 32 => "unknown_key", + IssuerUnavailable = 33 => "issuer_unavailable", + RefreshUnavailable = 34 => "refresh_unavailable", + UpstreamUnavailable = 35 => "upstream_unavailable", + UnauthorizedActor = 36 => "unauthorized_actor", + CrossDomain = 37 => "cross_domain", + ApprovalNotIndependent = 38 => "approval_not_independent", + } +} + +closed_registry! { + /// Closed actor provenance class. + pub enum ActorClass { + NotApplicable = 1 => "not_applicable", + Unresolved = 2 => "unresolved", + Direct = 3 => "direct", + Delegated = 4 => "delegated", + Operator = 5 => "operator", + ControlPlane = 6 => "control_plane", + } +} + +closed_registry! { + /// Protected operation class without resource identifiers. + pub enum OperationClass { + NotApplicable = 1 => "not_applicable", + Read = 2 => "read", + Write = 3 => "write", + Publish = 4 => "publish", + Join = 5 => "join", + Lifecycle = 6 => "lifecycle", + Inspection = 7 => "inspection", + Preview = 8 => "preview", + Repair = 9 => "repair", + EmergencyContainment = 10 => "emergency_containment", + } +} + +closed_registry! { + /// Transport class without routes, hosts, or provider names. + pub enum TransportClass { + NotApplicable = 1 => "not_applicable", + WebSocket = 2 => "websocket", + Http = 3 => "http", + Media = 4 => "media", + Repository = 5 => "repository", + Audio = 6 => "audio", + Internal = 7 => "internal", + } +} + +closed_registry! { + /// Evidence source class without deployment identifiers. + pub enum SourceClass { + LocalState = 1 => "local_state", + VerifiedProof = 2 => "verified_proof", + VerifiedAssertion = 3 => "verified_assertion", + Policy = 4 => "policy", + Lifecycle = 5 => "lifecycle", + Invalidation = 6 => "invalidation", + Exporter = 7 => "exporter", + Restore = 8 => "restore", + } +} + +/// Durable evidence lane. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum EvidenceStreamKind { + /// Transactional evidence for a committed state transition. + AuditOutbox = 1, + /// Durable evidence for a non-mutating decision. + Decision = 2, + /// Independent bounded pipeline-control evidence. + Control = 3, +} + +impl EvidenceStreamKind { + /// Stable storage code. + pub const fn discriminant(self) -> u8 { + self as u8 + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + fn assert_unique( + values: &[T], + discriminant: impl Fn(T) -> u16, + code: impl Fn(T) -> &'static str, + ) { + let numeric = values + .iter() + .copied() + .map(&discriminant) + .collect::>(); + let names = values.iter().copied().map(&code).collect::>(); + assert_eq!(numeric.len(), values.len()); + assert_eq!(names.len(), values.len()); + } + + #[test] + fn registries_have_unique_numeric_and_text_codes() { + assert_unique(EventKind::ALL, EventKind::discriminant, EventKind::code); + assert_unique( + EventResult::ALL, + EventResult::discriminant, + EventResult::code, + ); + assert_unique( + DecisionReason::ALL, + DecisionReason::discriminant, + DecisionReason::code, + ); + assert_unique(ActorClass::ALL, ActorClass::discriminant, ActorClass::code); + assert_unique( + OperationClass::ALL, + OperationClass::discriminant, + OperationClass::code, + ); + assert_unique( + TransportClass::ALL, + TransportClass::discriminant, + TransportClass::code, + ); + assert_unique( + SourceClass::ALL, + SourceClass::discriminant, + SourceClass::code, + ); + } +} diff --git a/crates/buzz-audit/src/lib.rs b/crates/buzz-audit/src/lib.rs index 0248a7dfd3..24694777d7 100644 --- a/crates/buzz-audit/src/lib.rs +++ b/crates/buzz-audit/src/lib.rs @@ -19,6 +19,8 @@ /// Audit action types recorded in the log. pub mod action; +/// Closed, redaction-safe authorization evidence contracts. +pub mod authorization; /// Audit log entry types (stored and input). pub mod entry; /// Error types for audit operations. diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 38e512bb42..7258b0fdd4 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Postgres event store and data access layer for Buzz" [dependencies] +buzz-audit = { workspace = true } buzz-auth = { workspace = true } buzz-core = { workspace = true } sqlx = { workspace = true } @@ -18,6 +19,8 @@ uuid = { workspace = true } chrono = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } +hmac = { workspace = true } +zeroize = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } nostr = { workspace = true } diff --git a/crates/buzz-db/src/authorization_evidence.rs b/crates/buzz-db/src/authorization_evidence.rs new file mode 100644 index 0000000000..b728e96e53 --- /dev/null +++ b/crates/buzz-db/src/authorization_evidence.rs @@ -0,0 +1,1338 @@ +//! Durable authorization evidence storage. +//! +//! Mutation callers use [`append_outbox_tx`] inside their existing transaction. +//! Non-mutating decisions use a separate short transaction through the decision +//! APIs in this module. Immutable event rows never carry mutable delivery state. + +use buzz_audit::authorization::{ + AcceptedEventMetadata, AuthorizationEventV1, CanonicalEvent, CapacityClass, ControlCode, + DeliveryAttemptId, DeliveryDisposition, DeliveryKind, DeliveryLease, EventId, + EvidenceStreamKind, ExporterId, RetryPolicy, StreamId, +}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sqlx::{Postgres, Row, Transaction}; +use std::time::Duration; +use uuid::Uuid; + +use crate::{DbError, Result}; + +/// Receipt proving one event was durably accepted into a stream. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AcceptedEvidence { + /// Immutable event identity. + pub event_id: EventId, + /// Durable stream identity. + pub stream_id: StreamId, + /// Stream-local position. + pub stream_position: u64, + /// Semantic content digest used for idempotency. + pub content_digest: [u8; 32], + /// Chain digest at this stream position. + pub chain_digest: [u8; 32], +} + +impl std::fmt::Debug for AcceptedEvidence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AcceptedEvidence") + .field("event_id", &"[redacted]") + .field("stream_id", &"[redacted]") + .field("stream_position", &self.stream_position) + .field("content_digest", &"[redacted]") + .field("chain_digest", &"[redacted]") + .finish() + } +} + +/// Opaque release token for a value whose non-mutating decision is durable. +pub struct AcceptedDecision { + value: T, + evidence: AcceptedEvidence, +} + +impl AcceptedDecision { + /// Consume the token and release the protected value. + pub fn into_value(self) -> T { + self.value + } + + /// Inspect only the redaction-safe durable acceptance receipt. + pub const fn evidence(&self) -> AcceptedEvidence { + self.evidence + } +} + +impl std::fmt::Debug for AcceptedDecision { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AcceptedDecision") + .field("value", &"[redacted]") + .field("evidence", &self.evidence) + .finish() + } +} + +impl crate::Db { + /// Durably accept a non-mutating decision before releasing its value. + pub async fn accept_authorization_decision( + &self, + event: &AuthorizationEventV1, + capacity: CapacityClass, + value: T, + ) -> Result> { + let mut tx = self.pool.begin().await?; + let evidence = append_decision_tx(&mut tx, event, capacity).await?; + tx.commit().await?; + Ok(AcceptedDecision { value, evidence }) + } + + /// Claim the earliest unexported event while preserving stream-local order. + pub async fn claim_authorization_delivery( + &self, + community_id: CommunityId, + kind: DeliveryKind, + exporter_id: ExporterId, + lease_duration: Duration, + ) -> Result> { + let lease_millis = i64::try_from(lease_duration.as_millis()).map_err(|_| { + DbError::InvalidData("authorization delivery lease is out of range".into()) + })?; + if lease_millis <= 0 || lease_duration > Duration::from_secs(5 * 60) { + return Err(DbError::InvalidData( + "authorization delivery lease is out of range".into(), + )); + } + let lane = EvidenceLane::from_delivery(kind); + let mut tx = self.pool.begin().await?; + let row = sqlx::query(lane.claim_sql()) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.commit().await?; + return Ok(None); + }; + let event_id = EventId::from_uuid(row.try_get("event_id")?) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + let attempt = positive_u32( + row.try_get::("attempt_count")? + .checked_add(1) + .ok_or_else(|| { + DbError::InvalidData("authorization delivery attempt exhausted".into()) + })?, + "attempt", + )?; + let delivery_attempt_id = DeliveryAttemptId::generate(); + let lease_expires_at: DateTime = sqlx::query_scalar(lane.lease_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .bind(delivery_attempt_id.as_uuid()) + .bind(exporter_id.as_uuid()) + .bind(lease_millis) + .fetch_one(&mut *tx) + .await?; + let lease = DeliveryLease::new( + kind, + event_id, + StreamId::from_uuid(row.try_get("stream_id")?) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + positive_u64(row.try_get("stream_position")?, "stream position")?, + delivery_attempt_id, + attempt, + lease_expires_at, + row.try_get("canonical_event")?, + digest_array(row.try_get("content_digest")?)?, + digest_array(row.try_get("chain_digest")?)?, + ); + let now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut *tx) + .await?; + lease + .validate(now) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + tx.commit().await?; + Ok(Some(lease)) + } + + /// Idempotently acknowledge sink acceptance for the current delivery attempt. + pub async fn acknowledge_authorization_delivery( + &self, + community_id: CommunityId, + kind: DeliveryKind, + event_id: EventId, + delivery_attempt_id: DeliveryAttemptId, + content_digest: [u8; 32], + ) -> Result<()> { + let lane = EvidenceLane::from_delivery(kind); + let mut tx = self.pool.begin().await?; + let row = sqlx::query(lane.delivery_state_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("authorization delivery was not found".into()))?; + let state: String = row.try_get("delivery_state")?; + let stored_digest = digest_array(row.try_get("content_digest")?)?; + if stored_digest != content_digest { + return Err(DbError::InvalidData( + "authorization sink acknowledgement digest does not match".into(), + )); + } + if state == "exported" { + tx.commit().await?; + return Ok(()); + } + let stored_attempt: Option = row.try_get("delivery_attempt_id")?; + if state != "leased" || stored_attempt != Some(delivery_attempt_id.as_uuid()) { + return Err(DbError::InvalidData( + "authorization delivery attempt is not current".into(), + )); + } + let capacity = parse_capacity(row.try_get("capacity_class")?)?; + let updated = sqlx::query(lane.ack_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .bind(delivery_attempt_id.as_uuid()) + .execute(&mut *tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "authorization delivery acknowledgement lost its lease".into(), + )); + } + release_capacity_tx(&mut tx, community_id, capacity).await?; + tx.commit().await?; + Ok(()) + } + + /// Retry or quarantine one failed delivery without mutating its event row. + #[allow(clippy::too_many_arguments)] + pub async fn fail_authorization_delivery( + &self, + community_id: CommunityId, + kind: DeliveryKind, + event_id: EventId, + delivery_attempt_id: DeliveryAttemptId, + disposition: DeliveryDisposition, + retry_policy: RetryPolicy, + ) -> Result<()> { + let lane = EvidenceLane::from_delivery(kind); + let mut tx = self.pool.begin().await?; + let row = sqlx::query(lane.delivery_state_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("authorization delivery was not found".into()))?; + let state: String = row.try_get("delivery_state")?; + if state == "exported" || state == "quarantined" { + tx.commit().await?; + return Ok(()); + } + let stored_attempt: Option = row.try_get("delivery_attempt_id")?; + if state != "leased" || stored_attempt != Some(delivery_attempt_id.as_uuid()) { + return Err(DbError::InvalidData( + "authorization delivery attempt is not current".into(), + )); + } + let attempt = positive_u32(row.try_get("attempt_count")?, "attempt")?; + let (control_code, force_quarantine) = match disposition { + DeliveryDisposition::Accepted => { + return Err(DbError::InvalidData( + "accepted authorization delivery must use acknowledgement".into(), + )); + } + DeliveryDisposition::Retry(code) => (code, attempt >= retry_policy.maximum_attempts()), + DeliveryDisposition::Quarantine(code) => (code, true), + }; + if force_quarantine { + quarantine_delivery_tx( + &mut tx, + community_id, + lane, + event_id, + delivery_attempt_id, + control_code, + ) + .await?; + } else { + let delay = retry_policy.delay_for(attempt).ok_or_else(|| { + DbError::InvalidData("authorization retry attempt is out of range".into()) + })?; + let delay_millis = i64::try_from(delay.as_millis()).map_err(|_| { + DbError::InvalidData("authorization retry delay is out of range".into()) + })?; + let updated = sqlx::query(lane.retry_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .bind(delivery_attempt_id.as_uuid()) + .bind(control_code as i16) + .bind(delay_millis) + .execute(&mut *tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "authorization delivery retry lost its lease".into(), + )); + } + } + tx.commit().await?; + Ok(()) + } + + /// Requeue one quarantined event while retaining immutable dead-letter evidence. + #[allow(clippy::too_many_arguments)] + pub async fn restore_authorization_delivery( + &self, + community_id: CommunityId, + kind: DeliveryKind, + event_id: EventId, + content_digest: [u8; 32], + actor_reference: [u8; 32], + control_code: ControlCode, + ) -> Result { + if actor_reference == [0; 32] { + return Err(DbError::InvalidData( + "authorization restoration actor reference is invalid".into(), + )); + } + let lane = EvidenceLane::from_delivery(kind); + let mut tx = self.pool.begin().await?; + let row = sqlx::query(lane.delivery_state_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("authorization delivery was not found".into()))?; + let state: String = row.try_get("delivery_state")?; + let stored_digest = digest_array(row.try_get("content_digest")?)?; + if state != "quarantined" || stored_digest != content_digest { + return Err(DbError::InvalidData( + "authorization restoration target is not the exact quarantined event".into(), + )); + } + let prior_attempt: Uuid = sqlx::query_scalar(lane.dead_letter_attempt_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + DbError::InvalidData("authorization quarantine has no dead-letter attempt".into()) + })?; + let updated = sqlx::query(lane.restore_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .execute(&mut *tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "authorization restoration lost its quarantined event".into(), + )); + } + let restoration_id = Uuid::new_v4(); + sqlx::query(lane.insert_restoration_sql()) + .bind(community_id.as_uuid()) + .bind(restoration_id) + .bind(event_id.as_uuid()) + .bind(prior_attempt) + .bind(actor_reference.as_slice()) + .bind(control_code as i16) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(restoration_id) + } +} + +/// Append mutation evidence without committing the caller-owned transaction. +pub async fn append_outbox_tx( + tx: &mut Transaction<'_, Postgres>, + event: &AuthorizationEventV1, + capacity: CapacityClass, +) -> Result { + append_event_tx(tx, event, capacity, EvidenceLane::AuditOutbox).await +} + +/// Append non-mutating decision evidence without committing the caller transaction. +pub async fn append_decision_tx( + tx: &mut Transaction<'_, Postgres>, + event: &AuthorizationEventV1, + capacity: CapacityClass, +) -> Result { + append_event_tx(tx, event, capacity, EvidenceLane::Decision).await +} + +async fn append_event_tx( + tx: &mut Transaction<'_, Postgres>, + event: &AuthorizationEventV1, + capacity: CapacityClass, + lane: EvidenceLane, +) -> Result { + let content_digest = CanonicalEvent::semantic_digest(event); + // Serialize the first append and every replay by exact domain, lane, and + // event identity. Without this lock two concurrent first attempts can both + // miss the lookup and turn an equal replay into a unique-key error. + let append_lock = format!( + "buzz-authorization-evidence:{}:{}:{}", + event.domain().as_uuid(), + lane.stream_kind().discriminant(), + event.event_id().as_uuid(), + ); + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(append_lock) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO authorization_evidence_event_registry \ + (community_id, event_id, stream_kind, content_digest) VALUES ($1,$2,$3,$4) \ + ON CONFLICT (community_id, event_id) DO NOTHING", + ) + .bind(event.domain().as_uuid()) + .bind(event.event_id().as_uuid()) + .bind(i16::from(lane.stream_kind().discriminant())) + .bind(content_digest.as_slice()) + .execute(&mut **tx) + .await?; + let registered = sqlx::query( + "SELECT stream_kind, content_digest FROM authorization_evidence_event_registry \ + WHERE community_id=$1 AND event_id=$2 FOR SHARE", + ) + .bind(event.domain().as_uuid()) + .bind(event.event_id().as_uuid()) + .fetch_one(&mut **tx) + .await?; + let registered_kind: i16 = registered.try_get("stream_kind")?; + let registered_digest = digest_array(registered.try_get("content_digest")?)?; + if registered_kind != i16::from(lane.stream_kind().discriminant()) + || registered_digest != content_digest + { + return Err(DbError::InvalidData( + "authorization event identity was reused across evidence lanes or content".into(), + )); + } + if let Some(existing) = existing_event_tx(tx, event, lane).await? { + if existing.content_digest != content_digest { + return Err(DbError::InvalidData( + "authorization event ID was reused with different content".into(), + )); + } + return Ok(existing); + } + + reserve_capacity_tx(tx, event, capacity).await?; + let proposed_stream = StreamId::generate(); + sqlx::query( + "INSERT INTO authorization_evidence_stream_heads \ + (community_id, stream_kind, stream_id) VALUES ($1, $2, $3) \ + ON CONFLICT (community_id, stream_kind) DO NOTHING", + ) + .bind(event.domain().as_uuid()) + .bind(i16::from(lane.stream_kind().discriminant())) + .bind(proposed_stream.as_uuid()) + .execute(&mut **tx) + .await?; + + let head = sqlx::query( + "SELECT stream_id, next_position, terminal_digest \ + FROM authorization_evidence_stream_heads \ + WHERE community_id=$1 AND stream_kind=$2 FOR UPDATE", + ) + .bind(event.domain().as_uuid()) + .bind(i16::from(lane.stream_kind().discriminant())) + .fetch_one(&mut **tx) + .await?; + let stream_id = StreamId::from_uuid(head.try_get("stream_id")?) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + let stream_position = positive_u64(head.try_get("next_position")?, "stream position")?; + let previous_digest = digest_array(head.try_get("terminal_digest")?)?; + let accepted_at: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **tx) + .await?; + let accepted = + AcceptedEventMetadata::new(stream_id, stream_position, previous_digest, accepted_at) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + let canonical = CanonicalEvent::encode(event, accepted); + sqlx::query(lane.insert_event_sql()) + .bind(event.domain().as_uuid()) + .bind(event.event_id().as_uuid()) + .bind(stream_id.as_uuid()) + .bind( + i64::try_from(stream_position).map_err(|_| { + DbError::InvalidData("authorization stream position exhausted".into()) + })?, + ) + .bind(event.occurred_at()) + .bind(accepted_at) + .bind(event.operation_id().map(|value| value.as_uuid())) + .bind(event.correlation_id().as_uuid()) + .bind(event.attempt_id().as_uuid()) + .bind( + i16::try_from(event.kind().discriminant()).map_err(|_| { + DbError::InvalidData("authorization event kind is out of range".into()) + })?, + ) + .bind(i16::try_from(event.result().discriminant()).map_err(|_| { + DbError::InvalidData("authorization event result is out of range".into()) + })?) + .bind(i16::try_from(event.reason().discriminant()).map_err(|_| { + DbError::InvalidData("authorization decision reason is out of range".into()) + })?) + .bind( + i16::try_from(event.actor_class().discriminant()).map_err(|_| { + DbError::InvalidData("authorization actor class is out of range".into()) + })?, + ) + .bind(canonical.bytes()) + .bind(canonical.content_digest().as_slice()) + .bind(previous_digest.as_slice()) + .bind(canonical.chain_digest().as_slice()) + .execute(&mut **tx) + .await?; + + sqlx::query(lane.insert_delivery_sql()) + .bind(event.domain().as_uuid()) + .bind(event.event_id().as_uuid()) + .bind(capacity as i16) + .execute(&mut **tx) + .await?; + let next_position = stream_position + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("authorization stream position exhausted".into()))?; + sqlx::query( + "UPDATE authorization_evidence_stream_heads \ + SET next_position=$3, terminal_digest=$4, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND stream_kind=$2", + ) + .bind(event.domain().as_uuid()) + .bind(i16::from(lane.stream_kind().discriminant())) + .bind( + i64::try_from(next_position) + .map_err(|_| DbError::InvalidData("authorization stream position exhausted".into()))?, + ) + .bind(canonical.chain_digest().as_slice()) + .execute(&mut **tx) + .await?; + + Ok(AcceptedEvidence { + event_id: event.event_id(), + stream_id, + stream_position, + content_digest: canonical.content_digest(), + chain_digest: canonical.chain_digest(), + }) +} + +async fn existing_event_tx( + tx: &mut Transaction<'_, Postgres>, + event: &AuthorizationEventV1, + lane: EvidenceLane, +) -> Result> { + let row = sqlx::query(lane.select_event_sql()) + .bind(event.domain().as_uuid()) + .bind(event.event_id().as_uuid()) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(AcceptedEvidence { + event_id: event.event_id(), + stream_id: StreamId::from_uuid(row.try_get("stream_id")?) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + stream_position: positive_u64(row.try_get("stream_position")?, "stream position")?, + content_digest: digest_array(row.try_get("content_digest")?)?, + chain_digest: digest_array(row.try_get("chain_digest")?)?, + }) + }) + .transpose() +} + +async fn reserve_capacity_tx( + tx: &mut Transaction<'_, Postgres>, + event: &AuthorizationEventV1, + capacity: CapacityClass, +) -> Result<()> { + sqlx::query( + "INSERT INTO authorization_evidence_capacity_state (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(event.domain().as_uuid()) + .execute(&mut **tx) + .await?; + let query = match capacity { + CapacityClass::RestrictiveReserve => { + "UPDATE authorization_evidence_capacity_state \ + SET restrictive_remaining=restrictive_remaining-1, revision=revision+1, \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND restrictive_remaining>0 RETURNING revision" + } + CapacityClass::NewAllow => { + "UPDATE authorization_evidence_capacity_state \ + SET general_remaining=general_remaining-1, revision=revision+1, \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND general_remaining>0 RETURNING revision" + } + CapacityClass::NonessentialRead => { + "UPDATE authorization_evidence_capacity_state \ + SET general_remaining=general_remaining-1, revision=revision+1, \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND general_remaining>allow_reserve RETURNING revision" + } + }; + if sqlx::query_scalar::<_, i64>(query) + .bind(event.domain().as_uuid()) + .fetch_optional(&mut **tx) + .await? + .is_none() + { + return Err(DbError::InvalidData( + "authorization evidence capacity is exhausted".into(), + )); + } + Ok(()) +} + +async fn release_capacity_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + capacity: CapacityClass, +) -> Result<()> { + let query = match capacity { + CapacityClass::RestrictiveReserve => { + "UPDATE authorization_evidence_capacity_state \ + SET restrictive_remaining=restrictive_remaining+1, revision=revision+1, \ + updated_at=clock_timestamp() WHERE community_id=$1" + } + CapacityClass::NewAllow | CapacityClass::NonessentialRead => { + "UPDATE authorization_evidence_capacity_state \ + SET general_remaining=general_remaining+1, revision=revision+1, \ + updated_at=clock_timestamp() WHERE community_id=$1" + } + }; + let updated = sqlx::query(query) + .bind(community_id.as_uuid()) + .execute(&mut **tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "authorization evidence capacity state was not found".into(), + )); + } + Ok(()) +} + +async fn quarantine_delivery_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + lane: EvidenceLane, + event_id: EventId, + delivery_attempt_id: DeliveryAttemptId, + control_code: ControlCode, +) -> Result<()> { + let updated = sqlx::query(lane.quarantine_sql()) + .bind(community_id.as_uuid()) + .bind(event_id.as_uuid()) + .bind(delivery_attempt_id.as_uuid()) + .bind(control_code as i16) + .execute(&mut **tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "authorization delivery quarantine lost its lease".into(), + )); + } + sqlx::query(lane.dead_letter_sql()) + .bind(community_id.as_uuid()) + .bind(Uuid::new_v4()) + .bind(event_id.as_uuid()) + .bind(delivery_attempt_id.as_uuid()) + .bind(control_code as i16) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn parse_capacity(value: i16) -> Result { + match value { + 1 => Ok(CapacityClass::RestrictiveReserve), + 2 => Ok(CapacityClass::NewAllow), + 3 => Ok(CapacityClass::NonessentialRead), + _ => Err(DbError::InvalidData( + "authorization evidence capacity class is invalid".into(), + )), + } +} + +fn positive_u64(value: i64, label: &str) -> Result { + u64::try_from(value).map_err(|_| { + DbError::InvalidData(format!("authorization evidence {label} is out of range")) + }) +} + +fn positive_u32(value: i32, label: &str) -> Result { + u32::try_from(value).map_err(|_| { + DbError::InvalidData(format!("authorization evidence {label} is out of range")) + }) +} + +fn digest_array(value: Vec) -> Result<[u8; 32]> { + value.try_into().map_err(|_| { + DbError::InvalidData("authorization evidence digest has invalid length".into()) + }) +} + +#[derive(Clone, Copy)] +enum EvidenceLane { + AuditOutbox, + Decision, +} + +impl EvidenceLane { + const fn from_delivery(kind: DeliveryKind) -> Self { + match kind { + DeliveryKind::AuditOutbox => Self::AuditOutbox, + DeliveryKind::Decision => Self::Decision, + } + } + + const fn stream_kind(self) -> EvidenceStreamKind { + match self { + Self::AuditOutbox => EvidenceStreamKind::AuditOutbox, + Self::Decision => EvidenceStreamKind::Decision, + } + } + + const fn select_event_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "SELECT stream_id, stream_position, content_digest, chain_digest \ + FROM authorization_audit_outbox WHERE community_id=$1 AND event_id=$2" + } + Self::Decision => { + "SELECT stream_id, stream_position, content_digest, chain_digest \ + FROM authorization_decision_events WHERE community_id=$1 AND event_id=$2" + } + } + } + + const fn insert_event_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "INSERT INTO authorization_audit_outbox \ + (community_id, event_id, stream_id, stream_position, schema_version, \ + occurred_at, accepted_at, operation_id, correlation_id, attempt_id, \ + event_kind, event_result, decision_reason, actor_class, canonical_event, \ + content_digest, previous_digest, chain_digest) \ + VALUES ($1,$2,$3,$4,1,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)" + } + Self::Decision => { + "INSERT INTO authorization_decision_events \ + (community_id, event_id, stream_id, stream_position, schema_version, \ + occurred_at, accepted_at, operation_id, correlation_id, attempt_id, \ + event_kind, event_result, decision_reason, actor_class, canonical_event, \ + content_digest, previous_digest, chain_digest) \ + VALUES ($1,$2,$3,$4,1,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)" + } + } + } + + const fn insert_delivery_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "INSERT INTO authorization_audit_outbox_delivery \ + (community_id, event_id, capacity_class) VALUES ($1, $2, $3)" + } + Self::Decision => { + "INSERT INTO authorization_decision_delivery \ + (community_id, event_id, capacity_class) VALUES ($1, $2, $3)" + } + } + } + + const fn claim_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "SELECT event.event_id, event.stream_id, event.stream_position, \ + event.canonical_event, event.content_digest, event.chain_digest, delivery.attempt_count \ + FROM authorization_audit_outbox event \ + JOIN authorization_audit_outbox_delivery delivery \ + ON delivery.community_id=event.community_id \ + AND delivery.event_id=event.event_id \ + WHERE event.community_id=$1 \ + AND ((delivery.delivery_state='pending' \ + AND delivery.next_attempt_at<=clock_timestamp()) \ + OR (delivery.delivery_state='leased' \ + AND delivery.lease_expires_at<=clock_timestamp())) \ + AND NOT EXISTS ( \ + SELECT 1 FROM authorization_audit_outbox prior \ + JOIN authorization_audit_outbox_delivery prior_delivery \ + ON prior_delivery.community_id=prior.community_id \ + AND prior_delivery.event_id=prior.event_id \ + WHERE prior.community_id=event.community_id \ + AND prior.stream_id=event.stream_id \ + AND prior.stream_position { + "SELECT event.event_id, event.stream_id, event.stream_position, \ + event.canonical_event, event.content_digest, event.chain_digest, delivery.attempt_count \ + FROM authorization_decision_events event \ + JOIN authorization_decision_delivery delivery \ + ON delivery.community_id=event.community_id \ + AND delivery.event_id=event.event_id \ + WHERE event.community_id=$1 \ + AND ((delivery.delivery_state='pending' \ + AND delivery.next_attempt_at<=clock_timestamp()) \ + OR (delivery.delivery_state='leased' \ + AND delivery.lease_expires_at<=clock_timestamp())) \ + AND NOT EXISTS ( \ + SELECT 1 FROM authorization_decision_events prior \ + JOIN authorization_decision_delivery prior_delivery \ + ON prior_delivery.community_id=prior.community_id \ + AND prior_delivery.event_id=prior.event_id \ + WHERE prior.community_id=event.community_id \ + AND prior.stream_id=event.stream_id \ + AND prior.stream_position &'static str { + match self { + Self::AuditOutbox => { + "UPDATE authorization_audit_outbox_delivery \ + SET delivery_state='leased', attempt_count=attempt_count+1, \ + delivery_attempt_id=$3, lease_owner=$4, \ + lease_expires_at=clock_timestamp()+($5*interval '1 millisecond'), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 \ + RETURNING lease_expires_at" + } + Self::Decision => { + "UPDATE authorization_decision_delivery \ + SET delivery_state='leased', attempt_count=attempt_count+1, \ + delivery_attempt_id=$3, lease_owner=$4, \ + lease_expires_at=clock_timestamp()+($5*interval '1 millisecond'), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 \ + RETURNING lease_expires_at" + } + } + } + + const fn delivery_state_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "SELECT delivery.delivery_state, delivery.attempt_count, \ + delivery.delivery_attempt_id, delivery.capacity_class, event.content_digest \ + FROM authorization_audit_outbox_delivery delivery \ + JOIN authorization_audit_outbox event \ + ON event.community_id=delivery.community_id AND event.event_id=delivery.event_id \ + WHERE delivery.community_id=$1 AND delivery.event_id=$2 FOR UPDATE OF delivery" + } + Self::Decision => { + "SELECT delivery.delivery_state, delivery.attempt_count, \ + delivery.delivery_attempt_id, delivery.capacity_class, event.content_digest \ + FROM authorization_decision_delivery delivery \ + JOIN authorization_decision_events event \ + ON event.community_id=delivery.community_id AND event.event_id=delivery.event_id \ + WHERE delivery.community_id=$1 AND delivery.event_id=$2 FOR UPDATE OF delivery" + } + } + } + + const fn restore_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "UPDATE authorization_audit_outbox_delivery \ + SET delivery_state='pending', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, next_attempt_at=clock_timestamp(), \ + last_control_code=NULL, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='quarantined'" + } + Self::Decision => { + "UPDATE authorization_decision_delivery \ + SET delivery_state='pending', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, next_attempt_at=clock_timestamp(), \ + last_control_code=NULL, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='quarantined'" + } + } + } + + const fn insert_restoration_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "INSERT INTO authorization_evidence_restorations \ + (community_id,restoration_id,audit_event_id,prior_delivery_attempt_id, \ + actor_reference,control_code) VALUES ($1,$2,$3,$4,$5,$6)" + } + Self::Decision => { + "INSERT INTO authorization_evidence_restorations \ + (community_id,restoration_id,decision_event_id,prior_delivery_attempt_id, \ + actor_reference,control_code) VALUES ($1,$2,$3,$4,$5,$6)" + } + } + } + + const fn dead_letter_attempt_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "SELECT delivery_attempt_id FROM authorization_evidence_dead_letters \ + WHERE community_id=$1 AND audit_event_id=$2 \ + ORDER BY observed_at DESC, observation_id DESC LIMIT 1" + } + Self::Decision => { + "SELECT delivery_attempt_id FROM authorization_evidence_dead_letters \ + WHERE community_id=$1 AND decision_event_id=$2 \ + ORDER BY observed_at DESC, observation_id DESC LIMIT 1" + } + } + } + + const fn ack_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "UPDATE authorization_audit_outbox_delivery \ + SET delivery_state='exported', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, acknowledged_at=clock_timestamp(), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + Self::Decision => { + "UPDATE authorization_decision_delivery \ + SET delivery_state='exported', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, acknowledged_at=clock_timestamp(), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + } + } + + const fn retry_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "UPDATE authorization_audit_outbox_delivery \ + SET delivery_state='pending', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, last_control_code=$4, \ + next_attempt_at=clock_timestamp()+($5*interval '1 millisecond'), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + Self::Decision => { + "UPDATE authorization_decision_delivery \ + SET delivery_state='pending', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, last_control_code=$4, \ + next_attempt_at=clock_timestamp()+($5*interval '1 millisecond'), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + } + } + + const fn quarantine_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "UPDATE authorization_audit_outbox_delivery \ + SET delivery_state='quarantined', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, last_control_code=$4, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + Self::Decision => { + "UPDATE authorization_decision_delivery \ + SET delivery_state='quarantined', delivery_attempt_id=NULL, lease_owner=NULL, \ + lease_expires_at=NULL, last_control_code=$4, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_id=$2 AND delivery_state='leased' \ + AND delivery_attempt_id=$3" + } + } + } + + const fn dead_letter_sql(self) -> &'static str { + match self { + Self::AuditOutbox => { + "INSERT INTO authorization_evidence_dead_letters \ + (community_id, observation_id, audit_event_id, decision_event_id, \ + delivery_attempt_id, control_code) \ + VALUES ($1,$2,$3,NULL,$4,$5)" + } + Self::Decision => { + "INSERT INTO authorization_evidence_dead_letters \ + (community_id, observation_id, audit_event_id, decision_event_id, \ + delivery_attempt_id, control_code) \ + VALUES ($1,$2,NULL,$3,$4,$5)" + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use buzz_audit::authorization::{ + ActorReference, AttemptId, AuthorizationEventV1, CapacityClass, ControlCode, CorrelationId, + DecisionReason, DeliveryDisposition, DeliveryKind, EventId, EventKind, EventPayloadV1, + EventResult, ExporterId, OperationClass, OperationId, RetryPolicy, SourceClass, StreamId, + TransportClass, VersionVectorV1, + }; + use buzz_core::CommunityId; + use chrono::Utc; + use sqlx::Row; + use uuid::Uuid; + + use crate::test_support::IsolatedPostgres; + + use super::{append_outbox_tx, AcceptedDecision, AcceptedEvidence}; + + fn event( + domain: CommunityId, + event_id: EventId, + kind: EventKind, + result: EventResult, + reason: DecisionReason, + ) -> AuthorizationEventV1 { + AuthorizationEventV1::new( + event_id, + domain, + Utc::now(), + Some(OperationId::generate()), + CorrelationId::generate(), + AttemptId::generate(), + None, + ActorReference::ControlPlane, + TransportClass::Internal, + OperationClass::Lifecycle, + SourceClass::Lifecycle, + kind, + result, + reason, + VersionVectorV1::default(), + EventPayloadV1::None, + ) + } + + #[test] + fn migration_has_immutable_payload_and_separate_delivery() { + let outbox = include_str!("../../../migrations/0046_authorization_audit_outbox.sql"); + let decisions = include_str!("../../../migrations/0047_authorization_decision_queue.sql"); + let delivery = include_str!("../../../migrations/0048_authorization_evidence_delivery.sql"); + assert!(outbox.contains("CREATE TABLE authorization_audit_outbox")); + assert!(delivery.contains("CREATE TABLE authorization_audit_outbox_delivery")); + assert!(decisions.contains("CREATE TABLE authorization_decision_events")); + assert!(outbox.contains("BEFORE UPDATE OR DELETE ON authorization_audit_outbox")); + assert!(!format!("{outbox}{decisions}{delivery}").contains("JSONB")); + assert!(!outbox.contains("authorization_operation_receipts")); + } + + #[test] + fn accepted_decision_debug_redacts_protected_value() { + let token = AcceptedDecision { + value: "synthetic-private-result", + evidence: AcceptedEvidence { + event_id: EventId::generate(), + stream_id: StreamId::generate(), + stream_position: 1, + content_digest: [1; 32], + chain_digest: [2; 32], + }, + }; + assert!(!format!("{token:?}").contains("synthetic-private-result")); + assert!(!format!("{token:?}").contains(&hex::encode([1; 32]))); + assert!(!format!("{token:?}").contains(&hex::encode([2; 32]))); + } + + #[test] + fn delivery_sql_preserves_order_and_immutable_payloads() { + for lane in [ + super::EvidenceLane::AuditOutbox, + super::EvidenceLane::Decision, + ] { + assert!(lane.claim_sql().contains("NOT EXISTS")); + assert!(lane + .claim_sql() + .contains("stream_position = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 45); + assert_eq!(migrations.len(), 50); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1085,6 +1085,32 @@ mod tests { assert!(delegated_relationship.contains("delegated_relationship")); assert!(delegated_relationship .contains("authorization_invalidation_floors_selector_kind_check")); + + assert_eq!(migrations[45].version, 46); + let audit_outbox = migrations[45].sql.as_str(); + assert!(audit_outbox.contains("authorization_audit_outbox")); + assert!(audit_outbox.contains("authorization_immutable_row_guard")); + assert!(!audit_outbox.contains("JSONB")); + + assert_eq!(migrations[46].version, 47); + let decisions = migrations[46].sql.as_str(); + assert!(decisions.contains("authorization_decision_events")); + assert!(decisions.contains("authorization_decision_events_immutable")); + + assert_eq!(migrations[47].version, 48); + let delivery = migrations[47].sql.as_str(); + assert!(delivery.contains("authorization_evidence_dead_letters")); + assert!(delivery.contains("allow_reserve")); + + assert_eq!(migrations[48].version, 49); + let operator = migrations[48].sql.as_str(); + assert!(operator.contains("authorization_operator_operation_receipts")); + assert!(operator.contains("authorization_operator_authority_consumptions")); + + assert_eq!(migrations[49].version, 50); + let previews = migrations[49].sql.as_str(); + assert!(previews.contains("authorization_lifecycle_previews")); + assert!(previews.contains("authorization_decision_events")); } fn additive_identity_executable_sql(sql: &str) -> String { diff --git a/crates/buzz-db/src/operator_lifecycle.rs b/crates/buzz-db/src/operator_lifecycle.rs new file mode 100644 index 0000000000..d3c14e71c4 --- /dev/null +++ b/crates/buzz-db/src/operator_lifecycle.rs @@ -0,0 +1,2313 @@ +//! Atomic persistence for the disabled operator lifecycle surface. +//! +//! Authentication and capability policy remain deployment-owned. This module +//! consumes only intent-bound, pseudonymous authority evidence. It serializes +//! operations per authorization domain and commits lifecycle state, authority +//! consumption, immutable receipt, audit outbox event, and effect intent in a +//! single PostgreSQL transaction. + +use std::fmt; + +use buzz_audit::authorization::{ + ActorReference, AttemptId, AuthorizationEventV1, CapacityClass, CorrelationId, DecisionReason, + EffectId, EventId, EventKind, EventPayloadV1, EventResult, LifecycleEvidenceV1, OperationClass, + OperationId, PseudonymousReference, ReferenceKind, SourceClass, TransportClass, + VersionVectorV1, +}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use hmac::digest::KeyInit; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use sqlx::{Postgres, Row, Transaction}; +use thiserror::Error; +use uuid::Uuid; +use zeroize::Zeroize; + +use crate::authorization_evidence::{append_decision_tx, append_outbox_tx}; +use crate::authorization_invalidation::{ + authorization_invalidation_request_fingerprint, AuthorizationInvalidationEntry, + AuthorizationInvalidationRequest, +}; +use crate::{Db, DbError, Result}; + +const MAX_RECORDS: usize = 100; + +/// Secret namespace used only for stable access-controlled binding references. +pub struct OperatorReferenceKey { + bytes: [u8; 32], + epoch: u32, +} + +impl OperatorReferenceKey { + /// Bind a nonzero key epoch to already generated secret bytes. + pub fn new(bytes: [u8; 32], epoch: u32) -> Result { + if bytes == [0; 32] || epoch == 0 { + return Err(DbError::InvalidData( + "operator reference key and epoch must be valid".into(), + )); + } + Ok(Self { bytes, epoch }) + } + + /// Active reference-key epoch. + pub const fn epoch(&self) -> u32 { + self.epoch + } + + fn derive(&self, domain: CommunityId, binding_id: Uuid) -> [u8; 32] { + let mut mac = as KeyInit>::new_from_slice(&self.bytes) + .expect("HMAC accepts a 32-byte key"); + Mac::update(&mut mac, b"buzz-operator-binding-reference-v1"); + Mac::update(&mut mac, domain.as_uuid().as_bytes()); + Mac::update(&mut mac, &self.epoch.to_be_bytes()); + Mac::update(&mut mac, binding_id.as_bytes()); + mac.finalize().into_bytes().into() + } +} + +impl fmt::Debug for OperatorReferenceKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperatorReferenceKey") + .field("bytes", &"[redacted]") + .field("epoch", &self.epoch) + .finish() + } +} + +impl Drop for OperatorReferenceKey { + fn drop(&mut self) { + self.bytes.zeroize(); + } +} + +/// Closed operations supported by the initial reachable surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u16)] +pub enum OperatorLifecycleAction { + /// List active and historical bindings. + List = 1, + /// Preview one exact rotation. + Preview = 2, + /// Revoke one exact binding. + Revoke = 3, + /// Rotate one exact binding to a freshly proven key. + Rotate = 4, +} + +/// Closed result status retained in an immutable receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u16)] +pub enum OperatorLifecycleStatus { + /// Listing completed. + Listed = 1, + /// Preview completed. + Previewed = 2, + /// Binding was revoked. + Revoked = 3, + /// Binding was rotated. + Rotated = 4, + /// Operation was denied without mutation. + Denied = 5, +} + +/// Closed redacted binding state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u16)] +pub enum OperatorBindingState { + /// Active binding. + Active = 1, + /// Revoked binding. + Revoked = 2, + /// Rotated binding. + Rotated = 3, + /// Archived binding. + Archived = 4, +} + +/// One redacted listing record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperatorLifecycleRecord { + /// Stable access-controlled reference. + pub reference: [u8; 32], + /// Current lifecycle state. + pub state: OperatorBindingState, + /// Binding-local monotonic revision. + pub revision: u64, +} + +/// Fresh replacement material supplied only by the authenticated grant. +pub struct VerifiedOperatorReplacement { + reference: [u8; 32], + pubkey: [u8; 32], + policy_digest: [u8; 32], +} + +impl VerifiedOperatorReplacement { + /// Preserve a grant-bound replacement reference, proven key, and policy digest. + pub fn new(reference: [u8; 32], pubkey: [u8; 32], policy_digest: [u8; 32]) -> Result { + if reference == [0; 32] || pubkey == [0; 32] || policy_digest == [0; 32] { + return Err(DbError::InvalidData( + "operator replacement evidence is invalid".into(), + )); + } + Ok(Self { + reference, + pubkey, + policy_digest, + }) + } +} + +impl fmt::Debug for VerifiedOperatorReplacement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VerifiedOperatorReplacement([redacted])") + } +} + +/// Complete authenticated evidence consumed by PostgreSQL. +pub struct OperatorAuthorityEvidence { + /// Single-use authority evidence identity. + pub evidence_id: Uuid, + /// Audit-only actor pseudonym. + pub actor: PseudonymousReference, + /// Kind-neutral opaque actor reference used only for independence checks. + pub actor_independence_reference: [u8; 32], + /// Pseudonymous credential/provenance reference. + pub provenance_reference: [u8; 32], + /// Independently authenticated approver pseudonyms. + pub approvers: Vec, + /// Kind-neutral opaque approver references, parallel to `approvers`. + pub approver_independence_references: Vec<[u8; 32]>, + /// Single-use approval evidence identities, parallel to `approvers`. + pub approval_ids: Vec, + /// Exclusive trusted expiry rechecked using database time and at commit. + pub expires_at: DateTime, +} + +impl fmt::Debug for OperatorAuthorityEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperatorAuthorityEvidence") + .field("evidence_id", &"[redacted]") + .field("actor", &"[redacted]") + .field("actor_independence_reference", &"[redacted]") + .field("provenance_reference", &"[redacted]") + .field("approver_count", &self.approvers.len()) + .field("approver_independence_references", &"[redacted]") + .field("approval_ids", &"[redacted]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// One fully authorized operator command. +pub struct OperatorLifecycleCommand { + /// Server-resolved domain. + pub domain: CommunityId, + /// Stable semantic idempotency identity. + pub operation_id: Uuid, + /// Attempt correlation identity, distinct from operation identity. + pub correlation_id: Uuid, + /// Stable semantic intent digest. + pub semantic_fingerprint: [u8; 32], + /// Exact expected domain lifecycle revision. + pub expected_revision: u64, + /// Closed action. + pub action: OperatorLifecycleAction, + /// Closed provider-neutral reason discriminant. + pub reason_code: u16, + /// Exact target reference when the action requires one. + pub target_reference: Option<[u8; 32]>, + /// Audit-only target pseudonym, bound to `target_reference` by the caller. + pub target_pseudonym: Option, + /// Requested replacement reference for preview or rotation. + pub replacement_reference: Option<[u8; 32]>, + /// Authenticator-supplied fresh replacement proof for rotation. + pub replacement: Option, + /// Bounded list size. + pub list_limit: u16, + /// Optional stable listing cursor. + pub list_after: Option<[u8; 32]>, + /// Intent-bound authority and approvals. + pub authority: OperatorAuthorityEvidence, +} + +/// Redaction-safe authenticated denial accepted without lifecycle mutation. +pub struct OperatorLifecycleDenialAttempt { + /// Server-resolved domain. + pub domain: CommunityId, + /// Stable operation identity. + pub operation_id: Uuid, + /// Attempt correlation identity. + pub correlation_id: Uuid, + /// Stable semantic intent digest. + pub semantic_fingerprint: [u8; 32], + /// Requested lifecycle revision fence. + pub expected_revision: u64, + /// Closed attempted action. + pub action: OperatorLifecycleAction, + /// Closed provider-neutral purpose. + pub reason_code: u16, + /// Audit-only actor pseudonym. + pub actor: PseudonymousReference, + /// Pseudonymous credential/provenance reference. + pub provenance_reference: [u8; 32], + /// Independently authenticated approver pseudonyms, if resolved. + pub approvers: Vec, + /// Closed denial reason. + pub denial_reason: DecisionReason, +} + +impl fmt::Debug for OperatorLifecycleDenialAttempt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperatorLifecycleDenialAttempt") + .field("domain", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("semantic_fingerprint", &"[redacted]") + .field("expected_revision", &self.expected_revision) + .field("action", &self.action) + .field("reason_code", &self.reason_code) + .field("actor", &"[redacted]") + .field("provenance_reference", &"[redacted]") + .field("approver_count", &self.approvers.len()) + .field("denial_reason", &self.denial_reason) + .finish() + } +} + +impl fmt::Debug for OperatorLifecycleCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperatorLifecycleCommand") + .field("domain", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("semantic_fingerprint", &"[redacted]") + .field("expected_revision", &self.expected_revision) + .field("action", &self.action) + .field("reason_code", &self.reason_code) + .field("target_reference", &"[redacted]") + .field("target_pseudonym", &"[redacted]") + .field("replacement_reference", &"[redacted]") + .field("replacement", &"[redacted]") + .field("list_limit", &self.list_limit) + .field("list_after", &"[redacted]") + .field("authority", &self.authority) + .finish() + } +} + +/// Redaction-safe immutable result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperatorLifecycleResult { + /// Operation identity. + pub operation_id: Uuid, + /// Original correlation identity. + pub correlation_id: Uuid, + /// Completed action. + pub action: OperatorLifecycleAction, + /// Closed status. + pub status: OperatorLifecycleStatus, + /// Bounded affected count. + pub affected_count: u32, + /// Domain lifecycle revision after the operation. + pub lifecycle_revision: u64, + /// Listing records, empty for every non-list action. + pub records: Vec, +} + +/// Fail-closed execution result. +#[derive(Debug, Error)] +pub enum OperatorLifecycleFailure { + /// Trusted evidence, replay, or expected state denied the operation. + #[error("operator lifecycle denied: {0:?}")] + Denied(DecisionReason), + /// Storage failed; no successful mutation result is exposed. + #[error("operator lifecycle storage unavailable")] + Storage(#[source] DbError), +} + +impl From for OperatorLifecycleFailure { + fn from(value: DbError) -> Self { + Self::Storage(value) + } +} + +impl Db { + /// Execute or replay one operator command atomically. + pub async fn execute_operator_lifecycle( + &self, + key: &OperatorReferenceKey, + command: &OperatorLifecycleCommand, + ) -> std::result::Result { + validate_command(command)?; + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + ensure_revision_tx(&mut tx, command.domain).await?; + let revision = lock_revision_tx(&mut tx, command.domain).await?; + + if let Some(existing) = existing_receipt_tx(&mut tx, command).await? { + if matches!( + &existing, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::IntentConflict + )) + ) { + let event = operator_event( + command, + revision, + OperatorEventFacts { + kind: EventKind::OperatorDenied, + result: EventResult::Denied, + reason: DecisionReason::IntentConflict, + payload: Some(EventPayloadV1::None), + summary: None, + binding_version: None, + invalidation_generation: None, + }, + )?; + append_outbox_tx(&mut tx, &event, CapacityClass::RestrictiveReserve).await?; + } + tx.commit().await.map_err(DbError::from)?; + return existing; + } + + if let Err(failure) = consume_authority_tx(&mut tx, command).await { + match failure { + AuthorityConsumptionFailure::Denied(reason) => { + record_denial_tx(&mut tx, command, revision, reason).await?; + tx.commit().await.map_err(DbError::from)?; + return Err(OperatorLifecycleFailure::Denied(reason)); + } + AuthorityConsumptionFailure::Storage(error) => { + return Err(OperatorLifecycleFailure::Storage(error)); + } + } + } + if command.expected_revision != revision { + let reason = DecisionReason::StaleExpectedState; + record_denial_tx(&mut tx, command, revision, reason).await?; + tx.commit().await.map_err(DbError::from)?; + return Err(OperatorLifecycleFailure::Denied(reason)); + } + + let outcome = match command.action { + OperatorLifecycleAction::List => list_tx(&mut tx, key, command, revision).await?, + OperatorLifecycleAction::Preview => preview_tx(&mut tx, command, revision).await?, + OperatorLifecycleAction::Revoke => revoke_tx(&mut tx, key, command, revision).await?, + OperatorLifecycleAction::Rotate => rotate_tx(&mut tx, key, command, revision).await?, + }; + let outcome = match outcome { + OperationAttempt::Applied(value) => value, + OperationAttempt::Denied(reason) => { + record_denial_tx(&mut tx, command, revision, reason).await?; + tx.commit().await.map_err(DbError::from)?; + return Err(OperatorLifecycleFailure::Denied(reason)); + } + }; + tx.commit().await.map_err(DbError::from)?; + Ok(outcome) + } + + /// Durably record one authenticated denial without lifecycle mutation. + pub async fn record_operator_lifecycle_denial( + &self, + attempt: &OperatorLifecycleDenialAttempt, + ) -> std::result::Result<(), OperatorLifecycleFailure> { + validate_denial_attempt(attempt)?; + let mut tx = self.pool.begin().await.map_err(DbError::from)?; + ensure_revision_tx(&mut tx, attempt.domain).await?; + let revision = lock_revision_tx(&mut tx, attempt.domain).await?; + if let Some(existing_fingerprint) = existing_denial_receipt_tx(&mut tx, attempt).await? { + if existing_fingerprint == attempt.semantic_fingerprint { + tx.commit().await.map_err(DbError::from)?; + return Ok(()); + } + let event = denial_attempt_event(attempt, revision, DecisionReason::IntentConflict)?; + append_outbox_tx(&mut tx, &event, CapacityClass::RestrictiveReserve).await?; + tx.commit().await.map_err(DbError::from)?; + return Ok(()); + } + let event = denial_attempt_event(attempt, revision, attempt.denial_reason)?; + append_outbox_tx(&mut tx, &event, CapacityClass::RestrictiveReserve).await?; + insert_denial_receipt_tx(&mut tx, attempt, revision, event.event_id()).await?; + tx.commit().await.map_err(DbError::from)?; + Ok(()) + } +} + +enum OperationAttempt { + Applied(OperatorLifecycleResult), + Denied(DecisionReason), +} + +fn validate_command( + command: &OperatorLifecycleCommand, +) -> std::result::Result<(), OperatorLifecycleFailure> { + let mut approver_independence = command.authority.approver_independence_references.clone(); + approver_independence.sort_unstable(); + let invalid_common = command.operation_id.is_nil() + || command.correlation_id.is_nil() + || command.semantic_fingerprint == [0; 32] + || command.expected_revision == 0 + || !(1..=7).contains(&command.reason_code) + || command.authority.evidence_id.is_nil() + || command.authority.provenance_reference == [0; 32] + || command.authority.actor_independence_reference == [0; 32] + || command.authority.actor.kind() != ReferenceKind::Actor + || command.authority.approvers.len() != command.authority.approval_ids.len() + || command.authority.approvers.len() + != command.authority.approver_independence_references.len() + || command.authority.approvers.len() > 4 + || command.authority.approval_ids.iter().any(|id| id.is_nil()) + || command + .authority + .approver_independence_references + .contains(&[0; 32]) + || approver_independence + .windows(2) + .any(|pair| pair[0] == pair[1]) + || command + .authority + .approvers + .iter() + .any(|value| value.kind() != ReferenceKind::Approver); + let invalid_shape = match command.action { + OperatorLifecycleAction::List => { + command.list_limit == 0 + || usize::from(command.list_limit) > MAX_RECORDS + || command.target_reference.is_some() + || command.target_pseudonym.is_some() + || command.replacement_reference.is_some() + || command.replacement.is_some() + } + OperatorLifecycleAction::Preview => { + command.target_reference.is_none() + || command.target_pseudonym.is_none() + || command.replacement_reference.is_none() + || command.replacement.is_some() + || command.list_limit != 1 + || command.list_after.is_some() + } + OperatorLifecycleAction::Revoke => { + command.target_reference.is_none() + || command.target_pseudonym.is_none() + || command.replacement_reference.is_some() + || command.replacement.is_some() + || command.list_limit != 1 + || command.list_after.is_some() + } + OperatorLifecycleAction::Rotate => { + command.target_reference.is_none() + || command.target_pseudonym.is_none() + || command.replacement_reference.is_none() + || command.replacement.is_none() + || command.list_limit != 1 + || command.list_after.is_some() + } + }; + if invalid_common || invalid_shape { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + if command + .target_pseudonym + .is_some_and(|value| value.kind() != ReferenceKind::Binding) + { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + if let Some(replacement) = &command.replacement { + if Some(replacement.reference) != command.replacement_reference { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::IntentConflict, + )); + } + } + Ok(()) +} + +fn validate_denial_attempt( + attempt: &OperatorLifecycleDenialAttempt, +) -> std::result::Result<(), OperatorLifecycleFailure> { + let invalid = attempt.operation_id.is_nil() + || attempt.correlation_id.is_nil() + || attempt.semantic_fingerprint == [0; 32] + || attempt.expected_revision == 0 + || !(1..=7).contains(&attempt.reason_code) + || attempt.actor.kind() != ReferenceKind::Actor + || attempt.provenance_reference == [0; 32] + || attempt.approvers.len() > 4 + || attempt + .approvers + .iter() + .any(|value| value.kind() != ReferenceKind::Approver); + if invalid { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + Ok(()) +} + +async fn ensure_revision_tx(tx: &mut Transaction<'_, Postgres>, domain: CommunityId) -> Result<()> { + sqlx::query( + "INSERT INTO authorization_operator_lifecycle_revisions (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(domain.as_uuid()) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn lock_revision_tx(tx: &mut Transaction<'_, Postgres>, domain: CommunityId) -> Result { + let value: i64 = sqlx::query_scalar( + "SELECT revision FROM authorization_operator_lifecycle_revisions \ + WHERE community_id=$1 FOR UPDATE", + ) + .bind(domain.as_uuid()) + .fetch_one(&mut **tx) + .await?; + positive_u64(value, "lifecycle revision") +} + +async fn existing_receipt_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, +) -> Result>> { + let row = sqlx::query( + "SELECT semantic_fingerprint, correlation_id, action, outcome_status, \ + decision_reason, affected_count, lifecycle_revision \ + FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(command.domain.as_uuid()) + .bind(command.operation_id) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let fingerprint = digest(row.try_get("semantic_fingerprint")?)?; + if fingerprint != command.semantic_fingerprint { + return Ok(Some(Err(OperatorLifecycleFailure::Denied( + DecisionReason::IntentConflict, + )))); + } + let status = parse_status(row.try_get("outcome_status")?)?; + let reason = parse_reason(row.try_get("decision_reason")?)?; + if status == OperatorLifecycleStatus::Denied { + return Ok(Some(Err(OperatorLifecycleFailure::Denied(reason)))); + } + let action = parse_action(row.try_get("action")?)?; + let records = load_result_records_tx(tx, command.domain, command.operation_id).await?; + Ok(Some(Ok(OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: row.try_get("correlation_id")?, + action, + status, + affected_count: positive_u32(row.try_get("affected_count")?, "affected count")?, + lifecycle_revision: positive_u64(row.try_get("lifecycle_revision")?, "lifecycle revision")?, + records, + }))) +} + +async fn existing_denial_receipt_tx( + tx: &mut Transaction<'_, Postgres>, + attempt: &OperatorLifecycleDenialAttempt, +) -> Result> { + let value = sqlx::query_scalar::<_, Vec>( + "SELECT semantic_fingerprint FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(attempt.domain.as_uuid()) + .bind(attempt.operation_id) + .fetch_optional(&mut **tx) + .await?; + value.map(digest).transpose() +} + +enum AuthorityConsumptionFailure { + Denied(DecisionReason), + Storage(DbError), +} + +impl From for AuthorityConsumptionFailure { + fn from(value: DbError) -> Self { + Self::Storage(value) + } +} + +async fn consume_authority_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, +) -> std::result::Result<(), AuthorityConsumptionFailure> { + let now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **tx) + .await + .map_err(DbError::from)?; + if command.authority.expires_at <= now { + return Err(AuthorityConsumptionFailure::Denied( + DecisionReason::StaleApproval, + )); + } + if matches!( + command.action, + OperatorLifecycleAction::Revoke | OperatorLifecycleAction::Rotate + ) && command.authority.approvers.is_empty() + { + return Err(AuthorityConsumptionFailure::Denied( + DecisionReason::MissingApproval, + )); + } + if command + .authority + .approver_independence_references + .contains(&command.authority.actor_independence_reference) + { + return Err(AuthorityConsumptionFailure::Denied( + DecisionReason::SelfApproval, + )); + } + let inserted = sqlx::query( + "INSERT INTO authorization_operator_authority_consumptions \ + (community_id,evidence_id,operation_id,actor_reference,intent_digest,evidence_expires_at) \ + VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (community_id,evidence_id) DO NOTHING", + ) + .bind(command.domain.as_uuid()) + .bind(command.authority.evidence_id) + .bind(command.operation_id) + .bind(command.authority.actor.digest().as_slice()) + .bind(command.semantic_fingerprint.as_slice()) + .bind(command.authority.expires_at) + .execute(&mut **tx) + .await + .map_err(DbError::from)?; + if inserted.rows_affected() != 1 { + return Err(AuthorityConsumptionFailure::Denied( + DecisionReason::EvidenceReplayed, + )); + } + for (approval_id, approver) in command + .authority + .approval_ids + .iter() + .zip(&command.authority.approvers) + { + let inserted = sqlx::query( + "INSERT INTO authorization_operator_approval_consumptions \ + (community_id,approval_id,operation_id,approver_reference,intent_digest,approval_expires_at) \ + VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (community_id,approval_id) DO NOTHING", + ) + .bind(command.domain.as_uuid()) + .bind(approval_id) + .bind(command.operation_id) + .bind(approver.digest().as_slice()) + .bind(command.semantic_fingerprint.as_slice()) + .bind(command.authority.expires_at) + .execute(&mut **tx) + .await + .map_err(DbError::from)?; + if inserted.rows_affected() != 1 { + return Err(AuthorityConsumptionFailure::Denied( + DecisionReason::ReplayedApproval, + )); + } + } + Ok(()) +} + +async fn list_tx( + tx: &mut Transaction<'_, Postgres>, + key: &OperatorReferenceKey, + command: &OperatorLifecycleCommand, + revision: u64, +) -> Result { + let after_binding = match command.list_after { + Some(reference) => resolve_binding_id_tx(tx, command.domain, reference).await?, + None => None, + }; + let rows = sqlx::query( + "SELECT binding_id,binding_state,binding_version FROM identity_bindings \ + WHERE community_id=$1 AND ($2::UUID IS NULL OR binding_id>$2) \ + ORDER BY binding_id LIMIT $3", + ) + .bind(command.domain.as_uuid()) + .bind(after_binding) + .bind(i64::from(command.list_limit)) + .fetch_all(&mut **tx) + .await?; + let mut records = Vec::with_capacity(rows.len()); + for row in rows { + let binding_id: Uuid = row.try_get("binding_id")?; + let reference = binding_reference_tx(tx, key, command.domain, binding_id).await?; + records.push(OperatorLifecycleRecord { + reference, + state: parse_binding_state(row.try_get("binding_state")?)?, + revision: positive_u64(row.try_get("binding_version")?, "binding revision")?, + }); + } + let result = OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: command.correlation_id, + action: command.action, + status: OperatorLifecycleStatus::Listed, + affected_count: u32::try_from(records.len()) + .map_err(|_| DbError::InvalidData("operator result count exceeded".into()))?, + lifecycle_revision: revision, + records, + }; + accept_success_tx(tx, command, &result, None, None).await?; + Ok(OperationAttempt::Applied(result)) +} + +async fn preview_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + revision: u64, +) -> Result { + let target = command.target_reference.expect("validated preview target"); + if resolve_active_binding_tx(tx, command.domain, target) + .await? + .is_none() + { + return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + } + let result = OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: command.correlation_id, + action: command.action, + status: OperatorLifecycleStatus::Previewed, + affected_count: 1, + lifecycle_revision: revision, + records: Vec::new(), + }; + let decision_event = operator_event( + command, + revision, + OperatorEventFacts { + kind: EventKind::OperatorPreviewed, + result: EventResult::Previewed, + reason: DecisionReason::PreviewOnly, + payload: None, + summary: Some((1, command.semantic_fingerprint)), + binding_version: None, + invalidation_generation: None, + }, + )?; + let accepted = append_decision_tx(tx, &decision_event, CapacityClass::NonessentialRead).await?; + sqlx::query( + "INSERT INTO authorization_lifecycle_previews \ + (community_id,preview_digest,operation_id,target_reference,replacement_reference, \ + lifecycle_revision,affected_count,expires_at,decision_event_id) \ + VALUES ($1,$2,$3,$4,$5,$6,1,clock_timestamp()+INTERVAL '5 minutes',$7)", + ) + .bind(command.domain.as_uuid()) + .bind(command.semantic_fingerprint.as_slice()) + .bind(command.operation_id) + .bind(target.as_slice()) + .bind( + command + .replacement_reference + .expect("validated preview replacement") + .as_slice(), + ) + .bind(i64_revision(revision)?) + .bind(accepted.event_id.as_uuid()) + .execute(&mut **tx) + .await?; + accept_success_tx(tx, command, &result, None, None).await?; + Ok(OperationAttempt::Applied(result)) +} + +struct BindingRow { + binding_id: Uuid, + issuer: String, + subject: String, + pubkey: Vec, + version: u64, + provenance: String, +} + +async fn revoke_tx( + tx: &mut Transaction<'_, Postgres>, + _key: &OperatorReferenceKey, + command: &OperatorLifecycleCommand, + revision: u64, +) -> Result { + let target = command.target_reference.expect("validated revoke target"); + let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { + return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + }; + if has_pending_lineage_tx(tx, command.domain, &binding).await? { + return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); + } + let next_version = binding + .version + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("binding revision exhausted".into()))?; + retire_pair_tx(tx, command, &binding, next_version).await?; + let updated = sqlx::query( + "UPDATE identity_bindings SET binding_version=$3,binding_state='revoked', \ + revoked_at=clock_timestamp(),revoked_by=$4,revoked_reason=$5, \ + revocation_scope='binding',updated_at=clock_timestamp() \ + WHERE community_id=$1 AND binding_id=$2 AND binding_state='active' \ + AND revoked_at IS NULL AND binding_version=$6", + ) + .bind(command.domain.as_uuid()) + .bind(binding.binding_id) + .bind(i64_revision(next_version)?) + .bind(command.authority.actor.digest().as_slice()) + .bind(reason_code(command.reason_code)) + .bind(i64_revision(binding.version)?) + .execute(&mut **tx) + .await?; + if updated.rows_affected() != 1 { + return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); + } + insert_pending_tx(tx, command, &binding, next_version).await?; + append_binding_history_tx( + tx, + command, + &binding, + next_version, + "revoked", + "revoke_binding", + None, + ) + .await?; + let lifecycle_revision = advance_revision_tx(tx, command.domain, revision).await?; + let result = OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: command.correlation_id, + action: command.action, + status: OperatorLifecycleStatus::Revoked, + affected_count: 1, + lifecycle_revision, + records: Vec::new(), + }; + accept_success_tx( + tx, + command, + &result, + Some((target, binding.version, next_version)), + Some(binding.binding_id), + ) + .await?; + Ok(OperationAttempt::Applied(result)) +} + +async fn rotate_tx( + tx: &mut Transaction<'_, Postgres>, + key: &OperatorReferenceKey, + command: &OperatorLifecycleCommand, + revision: u64, +) -> Result { + let target = command.target_reference.expect("validated rotate target"); + let replacement = command.replacement.as_ref().expect("validated replacement"); + let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { + return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + }; + if binding.pubkey.as_slice() == replacement.pubkey + || has_pending_lineage_tx(tx, command.domain, &binding).await? + || replacement_denied_tx(tx, command.domain, &binding, &replacement.pubkey).await? + { + return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); + } + let next_version = binding + .version + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("binding revision exhausted".into()))?; + let replacement_binding_id = Uuid::new_v4(); + retire_pair_tx(tx, command, &binding, next_version).await?; + let updated = sqlx::query( + "UPDATE identity_bindings SET binding_version=$3,binding_state='rotated', \ + revoked_at=clock_timestamp(),revoked_by=$4,revoked_reason=$5, \ + revocation_scope='rotation',rotation_completed_at=clock_timestamp(), \ + rotated_to_pubkey=$6,rotation_by=$4,rotation_reason=$5, \ + replacement_binding_id=$7,updated_at=clock_timestamp() \ + WHERE community_id=$1 AND binding_id=$2 AND binding_state='active' \ + AND revoked_at IS NULL AND binding_version=$8", + ) + .bind(command.domain.as_uuid()) + .bind(binding.binding_id) + .bind(i64_revision(next_version)?) + .bind(command.authority.actor.digest().as_slice()) + .bind(reason_code(command.reason_code)) + .bind(replacement.pubkey.as_slice()) + .bind(replacement_binding_id) + .bind(i64_revision(binding.version)?) + .execute(&mut **tx) + .await?; + if updated.rows_affected() != 1 { + return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); + } + let policy = hex::encode(replacement.policy_digest); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,binding_id,binding_version,binding_state, \ + binding_provenance,created_by,created_policy_version,creation_attribution_kind) \ + VALUES ($1,$2,$3,$4,'db_binding',$5,1,'active','provisioned',$6,$7,'operator')", + ) + .bind(command.domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(replacement.pubkey.as_slice()) + .bind(replacement_binding_id) + .bind(command.authority.actor.digest().as_slice()) + .bind(policy) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO identity_binding_lineage \ + (community_id,predecessor_binding_id,successor_binding_id) VALUES ($1,$2,$3)", + ) + .bind(command.domain.as_uuid()) + .bind(binding.binding_id) + .bind(replacement_binding_id) + .execute(&mut **tx) + .await?; + append_binding_history_tx( + tx, + command, + &binding, + next_version, + "rotated", + "rotate", + Some(replacement_binding_id), + ) + .await?; + let replacement_row = BindingRow { + binding_id: replacement_binding_id, + issuer: binding.issuer.clone(), + subject: binding.subject.clone(), + pubkey: replacement.pubkey.to_vec(), + version: 1, + provenance: "provisioned".into(), + }; + append_binding_history_tx(tx, command, &replacement_row, 1, "active", "rotate", None).await?; + let _ = binding_reference_tx(tx, key, command.domain, replacement_binding_id).await?; + let lifecycle_revision = advance_revision_tx(tx, command.domain, revision).await?; + let result = OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: command.correlation_id, + action: command.action, + status: OperatorLifecycleStatus::Rotated, + affected_count: 1, + lifecycle_revision, + records: Vec::new(), + }; + accept_success_tx( + tx, + command, + &result, + Some((target, binding.version, next_version)), + Some(binding.binding_id), + ) + .await?; + Ok(OperationAttempt::Applied(result)) +} + +async fn apply_binding_invalidation_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + binding_id: Uuid, + invalid_through: u64, +) -> Result { + let request = AuthorizationInvalidationRequest::new( + command.operation_id, + vec![ + AuthorizationInvalidationEntry::binding_version_floor(binding_id, invalid_through)?, + AuthorizationInvalidationEntry::domain_fence(), + ], + )?; + let fingerprint = authorization_invalidation_request_fingerprint(command.domain, &request); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(command.domain.as_uuid()) + .execute(&mut **tx) + .await?; + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR UPDATE", + ) + .bind(command.domain.as_uuid()) + .fetch_one(&mut **tx) + .await?; + if let Some(row) = sqlx::query( + "SELECT generation,request_fingerprint FROM authorization_invalidation_receipts \ + WHERE community_id=$1 AND event_id=$2", + ) + .bind(command.domain.as_uuid()) + .bind(request.event_id()) + .fetch_optional(&mut **tx) + .await? + { + let stored = digest(row.try_get("request_fingerprint")?)?; + if stored != fingerprint { + return Err(DbError::InvalidData( + "operator invalidation identity was reused with different input".into(), + )); + } + return positive_u64(row.try_get("generation")?, "invalidation generation"); + } + let next_generation = generation + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("operator invalidation generation exhausted".into()))?; + sqlx::query( + "INSERT INTO authorization_invalidation_receipts \ + (community_id,event_id,generation,request_fingerprint) VALUES ($1,$2,$3,$4)", + ) + .bind(command.domain.as_uuid()) + .bind(request.event_id()) + .bind(next_generation) + .bind(fingerprint.as_slice()) + .execute(&mut **tx) + .await?; + for entry in request.entries() { + let selector = entry.selector(); + let binding_floor = selector + .binding_version_floor() + .map(i64::try_from) + .transpose() + .map_err(|_| { + DbError::InvalidData("operator binding version exceeds database range".into()) + })?; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id,selector_kind,selector_fingerprint,generation, \ + sticky_deny,binding_version_floor) VALUES ($1,$2,$3,$4,FALSE,$5) \ + ON CONFLICT (community_id,selector_kind,selector_fingerprint) DO UPDATE SET \ + generation=EXCLUDED.generation, \ + binding_version_floor=CASE \ + WHEN authorization_invalidation_floors.binding_version_floor IS NULL \ + THEN EXCLUDED.binding_version_floor \ + WHEN EXCLUDED.binding_version_floor IS NULL \ + THEN authorization_invalidation_floors.binding_version_floor \ + ELSE GREATEST(authorization_invalidation_floors.binding_version_floor, \ + EXCLUDED.binding_version_floor) END, \ + updated_at=NOW()", + ) + .bind(command.domain.as_uuid()) + .bind(selector.kind().as_str()) + .bind(selector.fingerprint().as_slice()) + .bind(next_generation) + .bind(binding_floor) + .execute(&mut **tx) + .await?; + } + sqlx::query( + "UPDATE authorization_invalidation_domains SET generation=$2,updated_at=NOW() \ + WHERE community_id=$1", + ) + .bind(command.domain.as_uuid()) + .bind(next_generation) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id,operation_id,operation_kind,request_fingerprint,result_payload, \ + lease_expires_at) VALUES ($1,$2,'authorization.invalidation',$3,$4, \ + clock_timestamp()+INTERVAL '100 years')", + ) + .bind(command.domain.as_uuid()) + .bind(command.operation_id) + .bind(fingerprint.as_slice()) + .bind(next_generation.to_be_bytes().as_slice()) + .execute(&mut **tx) + .await?; + positive_u64(next_generation, "invalidation generation") +} + +async fn accept_success_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + result: &OperatorLifecycleResult, + lifecycle: Option<([u8; 32], u64, u64)>, + binding_id: Option, +) -> Result<()> { + let invalidation_generation = match (lifecycle, binding_id) { + (Some((_, _, current)), Some(binding_id)) => { + Some(apply_binding_invalidation_tx(tx, command, binding_id, current).await?) + } + (None, None) => None, + _ => { + return Err(DbError::InvalidData( + "operator lifecycle invalidation target is incomplete".into(), + )); + } + }; + let effect_id = lifecycle.map(|_| EffectId::generate()); + let payload = lifecycle + .map(|(reference, previous, current)| { + let target = command_target_pseudonym(command, reference)?; + LifecycleEvidenceV1::new( + target, + Some(previous), + Some(current), + None, + effect_id, + invalidation_generation, + None, + ) + .map(EventPayloadV1::Lifecycle) + .map_err(|error| DbError::InvalidData(error.to_string())) + }) + .transpose()? + .unwrap_or(EventPayloadV1::BoundedSummary { + count: result.affected_count, + snapshot_digest: command.semantic_fingerprint, + }); + let event = operator_event( + command, + result.lifecycle_revision, + OperatorEventFacts { + kind: event_kind(result.status), + result: match result.status { + OperatorLifecycleStatus::Listed => EventResult::NoChange, + OperatorLifecycleStatus::Previewed => EventResult::Previewed, + OperatorLifecycleStatus::Revoked | OperatorLifecycleStatus::Rotated => { + EventResult::Applied + } + OperatorLifecycleStatus::Denied => EventResult::Denied, + }, + reason: DecisionReason::Applied, + payload: Some(payload), + summary: None, + binding_version: lifecycle.map(|(_, _, current)| current), + invalidation_generation, + }, + )?; + let capacity = if result.status == OperatorLifecycleStatus::Revoked { + CapacityClass::RestrictiveReserve + } else { + CapacityClass::NewAllow + }; + append_outbox_tx(tx, &event, capacity).await?; + insert_receipt_tx( + tx, + command, + result, + DecisionReason::Applied, + event.event_id(), + ) + .await?; + if let (Some(effect_id), Some((target, _, _))) = (effect_id, lifecycle) { + sqlx::query( + "INSERT INTO authorization_operator_effects \ + (community_id,effect_id,operation_id,effect_kind,target_reference, \ + lifecycle_revision,audit_event_id) VALUES ($1,$2,$3,$4,$5,$6,$7)", + ) + .bind(command.domain.as_uuid()) + .bind(effect_id.as_uuid()) + .bind(command.operation_id) + .bind(match command.action { + OperatorLifecycleAction::Revoke => 1_i16, + OperatorLifecycleAction::Rotate => 2_i16, + _ => return Err(DbError::InvalidData("unexpected operator effect".into())), + }) + .bind(target.as_slice()) + .bind(i64_revision(result.lifecycle_revision)?) + .bind(event.event_id().as_uuid()) + .execute(&mut **tx) + .await?; + } + insert_result_records_tx(tx, command.domain, command.operation_id, &result.records).await +} + +async fn record_denial_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + revision: u64, + reason: DecisionReason, +) -> Result<()> { + let event = operator_event( + command, + revision, + OperatorEventFacts { + kind: EventKind::OperatorDenied, + result: EventResult::Denied, + reason, + payload: Some(EventPayloadV1::None), + summary: None, + binding_version: None, + invalidation_generation: None, + }, + )?; + append_outbox_tx(tx, &event, CapacityClass::RestrictiveReserve).await?; + let result = OperatorLifecycleResult { + operation_id: command.operation_id, + correlation_id: command.correlation_id, + action: command.action, + status: OperatorLifecycleStatus::Denied, + affected_count: 0, + lifecycle_revision: revision, + records: Vec::new(), + }; + insert_receipt_tx(tx, command, &result, reason, event.event_id()).await +} + +struct OperatorEventFacts { + kind: EventKind, + result: EventResult, + reason: DecisionReason, + payload: Option, + summary: Option<(u32, [u8; 32])>, + binding_version: Option, + invalidation_generation: Option, +} + +fn operator_event( + command: &OperatorLifecycleCommand, + revision: u64, + facts: OperatorEventFacts, +) -> Result { + let actor = + ActorReference::operator(command.authority.actor, command.authority.approvers.clone()) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + let payload = facts.payload.unwrap_or_else(|| { + let (count, snapshot_digest) = facts.summary.unwrap_or((0, command.semantic_fingerprint)); + EventPayloadV1::BoundedSummary { + count, + snapshot_digest, + } + }); + Ok(AuthorizationEventV1::new( + EventId::generate(), + command.domain, + Utc::now(), + Some( + OperationId::from_uuid(command.operation_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + ), + CorrelationId::from_uuid(command.correlation_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + AttemptId::generate(), + None, + actor, + TransportClass::Internal, + match command.action { + OperatorLifecycleAction::List => OperationClass::Inspection, + OperatorLifecycleAction::Preview => OperationClass::Preview, + OperatorLifecycleAction::Revoke | OperatorLifecycleAction::Rotate => { + OperationClass::Lifecycle + } + }, + SourceClass::Lifecycle, + facts.kind, + facts.result, + facts.reason, + VersionVectorV1 { + binding: facts.binding_version, + lifecycle: Some(revision), + invalidation: facts.invalidation_generation, + ..VersionVectorV1::default() + }, + payload, + )) +} + +fn command_target_pseudonym( + command: &OperatorLifecycleCommand, + reference: [u8; 32], +) -> Result { + command + .target_reference + .filter(|value| *value == reference) + .and(command.target_pseudonym) + .ok_or_else(|| DbError::InvalidData("operator target evidence mismatch".into())) +} + +async fn insert_receipt_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + result: &OperatorLifecycleResult, + reason: DecisionReason, + audit_event_id: EventId, +) -> Result<()> { + sqlx::query( + "INSERT INTO authorization_operator_operation_receipts \ + (community_id,operation_id,semantic_fingerprint,correlation_id,action, \ + outcome_status,decision_reason,reason_code,actor_reference,provenance_reference, \ + affected_count,lifecycle_revision,audit_event_id) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", + ) + .bind(command.domain.as_uuid()) + .bind(command.operation_id) + .bind(command.semantic_fingerprint.as_slice()) + .bind(command.correlation_id) + .bind(command.action as i16) + .bind(result.status as i16) + .bind(reason.discriminant() as i16) + .bind(command.reason_code as i16) + .bind(command.authority.actor.digest().as_slice()) + .bind(command.authority.provenance_reference.as_slice()) + .bind( + i32::try_from(result.affected_count) + .map_err(|_| DbError::InvalidData("operator affected count is out of range".into()))?, + ) + .bind(i64_revision(result.lifecycle_revision)?) + .bind(audit_event_id.as_uuid()) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn insert_denial_receipt_tx( + tx: &mut Transaction<'_, Postgres>, + attempt: &OperatorLifecycleDenialAttempt, + revision: u64, + audit_event_id: EventId, +) -> Result<()> { + sqlx::query( + "INSERT INTO authorization_operator_operation_receipts \ + (community_id,operation_id,semantic_fingerprint,correlation_id,action, \ + outcome_status,decision_reason,reason_code,actor_reference,provenance_reference, \ + affected_count,lifecycle_revision,audit_event_id) \ + VALUES ($1,$2,$3,$4,$5,5,$6,$7,$8,$9,0,$10,$11)", + ) + .bind(attempt.domain.as_uuid()) + .bind(attempt.operation_id) + .bind(attempt.semantic_fingerprint.as_slice()) + .bind(attempt.correlation_id) + .bind(attempt.action as i16) + .bind(attempt.denial_reason.discriminant() as i16) + .bind(attempt.reason_code as i16) + .bind(attempt.actor.digest().as_slice()) + .bind(attempt.provenance_reference.as_slice()) + .bind(i64_revision(revision)?) + .bind(audit_event_id.as_uuid()) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn denial_attempt_event( + attempt: &OperatorLifecycleDenialAttempt, + revision: u64, + reason: DecisionReason, +) -> Result { + let actor = ActorReference::operator(attempt.actor, attempt.approvers.clone()) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + Ok(AuthorizationEventV1::new( + EventId::generate(), + attempt.domain, + Utc::now(), + Some( + OperationId::from_uuid(attempt.operation_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + ), + CorrelationId::from_uuid(attempt.correlation_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?, + AttemptId::generate(), + None, + actor, + TransportClass::Internal, + match attempt.action { + OperatorLifecycleAction::List => OperationClass::Inspection, + OperatorLifecycleAction::Preview => OperationClass::Preview, + OperatorLifecycleAction::Revoke | OperatorLifecycleAction::Rotate => { + OperationClass::Lifecycle + } + }, + SourceClass::Lifecycle, + EventKind::OperatorDenied, + EventResult::Denied, + reason, + VersionVectorV1 { + lifecycle: Some(revision), + ..VersionVectorV1::default() + }, + EventPayloadV1::None, + )) +} + +async fn insert_result_records_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + operation_id: Uuid, + records: &[OperatorLifecycleRecord], +) -> Result<()> { + for (ordinal, record) in records.iter().enumerate() { + sqlx::query( + "INSERT INTO authorization_operator_result_records \ + (community_id,operation_id,ordinal,record_reference,record_state,record_revision) \ + VALUES ($1,$2,$3,$4,$5,$6)", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .bind( + i16::try_from(ordinal).map_err(|_| { + DbError::InvalidData("operator result ordinal is out of range".into()) + })?, + ) + .bind(record.reference.as_slice()) + .bind(record.state as i16) + .bind(i64_revision(record.revision)?) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn load_result_records_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + operation_id: Uuid, +) -> Result> { + let rows = sqlx::query( + "SELECT record_reference,record_state,record_revision \ + FROM authorization_operator_result_records \ + WHERE community_id=$1 AND operation_id=$2 ORDER BY ordinal", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .fetch_all(&mut **tx) + .await?; + rows.into_iter() + .map(|row| { + Ok(OperatorLifecycleRecord { + reference: digest(row.try_get("record_reference")?)?, + state: parse_record_state(row.try_get("record_state")?)?, + revision: positive_u64(row.try_get("record_revision")?, "record revision")?, + }) + }) + .collect() +} + +async fn binding_reference_tx( + tx: &mut Transaction<'_, Postgres>, + key: &OperatorReferenceKey, + domain: CommunityId, + binding_id: Uuid, +) -> Result<[u8; 32]> { + if let Some(reference) = sqlx::query_scalar::<_, Vec>( + "SELECT binding_reference FROM authorization_operator_binding_refs \ + WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_optional(&mut **tx) + .await? + { + return digest(reference); + } + let reference = key.derive(domain, binding_id); + sqlx::query( + "INSERT INTO authorization_operator_binding_refs \ + (community_id,binding_reference,binding_id,key_epoch) VALUES ($1,$2,$3,$4)", + ) + .bind(domain.as_uuid()) + .bind(reference.as_slice()) + .bind(binding_id) + .bind( + i32::try_from(key.epoch()) + .map_err(|_| DbError::InvalidData("operator reference epoch is out of range".into()))?, + ) + .execute(&mut **tx) + .await?; + Ok(reference) +} + +async fn resolve_binding_id_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + reference: [u8; 32], +) -> Result> { + Ok(sqlx::query_scalar( + "SELECT binding_id FROM authorization_operator_binding_refs \ + WHERE community_id=$1 AND binding_reference=$2", + ) + .bind(domain.as_uuid()) + .bind(reference.as_slice()) + .fetch_optional(&mut **tx) + .await?) +} + +async fn resolve_active_binding_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + reference: [u8; 32], +) -> Result> { + let row = sqlx::query( + "SELECT binding.binding_id,binding.issuer,binding.uid,binding.pubkey, \ + binding.binding_version,binding.binding_provenance \ + FROM authorization_operator_binding_refs reference \ + JOIN identity_bindings binding ON binding.community_id=reference.community_id \ + AND binding.binding_id=reference.binding_id \ + WHERE reference.community_id=$1 AND reference.binding_reference=$2 \ + AND binding.binding_state='active' AND binding.revoked_at IS NULL \ + FOR UPDATE OF binding", + ) + .bind(domain.as_uuid()) + .bind(reference.as_slice()) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(BindingRow { + binding_id: row.try_get("binding_id")?, + issuer: row.try_get("issuer")?, + subject: row.try_get("uid")?, + pubkey: row.try_get("pubkey")?, + version: positive_u64(row.try_get("binding_version")?, "binding version")?, + provenance: row.try_get("binding_provenance")?, + }) + }) + .transpose() +} + +async fn has_pending_lineage_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + binding: &BindingRow, +) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3 AND cleared_at IS NULL)", + ) + .bind(domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .fetch_one(&mut **tx) + .await?) +} + +async fn replacement_denied_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + binding: &BindingRow, + replacement: &[u8; 32], +) -> Result { + Ok(sqlx::query_scalar( + "SELECT \ + EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 \ + AND issuer=$2 AND uid=$3 AND disabled_at IS NOT NULL) OR \ + EXISTS(SELECT 1 FROM identity_migration_denials WHERE community_id=$1 \ + AND issuer=$2 AND subject=$3) OR \ + EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$4) OR \ + EXISTS(SELECT 1 FROM identity_migration_denied_keys WHERE community_id=$1 AND pubkey=$4) OR \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$4 \ + AND binding_state='active' AND revoked_at IS NULL)", + ) + .bind(domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(replacement.as_slice()) + .fetch_one(&mut **tx) + .await?) +} + +async fn retire_pair_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + binding: &BindingRow, + version: u64, +) -> Result<()> { + sqlx::query( + "INSERT INTO identity_retired_pairs \ + (community_id,issuer,subject,pubkey,retired_binding_id,retired_binding_version, \ + retired_at,retired_by,reason) \ + VALUES ($1,$2,$3,$4,$5,$6,clock_timestamp(),$7,$8) \ + ON CONFLICT (community_id,issuer,subject,pubkey) DO NOTHING", + ) + .bind(command.domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(&binding.pubkey) + .bind(binding.binding_id) + .bind(i64_revision(version)?) + .bind(command.authority.actor.digest().as_slice()) + .bind(reason_code(command.reason_code)) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn insert_pending_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + binding: &BindingRow, + version: u64, +) -> Result<()> { + let selector: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(selector_version),0)+1 FROM identity_pending_replacements \ + WHERE community_id=$1 AND issuer=$2 AND subject=$3", + ) + .bind(command.domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .fetch_one(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO identity_pending_replacements \ + (community_id,issuer,subject,selector_version,retired_pubkey,retired_binding_id, \ + retired_binding_version,created_operation_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + ) + .bind(command.domain.as_uuid()) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(selector) + .bind(&binding.pubkey) + .bind(binding.binding_id) + .bind(i64_revision(version)?) + .bind(command.operation_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn append_binding_history_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + binding: &BindingRow, + version: u64, + state: &str, + transition: &str, + replacement_binding_id: Option, +) -> Result<()> { + sqlx::query( + "INSERT INTO identity_binding_history \ + (community_id,binding_id,binding_version,issuer,subject,pubkey,binding_state, \ + binding_provenance,transition_kind,replacement_binding_id,operation_id,actor,reason) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", + ) + .bind(command.domain.as_uuid()) + .bind(binding.binding_id) + .bind(i64_revision(version)?) + .bind(&binding.issuer) + .bind(&binding.subject) + .bind(&binding.pubkey) + .bind(state) + .bind(&binding.provenance) + .bind(transition) + .bind(replacement_binding_id) + .bind(command.operation_id) + .bind(command.authority.actor.digest().as_slice()) + .bind(reason_code(command.reason_code)) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn advance_revision_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + current: u64, +) -> Result { + let next = current + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("operator lifecycle revision exhausted".into()))?; + let updated = sqlx::query( + "UPDATE authorization_operator_lifecycle_revisions \ + SET revision=$2,updated_at=clock_timestamp() WHERE community_id=$1 AND revision=$3", + ) + .bind(domain.as_uuid()) + .bind(i64_revision(next)?) + .bind(i64_revision(current)?) + .execute(&mut **tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "operator lifecycle revision changed concurrently".into(), + )); + } + Ok(next) +} + +fn event_kind(status: OperatorLifecycleStatus) -> EventKind { + match status { + OperatorLifecycleStatus::Listed => EventKind::OperatorListed, + OperatorLifecycleStatus::Previewed => EventKind::OperatorPreviewed, + OperatorLifecycleStatus::Revoked => EventKind::OperatorBindingRevoked, + OperatorLifecycleStatus::Rotated => EventKind::OperatorRotated, + OperatorLifecycleStatus::Denied => EventKind::OperatorDenied, + } +} + +fn parse_action(value: i16) -> Result { + match value { + 1 => Ok(OperatorLifecycleAction::List), + 2 => Ok(OperatorLifecycleAction::Preview), + 3 => Ok(OperatorLifecycleAction::Revoke), + 4 => Ok(OperatorLifecycleAction::Rotate), + _ => Err(DbError::InvalidData("operator action is invalid".into())), + } +} + +fn parse_status(value: i16) -> Result { + match value { + 1 => Ok(OperatorLifecycleStatus::Listed), + 2 => Ok(OperatorLifecycleStatus::Previewed), + 3 => Ok(OperatorLifecycleStatus::Revoked), + 4 => Ok(OperatorLifecycleStatus::Rotated), + 5 => Ok(OperatorLifecycleStatus::Denied), + _ => Err(DbError::InvalidData("operator status is invalid".into())), + } +} + +fn parse_binding_state(value: String) -> Result { + match value.as_str() { + "active" => Ok(OperatorBindingState::Active), + "revoked" => Ok(OperatorBindingState::Revoked), + "rotated" => Ok(OperatorBindingState::Rotated), + "archived" => Ok(OperatorBindingState::Archived), + _ => Err(DbError::InvalidData( + "operator binding state is invalid".into(), + )), + } +} + +fn parse_record_state(value: i16) -> Result { + match value { + 1 => Ok(OperatorBindingState::Active), + 2 => Ok(OperatorBindingState::Revoked), + 3 => Ok(OperatorBindingState::Rotated), + 4 => Ok(OperatorBindingState::Archived), + _ => Err(DbError::InvalidData( + "operator result state is invalid".into(), + )), + } +} + +fn parse_reason(value: i16) -> Result { + DecisionReason::ALL + .iter() + .copied() + .find(|reason| reason.discriminant() == value as u16) + .ok_or_else(|| DbError::InvalidData("operator decision reason is invalid".into())) +} + +fn reason_code(value: u16) -> &'static str { + match value { + 1 => "offboarding", + 2 => "compromise_containment", + 3 => "planned_rotation", + 4 => "verified_recovery", + 5 => "integrity_repair", + 6 => "emergency_containment", + 7 => "retention_archive", + _ => "invalid", + } +} + +fn positive_u64(value: i64, label: &str) -> Result { + u64::try_from(value) + .map_err(|_| DbError::InvalidData(format!("operator {label} is out of range"))) +} + +fn positive_u32(value: i32, label: &str) -> Result { + u32::try_from(value) + .map_err(|_| DbError::InvalidData(format!("operator {label} is out of range"))) +} + +fn i64_revision(value: u64) -> Result { + i64::try_from(value) + .map_err(|_| DbError::InvalidData("operator revision is out of range".into())) +} + +fn digest(value: Vec) -> Result<[u8; 32]> { + value + .try_into() + .map_err(|_| DbError::InvalidData("operator digest has invalid length".into())) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use buzz_audit::authorization::{ + ControlCode, DeliveryDisposition, DeliveryKind, ExporterId, PseudonymKey, Pseudonymizer, + RetryPolicy, + }; + + use crate::test_support::IsolatedPostgres; + + use super::*; + + fn authority( + pseudonymizer: &Pseudonymizer, + domain: CommunityId, + actor_raw: [u8; 32], + approver_raw: Option<[u8; 32]>, + ) -> OperatorAuthorityEvidence { + let approver_values = approver_raw.into_iter().collect::>(); + OperatorAuthorityEvidence { + evidence_id: Uuid::new_v4(), + actor: pseudonymizer + .derive(domain, ReferenceKind::Actor, &actor_raw) + .unwrap(), + actor_independence_reference: actor_raw, + provenance_reference: [99; 32], + approvers: approver_values + .iter() + .map(|value| { + pseudonymizer + .derive(domain, ReferenceKind::Approver, value) + .unwrap() + }) + .collect(), + approver_independence_references: approver_values.clone(), + approval_ids: approver_values.iter().map(|_| Uuid::new_v4()).collect(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + } + } + + #[test] + fn reference_key_is_domain_and_epoch_separated_and_redacted() { + let first = OperatorReferenceKey::new([7; 32], 1).unwrap(); + let second = OperatorReferenceKey::new([7; 32], 2).unwrap(); + let binding = Uuid::new_v4(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + assert_ne!( + first.derive(domain, binding), + second.derive(domain, binding) + ); + assert!(format!("{first:?}").contains("[redacted]")); + } + + #[test] + fn migrations_keep_results_closed_and_previews_in_decision_lane() { + let lifecycle = + include_str!("../../../migrations/0049_authorization_operator_lifecycle.sql"); + let previews = + include_str!("../../../migrations/0050_authorization_lifecycle_previews.sql"); + assert!(!lifecycle.contains("JSONB")); + assert!(lifecycle.contains("authorization_operator_operation_receipts")); + assert!(previews.contains("authorization_decision_events")); + } + + #[tokio::test] + async fn postgres_operator_lifecycle_is_atomic_idempotent_and_serialized() { + const RAW_ISSUER_CANARY: &str = "https://issuer-canary.invalid/private"; + const JWT_CANARY: &str = "eyJ.synthetic.jwt.canary"; + const PRIVATE_DISPLAY_CANARY: &str = "private-display-claim-canary"; + const JWKS_CANARY: &str = "{\"keys\":[{\"kid\":\"private-jwks-canary\"}]}"; + let fixture = IsolatedPostgres::migrated("operator").await; + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let binding_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain.as_uuid()) + .bind(format!("{}.operator.o5.test", domain.as_uuid())) + .execute(&fixture.pool) + .await + .expect("insert synthetic operator domain"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,display_name,source,binding_id, \ + creation_attribution_kind) \ + VALUES ($1,$2,$3,$4,$5,'db_binding',$6,'legacy_unknown')", + ) + .bind(domain.as_uuid()) + .bind(RAW_ISSUER_CANARY) + .bind(JWT_CANARY) + .bind([31_u8; 32].as_slice()) + .bind(format!("{PRIVATE_DISPLAY_CANARY}:{JWKS_CANARY}")) + .bind(binding_id) + .execute(&fixture.pool) + .await + .expect("insert synthetic active binding"); + + let reference_key = OperatorReferenceKey::new([41; 32], 1).unwrap(); + let pseudonymizer = + Pseudonymizer::new(PseudonymKey::new([51; 32]).expect("pseudonym key"), 1); + let list = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [61; 32], + expected_revision: 1, + action: OperatorLifecycleAction::List, + reason_code: 3, + target_reference: None, + target_pseudonym: None, + replacement_reference: None, + replacement: None, + list_limit: 10, + list_after: None, + authority: authority(&pseudonymizer, domain, [71; 32], None), + }; + let listed = fixture + .db + .execute_operator_lifecycle(&reference_key, &list) + .await + .expect("list through durable operator executor"); + assert_eq!(listed.status, OperatorLifecycleStatus::Listed); + assert_eq!(listed.records.len(), 1); + let target = listed.records[0].reference; + let target_pseudonym = pseudonymizer + .derive(domain, ReferenceKind::Binding, &target) + .unwrap(); + + sqlx::query( + "UPDATE authorization_evidence_capacity_state \ + SET restrictive_remaining=0 WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .execute(&fixture.pool) + .await + .expect("exhaust restrictive audit capacity"); + let unavailable_audit = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [80; 32], + expected_revision: 1, + action: OperatorLifecycleAction::Revoke, + reason_code: 2, + target_reference: Some(target), + target_pseudonym: Some(target_pseudonym), + replacement_reference: None, + replacement: None, + list_limit: 1, + list_after: None, + authority: authority(&pseudonymizer, domain, [70; 32], Some([90; 32])), + }; + assert!(matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &unavailable_audit) + .await, + Err(OperatorLifecycleFailure::Storage(_)) + )); + let unchanged_state: String = sqlx::query_scalar( + "SELECT binding_state FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect rollback after audit failure"); + assert_eq!(unchanged_state, "active"); + let failed_receipt_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(domain.as_uuid()) + .bind(unavailable_audit.operation_id) + .fetch_one(&fixture.pool) + .await + .expect("audit failure leaves no success receipt"); + assert_eq!(failed_receipt_count, 0); + sqlx::query( + "UPDATE authorization_evidence_capacity_state \ + SET restrictive_remaining=10000 WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .execute(&fixture.pool) + .await + .expect("restore synthetic restrictive capacity"); + + let revoke_a = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [81; 32], + expected_revision: 1, + action: OperatorLifecycleAction::Revoke, + reason_code: 1, + target_reference: Some(target), + target_pseudonym: Some(target_pseudonym), + replacement_reference: None, + replacement: None, + list_limit: 1, + list_after: None, + authority: authority(&pseudonymizer, domain, [72; 32], Some([91; 32])), + }; + let revoke_b = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [82; 32], + expected_revision: 1, + action: OperatorLifecycleAction::Revoke, + reason_code: 1, + target_reference: Some(target), + target_pseudonym: Some(target_pseudonym), + replacement_reference: None, + replacement: None, + list_limit: 1, + list_after: None, + authority: authority(&pseudonymizer, domain, [73; 32], Some([92; 32])), + }; + let (first, second) = tokio::join!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &revoke_a), + fixture + .db + .execute_operator_lifecycle(&reference_key, &revoke_b), + ); + let successes = [&first, &second] + .into_iter() + .filter(|result| result.is_ok()) + .count(); + let stale_denials = [&first, &second] + .into_iter() + .filter(|result| { + matches!( + result, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ) + }) + .count(); + assert_eq!(successes, 1, "exactly one concurrent revoke commits"); + assert_eq!(stale_denials, 1, "the stale contender is durably denied"); + + let winner = if first.is_ok() { &revoke_a } else { &revoke_b }; + let replay = fixture + .db + .execute_operator_lifecycle(&reference_key, winner) + .await + .expect("exact operation replay returns original result"); + assert_eq!(replay.status, OperatorLifecycleStatus::Revoked); + assert_eq!(replay.lifecycle_revision, 2); + + let mut replayed_authority = authority(&pseudonymizer, domain, [74; 32], None); + replayed_authority.evidence_id = winner.authority.evidence_id; + let replayed_authority_command = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [84; 32], + expected_revision: 2, + action: OperatorLifecycleAction::List, + reason_code: 3, + target_reference: None, + target_pseudonym: None, + replacement_reference: None, + replacement: None, + list_limit: 10, + list_after: None, + authority: replayed_authority, + }; + assert!(matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &replayed_authority_command) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceReplayed + )) + )); + + let state: (String, i64) = sqlx::query_as( + "SELECT binding_state,binding_version FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect revoked binding"); + assert_eq!(state, ("revoked".into(), 2)); + let receipt_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_operation_receipts WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("count operator receipts"); + let effect_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_effects WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("count operator effects"); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_audit_outbox WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("count operator audit events"); + assert_eq!( + receipt_count, 4, + "list, winner, stale denial, and replay denial all have receipts" + ); + assert_eq!(effect_count, 1, "one revoke effect commits"); + assert_eq!( + outbox_count, 4, + "every operator result is atomically audited" + ); + let invalidation_receipts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_invalidation_receipts WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("count atomic invalidation receipts"); + let invalidation_floors: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_invalidation_floors \ + WHERE community_id=$1 AND generation=1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("count exact invalidation floors"); + assert_eq!( + invalidation_receipts, 1, + "one committed mutation invalidates" + ); + assert_eq!( + invalidation_floors, 2, + "binding floor and domain fence commit atomically" + ); + + let self_approved = OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [83; 32], + expected_revision: 2, + action: OperatorLifecycleAction::Revoke, + reason_code: 2, + target_reference: Some(target), + target_pseudonym: Some(target_pseudonym), + replacement_reference: None, + replacement: None, + list_limit: 1, + list_after: None, + authority: authority(&pseudonymizer, domain, [93; 32], Some([93; 32])), + }; + assert!(matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &self_approved) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::SelfApproval + )) + )); + let state_after_denial: String = sqlx::query_scalar( + "SELECT binding_state FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("self-approval cannot mutate binding"); + assert_eq!(state_after_denial, "revoked"); + + let exporter = ExporterId::generate(); + let lease = fixture + .db + .claim_authorization_delivery( + domain, + DeliveryKind::AuditOutbox, + exporter, + Duration::from_secs(30), + ) + .await + .expect("claim operator audit export") + .expect("operator audit lease"); + fixture + .db + .fail_authorization_delivery( + domain, + DeliveryKind::AuditOutbox, + lease.event_id(), + lease.delivery_attempt_id(), + DeliveryDisposition::Quarantine(ControlCode::PoisonEvent), + RetryPolicy::new(Duration::from_millis(1), Duration::from_secs(1), 3) + .expect("bounded retry policy"), + ) + .await + .expect("dead-letter synthetic operator export"); + let canonical_events: Vec> = sqlx::query_scalar( + "SELECT canonical_event FROM authorization_audit_outbox WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_all(&fixture.pool) + .await + .expect("read immutable operator audit bytes"); + assert_eq!( + canonical_events.len(), + 5, + "all operator outcomes are present" + ); + let dead_letters: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_evidence_dead_letters \ + WHERE community_id=$1 AND audit_event_id=$2", + ) + .bind(domain.as_uuid()) + .bind(lease.event_id().as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("read synthetic operator dead letter"); + assert_eq!(dead_letters, 1); + let dead_letter_projections: Vec = sqlx::query_scalar( + "SELECT to_jsonb(dead_letter)::text FROM authorization_evidence_dead_letters dead_letter \ + WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_all(&fixture.pool) + .await + .expect("read bounded dead-letter projections"); + for canary in [ + RAW_ISSUER_CANARY, + JWT_CANARY, + PRIVATE_DISPLAY_CANARY, + JWKS_CANARY, + ] { + assert!( + !lease + .canonical_event() + .windows(canary.len()) + .any(|window| window == canary.as_bytes()), + "raw identity canary crossed the export lease" + ); + assert!( + canonical_events.iter().all(|event| !event + .windows(canary.len()) + .any(|window| window == canary.as_bytes())), + "raw identity canary crossed durable audit" + ); + assert!( + dead_letter_projections + .iter() + .all(|projection| !projection.contains(canary)), + "raw identity canary crossed dead-letter evidence" + ); + } + + assert!( + sqlx::query( + "UPDATE authorization_operator_operation_receipts \ + SET affected_count=99 WHERE community_id=$1" + ) + .bind(domain.as_uuid()) + .execute(&fixture.pool) + .await + .is_err(), + "immutable operator receipts reject row tampering" + ); + assert!( + sqlx::query("TRUNCATE authorization_operator_effects") + .execute(&fixture.pool) + .await + .is_err(), + "immutable operator effects reject truncation" + ); + assert!( + sqlx::query("TRUNCATE authorization_lifecycle_previews") + .execute(&fixture.pool) + .await + .is_err(), + "immutable previews reject truncation" + ); + + fixture.cleanup().await; + } +} diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs new file mode 100644 index 0000000000..e07d852785 --- /dev/null +++ b/crates/buzz-db/src/test_support.rs @@ -0,0 +1,86 @@ +use std::str::FromStr; + +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::Db; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + +pub(crate) struct IsolatedPostgres { + pub(crate) db: Db, + pub(crate) pool: PgPool, + admin: PgPool, + database: String, +} + +impl IsolatedPostgres { + pub(crate) async fn migrated(label: &str) -> Self { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let admin_options = PgConnectOptions::from_str(&database_url) + .expect("O5 test database URL must be valid PostgreSQL"); + let admin = PgPoolOptions::new() + .max_connections(2) + .connect_with(admin_options.clone()) + .await + .expect( + "O5 PostgreSQL gate requires a reachable test database; a green zero-scenario path is prohibited", + ); + let database = format!("o5_{}_{}", label, Uuid::new_v4().simple()); + assert!( + database + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'), + "generated test database must be identifier-safe" + ); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE DATABASE \"{database}\"" + ))) + .execute(&admin) + .await + .expect("create isolated O5 test database"); + let pool = PgPoolOptions::new() + .max_connections(8) + .connect_with(admin_options.database(&database)) + .await + .expect("connect isolated O5 test database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("apply the exact embedded O5 SQLx chain"); + let versions: Vec = sqlx::query_scalar( + "SELECT version FROM _sqlx_migrations WHERE success ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("read exact embedded migration versions"); + assert_eq!(versions.len(), 50, "O5 embedded migrator count"); + assert_eq!(versions.first(), Some(&1)); + assert_eq!(versions.last(), Some(&50)); + assert!( + versions.windows(2).all(|pair| pair[1] == pair[0] + 1), + "O5 migration chain must be gap-free" + ); + Self { + db: Db::from_pool(pool.clone()), + pool, + admin, + database, + } + } + + pub(crate) async fn cleanup(self) { + self.pool.close().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{}\" WITH (FORCE)", + self.database + ))) + .execute(&self.admin) + .await + .expect("drop isolated O5 test database"); + self.admin.close().await; + } +} diff --git a/migrations/0046_authorization_audit_outbox.sql b/migrations/0046_authorization_audit_outbox.sql new file mode 100644 index 0000000000..7029aea83a --- /dev/null +++ b/migrations/0046_authorization_audit_outbox.sql @@ -0,0 +1,79 @@ +-- Transactional, append-only authorization audit outbox. +-- +-- Immutable evidence is separated from delivery state. Stream positions are +-- allocated while holding the stream-head row lock. No column accepts raw +-- identity, credentials, arbitrary JSON, or unbounded policy data. + +CREATE TABLE authorization_evidence_stream_heads ( + community_id UUID NOT NULL REFERENCES communities(id), + stream_kind SMALLINT NOT NULL CHECK (stream_kind IN (1, 2, 3)), + stream_id UUID NOT NULL, + next_position BIGINT NOT NULL DEFAULT 1 CHECK (next_position > 0), + terminal_digest BYTEA NOT NULL DEFAULT decode(repeat('00', 32), 'hex') + CHECK (octet_length(terminal_digest) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, stream_kind), + UNIQUE (community_id, stream_id) +); + +CREATE TABLE authorization_evidence_event_registry ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + stream_kind SMALLINT NOT NULL CHECK (stream_kind IN (1, 2)), + content_digest BYTEA NOT NULL CHECK (octet_length(content_digest) = 32), + registered_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, event_id) +); + +CREATE TABLE authorization_audit_outbox ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + stream_id UUID NOT NULL, + stream_position BIGINT NOT NULL CHECK (stream_position > 0), + schema_version SMALLINT NOT NULL CHECK (schema_version = 1), + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL, + operation_id UUID, + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + event_kind SMALLINT NOT NULL CHECK (event_kind BETWEEN 1 AND 64), + event_result SMALLINT NOT NULL CHECK (event_result BETWEEN 1 AND 8), + decision_reason SMALLINT NOT NULL CHECK (decision_reason BETWEEN 1 AND 38), + actor_class SMALLINT NOT NULL CHECK (actor_class BETWEEN 1 AND 6), + canonical_event BYTEA NOT NULL CHECK ( + octet_length(canonical_event) BETWEEN 1 AND 65536 + ), + content_digest BYTEA NOT NULL CHECK (octet_length(content_digest) = 32), + previous_digest BYTEA NOT NULL CHECK (octet_length(previous_digest) = 32), + chain_digest BYTEA NOT NULL CHECK (octet_length(chain_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, stream_id, stream_position), + FOREIGN KEY (community_id, stream_id) + REFERENCES authorization_evidence_stream_heads (community_id, stream_id) + ON DELETE RESTRICT +); + +CREATE FUNCTION authorization_immutable_row_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'authorization evidence rows are append-only' + USING ERRCODE = 'integrity_constraint_violation'; +END +$$; + +CREATE TRIGGER authorization_audit_outbox_immutable + BEFORE UPDATE OR DELETE ON authorization_audit_outbox + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_audit_outbox_no_truncate + BEFORE TRUNCATE ON authorization_audit_outbox + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_event_registry_immutable + BEFORE UPDATE OR DELETE ON authorization_evidence_event_registry + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_event_registry_no_truncate + BEFORE TRUNCATE ON authorization_evidence_event_registry + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); diff --git a/migrations/0047_authorization_decision_queue.sql b/migrations/0047_authorization_decision_queue.sql new file mode 100644 index 0000000000..52bb2619b1 --- /dev/null +++ b/migrations/0047_authorization_decision_queue.sql @@ -0,0 +1,37 @@ +-- Append-only evidence for non-mutating authorization decisions. + +CREATE TABLE authorization_decision_events ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + stream_id UUID NOT NULL, + stream_position BIGINT NOT NULL CHECK (stream_position > 0), + schema_version SMALLINT NOT NULL CHECK (schema_version = 1), + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL, + operation_id UUID, + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + event_kind SMALLINT NOT NULL CHECK (event_kind BETWEEN 1 AND 64), + event_result SMALLINT NOT NULL CHECK (event_result BETWEEN 1 AND 8), + decision_reason SMALLINT NOT NULL CHECK (decision_reason BETWEEN 1 AND 38), + actor_class SMALLINT NOT NULL CHECK (actor_class BETWEEN 1 AND 6), + canonical_event BYTEA NOT NULL CHECK ( + octet_length(canonical_event) BETWEEN 1 AND 65536 + ), + content_digest BYTEA NOT NULL CHECK (octet_length(content_digest) = 32), + previous_digest BYTEA NOT NULL CHECK (octet_length(previous_digest) = 32), + chain_digest BYTEA NOT NULL CHECK (octet_length(chain_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, stream_id, stream_position), + FOREIGN KEY (community_id, stream_id) + REFERENCES authorization_evidence_stream_heads (community_id, stream_id) + ON DELETE RESTRICT +); + +CREATE TRIGGER authorization_decision_events_immutable + BEFORE UPDATE OR DELETE ON authorization_decision_events + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_decision_events_no_truncate + BEFORE TRUNCATE ON authorization_decision_events + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); diff --git a/migrations/0048_authorization_evidence_delivery.sql b/migrations/0048_authorization_evidence_delivery.sql new file mode 100644 index 0000000000..8608b8cfb3 --- /dev/null +++ b/migrations/0048_authorization_evidence_delivery.sql @@ -0,0 +1,154 @@ +-- Bounded delivery, retry, quarantine, dead-letter, and capacity state. +-- Mutable pipeline state is never stored in immutable event rows. + +CREATE TABLE authorization_evidence_capacity_state ( + community_id UUID NOT NULL REFERENCES communities(id), + general_remaining BIGINT NOT NULL DEFAULT 100000 CHECK (general_remaining >= 0), + allow_reserve BIGINT NOT NULL DEFAULT 10000 CHECK ( + allow_reserve BETWEEN 0 AND 100000 + ), + restrictive_remaining BIGINT NOT NULL DEFAULT 10000 CHECK (restrictive_remaining >= 0), + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id) +); + +CREATE TABLE authorization_audit_outbox_delivery ( + community_id UUID NOT NULL, + event_id UUID NOT NULL, + capacity_class SMALLINT NOT NULL CHECK (capacity_class IN (1, 2, 3)), + delivery_state TEXT NOT NULL DEFAULT 'pending' CHECK ( + delivery_state IN ('pending', 'leased', 'exported', 'quarantined') + ), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + delivery_attempt_id UUID, + lease_owner UUID, + lease_expires_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + acknowledged_at TIMESTAMPTZ, + last_control_code SMALLINT CHECK (last_control_code BETWEEN 1 AND 8), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, event_id), + FOREIGN KEY (community_id, event_id) + REFERENCES authorization_audit_outbox (community_id, event_id) + ON DELETE RESTRICT, + CHECK ((delivery_state = 'leased') = + (delivery_attempt_id IS NOT NULL AND lease_owner IS NOT NULL + AND lease_expires_at IS NOT NULL)), + CHECK ((delivery_state = 'exported') = (acknowledged_at IS NOT NULL)) +); + +CREATE INDEX idx_authorization_audit_delivery_claim + ON authorization_audit_outbox_delivery + (community_id, delivery_state, next_attempt_at) + WHERE delivery_state IN ('pending', 'leased'); + +CREATE TABLE authorization_decision_delivery ( + community_id UUID NOT NULL, + event_id UUID NOT NULL, + capacity_class SMALLINT NOT NULL CHECK (capacity_class IN (1, 2, 3)), + delivery_state TEXT NOT NULL DEFAULT 'pending' CHECK ( + delivery_state IN ('pending', 'leased', 'exported', 'quarantined') + ), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + delivery_attempt_id UUID, + lease_owner UUID, + lease_expires_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + acknowledged_at TIMESTAMPTZ, + last_control_code SMALLINT CHECK (last_control_code BETWEEN 1 AND 8), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, event_id), + FOREIGN KEY (community_id, event_id) + REFERENCES authorization_decision_events (community_id, event_id) + ON DELETE RESTRICT, + CHECK ((delivery_state = 'leased') = + (delivery_attempt_id IS NOT NULL AND lease_owner IS NOT NULL + AND lease_expires_at IS NOT NULL)), + CHECK ((delivery_state = 'exported') = (acknowledged_at IS NOT NULL)) +); + +CREATE INDEX idx_authorization_decision_delivery_claim + ON authorization_decision_delivery + (community_id, delivery_state, next_attempt_at) + WHERE delivery_state IN ('pending', 'leased'); + +CREATE TABLE authorization_evidence_dead_letters ( + community_id UUID NOT NULL REFERENCES communities(id), + observation_id UUID NOT NULL, + audit_event_id UUID, + decision_event_id UUID, + delivery_attempt_id UUID NOT NULL, + control_code SMALLINT NOT NULL CHECK (control_code BETWEEN 1 AND 8), + observed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, observation_id), + FOREIGN KEY (community_id, audit_event_id) + REFERENCES authorization_audit_outbox (community_id, event_id) + ON DELETE RESTRICT, + FOREIGN KEY (community_id, decision_event_id) + REFERENCES authorization_decision_events (community_id, event_id) + ON DELETE RESTRICT, + CHECK ((audit_event_id IS NOT NULL)::INTEGER + + (decision_event_id IS NOT NULL)::INTEGER = 1) +); + +CREATE TABLE authorization_evidence_segment_manifests ( + community_id UUID NOT NULL REFERENCES communities(id), + manifest_id UUID NOT NULL, + stream_id UUID NOT NULL, + first_position BIGINT NOT NULL CHECK (first_position > 0), + last_position BIGINT NOT NULL CHECK (last_position >= first_position), + first_digest BYTEA NOT NULL CHECK (octet_length(first_digest) = 32), + terminal_digest BYTEA NOT NULL CHECK (octet_length(terminal_digest) = 32), + retention_digest BYTEA NOT NULL CHECK (octet_length(retention_digest) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, manifest_id), + UNIQUE (community_id, stream_id, first_position, last_position), + FOREIGN KEY (community_id, stream_id) + REFERENCES authorization_evidence_stream_heads (community_id, stream_id) + ON DELETE RESTRICT +); + +CREATE TABLE authorization_evidence_restorations ( + community_id UUID NOT NULL REFERENCES communities(id), + restoration_id UUID NOT NULL, + audit_event_id UUID, + decision_event_id UUID, + prior_delivery_attempt_id UUID NOT NULL, + actor_reference BYTEA NOT NULL CHECK (octet_length(actor_reference) = 32), + control_code SMALLINT NOT NULL CHECK (control_code BETWEEN 1 AND 8), + restored_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, restoration_id), + FOREIGN KEY (community_id, audit_event_id) + REFERENCES authorization_audit_outbox (community_id, event_id) + ON DELETE RESTRICT, + FOREIGN KEY (community_id, decision_event_id) + REFERENCES authorization_decision_events (community_id, event_id) + ON DELETE RESTRICT, + CHECK ((audit_event_id IS NOT NULL)::INTEGER + + (decision_event_id IS NOT NULL)::INTEGER = 1) +); + +CREATE TRIGGER authorization_evidence_dead_letters_immutable + BEFORE UPDATE OR DELETE ON authorization_evidence_dead_letters + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_dead_letters_no_truncate + BEFORE TRUNCATE ON authorization_evidence_dead_letters + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_segment_manifests_immutable + BEFORE UPDATE OR DELETE ON authorization_evidence_segment_manifests + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_segment_manifests_no_truncate + BEFORE TRUNCATE ON authorization_evidence_segment_manifests + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_restorations_immutable + BEFORE UPDATE OR DELETE ON authorization_evidence_restorations + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_evidence_restorations_no_truncate + BEFORE TRUNCATE ON authorization_evidence_restorations + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); diff --git a/migrations/0049_authorization_operator_lifecycle.sql b/migrations/0049_authorization_operator_lifecycle.sql new file mode 100644 index 0000000000..1a70877067 --- /dev/null +++ b/migrations/0049_authorization_operator_lifecycle.sql @@ -0,0 +1,185 @@ +-- Authenticated, idempotent operator lifecycle receipts and effects. +-- +-- The stock relay does not register the corresponding routes. These tables +-- retain only pseudonymous or access-controlled references and closed fields. + +CREATE TABLE authorization_operator_lifecycle_revisions ( + community_id UUID NOT NULL REFERENCES communities(id), + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id) +); + +CREATE TABLE authorization_operator_binding_refs ( + community_id UUID NOT NULL REFERENCES communities(id), + binding_reference BYTEA NOT NULL CHECK (octet_length(binding_reference) = 32), + binding_id UUID NOT NULL, + key_epoch INTEGER NOT NULL CHECK (key_epoch > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, binding_reference), + UNIQUE (community_id, binding_id), + FOREIGN KEY (community_id, binding_id) + REFERENCES identity_bindings (community_id, binding_id) + ON DELETE RESTRICT +); + +CREATE TABLE authorization_operator_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), + correlation_id UUID NOT NULL, + action SMALLINT NOT NULL CHECK (action BETWEEN 1 AND 4), + outcome_status SMALLINT NOT NULL CHECK (outcome_status BETWEEN 1 AND 5), + decision_reason SMALLINT NOT NULL CHECK (decision_reason BETWEEN 1 AND 38), + reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 7), + actor_reference BYTEA NOT NULL CHECK (octet_length(actor_reference) = 32), + provenance_reference BYTEA NOT NULL CHECK (octet_length(provenance_reference) = 32), + affected_count INTEGER NOT NULL CHECK (affected_count BETWEEN 0 AND 100), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision > 0), + audit_event_id UUID NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, audit_event_id) + REFERENCES authorization_audit_outbox (community_id, event_id) + ON DELETE RESTRICT +); + +CREATE TABLE authorization_operator_result_records ( + community_id UUID NOT NULL, + operation_id UUID NOT NULL, + ordinal SMALLINT NOT NULL CHECK (ordinal BETWEEN 0 AND 99), + record_reference BYTEA NOT NULL CHECK (octet_length(record_reference) = 32), + record_state SMALLINT NOT NULL CHECK (record_state BETWEEN 1 AND 4), + record_revision BIGINT NOT NULL CHECK (record_revision > 0), + PRIMARY KEY (community_id, operation_id, ordinal), + FOREIGN KEY (community_id, operation_id) + REFERENCES authorization_operator_operation_receipts (community_id, operation_id) + ON DELETE RESTRICT +); + +CREATE TABLE authorization_operator_authority_consumptions ( + community_id UUID NOT NULL REFERENCES communities(id), + evidence_id UUID NOT NULL, + operation_id UUID NOT NULL, + actor_reference BYTEA NOT NULL CHECK (octet_length(actor_reference) = 32), + intent_digest BYTEA NOT NULL CHECK (octet_length(intent_digest) = 32), + evidence_expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, evidence_id) +); + +CREATE TABLE authorization_operator_approval_consumptions ( + community_id UUID NOT NULL REFERENCES communities(id), + approval_id UUID NOT NULL, + operation_id UUID NOT NULL, + approver_reference BYTEA NOT NULL CHECK (octet_length(approver_reference) = 32), + intent_digest BYTEA NOT NULL CHECK (octet_length(intent_digest) = 32), + approval_expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, approval_id) +); + +CREATE FUNCTION authorization_operator_authority_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.evidence_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'operator evidence expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operator_authority_expiry + AFTER INSERT OR UPDATE OF evidence_expires_at + ON authorization_operator_authority_consumptions + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operator_authority_expiry_guard(); + +CREATE FUNCTION authorization_operator_approval_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.approval_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'operator approval expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operator_approval_expiry + AFTER INSERT OR UPDATE OF approval_expires_at + ON authorization_operator_approval_consumptions + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operator_approval_expiry_guard(); + +CREATE TABLE authorization_operator_effects ( + community_id UUID NOT NULL REFERENCES communities(id), + effect_id UUID NOT NULL, + operation_id UUID NOT NULL, + effect_kind SMALLINT NOT NULL CHECK (effect_kind IN (1, 2)), + target_reference BYTEA NOT NULL CHECK (octet_length(target_reference) = 32), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision > 0), + audit_event_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, effect_id), + UNIQUE (community_id, operation_id), + FOREIGN KEY (community_id, audit_event_id) + REFERENCES authorization_audit_outbox (community_id, event_id) + ON DELETE RESTRICT +); + +ALTER TABLE identity_bindings + DROP CONSTRAINT identity_bindings_revocation_scope_check; +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_revocation_scope_check CHECK ( + revocation_scope IN ('principal', 'key', 'rotation', 'binding') + ); + +ALTER TABLE identity_binding_history + DROP CONSTRAINT identity_binding_history_transition_kind_check; +ALTER TABLE identity_binding_history + ADD CONSTRAINT identity_binding_history_transition_kind_check CHECK ( + transition_kind IN ( + 'legacy_import', 'enroll', 'provision', 'provenance_strengthened', + 'retire_pair', 'disable_identity', 'revoke_key', 'revoke_binding', + 'rotate', 'recover', 'enable_identity', 'archive' + ) + ); + +CREATE TRIGGER authorization_operator_binding_refs_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_binding_refs + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_binding_refs_no_truncate + BEFORE TRUNCATE ON authorization_operator_binding_refs + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_receipts_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_operation_receipts + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_receipts_no_truncate + BEFORE TRUNCATE ON authorization_operator_operation_receipts + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_results_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_result_records + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_results_no_truncate + BEFORE TRUNCATE ON authorization_operator_result_records + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_authority_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_authority_consumptions + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_authority_no_truncate + BEFORE TRUNCATE ON authorization_operator_authority_consumptions + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_approvals_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_approval_consumptions + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_approvals_no_truncate + BEFORE TRUNCATE ON authorization_operator_approval_consumptions + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_effects_immutable + BEFORE UPDATE OR DELETE ON authorization_operator_effects + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); +CREATE TRIGGER authorization_operator_effects_no_truncate + BEFORE TRUNCATE ON authorization_operator_effects + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); diff --git a/migrations/0050_authorization_lifecycle_previews.sql b/migrations/0050_authorization_lifecycle_previews.sql new file mode 100644 index 0000000000..a4952de43a --- /dev/null +++ b/migrations/0050_authorization_lifecycle_previews.sql @@ -0,0 +1,30 @@ +-- Immutable, bounded rotation-preview evidence. + +CREATE TABLE authorization_lifecycle_previews ( + community_id UUID NOT NULL REFERENCES communities(id), + preview_digest BYTEA NOT NULL CHECK (octet_length(preview_digest) = 32), + operation_id UUID NOT NULL, + target_reference BYTEA NOT NULL CHECK (octet_length(target_reference) = 32), + replacement_reference BYTEA NOT NULL CHECK (octet_length(replacement_reference) = 32), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision > 0), + affected_count INTEGER NOT NULL CHECK (affected_count BETWEEN 0 AND 100), + expires_at TIMESTAMPTZ NOT NULL, + decision_event_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, preview_digest), + UNIQUE (community_id, operation_id), + FOREIGN KEY (community_id, decision_event_id) + REFERENCES authorization_decision_events (community_id, event_id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_authorization_lifecycle_previews_expiry + ON authorization_lifecycle_previews (community_id, expires_at); + +CREATE TRIGGER authorization_lifecycle_previews_immutable + BEFORE UPDATE OR DELETE ON authorization_lifecycle_previews + FOR EACH ROW EXECUTE FUNCTION authorization_immutable_row_guard(); + +CREATE TRIGGER authorization_lifecycle_previews_no_truncate + BEFORE TRUNCATE ON authorization_lifecycle_previews + FOR EACH STATEMENT EXECUTE FUNCTION authorization_immutable_row_guard(); From ace026d94a1a3fd72214f659f49b21eecdb212e6 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:19:11 -0500 Subject: [PATCH 03/18] feat(auth): enforce durable decision acceptance Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../src/authorization_runtime/evidence.rs | 130 +++++++ .../src/authorization_runtime/mod.rs | 2 + .../src/authorization_runtime/production.rs | 366 +++++++++++++++++- .../tests/o5_decision_evidence_fail_closed.rs | 133 +++++++ 4 files changed, 627 insertions(+), 4 deletions(-) create mode 100644 crates/buzz-relay/src/authorization_runtime/evidence.rs create mode 100644 crates/buzz-relay/tests/o5_decision_evidence_fail_closed.rs diff --git a/crates/buzz-relay/src/authorization_runtime/evidence.rs b/crates/buzz-relay/src/authorization_runtime/evidence.rs new file mode 100644 index 0000000000..b19dc48d73 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/evidence.rs @@ -0,0 +1,130 @@ +//! Durable decision-evidence gate for protected authorization outcomes. +//! +//! A newly allowed value is released only after the append-only decision lane +//! accepts its event. A denial is never widened when that lane is degraded; +//! instead, a bounded independent control signal records the evidence gap. + +use async_trait::async_trait; +use buzz_audit::authorization::{ + AuthorizationEventV1, CapacityClass, ControlCode, EvidenceHealthSignal, +}; +use buzz_db::authorization_evidence::AcceptedEvidence; +use thiserror::Error; + +/// Closed decision disposition at the durable-acceptance boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DecisionDisposition { + /// A value that would newly allow access. + Allow, + /// A value that preserves a denial or unavailability outcome. + Deny, +} + +/// Redaction-safe failure returned when a new allow cannot be made durable. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum DecisionEvidenceError { + /// Durable evidence storage did not accept the event. + #[error("authorization evidence acceptance is unavailable")] + AcceptanceUnavailable, +} + +impl DecisionEvidenceError { + /// Stable provider-neutral client/control code. + pub const fn code(self) -> &'static str { + match self { + Self::AcceptanceUnavailable => "authorization_evidence_acceptance_unavailable", + } + } +} + +/// Minimal object-safe sink needed by the decision gate. +#[async_trait] +pub trait DecisionEvidenceSink: Send + Sync { + /// Durably accept one immutable decision event. + async fn accept( + &self, + event: &AuthorizationEventV1, + capacity: CapacityClass, + ) -> Result; +} + +#[async_trait] +impl DecisionEvidenceSink for buzz_db::Db { + async fn accept( + &self, + event: &AuthorizationEventV1, + capacity: CapacityClass, + ) -> Result { + self.accept_authorization_decision(event, capacity, ()) + .await + .map(|accepted| accepted.evidence()) + .map_err(|_| DecisionEvidenceError::AcceptanceUnavailable) + } +} + +/// Decision released by the durable evidence gate. +pub enum AcceptedAuthorizationDecision { + /// A newly allowed value with its mandatory durable receipt. + Allow { + /// Protected value released after commit. + value: T, + /// Durable decision-stream receipt. + evidence: AcceptedEvidence, + }, + /// A denied value; evidence may be absent only when the independent health + /// signal records a storage failure. + Deny { + /// Original denied value. + value: T, + /// Durable decision-stream receipt, when storage accepted it. + evidence: Option, + }, +} + +impl std::fmt::Debug for AcceptedAuthorizationDecision { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (disposition, evidence) = match self { + Self::Allow { evidence, .. } => ("allow", Some(evidence)), + Self::Deny { evidence, .. } => ("deny", evidence.as_ref()), + }; + formatter + .debug_struct("AcceptedAuthorizationDecision") + .field("disposition", &disposition) + .field("value", &"[redacted]") + .field("evidence", &evidence) + .finish() + } +} + +/// Accept one decision without ever widening a denial during evidence outage. +pub async fn accept_authorization_decision( + sink: &dyn DecisionEvidenceSink, + health: &EvidenceHealthSignal, + event: &AuthorizationEventV1, + disposition: DecisionDisposition, + value: T, +) -> Result, DecisionEvidenceError> { + let capacity = match disposition { + DecisionDisposition::Allow => CapacityClass::NewAllow, + DecisionDisposition::Deny => CapacityClass::RestrictiveReserve, + }; + match sink.accept(event, capacity).await { + Ok(evidence) => Ok(match disposition { + DecisionDisposition::Allow => AcceptedAuthorizationDecision::Allow { value, evidence }, + DecisionDisposition::Deny => AcceptedAuthorizationDecision::Deny { + value, + evidence: Some(evidence), + }, + }), + Err(error) => match disposition { + DecisionDisposition::Allow => Err(error), + DecisionDisposition::Deny => { + health.record(ControlCode::AcceptanceUnavailable); + Ok(AcceptedAuthorizationDecision::Deny { + value, + evidence: None, + }) + } + }, + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs index 7292811390..ec4a360c3d 100644 --- a/crates/buzz-relay/src/authorization_runtime/mod.rs +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -6,6 +6,8 @@ //! extension lanes. pub(crate) mod ephemeral; +/// Durable decision-evidence acceptance and fail-closed release. +pub mod evidence; /// Transaction-owned protected mutation execution and idempotency. pub mod executor; /// Exact-domain provider selection and authorization finalization. diff --git a/crates/buzz-relay/src/authorization_runtime/production.rs b/crates/buzz-relay/src/authorization_runtime/production.rs index 208ea804af..5a0891fff2 100644 --- a/crates/buzz-relay/src/authorization_runtime/production.rs +++ b/crates/buzz-relay/src/authorization_runtime/production.rs @@ -6,15 +6,22 @@ use std::{collections::HashMap, env, sync::Arc, time::Duration}; use async_trait::async_trait; +use buzz_audit::authorization::{ + ActorReference, AttemptId, AuthorizationEventV1, CorrelationId, DecisionReason, EventId, + EventKind, EventPayloadV1, EventResult, EvidenceHealthSignal, OperationClass, PseudonymKey, + Pseudonymizer, ReferenceKind, SourceClass, TransportClass, VersionVectorV1, +}; use buzz_auth::{ resolve_current_federated_policy, AccessLeasePolicy, ActiveBindingResolution, - ApplicationLeaseLimit, AuthContextInput, AuthorizationClockSkew, AuthorizationOutcome, + ApplicationLeaseLimit, AuthContextInput, AuthTransport, AuthorizationCapability, + AuthorizationClockSkew, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, BindingLeaseBound, BindingSource, CapabilitySet, EnrollmentMode, FederatedAuthorization, LeaseVersion, ProviderTimeout, ResolvedFederatedPolicy, Scope, SharedAuthorizationClock, SystemAuthorizationClock, VerificationStatusPolicy, VerifiedEvidenceAdapter, VerifiedProviderEvidence, }; use buzz_core::{CommunityId, TenantContext}; +use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -39,6 +46,8 @@ const DOMAINS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_DOMAINS"; const PROFILE_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_PROFILE"; const LEASE_SECONDS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_LEASE_SECONDS"; const RESTORE_BOOTSTRAPS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_RESTORE_BOOTSTRAPS"; +const AUDIT_PSEUDONYM_KEY_ENV: &str = "BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_HEX"; +const AUDIT_PSEUDONYM_KEY_EPOCH_ENV: &str = "BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_EPOCH"; const MAX_AUDIO_RECONCILIATION_SWEEPS: usize = 8; /// Runtime plus its durable invalidation worker. @@ -129,9 +138,210 @@ struct ProductionResolver { invalidation: AuthorizationInvalidationRuntime, clock: SharedAuthorizationClock, profiles: HashMap, + evidence_pseudonymizer: Option, + evidence_health: EvidenceHealthSignal, } impl ProductionResolver { + fn decision_event( + &self, + request: &ProtectedOperationRequest, + outcome: &AuthorizationOutcome, + ) -> Result { + let domain = request.authorization_domain(); + let pseudonymizer = self + .evidence_pseudonymizer + .as_ref() + .ok_or(ProtectedResolutionError::new("evidence_configuration"))?; + let actor = pseudonymizer + .derive( + domain, + ReferenceKind::Actor, + &request.actor_pubkey().to_bytes(), + ) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))?; + let actor = if let Some(delegation) = request.verified_proof().verified_delegation() { + let owner = pseudonymizer + .derive( + domain, + ReferenceKind::Actor, + &delegation.owner_pubkey().to_bytes(), + ) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))?; + ActorReference::delegated(actor, owner, delegation.relationship_revision().get()) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))? + } else { + ActorReference::direct(actor) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))? + }; + let delegated = request.owner_pubkey().is_some(); + let (kind, result, reason, versions) = match outcome { + AuthorizationOutcome::Allow(snapshot) => { + let mut policy = Sha256::new(); + policy.update(b"buzz-authorization-policy-version-v1"); + policy.update(snapshot.policy_version().as_str().as_bytes()); + ( + if delegated { + EventKind::DelegatedAllowed + } else { + EventKind::AdmissionAllowed + }, + EventResult::Allowed, + DecisionReason::PolicyAllowed, + VersionVectorV1 { + binding: snapshot.binding_version().map(|value| value.get()), + lease: None, + lifecycle: None, + invalidation: None, + policy_digest: Some(policy.finalize().into()), + }, + ) + } + AuthorizationOutcome::Deny(denial) => ( + if delegated { + EventKind::DelegatedDenied + } else { + EventKind::AdmissionDenied + }, + EventResult::Denied, + decision_reason_for_denial(denial.reason()), + VersionVectorV1::default(), + ), + AuthorizationOutcome::Unavailable(_) => ( + if delegated { + EventKind::DelegatedDenied + } else { + EventKind::AdmissionDenied + }, + EventResult::Unavailable, + DecisionReason::EvidenceUnavailable, + VersionVectorV1::default(), + ), + _ => ( + if delegated { + EventKind::DelegatedDenied + } else { + EventKind::AdmissionDenied + }, + EventResult::Unavailable, + DecisionReason::SchemaUnsupported, + VersionVectorV1::default(), + ), + }; + let occurred_at = self + .clock + .now() + .ok() + .and_then(|time| DateTime::::from_timestamp(time.unix_seconds() as i64, 0)) + .ok_or(ProtectedResolutionError::new("authorization_clock"))?; + let key_reference = pseudonymizer + .derive( + domain, + ReferenceKind::Key, + &request.actor_pubkey().to_bytes(), + ) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))?; + let principal = match outcome { + AuthorizationOutcome::Allow(snapshot) => Some(snapshot.principal()), + _ => request + .provider_evidence() + .map(|evidence| evidence.verified_assertion().principal()) + .or_else(|| { + request + .verified_assertion() + .map(|assertion| assertion.principal()) + }), + }; + let principal_reference = principal + .map(|principal| { + let issuer = principal.issuer().as_bytes(); + let subject = principal.subject().as_bytes(); + let mut input = Vec::with_capacity(16 + issuer.len() + subject.len()); + input.extend_from_slice(&(issuer.len() as u64).to_be_bytes()); + input.extend_from_slice(issuer); + input.extend_from_slice(&(subject.len() as u64).to_be_bytes()); + input.extend_from_slice(subject); + pseudonymizer.derive(domain, ReferenceKind::Principal, &input) + }) + .transpose() + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym"))?; + AuthorizationEventV1::new( + EventId::generate(), + domain, + occurred_at, + None, + CorrelationId::from_uuid(request.correlation_id()) + .map_err(|_| ProtectedResolutionError::new("evidence_correlation"))?, + AttemptId::generate(), + None, + actor, + transport_class(request.transport()), + operation_class(request.capability()), + SourceClass::Policy, + kind, + result, + reason, + versions, + EventPayloadV1::None, + ) + .with_subject_references(principal_reference, Some(key_reference)) + .map_err(|_| ProtectedResolutionError::new("evidence_pseudonym")) + } + + async fn accept_outcome( + &self, + request: &ProtectedOperationRequest, + outcome: AuthorizationOutcome, + ) -> Result { + use super::evidence::{ + accept_authorization_decision, AcceptedAuthorizationDecision, DecisionDisposition, + }; + + let event = self.decision_event(request, &outcome)?; + let disposition = if matches!(outcome, AuthorizationOutcome::Allow(_)) { + DecisionDisposition::Allow + } else { + DecisionDisposition::Deny + }; + match accept_authorization_decision( + &self.db, + &self.evidence_health, + &event, + disposition, + outcome, + ) + .await + { + Ok(AcceptedAuthorizationDecision::Allow { value, .. }) => Ok(value), + Ok(AcceptedAuthorizationDecision::Deny { value, evidence }) => { + if evidence.is_none() { + metrics::counter!( + "buzz_authorization_evidence_control_total", + "code" => "acceptance_unavailable" + ) + .increment(1); + tracing::error!( + code = "acceptance_unavailable", + "authorization denial evidence was unavailable; denial preserved" + ); + } + Ok(value) + } + Err(error) => { + metrics::counter!( + "buzz_authorization_evidence_control_total", + "code" => "allow_acceptance_unavailable" + ) + .increment(1); + tracing::error!( + code = error.code(), + "authorization allow rejected because durable evidence was unavailable" + ); + Err(ProtectedResolutionError::new(error.code())) + } + } + } + fn tenant(&self, domain: CommunityId) -> Result<&TenantContext, ProtectedResolutionError> { self.tenants .get(&domain) @@ -340,6 +550,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { .await } .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let outcome = self.accept_outcome(request, outcome).await?; if !matches!(outcome, AuthorizationOutcome::Allow(_)) { return Err(ProtectedResolutionError::new("provider_denied")); } @@ -377,7 +588,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { let evaluation_policy = self .current_policy(request.authorization_domain(), request.correlation_id()) .await?; - let snapshot = match self + let outcome = self .finalizer .evaluate_direct( tenant, @@ -388,8 +599,8 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.correlation_id(), ) .await - .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))? - { + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let snapshot = match self.accept_outcome(request, outcome).await? { AuthorizationOutcome::Allow(snapshot) => snapshot, _ => return Err(ProtectedResolutionError::new("provider_denied")), }; @@ -480,6 +691,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { ) .await .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let outcome = self.accept_outcome(request, outcome).await?; let AuthorizationOutcome::Allow(snapshot) = outcome else { return Err(ProtectedResolutionError::new("provider_denied")); }; @@ -539,6 +751,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { ) .await .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let outcome = self.accept_outcome(request, outcome).await?; let AuthorizationOutcome::Allow(snapshot) = outcome else { return Err(ProtectedResolutionError::new("provider_denied")); }; @@ -572,6 +785,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { ) .await .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let outcome = self.accept_outcome(request, outcome).await?; let AuthorizationOutcome::Allow(snapshot) = outcome else { return Err(ProtectedResolutionError::new("provider_denied")); }; @@ -792,6 +1006,11 @@ pub async fn build_from_environment_with_providers_and_evidence( if configured.is_empty() { return Ok(None); } + let evidence_pseudonymizer = if configured.values().any(|mode| mode.evaluates_provider()) { + Some(parse_evidence_pseudonymizer()?) + } else { + None + }; validate_provider_coverage(&configured, &providers)?; if evidence_resolver.is_none() && configured.values().any(|mode| mode.evaluates_provider()) @@ -892,6 +1111,8 @@ pub async fn build_from_environment_with_providers_and_evidence( invalidation: invalidation.clone(), clock: Arc::clone(&clock), profiles, + evidence_pseudonymizer, + evidence_health: EvidenceHealthSignal::default(), }); let mut transport = ProtectedTransportRuntime::new(transports, resolver, clock)?; if let Some(evidence_resolver) = evidence_resolver { @@ -908,6 +1129,69 @@ pub async fn build_from_environment_with_providers_and_evidence( })) } +fn parse_evidence_pseudonymizer() -> Result { + let raw_key = env::var(AUDIT_PSEUDONYM_KEY_ENV) + .map_err(|_| ProductionRuntimeError::EvidenceConfigurationMissing)?; + let decoded = + hex::decode(raw_key).map_err(|_| ProductionRuntimeError::EvidenceConfigurationInvalid)?; + let key: [u8; 32] = decoded + .try_into() + .map_err(|_| ProductionRuntimeError::EvidenceConfigurationInvalid)?; + let epoch = env::var(AUDIT_PSEUDONYM_KEY_EPOCH_ENV) + .map_err(|_| ProductionRuntimeError::EvidenceConfigurationMissing)? + .parse::() + .map_err(|_| ProductionRuntimeError::EvidenceConfigurationInvalid)?; + if epoch == 0 { + return Err(ProductionRuntimeError::EvidenceConfigurationInvalid); + } + let key = + PseudonymKey::new(key).map_err(|_| ProductionRuntimeError::EvidenceConfigurationInvalid)?; + Ok(Pseudonymizer::new(key, epoch)) +} + +fn transport_class(transport: AuthTransport) -> TransportClass { + match transport { + AuthTransport::RelayWebSocket => TransportClass::WebSocket, + AuthTransport::HttpBridge => TransportClass::Http, + AuthTransport::Git => TransportClass::Repository, + AuthTransport::MediaUpload | AuthTransport::MediaDownload => TransportClass::Media, + AuthTransport::Audio => TransportClass::Audio, + } +} + +fn operation_class(capability: AuthorizationCapability) -> OperationClass { + match capability { + AuthorizationCapability::CommunityRead + | AuthorizationCapability::MediaRead + | AuthorizationCapability::GitRead => OperationClass::Read, + AuthorizationCapability::CommunityWrite => OperationClass::Publish, + AuthorizationCapability::MediaWrite | AuthorizationCapability::GitWrite => { + OperationClass::Write + } + AuthorizationCapability::AudioJoin => OperationClass::Join, + AuthorizationCapability::Moderate + | AuthorizationCapability::InviteMint + | AuthorizationCapability::InviteClaim => OperationClass::Lifecycle, + _ => OperationClass::NotApplicable, + } +} + +fn decision_reason_for_denial(reason: AuthorizationDenialReason) -> DecisionReason { + match reason { + AuthorizationDenialReason::ProviderDenied + | AuthorizationDenialReason::AuthorizationProfileMismatch + | AuthorizationDenialReason::FederatedPolicyNotCurrent => DecisionReason::PolicyDenied, + AuthorizationDenialReason::AuthorizationDomainMismatch => DecisionReason::DomainMismatch, + AuthorizationDenialReason::PrincipalMismatch => DecisionReason::TargetMismatch, + AuthorizationDenialReason::MissingCapability => DecisionReason::OperationMismatch, + AuthorizationDenialReason::StaleDecision + | AuthorizationDenialReason::IdentityEvidenceExpired => DecisionReason::EvidenceExpired, + AuthorizationDenialReason::FutureDecision + | AuthorizationDenialReason::IdentityEvidenceNotYetValid => DecisionReason::EvidenceInvalid, + _ => DecisionReason::SchemaUnsupported, + } +} + async fn activate_protected_domains( db: &buzz_db::Db, restore: &Arc, @@ -1226,6 +1510,12 @@ pub enum ProductionRuntimeError { /// Configuration was malformed or ambiguous. #[error("protected authorization configuration is invalid")] InvalidConfiguration, + /// An evaluating domain has no audit-only pseudonymization key or epoch. + #[error("authorization evidence configuration is missing")] + EvidenceConfigurationMissing, + /// The audit-only pseudonymization key or epoch is malformed. + #[error("authorization evidence configuration is invalid")] + EvidenceConfigurationInvalid, /// An exact configured domain has no durable host mapping. #[error("protected authorization domain is not present")] ConfiguredDomainMissing, @@ -1289,6 +1579,74 @@ mod tests { use super::*; + #[test] + fn every_production_provider_evaluation_reaches_durable_acceptance() { + fn offsets(section: &str, needles: &[&str]) -> Vec { + let mut offsets = needles + .iter() + .flat_map(|needle| section.match_indices(needle).map(|(offset, _)| offset)) + .collect::>(); + offsets.sort_unstable(); + offsets + } + + let source = include_str!("production.rs"); + let production = source + .split_once("\n#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("production source has a bounded test module"); + let present_start = production + .find(" async fn present(") + .expect("production resolver has present"); + let resolve_start = production + .find(" async fn resolve(") + .expect("production resolver has resolve"); + let observe = &production[..present_start]; + let present = &production[present_start..resolve_start]; + let resolve = &production[resolve_start..]; + let evaluation_needles = [".evaluate_direct(", ".evaluate_delegated("]; + let acceptance_needles = [".accept_outcome(request, outcome).await?"]; + + let evaluations = offsets(production, &evaluation_needles); + let acceptances = offsets(production, &acceptance_needles); + assert_eq!(evaluations.len(), 6, "inventory all six decision branches"); + assert_eq!(acceptances.len(), 5, "inventory five durable joins"); + + let observe_evaluations = offsets(observe, &evaluation_needles); + let observe_acceptances = offsets(observe, &acceptance_needles); + assert_eq!(observe_evaluations.len(), 2); + assert_eq!(observe_acceptances.len(), 1); + assert!(observe.contains("let outcome = if let Some(owner)")); + assert!( + observe_evaluations + .iter() + .all(|evaluation| *evaluation < observe_acceptances[0]), + "both mutually exclusive observe branches must converge on the durable join" + ); + + for (section, expected_evaluations) in [(present, 1_usize), (resolve, 3_usize)] { + let section_evaluations = offsets(section, &evaluation_needles); + let section_acceptances = offsets(section, &acceptance_needles); + assert_eq!(section_evaluations.len(), expected_evaluations); + assert_eq!(section_acceptances.len(), expected_evaluations); + assert!( + section_evaluations + .iter() + .zip(section_acceptances.iter()) + .all(|(evaluation, acceptance)| evaluation < acceptance), + "no production decision branch may bypass its durable acceptance join" + ); + } + + assert_eq!( + observe_evaluations.len() + + offsets(present, &evaluation_needles).len() + + offsets(resolve, &evaluation_needles).len(), + evaluations.len(), + "every intended production decision branch remains in the structural inventory" + ); + } + struct SyntheticUnavailableProvider; impl AuthorizationProvider for SyntheticUnavailableProvider { diff --git a/crates/buzz-relay/tests/o5_decision_evidence_fail_closed.rs b/crates/buzz-relay/tests/o5_decision_evidence_fail_closed.rs new file mode 100644 index 0000000000..ce897b2af5 --- /dev/null +++ b/crates/buzz-relay/tests/o5_decision_evidence_fail_closed.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use buzz_audit::authorization::{ + ActorReference, AttemptId, AuthorizationEventV1, CapacityClass, ControlCode, CorrelationId, + DecisionReason, EventId, EventKind, EventPayloadV1, EventResult, EvidenceHealthSignal, + OperationClass, SourceClass, StreamId, TransportClass, VersionVectorV1, +}; +use buzz_core::CommunityId; +use buzz_db::authorization_evidence::AcceptedEvidence; +use buzz_relay::authorization_runtime::evidence::{ + accept_authorization_decision, AcceptedAuthorizationDecision, DecisionDisposition, + DecisionEvidenceError, DecisionEvidenceSink, +}; +use chrono::Utc; +use uuid::Uuid; + +struct AlwaysUnavailable; + +#[async_trait] +impl DecisionEvidenceSink for AlwaysUnavailable { + async fn accept( + &self, + _event: &AuthorizationEventV1, + _capacity: CapacityClass, + ) -> Result { + Err(DecisionEvidenceError::AcceptanceUnavailable) + } +} + +struct AlwaysAccept; + +#[async_trait] +impl DecisionEvidenceSink for AlwaysAccept { + async fn accept( + &self, + event: &AuthorizationEventV1, + _capacity: CapacityClass, + ) -> Result { + Ok(AcceptedEvidence { + event_id: event.event_id(), + stream_id: StreamId::generate(), + stream_position: 1, + content_digest: [7; 32], + chain_digest: [9; 32], + }) + } +} + +fn event(result: EventResult, kind: EventKind) -> AuthorizationEventV1 { + AuthorizationEventV1::new( + EventId::generate(), + CommunityId::from_uuid(Uuid::new_v4()), + Utc::now(), + None, + CorrelationId::generate(), + AttemptId::generate(), + None, + ActorReference::Unresolved, + TransportClass::Internal, + OperationClass::Read, + SourceClass::Policy, + kind, + result, + DecisionReason::PolicyDenied, + VersionVectorV1::default(), + EventPayloadV1::None, + ) +} + +#[tokio::test] +async fn new_allow_never_releases_when_durable_acceptance_fails() { + let health = EvidenceHealthSignal::default(); + let result = accept_authorization_decision( + &AlwaysUnavailable, + &health, + &event(EventResult::Allowed, EventKind::AdmissionAllowed), + DecisionDisposition::Allow, + "protected-value", + ) + .await; + + assert_eq!( + result.expect_err("new allow must fail closed").code(), + "authorization_evidence_acceptance_unavailable" + ); + assert_eq!(health.count(ControlCode::AcceptanceUnavailable), 0); +} + +#[tokio::test] +async fn deny_remains_denied_and_emits_one_independent_health_signal() { + let health = EvidenceHealthSignal::default(); + let result = accept_authorization_decision( + &AlwaysUnavailable, + &health, + &event(EventResult::Denied, EventKind::AdmissionDenied), + DecisionDisposition::Deny, + "denied-value", + ) + .await + .expect("evidence degradation must not turn a denial into an error or allow"); + + match result { + AcceptedAuthorizationDecision::Deny { value, evidence } => { + assert_eq!(value, "denied-value"); + assert!(evidence.is_none()); + } + AcceptedAuthorizationDecision::Allow { .. } => panic!("denial became an allow"), + } + assert_eq!(health.count(ControlCode::AcceptanceUnavailable), 1); +} + +#[tokio::test] +async fn successful_acceptance_preserves_disposition_and_receipt_identity() { + let health = EvidenceHealthSignal::default(); + let evidence_event = event(EventResult::Allowed, EventKind::AdmissionAllowed); + let result = accept_authorization_decision( + &AlwaysAccept, + &health, + &evidence_event, + DecisionDisposition::Allow, + "protected-value", + ) + .await + .expect("synthetic durable acceptance succeeds"); + + match result { + AcceptedAuthorizationDecision::Allow { value, evidence } => { + assert_eq!(value, "protected-value"); + assert_eq!(evidence.event_id, evidence_event.event_id()); + } + AcceptedAuthorizationDecision::Deny { .. } => panic!("allow became a denial"), + } + assert_eq!(health.count(ControlCode::AcceptanceUnavailable), 0); +} From 18d820db10578008291ddecedf82ba1f389d6565 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:19:42 -0500 Subject: [PATCH 04/18] feat(auth): integrate durable operator lifecycle Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/src/api/operator.rs | 3 + crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/operator_persistence.rs | 334 ++++++++++++++++++ crates/buzz-relay/src/operator_runtime.rs | 234 +++++++++++- .../buzz-relay/tests/o5_operator_postgres.rs | 316 +++++++++++++++++ .../buzz-relay/tests/o5_operator_surface.rs | 282 ++++++++++++++- 6 files changed, 1151 insertions(+), 20 deletions(-) create mode 100644 crates/buzz-relay/src/operator_persistence.rs create mode 100644 crates/buzz-relay/tests/o5_operator_postgres.rs diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 8148156af8..6e5155089a 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -655,6 +655,9 @@ fn lifecycle_error(error: OperatorRuntimeError) -> (StatusCode, Json) { OperatorRuntimeError::CrossDomain | OperatorRuntimeError::StaleAuthority | OperatorRuntimeError::MissingCapability + | OperatorRuntimeError::MissingApproval + | OperatorRuntimeError::SelfApproval + | OperatorRuntimeError::ReplayedAuthority | OperatorRuntimeError::InvalidAuthority => StatusCode::FORBIDDEN, OperatorRuntimeError::InvalidRequest => StatusCode::BAD_REQUEST, OperatorRuntimeError::IdempotencyConflict => StatusCode::CONFLICT, diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index a590737869..314c8ac9b0 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -34,6 +34,8 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// PostgreSQL executor for explicitly composed operator lifecycle runtimes. +pub mod operator_persistence; /// Disabled-by-default provider-neutral operator lifecycle composition root. pub mod operator_runtime; /// Provider-neutral inventory of every protected relay surface. diff --git a/crates/buzz-relay/src/operator_persistence.rs b/crates/buzz-relay/src/operator_persistence.rs new file mode 100644 index 0000000000..c9cef87917 --- /dev/null +++ b/crates/buzz-relay/src/operator_persistence.rs @@ -0,0 +1,334 @@ +//! PostgreSQL executor for the explicitly composed operator lifecycle runtime. +//! +//! This module does not register routes or construct an authenticator. A caller +//! must explicitly provide independent authentication and the two dedicated +//! pseudonymization keys before it can build [`crate::operator_runtime::OperatorRuntime`]. + +use async_trait::async_trait; +use buzz_audit::authorization::{ + DecisionReason, Pseudonymizer, PseudonymousReference, ReferenceKind, +}; +use buzz_core::CommunityId; +use buzz_db::operator_lifecycle::{ + OperatorAuthorityEvidence, OperatorBindingState, OperatorLifecycleAction, + OperatorLifecycleCommand, OperatorLifecycleDenialAttempt, OperatorLifecycleFailure, + OperatorLifecycleResult, OperatorLifecycleStatus, OperatorReferenceKey, + VerifiedOperatorReplacement, +}; +use buzz_db::Db; +use chrono::{DateTime, Utc}; + +use crate::operator_runtime::{ + AuthenticatedOperatorDenial, AuthorizedOperatorOperation, DurableOperatorExecutor, + OpaqueOperatorReference, OperatorAction, OperatorIntent, OperatorOutcome, + OperatorOutcomeStatus, OperatorRecord, OperatorRecordState, OperatorRuntimeError, +}; + +/// Durable PostgreSQL implementation of the disabled lifecycle executor seam. +pub struct PostgresOperatorExecutor { + db: Db, + reference_key: OperatorReferenceKey, + pseudonymizer: Pseudonymizer, +} + +impl PostgresOperatorExecutor { + /// Bind the database to dedicated operator-reference and audit pseudonym keys. + pub const fn new( + db: Db, + reference_key: OperatorReferenceKey, + pseudonymizer: Pseudonymizer, + ) -> Self { + Self { + db, + reference_key, + pseudonymizer, + } + } + + fn pseudonymize( + &self, + domain: CommunityId, + kind: ReferenceKind, + reference: OpaqueOperatorReference, + ) -> Result { + self.pseudonymizer + .derive(domain, kind, &reference.digest()) + .map_err(|_| OperatorRuntimeError::InvalidAuthority) + } + + fn authority( + &self, + operation: &AuthorizedOperatorOperation, + domain: CommunityId, + ) -> Result { + let expires_at_seconds = i64::try_from(operation.expires_at_unix_seconds()) + .map_err(|_| OperatorRuntimeError::InvalidAuthority)?; + let expires_at = DateTime::::from_timestamp(expires_at_seconds, 0) + .ok_or(OperatorRuntimeError::InvalidAuthority)?; + let actor = self.pseudonymize(domain, ReferenceKind::Actor, operation.actor_reference())?; + let approvers = operation + .invocation() + .context() + .approval_references() + .iter() + .copied() + .map(|reference| self.pseudonymize(domain, ReferenceKind::Approver, reference)) + .collect::, _>>()?; + Ok(OperatorAuthorityEvidence { + evidence_id: operation.authority_evidence_id(), + actor, + actor_independence_reference: operation.actor_reference().digest(), + provenance_reference: operation.provenance_reference().digest(), + approvers, + approver_independence_references: operation + .invocation() + .context() + .approval_references() + .iter() + .map(|reference| reference.digest()) + .collect(), + approval_ids: operation.approval_evidence_ids().to_vec(), + expires_at, + }) + } + + fn command( + &self, + operation: &AuthorizedOperatorOperation, + ) -> Result { + let invocation = operation.invocation(); + let context = invocation.context(); + let domain = CommunityId::from_uuid(context.domain_id()); + let (action, target, replacement_reference, list_limit, list_after) = + match invocation.intent() { + OperatorIntent::List { limit, after } => ( + OperatorLifecycleAction::List, + None, + None, + *limit, + after.map(OpaqueOperatorReference::digest), + ), + OperatorIntent::Preview { + target, + replacement, + } => ( + OperatorLifecycleAction::Preview, + Some(target.digest()), + Some(replacement.digest()), + 1, + None, + ), + OperatorIntent::Revoke { target } => ( + OperatorLifecycleAction::Revoke, + Some(target.digest()), + None, + 1, + None, + ), + OperatorIntent::Rotate { + target, + replacement, + } => ( + OperatorLifecycleAction::Rotate, + Some(target.digest()), + Some(replacement.digest()), + 1, + None, + ), + }; + let target_pseudonym = target + .map(OpaqueOperatorReference::from_digest) + .map(|reference| self.pseudonymize(domain, ReferenceKind::Binding, reference)) + .transpose()?; + let replacement = operation + .replacement() + .map(|value| { + VerifiedOperatorReplacement::new( + value.reference().digest(), + value.public_key(), + value.policy_digest(), + ) + .map_err(|_| OperatorRuntimeError::InvalidAuthority) + }) + .transpose()?; + Ok(OperatorLifecycleCommand { + domain, + operation_id: context.operation_id(), + correlation_id: context.correlation_id(), + semantic_fingerprint: invocation.fingerprint(), + expected_revision: context.expected_revision(), + action, + reason_code: context.reason().discriminant(), + target_reference: target, + target_pseudonym, + replacement_reference, + replacement, + list_limit, + list_after, + authority: self.authority(operation, domain)?, + }) + } + + fn denial_attempt( + &self, + denial: &AuthenticatedOperatorDenial, + ) -> Result { + let invocation = denial.invocation(); + let context = invocation.context(); + let domain = CommunityId::from_uuid(context.domain_id()); + let actor = self.pseudonymize(domain, ReferenceKind::Actor, denial.actor_reference())?; + let approvers = context + .approval_references() + .iter() + .copied() + .map(|reference| self.pseudonymize(domain, ReferenceKind::Approver, reference)) + .collect::, _>>()?; + Ok(OperatorLifecycleDenialAttempt { + domain, + operation_id: context.operation_id(), + correlation_id: context.correlation_id(), + semantic_fingerprint: invocation.fingerprint(), + expected_revision: context.expected_revision(), + action: lifecycle_action(invocation.intent().action()), + reason_code: context.reason().discriminant(), + actor, + provenance_reference: denial.provenance_reference().digest(), + approvers, + denial_reason: denial_reason(denial.reason()), + }) + } +} + +#[async_trait] +impl DurableOperatorExecutor for PostgresOperatorExecutor { + async fn execute_idempotent( + &self, + operation: AuthorizedOperatorOperation, + ) -> Result { + let command = self.command(&operation)?; + self.db + .execute_operator_lifecycle(&self.reference_key, &command) + .await + .map_err(map_lifecycle_failure) + .and_then(map_lifecycle_result) + } + + async fn record_denial( + &self, + denial: AuthenticatedOperatorDenial, + ) -> Result<(), OperatorRuntimeError> { + let attempt = self.denial_attempt(&denial)?; + self.db + .record_operator_lifecycle_denial(&attempt) + .await + .map_err(map_lifecycle_failure) + } +} + +const fn lifecycle_action(action: OperatorAction) -> OperatorLifecycleAction { + match action { + OperatorAction::List => OperatorLifecycleAction::List, + OperatorAction::Preview => OperatorLifecycleAction::Preview, + OperatorAction::Revoke => OperatorLifecycleAction::Revoke, + OperatorAction::Rotate => OperatorLifecycleAction::Rotate, + } +} + +const fn denial_reason(error: OperatorRuntimeError) -> DecisionReason { + match error { + OperatorRuntimeError::CrossDomain => DecisionReason::CrossDomain, + OperatorRuntimeError::StaleAuthority => DecisionReason::StaleApproval, + OperatorRuntimeError::MissingCapability => DecisionReason::UnauthorizedActor, + OperatorRuntimeError::MissingApproval => DecisionReason::MissingApproval, + OperatorRuntimeError::SelfApproval => DecisionReason::SelfApproval, + OperatorRuntimeError::ReplayedAuthority => DecisionReason::EvidenceReplayed, + OperatorRuntimeError::IdempotencyConflict => DecisionReason::IntentConflict, + OperatorRuntimeError::StorageUnavailable => DecisionReason::StorageUnavailable, + OperatorRuntimeError::MissingCredential + | OperatorRuntimeError::InvalidCredential + | OperatorRuntimeError::InvalidRequest + | OperatorRuntimeError::Unauthenticated + | OperatorRuntimeError::InvalidAuthority + | OperatorRuntimeError::ExecutorContract => DecisionReason::EvidenceInvalid, + } +} + +fn map_lifecycle_failure(failure: OperatorLifecycleFailure) -> OperatorRuntimeError { + match failure { + OperatorLifecycleFailure::Storage(_) => OperatorRuntimeError::StorageUnavailable, + OperatorLifecycleFailure::Denied(reason) => match reason { + DecisionReason::IntentConflict => OperatorRuntimeError::IdempotencyConflict, + DecisionReason::EvidenceReplayed | DecisionReason::ReplayedApproval => { + OperatorRuntimeError::ReplayedAuthority + } + DecisionReason::StaleApproval => OperatorRuntimeError::StaleAuthority, + DecisionReason::MissingApproval => OperatorRuntimeError::MissingApproval, + DecisionReason::SelfApproval | DecisionReason::ApprovalNotIndependent => { + OperatorRuntimeError::SelfApproval + } + DecisionReason::CrossDomain | DecisionReason::DomainMismatch => { + OperatorRuntimeError::CrossDomain + } + DecisionReason::UnauthorizedActor => OperatorRuntimeError::MissingCapability, + _ => OperatorRuntimeError::InvalidAuthority, + }, + } +} + +fn map_lifecycle_result( + result: OperatorLifecycleResult, +) -> Result { + let action = match result.action { + OperatorLifecycleAction::List => OperatorAction::List, + OperatorLifecycleAction::Preview => OperatorAction::Preview, + OperatorLifecycleAction::Revoke => OperatorAction::Revoke, + OperatorLifecycleAction::Rotate => OperatorAction::Rotate, + }; + let status = match result.status { + OperatorLifecycleStatus::Listed => OperatorOutcomeStatus::Listed, + OperatorLifecycleStatus::Previewed => OperatorOutcomeStatus::Previewed, + OperatorLifecycleStatus::Revoked => OperatorOutcomeStatus::Revoked, + OperatorLifecycleStatus::Rotated => OperatorOutcomeStatus::Rotated, + OperatorLifecycleStatus::Denied => return Err(OperatorRuntimeError::ExecutorContract), + }; + let records = result + .records + .into_iter() + .map(|record| OperatorRecord { + reference: OpaqueOperatorReference::from_digest(record.reference), + state: match record.state { + OperatorBindingState::Active => OperatorRecordState::Active, + OperatorBindingState::Revoked => OperatorRecordState::Revoked, + OperatorBindingState::Rotated => OperatorRecordState::Rotated, + OperatorBindingState::Archived => OperatorRecordState::Archived, + }, + revision: record.revision, + }) + .collect(); + OperatorOutcome::new( + result.operation_id, + result.correlation_id, + action, + status, + result.affected_count, + result.lifecycle_revision, + records, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_denials_have_closed_database_reasons() { + assert_eq!( + denial_reason(OperatorRuntimeError::MissingCapability), + DecisionReason::UnauthorizedActor + ); + assert_eq!( + denial_reason(OperatorRuntimeError::SelfApproval), + DecisionReason::SelfApproval + ); + } +} diff --git a/crates/buzz-relay/src/operator_runtime.rs b/crates/buzz-relay/src/operator_runtime.rs index 67ddab3a79..e4fd0c2904 100644 --- a/crates/buzz-relay/src/operator_runtime.rs +++ b/crates/buzz-relay/src/operator_runtime.rs @@ -136,7 +136,8 @@ pub enum OperatorReasonCode { } impl OperatorReasonCode { - fn discriminant(self) -> u16 { + /// Numeric representation frozen by the provider-neutral lifecycle contract. + pub const fn discriminant(self) -> u16 { match self { Self::Offboarding => 1, Self::CompromiseContainment => 2, @@ -410,15 +411,21 @@ pub struct OperatorAuthorizationRequest { operation_id: Uuid, capability: OperatorCapability, intent_fingerprint: [u8; 32], + replacement_reference: Option, } impl OperatorAuthorizationRequest { fn from_invocation(invocation: &OperatorInvocation) -> Self { + let replacement_reference = match invocation.intent { + OperatorIntent::Rotate { replacement, .. } => Some(replacement), + _ => None, + }; Self { domain_id: invocation.context.domain_id, operation_id: invocation.context.operation_id, capability: invocation.intent.action().capability(), intent_fingerprint: invocation.fingerprint, + replacement_reference, } } @@ -441,10 +448,64 @@ impl OperatorAuthorizationRequest { pub const fn intent_fingerprint(self) -> [u8; 32] { self.intent_fingerprint } + + /// Requested replacement reference when a rotation needs fresh proof. + pub const fn replacement_reference(self) -> Option { + self.replacement_reference + } +} + +/// Fresh replacement material supplied only by an authenticated rotation grant. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct GrantedOperatorReplacement { + reference: OpaqueOperatorReference, + public_key: [u8; 32], + policy_digest: [u8; 32], +} + +impl GrantedOperatorReplacement { + /// Bind a proven replacement key and policy revision to an opaque reference. + pub fn new( + reference: OpaqueOperatorReference, + public_key: [u8; 32], + policy_digest: [u8; 32], + ) -> Result { + if reference.is_zero() || public_key == [0; 32] || policy_digest == [0; 32] { + return Err(OperatorRuntimeError::InvalidAuthority); + } + Ok(Self { + reference, + public_key, + policy_digest, + }) + } + + /// Opaque replacement identity bound into the requested intent. + pub const fn reference(self) -> OpaqueOperatorReference { + self.reference + } + + /// Fresh public key proven by the authenticator. + pub const fn public_key(self) -> [u8; 32] { + self.public_key + } + + /// Digest of the policy revision that authorized the replacement. + pub const fn policy_digest(self) -> [u8; 32] { + self.policy_digest + } +} + +impl fmt::Debug for GrantedOperatorReplacement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GrantedOperatorReplacement([redacted])") + } } /// Authenticated capability grant returned by a deployment-owned verifier. pub trait GrantedOperatorCapability: Send + Sync { + /// Single-use authority evidence identity. + fn authority_evidence_id(&self) -> Uuid; /// Authorization domain bound by the grant. fn domain_id(&self) -> Uuid; /// Stable operation identity bound by the grant. @@ -455,6 +516,10 @@ pub trait GrantedOperatorCapability: Send + Sync { fn actor_reference(&self) -> OpaqueOperatorReference; /// Pseudonymous credential/provenance reference stored in durable evidence. fn provenance_reference(&self) -> OpaqueOperatorReference; + /// Single-use approval evidence identities, parallel to request approvals. + fn approval_evidence_ids(&self) -> &[Uuid]; + /// Fresh replacement proof for an exact rotate intent, if any. + fn replacement(&self) -> Option; /// Exclusive trusted expiry in Unix seconds. fn expires_at_unix_seconds(&self) -> u64; /// Whether this grant permits the exact closed capability. @@ -482,8 +547,12 @@ pub trait OperatorClock: Send + Sync { #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthorizedOperatorOperation { invocation: OperatorInvocation, + authority_evidence_id: Uuid, actor_reference: OpaqueOperatorReference, provenance_reference: OpaqueOperatorReference, + approval_evidence_ids: Box<[Uuid]>, + expires_at_unix_seconds: u64, + replacement: Option, } impl AuthorizedOperatorOperation { @@ -492,6 +561,11 @@ impl AuthorizedOperatorOperation { &self.invocation } + /// Single-use authority evidence identity. + pub const fn authority_evidence_id(&self) -> Uuid { + self.authority_evidence_id + } + /// Pseudonymous actor reference. pub const fn actor_reference(&self) -> OpaqueOperatorReference { self.actor_reference @@ -501,6 +575,52 @@ impl AuthorizedOperatorOperation { pub const fn provenance_reference(&self) -> OpaqueOperatorReference { self.provenance_reference } + + /// Single-use approval evidence identities. + pub fn approval_evidence_ids(&self) -> &[Uuid] { + &self.approval_evidence_ids + } + + /// Exclusive trusted authority expiry. + pub const fn expires_at_unix_seconds(&self) -> u64 { + self.expires_at_unix_seconds + } + + /// Fresh replacement material for a rotate operation. + pub const fn replacement(&self) -> Option { + self.replacement + } +} + +/// Authenticated denial facts accepted by the independent durable recorder. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticatedOperatorDenial { + invocation: OperatorInvocation, + actor_reference: OpaqueOperatorReference, + provenance_reference: OpaqueOperatorReference, + reason: OperatorRuntimeError, +} + +impl AuthenticatedOperatorDenial { + /// Denied invocation. + pub const fn invocation(&self) -> &OperatorInvocation { + &self.invocation + } + + /// Pseudonymous actor reference. + pub const fn actor_reference(&self) -> OpaqueOperatorReference { + self.actor_reference + } + + /// Pseudonymous credential/provenance reference. + pub const fn provenance_reference(&self) -> OpaqueOperatorReference { + self.provenance_reference + } + + /// Closed denial reason. + pub const fn reason(&self) -> OperatorRuntimeError { + self.reason + } } /// Redacted lifecycle state returned by listing operations. @@ -624,6 +744,12 @@ pub trait DurableOperatorExecutor: Send + Sync { &self, operation: AuthorizedOperatorOperation, ) -> Result; + + /// Record one authenticated denial without performing a lifecycle mutation. + async fn record_denial( + &self, + denial: AuthenticatedOperatorDenial, + ) -> Result<(), OperatorRuntimeError>; } /// Explicit composition root for the disabled operator lifecycle surface. @@ -657,27 +783,83 @@ impl OperatorRuntime { let grant = self.authenticator.authenticate(credential, request).await?; let required = invocation.intent.action().capability(); let now = self.clock.now_unix_seconds()?; + let actor_reference = grant.actor_reference(); + let provenance_reference = grant.provenance_reference(); + if actor_reference.is_zero() + || provenance_reference.is_zero() + || actor_reference == provenance_reference + { + tracing::warn!( + reason = OperatorRuntimeError::InvalidAuthority.code(), + "operator denial could not be durably attributed" + ); + return Err(OperatorRuntimeError::InvalidAuthority); + } + let deny = |reason| AuthenticatedOperatorDenial { + invocation: invocation.clone(), + actor_reference, + provenance_reference, + reason, + }; if grant.domain_id() != invocation.context.domain_id { - return Err(OperatorRuntimeError::CrossDomain); + return self + .reject_authenticated(deny(OperatorRuntimeError::CrossDomain)) + .await; } if grant.operation_id() != invocation.context.operation_id || grant.intent_fingerprint() != invocation.fingerprint { - return Err(OperatorRuntimeError::InvalidAuthority); + return self + .reject_authenticated(deny(OperatorRuntimeError::InvalidAuthority)) + .await; } if grant.expires_at_unix_seconds() <= now { - return Err(OperatorRuntimeError::StaleAuthority); + return self + .reject_authenticated(deny(OperatorRuntimeError::StaleAuthority)) + .await; } if !grant.permits(required) { - return Err(OperatorRuntimeError::MissingCapability); + return self + .reject_authenticated(deny(OperatorRuntimeError::MissingCapability)) + .await; } - let actor_reference = grant.actor_reference(); - let provenance_reference = grant.provenance_reference(); - if actor_reference.is_zero() - || provenance_reference.is_zero() - || actor_reference == provenance_reference + let authority_evidence_id = grant.authority_evidence_id(); + let approval_evidence_ids = grant.approval_evidence_ids(); + let approvals = invocation.context.approval_references(); + let mut approval_ids = approval_evidence_ids.to_vec(); + approval_ids.sort_unstable(); + let invalid_approval_ids = authority_evidence_id.is_nil() + || approval_evidence_ids.len() != approvals.len() + || approval_evidence_ids.iter().any(Uuid::is_nil) + || approval_ids.windows(2).any(|pair| pair[0] == pair[1]); + if invalid_approval_ids { + return self + .reject_authenticated(deny(OperatorRuntimeError::InvalidAuthority)) + .await; + } + if matches!( + invocation.intent.action(), + OperatorAction::Revoke | OperatorAction::Rotate + ) && approvals.is_empty() { - return Err(OperatorRuntimeError::InvalidAuthority); + return self + .reject_authenticated(deny(OperatorRuntimeError::MissingApproval)) + .await; + } + if approvals.contains(&actor_reference) { + return self + .reject_authenticated(deny(OperatorRuntimeError::SelfApproval)) + .await; + } + let replacement = grant.replacement(); + let expected_replacement = match invocation.intent { + OperatorIntent::Rotate { replacement, .. } => Some(replacement), + _ => None, + }; + if replacement.map(GrantedOperatorReplacement::reference) != expected_replacement { + return self + .reject_authenticated(deny(OperatorRuntimeError::InvalidAuthority)) + .await; } let expected_operation_id = invocation.context.operation_id; let expected_correlation_id = invocation.context.correlation_id; @@ -686,8 +868,12 @@ impl OperatorRuntime { .executor .execute_idempotent(AuthorizedOperatorOperation { invocation, + authority_evidence_id, actor_reference, provenance_reference, + approval_evidence_ids: approval_evidence_ids.to_vec().into_boxed_slice(), + expires_at_unix_seconds: grant.expires_at_unix_seconds(), + replacement, }) .await?; if outcome.operation_id() != expected_operation_id @@ -698,6 +884,20 @@ impl OperatorRuntime { } Ok(outcome) } + + async fn reject_authenticated( + &self, + denial: AuthenticatedOperatorDenial, + ) -> Result { + let reason = denial.reason(); + if self.executor.record_denial(denial).await.is_err() { + tracing::warn!( + reason = reason.code(), + "operator denial evidence unavailable; request remains denied" + ); + } + Err(reason) + } } /// Closed, redaction-safe failure returned by the operator runtime. @@ -724,6 +924,15 @@ pub enum OperatorRuntimeError { /// Authenticated authority lacks the exact capability. #[error("operator capability is missing")] MissingCapability, + /// A mutating lifecycle operation lacks independent approval evidence. + #[error("operator independent approval is required")] + MissingApproval, + /// The authenticated actor attempted to approve its own operation. + #[error("operator approval is not independent")] + SelfApproval, + /// Single-use authority or approval evidence was already consumed. + #[error("operator authority evidence was replayed")] + ReplayedAuthority, /// Authenticated grant contains invalid evidence references. #[error("operator authority is invalid")] InvalidAuthority, @@ -749,6 +958,9 @@ impl OperatorRuntimeError { Self::CrossDomain => "operator_cross_domain_denied", Self::StaleAuthority => "operator_authority_stale", Self::MissingCapability => "operator_capability_missing", + Self::MissingApproval => "operator_approval_missing", + Self::SelfApproval => "operator_self_approval_denied", + Self::ReplayedAuthority => "operator_authority_replayed", Self::InvalidAuthority => "operator_authority_invalid", Self::IdempotencyConflict => "operator_idempotency_conflict", Self::StorageUnavailable => "operator_storage_unavailable", diff --git a/crates/buzz-relay/tests/o5_operator_postgres.rs b/crates/buzz-relay/tests/o5_operator_postgres.rs new file mode 100644 index 0000000000..94938faf1a --- /dev/null +++ b/crates/buzz-relay/tests/o5_operator_postgres.rs @@ -0,0 +1,316 @@ +//! Synthetic route-to-PostgreSQL proof for the explicitly composed O5 surface. + +use std::{str::FromStr, sync::Arc, time::SystemTime}; + +use async_trait::async_trait; +use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, +}; +use buzz_audit::authorization::{PseudonymKey, Pseudonymizer}; +use buzz_db::operator_lifecycle::OperatorReferenceKey; +use buzz_relay::{ + api::operator::lifecycle_router, + operator_persistence::PostgresOperatorExecutor, + operator_runtime::{ + GrantedOperatorCapability, GrantedOperatorReplacement, OpaqueOperatorReference, + OperatorAuthenticator, OperatorAuthorizationRequest, OperatorCapability, OperatorClock, + OperatorCredential, OperatorRuntime, OperatorRuntimeError, + }, +}; +use serde_json::{json, Value}; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 +const CREDENTIAL: &str = "synthetic-route-postgres-credential-canary"; + +struct IsolatedDatabase { + pool: PgPool, + admin: PgPool, + name: String, +} + +impl IsolatedDatabase { + async fn migrated() -> Self { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let admin_options = PgConnectOptions::from_str(&database_url) + .expect("O5 route test database URL must be valid PostgreSQL"); + let admin = PgPoolOptions::new() + .max_connections(2) + .connect_with(admin_options.clone()) + .await + .expect( + "O5 route PostgreSQL gate requires a database; a zero-assertion success is prohibited", + ); + let name = format!("o5_route_{}", Uuid::new_v4().simple()); + assert!(name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE \"{name}\""))) + .execute(&admin) + .await + .expect("create isolated O5 route database"); + let pool = PgPoolOptions::new() + .max_connections(8) + .connect_with(admin_options.database(&name)) + .await + .expect("connect isolated O5 route database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("apply exact O5 migrations for route test"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .expect("count executed route-test migrations"); + assert_eq!(count, 50, "route test executes the full O5 SQLx chain"); + Self { pool, admin, name } + } + + async fn cleanup(self) { + self.pool.close().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{}\" WITH (FORCE)", + self.name + ))) + .execute(&self.admin) + .await + .expect("drop isolated O5 route database"); + self.admin.close().await; + } +} + +struct SystemClock; + +impl OperatorClock for SystemClock { + fn now_unix_seconds(&self) -> Result { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| OperatorRuntimeError::InvalidAuthority) + } +} + +struct Grant { + domain_id: Uuid, + operation_id: Uuid, + fingerprint: [u8; 32], + authority_id: Uuid, + approval_ids: Vec, + replacement: Option, + expires_at: u64, +} + +impl GrantedOperatorCapability for Grant { + fn authority_evidence_id(&self) -> Uuid { + self.authority_id + } + + fn domain_id(&self) -> Uuid { + self.domain_id + } + + fn operation_id(&self) -> Uuid { + self.operation_id + } + + fn intent_fingerprint(&self) -> [u8; 32] { + self.fingerprint + } + + fn actor_reference(&self) -> OpaqueOperatorReference { + OpaqueOperatorReference::from_digest([11; 32]) + } + + fn provenance_reference(&self) -> OpaqueOperatorReference { + OpaqueOperatorReference::from_digest([12; 32]) + } + + fn approval_evidence_ids(&self) -> &[Uuid] { + &self.approval_ids + } + + fn replacement(&self) -> Option { + self.replacement + } + + fn expires_at_unix_seconds(&self) -> u64 { + self.expires_at + } + + fn permits(&self, _capability: OperatorCapability) -> bool { + true + } +} + +struct Authenticator; + +#[async_trait] +impl OperatorAuthenticator for Authenticator { + async fn authenticate( + &self, + credential: &OperatorCredential, + request: OperatorAuthorizationRequest, + ) -> Result, OperatorRuntimeError> { + assert_eq!(credential.expose_to_authenticator(), CREDENTIAL.as_bytes()); + let replacement = request + .replacement_reference() + .map(|reference| GrantedOperatorReplacement::new(reference, [77; 32], [78; 32])) + .transpose()?; + let now = SystemClock.now_unix_seconds()?; + Ok(Box::new(Grant { + domain_id: request.domain_id(), + operation_id: request.operation_id(), + fingerprint: request.intent_fingerprint(), + authority_id: Uuid::new_v4(), + approval_ids: vec![Uuid::new_v4()], + replacement, + expires_at: now + 300, + })) + } +} + +fn reference(byte: u8) -> String { + hex::encode([byte; 32]) +} + +fn common_body(domain_id: Uuid, operation_id: Uuid, revision: u64) -> Value { + json!({ + "domain_id": domain_id, + "operation_id": operation_id, + "correlation_id": Uuid::new_v4(), + "reason": "planned_rotation", + "expected_revision": revision, + "approval_references": [reference(13)], + }) +} + +async fn post(runtime: Arc, path: &str, body: Value) -> (StatusCode, Value) { + let response = lifecycle_router(runtime) + .oneshot( + Request::post(path) + .header(header::AUTHORIZATION, CREDENTIAL) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("operator route request"), + ) + .await + .expect("operator route response"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("operator route response body"); + let body = serde_json::from_slice(&bytes).expect("operator route JSON response"); + (status, body) +} + +#[tokio::test] +async fn explicitly_composed_routes_reach_real_postgres_list_preview_revoke_and_rotate() { + let fixture = IsolatedDatabase::migrated().await; + let domain = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain) + .bind(format!("{domain}.route.o5.test")) + .execute(&fixture.pool) + .await + .expect("insert synthetic route domain"); + for (binding_id, issuer, subject, key) in [ + ( + Uuid::new_v4(), + "https://issuer-a.invalid", + "subject-a", + [31_u8; 32], + ), + ( + Uuid::new_v4(), + "https://issuer-b.invalid", + "subject-b", + [32_u8; 32], + ), + ] { + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,binding_id,creation_attribution_kind) \ + VALUES ($1,$2,$3,$4,'db_binding',$5,'legacy_unknown')", + ) + .bind(domain) + .bind(issuer) + .bind(subject) + .bind(key.as_slice()) + .bind(binding_id) + .execute(&fixture.pool) + .await + .expect("insert synthetic route binding"); + } + let db = buzz_db::Db::from_pool(fixture.pool.clone()); + let executor = Arc::new(PostgresOperatorExecutor::new( + db, + OperatorReferenceKey::new([41; 32], 1).expect("operator reference key"), + Pseudonymizer::new(PseudonymKey::new([42; 32]).expect("pseudonym key"), 1), + )); + let runtime = Arc::new(OperatorRuntime::new( + Arc::new(Authenticator), + executor, + Arc::new(SystemClock), + )); + let mut scenarios = 0_u32; + + let mut list = common_body(domain, Uuid::new_v4(), 1); + list["limit"] = json!(10); + let (status, listed) = post(runtime.clone(), "/operator/v1/lifecycle/list", list).await; + assert_eq!(status, StatusCode::OK, "list response: {listed}"); + let records = listed["records"].as_array().expect("redacted list records"); + assert_eq!(records.len(), 2); + let first = records[0]["reference"].as_str().expect("first reference"); + let second = records[1]["reference"].as_str().expect("second reference"); + scenarios += 1; + + let mut preview = common_body(domain, Uuid::new_v4(), 1); + preview["target"] = json!(first); + preview["replacement"] = json!(reference(70)); + let (status, previewed) = + post(runtime.clone(), "/operator/v1/lifecycle/preview", preview).await; + assert_eq!(status, StatusCode::OK, "preview response: {previewed}"); + scenarios += 1; + + let mut revoke = common_body(domain, Uuid::new_v4(), 1); + revoke["target"] = json!(first); + revoke["reason"] = json!("emergency_containment"); + let (status, revoked) = post(runtime.clone(), "/operator/v1/lifecycle/revoke", revoke).await; + assert_eq!(status, StatusCode::OK, "revoke response: {revoked}"); + assert_eq!(revoked["lifecycle_revision"], 2); + scenarios += 1; + + let mut rotate = common_body(domain, Uuid::new_v4(), 2); + rotate["target"] = json!(second); + rotate["replacement"] = json!(reference(71)); + let (status, rotated) = post(runtime, "/operator/v1/lifecycle/rotate", rotate).await; + assert_eq!(status, StatusCode::OK, "rotate response: {rotated}"); + assert_eq!(rotated["lifecycle_revision"], 3); + scenarios += 1; + + let receipts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_operation_receipts WHERE community_id=$1", + ) + .bind(domain) + .fetch_one(&fixture.pool) + .await + .expect("count reachable operator receipts"); + let effects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_effects WHERE community_id=$1", + ) + .bind(domain) + .fetch_one(&fixture.pool) + .await + .expect("count reachable operator effects"); + assert_eq!(receipts, 4); + assert_eq!(effects, 2); + assert_eq!(scenarios, 4, "every reachable route scenario executed"); + + fixture.cleanup().await; +} diff --git a/crates/buzz-relay/tests/o5_operator_surface.rs b/crates/buzz-relay/tests/o5_operator_surface.rs index 4031027919..64c59bc75e 100644 --- a/crates/buzz-relay/tests/o5_operator_surface.rs +++ b/crates/buzz-relay/tests/o5_operator_surface.rs @@ -2,6 +2,7 @@ use std::{ collections::HashMap, + io::Write, sync::{Arc, Mutex}, }; @@ -13,11 +14,11 @@ use axum::{ use buzz_relay::{ api::operator::lifecycle_router, operator_runtime::{ - AuthorizedOperatorOperation, DurableOperatorExecutor, GrantedOperatorCapability, - OpaqueOperatorReference, OperatorAction, OperatorAuthenticator, - OperatorAuthorizationRequest, OperatorCapability, OperatorClock, OperatorCredential, - OperatorOutcome, OperatorOutcomeStatus, OperatorRecord, OperatorRecordState, - OperatorRuntime, OperatorRuntimeError, + AuthenticatedOperatorDenial, AuthorizedOperatorOperation, DurableOperatorExecutor, + GrantedOperatorCapability, GrantedOperatorReplacement, OpaqueOperatorReference, + OperatorAction, OperatorAuthenticator, OperatorAuthorizationRequest, OperatorCapability, + OperatorClock, OperatorCredential, OperatorOutcome, OperatorOutcomeStatus, OperatorRecord, + OperatorRecordState, OperatorRuntime, OperatorRuntimeError, }, }; use serde_json::{json, Value}; @@ -39,10 +40,19 @@ struct TestGrant { domain_id: Uuid, operation_id: Uuid, intent_fingerprint: [u8; 32], + authority_evidence_id: Uuid, + approval_evidence_ids: Vec, + replacement: Option, allow: bool, + actor_reference: OpaqueOperatorReference, + expires_at: u64, } impl GrantedOperatorCapability for TestGrant { + fn authority_evidence_id(&self) -> Uuid { + self.authority_evidence_id + } + fn domain_id(&self) -> Uuid { self.domain_id } @@ -56,15 +66,23 @@ impl GrantedOperatorCapability for TestGrant { } fn actor_reference(&self) -> OpaqueOperatorReference { - OpaqueOperatorReference::from_digest([1; 32]) + self.actor_reference } fn provenance_reference(&self) -> OpaqueOperatorReference { OpaqueOperatorReference::from_digest([2; 32]) } + fn approval_evidence_ids(&self) -> &[Uuid] { + &self.approval_evidence_ids + } + + fn replacement(&self) -> Option { + self.replacement + } + fn expires_at_unix_seconds(&self) -> u64 { - 200 + self.expires_at } fn permits(&self, _capability: OperatorCapability) -> bool { @@ -75,6 +93,10 @@ impl GrantedOperatorCapability for TestGrant { struct TestAuthenticator { allow: bool, calls: Mutex>, + domain_override: Option, + actor_reference: OpaqueOperatorReference, + expires_at: u64, + approval_count: usize, } #[async_trait] @@ -90,19 +112,33 @@ impl OperatorAuthenticator for TestAuthenticator { ); assert_ne!(request.intent_fingerprint(), [0; 32]); self.calls.lock().expect("auth calls").push(request); + let replacement = request + .replacement_reference() + .map(|reference| GrantedOperatorReplacement::new(reference, [5; 32], [6; 32])) + .transpose()?; Ok(Box::new(TestGrant { - domain_id: request.domain_id(), + domain_id: self.domain_override.unwrap_or_else(|| request.domain_id()), operation_id: request.operation_id(), intent_fingerprint: request.intent_fingerprint(), + authority_evidence_id: Uuid::new_v4(), + approval_evidence_ids: (0..self.approval_count).map(|_| Uuid::new_v4()).collect(), + replacement, allow: self.allow, + actor_reference: self.actor_reference, + expires_at: self.expires_at, })) } } +type ReceiptKey = (Uuid, Uuid); +type ReceiptValue = ([u8; 32], OperatorOutcome); + #[derive(Default)] struct TestExecutor { - receipts: Mutex>, + receipts: Mutex>, committed_actions: Mutex>, + denials: Mutex>, + fail_denial_recording: bool, } #[async_trait] @@ -157,6 +193,17 @@ impl DurableOperatorExecutor for TestExecutor { .push(action); Ok(outcome) } + + async fn record_denial( + &self, + denial: AuthenticatedOperatorDenial, + ) -> Result<(), OperatorRuntimeError> { + self.denials.lock().expect("denials").push(denial.reason()); + if self.fail_denial_recording { + return Err(OperatorRuntimeError::StorageUnavailable); + } + Ok(()) + } } fn runtime() -> ( @@ -173,10 +220,28 @@ fn runtime_with_capability( Arc, Arc, Arc, +) { + runtime_with_grant(allow, None, [1; 32], 200, 1) +} + +fn runtime_with_grant( + allow: bool, + domain_override: Option, + actor_reference: [u8; 32], + expires_at: u64, + approval_count: usize, +) -> ( + Arc, + Arc, + Arc, ) { let authenticator = Arc::new(TestAuthenticator { allow, calls: Mutex::new(Vec::new()), + domain_override, + actor_reference: OpaqueOperatorReference::from_digest(actor_reference), + expires_at, + approval_count, }); let executor = Arc::new(TestExecutor::default()); let runtime = Arc::new(OperatorRuntime::new( @@ -191,6 +256,17 @@ fn reference(byte: u8) -> String { hex::encode([byte; 32]) } +fn assert_no_committed_actions(executor: &TestExecutor) { + assert!( + executor + .committed_actions + .lock() + .expect("committed actions") + .is_empty(), + "denied operator request must not mutate" + ); +} + fn request_body(domain_id: Uuid, operation_id: Uuid, correlation_id: Uuid) -> Value { json!({ "domain_id": domain_id, @@ -330,6 +406,96 @@ async fn missing_credential_and_missing_capability_never_reach_executor() { .lock() .expect("committed actions") .is_empty()); + assert_eq!( + executor.denials.lock().expect("denials").as_slice(), + &[OperatorRuntimeError::MissingCapability] + ); +} + +#[tokio::test] +async fn malformed_and_authenticated_adversarial_requests_never_mutate() { + let domain_id = Uuid::from_u128(0x540); + + let (runtime, authenticator, executor) = runtime(); + let mut missing_reason = + request_body(domain_id, Uuid::from_u128(0x541), Uuid::from_u128(0x542)); + missing_reason + .as_object_mut() + .expect("request object") + .remove("reason"); + missing_reason["target"] = json!(reference(3)); + assert_eq!( + post(runtime, "/operator/v1/lifecycle/revoke", missing_reason) + .await + .0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert!(authenticator.calls.lock().expect("auth calls").is_empty()); + assert!(executor + .committed_actions + .lock() + .expect("committed actions") + .is_empty()); + + let (runtime, _, executor) = + runtime_with_grant(true, Some(Uuid::from_u128(0x54f)), [1; 32], 200, 1); + let mut cross_domain = request_body(domain_id, Uuid::from_u128(0x543), Uuid::from_u128(0x544)); + cross_domain["target"] = json!(reference(3)); + assert_eq!( + post(runtime, "/operator/v1/lifecycle/revoke", cross_domain) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + executor.denials.lock().expect("denials").as_slice(), + &[OperatorRuntimeError::CrossDomain] + ); + assert_no_committed_actions(&executor); + + let (runtime, _, executor) = runtime_with_grant(true, None, [1; 32], 100, 1); + let mut stale = request_body(domain_id, Uuid::from_u128(0x545), Uuid::from_u128(0x546)); + stale["target"] = json!(reference(3)); + assert_eq!( + post(runtime, "/operator/v1/lifecycle/revoke", stale) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + executor.denials.lock().expect("denials").as_slice(), + &[OperatorRuntimeError::StaleAuthority] + ); + assert_no_committed_actions(&executor); + + let (runtime, _, executor) = runtime_with_grant(true, None, [9; 32], 200, 1); + let mut self_approved = request_body(domain_id, Uuid::from_u128(0x547), Uuid::from_u128(0x548)); + self_approved["target"] = json!(reference(3)); + assert_eq!( + post(runtime, "/operator/v1/lifecycle/revoke", self_approved) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + executor.denials.lock().expect("denials").as_slice(), + &[OperatorRuntimeError::SelfApproval] + ); + assert_no_committed_actions(&executor); + + let (runtime, _, executor) = runtime_with_grant(true, None, [1; 32], 200, 0); + let mut missing_approval = + request_body(domain_id, Uuid::from_u128(0x549), Uuid::from_u128(0x54a)); + missing_approval["target"] = json!(reference(3)); + missing_approval["approval_references"] = json!([]); + let (status, response) = post(runtime, "/operator/v1/lifecycle/revoke", missing_approval).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(response.contains("operator_approval_missing")); + assert_eq!( + executor.denials.lock().expect("denials").as_slice(), + &[OperatorRuntimeError::MissingApproval] + ); + assert_no_committed_actions(&executor); } #[test] @@ -338,3 +504,101 @@ fn stock_router_does_not_register_lifecycle_surface() { assert!(!stock_router.contains("lifecycle_router")); assert!(!stock_router.contains("/operator/v1/lifecycle")); } + +#[derive(Clone)] +struct CapturingMakeWriter { + buffer: Arc>>, +} + +struct CapturingWriter { + buffer: Arc>>, +} + +impl Write for CapturingWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.buffer.lock().expect("trace buffer").extend(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturingMakeWriter { + type Writer = CapturingWriter; + + fn make_writer(&'writer self) -> Self::Writer { + CapturingWriter { + buffer: Arc::clone(&self.buffer), + } + } +} + +#[test] +fn planted_canaries_never_cross_response_logs_or_metrics() { + const RAW_ISSUER_CANARY: &str = "issuer-canary.invalid/private"; + const JWT_CANARY: &str = "eyJ.synthetic.jwt.canary"; + const JWKS_CANARY: &str = "{\"keys\":[{\"kid\":\"private-jwks-canary\"}]}"; + let authenticator = Arc::new(TestAuthenticator { + allow: false, + calls: Mutex::new(Vec::new()), + domain_override: None, + actor_reference: OpaqueOperatorReference::from_digest([1; 32]), + expires_at: 200, + approval_count: 1, + }); + let executor = Arc::new(TestExecutor { + fail_denial_recording: true, + ..TestExecutor::default() + }); + let runtime = Arc::new(OperatorRuntime::new( + authenticator, + executor, + Arc::new(FixedClock), + )); + let domain = Uuid::from_u128(0x550); + let mut body = request_body(domain, Uuid::from_u128(0x551), Uuid::from_u128(0x552)); + body["target"] = json!(reference(3)); + body["raw_issuer_canary"] = json!(RAW_ISSUER_CANARY); + body["jwt_canary"] = json!(JWT_CANARY); + body["jwks_canary"] = json!(JWKS_CANARY); + + let trace_buffer = Arc::new(Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::fmt() + .with_writer(CapturingMakeWriter { + buffer: Arc::clone(&trace_buffer), + }) + .with_ansi(false) + .finish(); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let runtime_handle = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread sentinel runtime"); + let (status, response) = metrics::with_local_recorder(&recorder, || { + tracing::subscriber::with_default(subscriber, || { + runtime_handle.block_on(post(runtime, "/operator/v1/lifecycle/revoke", body)) + }) + }); + assert_eq!(status, StatusCode::FORBIDDEN); + + let logs = String::from_utf8(trace_buffer.lock().expect("trace buffer").clone()) + .expect("UTF-8 trace output"); + let metrics = format!("{:?}", snapshotter.snapshot().into_vec()); + let surfaces = format!("{response}\n{logs}\n{metrics}"); + for canary in [ + CREDENTIAL_CANARY, + PRIVATE_CLAIM_CANARY, + RAW_ISSUER_CANARY, + JWT_CANARY, + JWKS_CANARY, + ] { + assert!( + !surfaces.contains(canary), + "planted canary crossed a response, log, or metric surface" + ); + } + assert!(logs.contains("request remains denied")); +} From d17afc87d485628654b55c6a769535dd18913b43 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:20:05 -0500 Subject: [PATCH 05/18] test(auth): add OSS lifecycle playground Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- Justfile | 24 +++++++ docker-compose.oss-e2e.yml | 75 ++++++++++++++++++++++ docs/testing/oss-only-e2e.md | 73 +++++++++++++++++++++ scripts/oss-e2e.sh | 119 +++++++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 docker-compose.oss-e2e.yml create mode 100644 docs/testing/oss-only-e2e.md create mode 100755 scripts/oss-e2e.sh diff --git a/Justfile b/Justfile index c3d755ffeb..c9f1efdbc0 100644 --- a/Justfile +++ b/Justfile @@ -263,6 +263,30 @@ desktop-e2e-smoke: desktop-e2e-integration: _ensure-migrations cd {{desktop_dir}} && pnpm test:e2e:integration +# Start the isolated OSS-only authorization/lifecycle playground. +oss-e2e-setup: + ./scripts/oss-e2e.sh setup + +# Run all synthetic OSS-only authorization/lifecycle scenarios. +oss-e2e: + ./scripts/oss-e2e.sh run + +# Run one scenario ID, for example: `just oss-e2e-scenario O501`. +oss-e2e-scenario SCENARIO: + ./scripts/oss-e2e.sh scenario {{SCENARIO}} + +# Delete only the isolated playground's synthetic volumes and restart it. +oss-e2e-reset: + ./scripts/oss-e2e.sh reset + +# Stop the isolated playground while retaining its synthetic volumes. +oss-e2e-stop: + ./scripts/oss-e2e.sh stop + +# Show isolated playground service health. +oss-e2e-status: + ./scripts/oss-e2e.sh status + # Run only the e2e specs changed vs origin/main (both projects) before pushing desktop-e2e-pre-push: _ensure-migrations git fetch origin main diff --git a/docker-compose.oss-e2e.yml b/docker-compose.oss-e2e.yml new file mode 100644 index 0000000000..a21499a42a --- /dev/null +++ b/docker-compose.oss-e2e.yml @@ -0,0 +1,75 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: buzz_oss_e2e + POSTGRES_DB: buzz + ports: + - "5546:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U buzz -d buzz"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + deploy: + resources: + limits: + memory: 512m + + redis: + image: redis:7-alpine + ports: + - "6546:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 3s + deploy: + resources: + limits: + memory: 128m + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: buzz_oss_e2e + MINIO_ROOT_PASSWORD: buzz_oss_e2e_synthetic_secret + ports: + - "9546:9000" + - "9547:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + deploy: + resources: + limits: + memory: 256m + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 buzz_oss_e2e buzz_oss_e2e_synthetic_secret && + mc mb --ignore-existing local/buzz-media && + mc anonymous set none local/buzz-media + " + restart: "no" + +volumes: + postgres-data: + minio-data: diff --git a/docs/testing/oss-only-e2e.md b/docs/testing/oss-only-e2e.md new file mode 100644 index 0000000000..8f18d6e130 --- /dev/null +++ b/docs/testing/oss-only-e2e.md @@ -0,0 +1,73 @@ +# OSS-only authorization and lifecycle playground + +This playground exercises the public provider/evidence seam and the disabled +operator composition root with synthetic data only. It starts isolated +PostgreSQL, Redis, and MinIO services under the `buzz-oss-e2e` Compose project. +It does not register operator routes in the stock relay, grant operator +authority, connect to a private identity system, or use deployment data. + +## Quick start + +```sh +just oss-e2e-setup +just oss-e2e-scenario O501 +just oss-e2e-scenario P01 +just oss-e2e-stop +``` + +`just oss-e2e` runs the complete scenario table. `just oss-e2e-reset` deletes +only the local `buzz-oss-e2e` Compose project's synthetic volumes and starts a +fresh stack. Formal schema setup always uses the embedded SQLx migration chain; +the playground never imports a handwritten test schema. + +Database-backed scenarios fail when PostgreSQL is unavailable. The O5 tests +also assert that exactly 50 gap-free migrations ran and that their scenario +counters are nonzero, so unavailable infrastructure cannot produce a vacuous +green result. + +## Scenarios + +| ID | Expected outcome | +| --- | --- | +| A01 | A current, domain-bound provider decision produces the requested scoped allow snapshot. | +| D01 | Provider unavailability never falls back to allow. | +| D02 | Duplicate or unknown domain configuration is rejected as ambiguous. | +| D03 | Stale and future provider decisions deny. | +| D04 | A proof bound to the wrong authorization domain is rejected before authority I/O or mutation. | +| L01 | A durable principal tombstone blocks first enrollment and re-enrollment. | +| L02 | A protected authorization lease ends at the earliest binding/application expiry. | +| L03 | Rotation/revocation projection retries after restart and publishes one canonical withdrawal. | +| R01 | Restart bootstraps the complete durable invalidation state before readiness. | +| O501 | Explicit authenticated composition reaches list, preview, revoke, and rotate; outbox rollback, ordered retry, quarantine, restoration, and capacity cases execute against PostgreSQL. | +| P01 | Planted token, JWT, issuer, JWKS-body, display-claim, and private-identifier canaries are absent from client errors, tracing, metrics, immutable audit, export bytes, and dead-letter evidence. | + +Run one scenario directly with: + +```sh +scripts/oss-e2e.sh scenario D03 +``` + +## Operator boundary + +The stock binary installs no lifecycle routes. The test-only composition root +requires an explicitly supplied authenticator, capability grant, executor, +clock, pseudonymization key, and operator-reference key. The PostgreSQL route +test sends authenticated synthetic requests through that real composition root +and verifies durable operation receipts and effects. Direct storage calls alone +do not count as route evidence. + +List and preview return redacted opaque references. Revoke and rotate require a +reason, operation and correlation identities, matching intent, a fresh +independent approval, and the expected lifecycle revision. An unavailable +audit store blocks a new allow or operator mutation. A denial remains a denial +and emits a separate bounded control signal. + +## Data and cleanup + +The playground binds only to local high ports: PostgreSQL `5546`, Redis `6546`, +and MinIO `9546`/`9547`. Its credentials are fixed synthetic test strings. Each +O5 PostgreSQL test creates a uniquely named disposable database, applies +migrations `0001` through `0050`, and drops that database after success. + +Use `just oss-e2e-stop` to stop services while retaining synthetic volumes, or +`just oss-e2e-reset` for a destructive reset limited to this Compose project. diff --git a/scripts/oss-e2e.sh b/scripts/oss-e2e.sh new file mode 100755 index 0000000000..30943e1222 --- /dev/null +++ b/scripts/oss-e2e.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compose_file="${repo_root}/docker-compose.oss-e2e.yml" +project="buzz-oss-e2e" +scenario_ids=(A01 D01 D02 D03 D04 L01 L02 L03 R01 O501 P01) + +export DATABASE_URL="postgres://buzz:buzz_oss_e2e@127.0.0.1:5546/buzz" # sadscan:disable np.postgres.1 +export BUZZ_TEST_DATABASE_URL="${DATABASE_URL}" +export REDIS_URL="redis://127.0.0.1:6546" +export S3_ENDPOINT="http://127.0.0.1:9546" +export S3_ACCESS_KEY="buzz_oss_e2e" +export S3_SECRET_KEY="buzz_oss_e2e_synthetic_secret" +export S3_BUCKET="buzz-media" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" +export RUST_TEST_THREADS="${RUST_TEST_THREADS:-2}" + +compose() { + docker compose --project-name "${project}" --file "${compose_file}" "$@" +} + +cargo_test() { + "${repo_root}/bin/cargo" test "$@" +} + +setup() { + compose up --detach --wait postgres redis minio + compose run --rm minio-init + compose ps +} + +run_scenario() { + local scenario_id="${1:?scenario ID is required}" + case "${scenario_id}" in + A01) + cargo_test -p buzz-auth current_allow_returns_request_scoped_snapshot + ;; + D01) + cargo_test -p buzz-auth provider_unavailability_never_falls_back_to_allow + ;; + D02) + cargo_test -p buzz-relay duplicate_or_unknown_domains_fail_closed + ;; + D03) + cargo_test -p buzz-auth stale_and_future_provider_decisions_deny + ;; + D04) + cargo_test -p buzz-auth mismatched_embedded_proof_domain_fails_before_authority_io + ;; + L01) + cargo_test -p buzz-db principal_can_be_disabled_before_first_enrollment -- --ignored + ;; + L02) + cargo_test -p buzz-auth direct_lease_carries_binding_and_earliest_application_expiry + ;; + L03) + cargo_test -p buzz-relay projection_worker_retries_after_restart_and_fans_out_canonical_withdrawal -- --ignored + ;; + R01) + cargo_test -p buzz-relay restart_bootstraps_full_state_before_readiness + ;; + O501) + cargo_test -p buzz-relay --test o5_operator_postgres + cargo_test -p buzz-db postgres_o5_outbox_rollback_delivery_restore_and_capacity_are_non_vacuous + ;; + P01) + cargo_test -p buzz-relay --test o5_operator_surface planted_canaries_never_cross_response_logs_or_metrics + cargo_test -p buzz-db postgres_operator_lifecycle_is_atomic_idempotent_and_serialized + ;; + *) + printf 'unknown scenario: %s\nvalid scenarios: %s\n' \ + "${scenario_id}" "${scenario_ids[*]}" >&2 + return 64 + ;; + esac +} + +usage() { + cat <<'USAGE' +usage: scripts/oss-e2e.sh setup|run|reset|stop|status|scenario ID + +All services, credentials, fixtures, and identifiers are local and synthetic. +The lifecycle operator surface is constructed only inside its explicit tests; +the stock relay router remains unchanged. +USAGE +} + +command_name="${1:-}" +case "${command_name}" in + setup) + setup + ;; + run) + setup + for scenario_id in "${scenario_ids[@]}"; do + printf '\n[oss-e2e] scenario %s\n' "${scenario_id}" + run_scenario "${scenario_id}" + done + ;; + reset) + compose down --volumes --remove-orphans + setup + ;; + stop) + compose down --remove-orphans + ;; + status) + compose ps + ;; + scenario) + setup + run_scenario "${2:-}" + ;; + *) + usage >&2 + exit 64 + ;; +esac From 7e1402bafda85a8ae459c672098a34909a75d9c0 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:43:10 -0500 Subject: [PATCH 06/18] test: add bounded OSS reviewer playground Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- Cargo.lock | 1 + crates/buzz-relay/Cargo.toml | 1 + crates/buzz-relay/tests/oss_only_e2e.rs | 765 ++++++++++++++++++++++++ docs/testing/oss-only-e2e.md | 42 +- scripts/oss-e2e.sh | 318 +++++++++- 5 files changed, 1099 insertions(+), 28 deletions(-) create mode 100644 crates/buzz-relay/tests/oss_only_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index bd276c5f4e..6ac79dcff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1205,6 +1205,7 @@ dependencies = [ "buzz-sdk", "buzz-search", "buzz-workflow", + "buzz-ws-client", "bytes", "chrono", "dashmap", diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index cc0ac4d8ca..3e805b22ba 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -89,6 +89,7 @@ mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74. mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } +buzz-ws-client = { workspace = true } reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs new file mode 100644 index 0000000000..7ebaee6224 --- /dev/null +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -0,0 +1,765 @@ +//! Live OSS-only topology proof. +//! +//! The repository wrapper starts two stock relay processes with one formal +//! SQLx database, Redis, and MinIO. These ignored tests then drive real +//! WebSocket, HTTP, media, Git, and audio clients. Operator routes are not +//! registered by either relay; their explicit composition is covered by the +//! separate O5 operator tests. + +use std::{ + path::{Path, PathBuf}, + process::{Command, Output}, + time::Duration, +}; + +use anyhow::{bail, ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_ws_client::{build_auth_event, parse_relay_message, RelayMessage}; +use futures_util::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp, ToBech32}; +use reqwest::{header, Client}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use sqlx::postgres::PgPoolOptions; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + connect_async, + tungstenite::{ + client::IntoClientRequest, + http::{header::HOST as WS_HOST, HeaderValue as WsHeaderValue}, + Message, + }, + MaybeTlsStream, WebSocketStream, +}; +use uuid::Uuid; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); + +type WsStream = WebSocketStream>; + +#[derive(Clone)] +struct LiveConfig { + database_url: String, + relay_identity: String, + relay_a_ws: String, + relay_b_ws: String, + relay_a_http: String, + relay_b_http: String, + relay_a_metrics: String, + relay_b_metrics: String, + tenant_host: String, + relay_a_log: PathBuf, + relay_b_log: PathBuf, + git_helper: PathBuf, + restart_state: PathBuf, +} + +impl LiveConfig { + fn from_env() -> Result { + let relay_identity = env_or("OSS_E2E_RELAY_IDENTITY", "ws://127.0.0.1:3301"); + Ok(Self { + database_url: required_env("BUZZ_TEST_DATABASE_URL")?, + relay_a_ws: env_or("OSS_E2E_RELAY_A_WS", "ws://127.0.0.1:3301"), + relay_b_ws: env_or("OSS_E2E_RELAY_B_WS", "ws://127.0.0.1:3302"), + relay_a_http: env_or("OSS_E2E_RELAY_A_HTTP", "http://127.0.0.1:3301"), + relay_b_http: env_or("OSS_E2E_RELAY_B_HTTP", "http://127.0.0.1:3302"), + relay_a_metrics: env_or("OSS_E2E_RELAY_A_METRICS", "http://127.0.0.1:9301/metrics"), + relay_b_metrics: env_or("OSS_E2E_RELAY_B_METRICS", "http://127.0.0.1:9302/metrics"), + tenant_host: env_or("OSS_E2E_TENANT_HOST", "127.0.0.1:3301"), + relay_a_log: PathBuf::from(required_env("OSS_E2E_RELAY_A_LOG")?), + relay_b_log: PathBuf::from(required_env("OSS_E2E_RELAY_B_LOG")?), + git_helper: PathBuf::from(required_env("GIT_CREDENTIAL_NOSTR_BIN")?), + restart_state: PathBuf::from(required_env("OSS_E2E_RESTART_STATE")?), + relay_identity, + }) + } +} + +fn env_or(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.to_owned()) +} + +fn required_env(name: &str) -> Result { + std::env::var(name).with_context(|| format!("{name} is required for the live OSS E2E gate")) +} + +struct RelaySocket { + inner: WsStream, +} + +impl RelaySocket { + async fn connect( + bind_url: &str, + tenant_host: &str, + relay_identity: &str, + keys: &Keys, + ) -> Result { + let mut request = bind_url + .into_client_request() + .context("construct relay WebSocket request")?; + request.headers_mut().insert( + WS_HOST, + WsHeaderValue::from_str(tenant_host).context("construct tenant Host header")?, + ); + let (inner, response) = connect_async(request) + .await + .with_context(|| format!("connect to live relay {bind_url}"))?; + ensure!( + response.status().as_u16() == 101, + "live relay WebSocket upgrade returned {}", + response.status() + ); + let mut socket = Self { inner }; + let challenge = socket.wait_for_challenge().await?; + let auth = build_auth_event(&challenge, relay_identity, keys, None) + .context("build NIP-42 authentication event")?; + let auth_id = auth.id.to_hex(); + socket.send_json(&json!(["AUTH", auth])).await?; + let accepted = socket.wait_for_ok(&auth_id).await?; + ensure!( + accepted, + "live relay rejected synthetic NIP-42 authentication" + ); + Ok(socket) + } + + async fn send_json(&mut self, value: &Value) -> Result<()> { + self.inner + .send(Message::Text(value.to_string().into())) + .await + .context("send live relay WebSocket JSON") + } + + async fn send_event(&mut self, event: &Event) -> Result<()> { + let event_id = event.id.to_hex(); + self.send_json(&json!(["EVENT", event])).await?; + ensure!( + self.wait_for_ok(&event_id).await?, + "live relay rejected event {event_id}" + ); + Ok(()) + } + + async fn subscribe_channel( + &mut self, + subscription_id: &str, + channel_id: Uuid, + kind: u16, + ) -> Result<()> { + self.send_json(&json!([ + "REQ", + subscription_id, + {"kinds": [kind], "#h": [channel_id.to_string()]} + ])) + .await?; + self.wait_for_eose(subscription_id).await?; + // Topic retention is demand-driven and the Redis PSUBSCRIBE command is + // asynchronous. Match the repository's Redis round-trip proof by + // giving that acknowledgement one bounded scheduling window. + tokio::time::sleep(Duration::from_millis(200)).await; + Ok(()) + } + + async fn wait_for_challenge(&mut self) -> Result { + loop { + match self.next_message(Duration::from_secs(20)).await? { + RelayMessage::Auth { challenge } => return Ok(challenge), + RelayMessage::Notice { message } => bail!("relay notice before auth: {message}"), + _ => {} + } + } + } + + async fn wait_for_ok(&mut self, event_id: &str) -> Result { + loop { + if let RelayMessage::Ok(ok) = self.next_message(Duration::from_secs(30)).await? { + if ok.event_id == event_id { + if !ok.accepted { + bail!("relay rejected {event_id}: {}", ok.message); + } + return Ok(true); + } + } + } + } + + async fn wait_for_eose(&mut self, subscription_id: &str) -> Result<()> { + loop { + match self.next_message(Duration::from_secs(20)).await? { + RelayMessage::Eose { + subscription_id: observed, + } if observed == subscription_id => return Ok(()), + RelayMessage::Closed { + subscription_id: observed, + message, + } if observed == subscription_id => { + bail!("subscription {subscription_id} closed: {message}") + } + _ => {} + } + } + } + + async fn wait_for_event(&mut self, subscription_id: &str, event_id: &str) -> Result<()> { + loop { + match self.next_message(Duration::from_secs(30)).await? { + RelayMessage::Event { + subscription_id: observed, + event, + } if observed == subscription_id && event.id.to_hex() == event_id => return Ok(()), + RelayMessage::Closed { + subscription_id: observed, + message, + } if observed == subscription_id => { + bail!("subscription {subscription_id} closed: {message}") + } + _ => {} + } + } + } + + async fn next_message(&mut self, wait: Duration) -> Result { + let deadline = tokio::time::Instant::now() + wait; + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .context("timed out waiting for live relay message")?; + let message = tokio::time::timeout(remaining, self.inner.next()) + .await + .context("timed out waiting for live relay message")? + .context("live relay closed WebSocket")? + .context("read live relay WebSocket message")?; + match message { + Message::Text(text) => { + return parse_relay_message(&text).context("parse live relay message") + } + Message::Ping(bytes) => self + .inner + .send(Message::Pong(bytes)) + .await + .context("send live relay pong")?, + Message::Close(frame) => bail!("live relay closed WebSocket: {frame:?}"), + _ => {} + } + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct RestartState { + channel_id: Uuid, + event_id: String, +} + +struct LiveScenario { + owner: Keys, + channel_id: Uuid, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires the repository-managed two-relay OSS topology"] +async fn live_two_relay_clients_and_migrations() { + let config = LiveConfig::from_env().expect("load live OSS E2E configuration"); + verify_exact_migration_chain(&config) + .await + .expect("M01 exact SQLx migration chain"); + let scenario = websocket_http_fanout(&config) + .await + .expect("A01 real WebSocket/HTTP cross-relay fan-out"); + media_roundtrip(&config, &scenario.owner) + .await + .expect("M02 real media client through shared object storage"); + git_roundtrip(&config, &scenario) + .await + .expect("G01 real Git client through relay transport"); + audio_roundtrip(&config, &scenario) + .await + .expect("AU01 real audio clients exchange a v2 frame"); + runtime_canaries_are_absent(&config) + .await + .expect("P01 runtime logs, errors, and metrics redact planted canaries"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires relay B to have been restarted by the repository wrapper"] +async fn restarted_relay_restores_persisted_event() { + let config = LiveConfig::from_env().expect("load live OSS E2E configuration"); + let state: RestartState = serde_json::from_slice( + &std::fs::read(&config.restart_state).expect("read pre-restart state"), + ) + .expect("parse pre-restart state"); + let keys = Keys::generate(); + let mut relay_b = RelaySocket::connect( + &config.relay_b_ws, + &config.tenant_host, + &config.relay_identity, + &keys, + ) + .await + .expect("connect to restarted relay B"); + let subscription_id = "oss-restart-persistence"; + relay_b + .send_json(&json!([ + "REQ", + subscription_id, + {"kinds": [1], "#h": [state.channel_id.to_string()]} + ])) + .await + .expect("query restarted relay"); + relay_b + .wait_for_event(subscription_id, &state.event_id) + .await + .expect("R01 restarted relay returns the persisted event"); +} + +async fn verify_exact_migration_chain(config: &LiveConfig) -> Result<()> { + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&config.database_url) + .await + .context("connect to live OSS PostgreSQL")?; + let embedded = MIGRATOR.iter().collect::>(); + ensure!( + embedded.len() == 50, + "embedded migrator must contain 50 entries" + ); + ensure!( + embedded + .iter() + .enumerate() + .all(|(index, migration)| migration.version == (index + 1) as i64), + "embedded migrations must be gap-free from 0001 through 0050" + ); + let applied = sqlx::query_as::<_, (i64, Vec, bool)>( + "SELECT version, checksum, success FROM _sqlx_migrations ORDER BY version", + ) + .fetch_all(&pool) + .await + .context("read applied SQLx migrations")?; + ensure!( + applied.len() == embedded.len(), + "live database applied {} migrations, expected {}", + applied.len(), + embedded.len() + ); + for ((version, checksum, success), expected) in applied.iter().zip(embedded) { + ensure!(*success, "migration {version:04} is not successful"); + ensure!( + *version == expected.version, + "migration version mismatch: {version} != {}", + expected.version + ); + ensure!( + checksum.as_slice() == expected.checksum.as_ref(), + "migration {version:04} checksum differs from the embedded SQLx chain" + ); + } + pool.close().await; + Ok(()) +} + +async fn websocket_http_fanout(config: &LiveConfig) -> Result { + let owner = Keys::generate(); + let channel_id = Uuid::new_v4(); + let channel = EventBuilder::new(Kind::Custom(9007), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).context("channel h tag")?, + Tag::parse(["name", "OSS E2E fan-out"]).context("channel name tag")?, + Tag::parse(["channel_type", "stream"]).context("channel type tag")?, + Tag::parse(["visibility", "open"]).context("channel visibility tag")?, + ]) + .sign_with_keys(&owner) + .context("sign synthetic channel event")?; + + let mut relay_a = RelaySocket::connect( + &config.relay_a_ws, + &config.tenant_host, + &config.relay_identity, + &owner, + ) + .await?; + relay_a.send_event(&channel).await?; + + let mut relay_b = RelaySocket::connect( + &config.relay_b_ws, + &config.tenant_host, + &config.relay_identity, + &owner, + ) + .await?; + let subscription_id = "oss-live-fanout"; + relay_b + .subscribe_channel(subscription_id, channel_id, 1) + .await?; + let event = EventBuilder::new(Kind::TextNote, "synthetic cross-relay fan-out") + .tags([Tag::parse(["h", &channel_id.to_string()]).context("message h tag")?]) + .sign_with_keys(&owner) + .context("sign synthetic fan-out event")?; + let event_id = event.id.to_hex(); + relay_a.send_event(&event).await?; + relay_b.wait_for_event(subscription_id, &event_id).await?; + + let restart_state = RestartState { + channel_id, + event_id: event_id.clone(), + }; + std::fs::write( + &config.restart_state, + serde_json::to_vec_pretty(&restart_state).context("encode restart state")?, + ) + .context("write bounded synthetic restart state")?; + Ok(LiveScenario { owner, channel_id }) +} + +async fn post_event(config: &LiveConfig, event: &Event) -> Result<()> { + let response = Client::new() + .post(format!("{}/events", config.relay_a_http)) + .header(header::HOST, &config.tenant_host) + .header("x-pubkey", event.pubkey.to_hex()) + .header(header::CONTENT_TYPE, "application/json") + .json(event) + .send() + .await + .context("POST event through the live HTTP bridge")?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + ensure!( + status.is_success(), + "HTTP event bridge returned {status}: {body}" + ); + Ok(()) +} + +async fn media_roundtrip(config: &LiveConfig, keys: &Keys) -> Result<()> { + let bytes = STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + .context("decode synthetic one-pixel PNG")?; + let digest = hex::encode(Sha256::digest(&bytes)); + let expiration = (Timestamp::now().as_secs() + 300).to_string(); + let auth = EventBuilder::new(Kind::Custom(24242), "synthetic OSS upload") + .tags([ + Tag::parse(["t", "upload"]).context("media action tag")?, + Tag::parse(["x", &digest]).context("media digest tag")?, + Tag::parse(["expiration", &expiration]).context("media expiration tag")?, + ]) + .sign_with_keys(keys) + .context("sign synthetic media authorization")?; + let authorization = format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(auth.as_json().as_bytes()) + ); + let client = Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .context("build media client")?; + let upload = client + .put(format!("{}/upload", config.relay_a_http)) + .header(header::HOST, &config.tenant_host) + .header(header::AUTHORIZATION, authorization) + .header(header::CONTENT_TYPE, "image/png") + .header("x-sha-256", &digest) + .body(bytes.clone()) + .send() + .await + .context("upload synthetic media through relay A")?; + let upload_status = upload.status(); + let upload_body = upload.text().await.unwrap_or_default(); + ensure!( + upload_status.is_success(), + "media upload returned {upload_status}: {upload_body}" + ); + let descriptor: Value = serde_json::from_str(&upload_body).context("parse media descriptor")?; + ensure!( + descriptor["sha256"] == digest, + "media descriptor digest mismatch" + ); + + let download = client + .get(format!("{}/media/{digest}.png", config.relay_b_http)) + .header(header::HOST, &config.tenant_host) + .send() + .await + .context("download shared media through relay B")?; + let download_status = download.status(); + let downloaded = download.bytes().await.context("read downloaded media")?; + ensure!( + download_status.is_success(), + "relay B media download returned {download_status}" + ); + ensure!( + downloaded.as_ref() == bytes, + "downloaded media bytes differ" + ); + Ok(()) +} + +async fn git_roundtrip(config: &LiveConfig, scenario: &LiveScenario) -> Result<()> { + ensure!( + config.git_helper.is_file(), + "git credential helper is missing at {}", + config.git_helper.display() + ); + let repository = format!("oss-e2e-{}", Uuid::new_v4().simple()); + let announcement = EventBuilder::new(Kind::Custom(30617), "") + .tags([ + Tag::parse(["d", &repository]).context("repository d tag")?, + Tag::parse(["name", "OSS E2E repository"]).context("repository name tag")?, + Tag::parse(["buzz-channel", &scenario.channel_id.to_string()]) + .context("repository channel tag")?, + ]) + .sign_with_keys(&scenario.owner) + .context("sign synthetic repository announcement")?; + post_event(config, &announcement).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + + let temporary = tempfile::tempdir().context("create synthetic Git workspace")?; + let owner_hex = scenario.owner.public_key().to_hex(); + let owner_nsec = scenario + .owner + .secret_key() + .to_bech32() + .context("encode synthetic Git key")?; + let remote = format!("{}/git/{owner_hex}/{repository}", config.relay_a_http); + run_git( + config, + temporary.path(), + &owner_nsec, + &["clone", "--quiet", &remote, "writer"], + )?; + let writer = temporary.path().join("writer"); + std::fs::write(writer.join("README.md"), "synthetic OSS E2E\n") + .context("write synthetic Git fixture")?; + run_git(config, &writer, &owner_nsec, &["add", "README.md"])?; + run_git( + config, + &writer, + &owner_nsec, + &["commit", "--quiet", "-m", "synthetic fixture"], + )?; + run_git(config, &writer, &owner_nsec, &["branch", "-M", "main"])?; + run_git( + config, + &writer, + &owner_nsec, + &["push", "--quiet", "origin", "main"], + )?; + run_git( + config, + temporary.path(), + &owner_nsec, + &["clone", "--quiet", &remote, "reader"], + )?; + let observed = std::fs::read_to_string(temporary.path().join("reader/README.md")) + .context("read cloned synthetic Git fixture")?; + ensure!( + observed == "synthetic OSS E2E\n", + "Git clone content mismatch" + ); + Ok(()) +} + +fn run_git(config: &LiveConfig, cwd: &Path, nsec: &str, args: &[&str]) -> Result { + let output = Command::new("git") + .args([ + "-c", + "credential.useHttpPath=true", + "-c", + &format!("credential.helper={}", config.git_helper.display()), + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + "-c", + "user.name=OSS E2E", + "-c", + "user.email=oss-e2e@example.invalid", + ]) + .args(args) + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env_remove("GIT_CONFIG_COUNT") + .env("NOSTR_PRIVATE_KEY", nsec) + .output() + .with_context(|| format!("run synthetic git {args:?}"))?; + ensure!( + output.status.success(), + "git {args:?} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(output) +} + +struct AudioSocket { + inner: WsStream, +} + +impl AudioSocket { + async fn connect(config: &LiveConfig, channel_id: Uuid, keys: &Keys) -> Result { + let url = format!("{}/huddle/{channel_id}/audio", config.relay_a_ws); + let mut request = url + .into_client_request() + .context("construct audio request")?; + request.headers_mut().insert( + WS_HOST, + WsHeaderValue::from_str(&config.tenant_host).context("construct audio Host header")?, + ); + let (inner, _) = connect_async(request) + .await + .context("connect real audio client")?; + let mut socket = Self { inner }; + let challenge = loop { + let message = socket.next(Duration::from_secs(10)).await?; + if let Message::Text(text) = message { + let value: Value = serde_json::from_str(&text).context("parse audio challenge")?; + if value["type"] == "challenge" { + break value["challenge"] + .as_str() + .context("audio challenge string")? + .to_owned(); + } + } + }; + let auth = build_auth_event(&challenge, &config.relay_identity, keys, None) + .context("build audio NIP-42 event")?; + socket + .inner + .send(Message::Text( + json!({ + "type": "auth", + "event": auth, + "parent_channel_id": null, + "protocol_version": 2 + }) + .to_string() + .into(), + )) + .await + .context("send audio authentication")?; + loop { + if let Message::Text(text) = socket.next(Duration::from_secs(10)).await? { + let value: Value = serde_json::from_str(&text).context("parse audio join")?; + match value["type"].as_str() { + Some("joined") => return Ok(socket), + Some("error") => bail!("audio join rejected: {}", value["message"]), + _ => {} + } + } + } + } + + async fn next(&mut self, wait: Duration) -> Result { + let deadline = tokio::time::Instant::now() + wait; + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .context("timed out while servicing audio relay control frames")?; + let message = tokio::time::timeout(remaining, self.inner.next()) + .await + .context("timed out waiting for audio relay")? + .context("audio relay closed connection")? + .context("read audio relay frame")?; + match message { + Message::Ping(bytes) => self + .inner + .send(Message::Pong(bytes)) + .await + .context("send audio pong")?, + other => return Ok(other), + } + } + } +} + +async fn audio_roundtrip(config: &LiveConfig, scenario: &LiveScenario) -> Result<()> { + let peer = Keys::generate(); + let mut sender = AudioSocket::connect(config, scenario.channel_id, &scenario.owner).await?; + let mut receiver = AudioSocket::connect(config, scenario.channel_id, &peer).await?; + let frame = vec![0x00, 0x01, 0x00, 0x00, 0x03, 0xC0, 0xF0, 0x00, 0xF8, 0xFF]; + sender + .inner + .send(Message::Binary(frame.clone().into())) + .await + .context("send synthetic v2 audio frame")?; + loop { + if let Message::Binary(observed) = receiver.next(Duration::from_secs(10)).await? { + ensure!( + observed.len() == frame.len() + 1, + "audio relay must prepend exactly one peer-index byte" + ); + ensure!( + &observed[1..] == frame.as_slice(), + "audio relay altered the synthetic frame" + ); + break; + } + } + sender + .inner + .close(None) + .await + .context("close audio sender")?; + receiver + .inner + .close(None) + .await + .context("close audio receiver")?; + Ok(()) +} + +async fn runtime_canaries_are_absent(config: &LiveConfig) -> Result<()> { + let canaries = [ + "oss-e2e-bearer-canary-7f36", + "oss-e2e-jwt-canary-58aa", + "oss-e2e-private-claim-canary-91cd", + ]; + let response = Client::new() + .put(format!("{}/upload", config.relay_a_http)) + .header(header::HOST, &config.tenant_host) + .header(header::AUTHORIZATION, format!("Bearer {}", canaries[0])) + .header("x-forwarded-identity-token", canaries[1]) + .header("x-synthetic-private-claim", canaries[2]) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body("synthetic unauthorized body") + .send() + .await + .context("plant runtime redaction canaries")?; + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + ensure!( + status.is_client_error(), + "invalid media authorization unexpectedly returned {status}" + ); + + let metrics_a = Client::new() + .get(&config.relay_a_metrics) + .send() + .await + .context("read relay A metrics")? + .text() + .await + .context("read relay A metrics body")?; + let metrics_b = Client::new() + .get(&config.relay_b_metrics) + .send() + .await + .context("read relay B metrics")? + .text() + .await + .context("read relay B metrics body")?; + tokio::time::sleep(Duration::from_millis(200)).await; + let logs_a = std::fs::read_to_string(&config.relay_a_log) + .with_context(|| format!("read {}", config.relay_a_log.display()))?; + let logs_b = std::fs::read_to_string(&config.relay_b_log) + .with_context(|| format!("read {}", config.relay_b_log.display()))?; + let surfaces = [error_body, metrics_a, metrics_b, logs_a, logs_b]; + for canary in canaries { + ensure!( + surfaces.iter().all(|surface| !surface.contains(canary)), + "planted private canary crossed a runtime log, error, or metric boundary" + ); + } + Ok(()) +} diff --git a/docs/testing/oss-only-e2e.md b/docs/testing/oss-only-e2e.md index 8f18d6e130..e58e7d924b 100644 --- a/docs/testing/oss-only-e2e.md +++ b/docs/testing/oss-only-e2e.md @@ -2,29 +2,42 @@ This playground exercises the public provider/evidence seam and the disabled operator composition root with synthetic data only. It starts isolated -PostgreSQL, Redis, and MinIO services under the `buzz-oss-e2e` Compose project. -It does not register operator routes in the stock relay, grant operator -authority, connect to a private identity system, or use deployment data. +PostgreSQL, Redis, and MinIO services under the `buzz-oss-e2e` Compose project, +then builds and starts two real stock relay processes on loopback ports. It does +not register operator routes in either relay, grant operator authority, connect +to a private identity system, or use deployment data. ## Quick start ```sh just oss-e2e-setup +scripts/oss-e2e.sh scenario TOPOLOGY just oss-e2e-scenario O501 just oss-e2e-scenario P01 just oss-e2e-stop ``` -`just oss-e2e` runs the complete scenario table. `just oss-e2e-reset` deletes -only the local `buzz-oss-e2e` Compose project's synthetic volumes and starts a -fresh stack. Formal schema setup always uses the embedded SQLx migration chain; -the playground never imports a handwritten test schema. +`just oss-e2e` runs the live topology and the complete focused scenario table, +writes a bounded JSON scenario summary under the process-specific temporary +state directory, and stops every process it started. `just oss-e2e-reset` +deletes only the local `buzz-oss-e2e` Compose project's synthetic volumes and +starts a fresh stack. Formal schema setup always uses the embedded SQLx +migration chain; the playground never imports a handwritten test schema. Database-backed scenarios fail when PostgreSQL is unavailable. The O5 tests also assert that exactly 50 gap-free migrations ran and that their scenario counters are nonzero, so unavailable infrastructure cannot produce a vacuous green result. +The `TOPOLOGY` scenario is a real process/network gate, not a list of unit-test +aliases. It verifies all 50 applied SQLx checksums against the embedded +migrator, publishes over relay A while a WebSocket client on relay B observes +Redis fan-out, uploads media through A and downloads it through B, performs a +real Git clone/push/clone round trip, exchanges a v2 audio frame between two +clients, restarts relay B, and queries the persisted event from the restarted +process. It also plants synthetic secret canaries and scans public errors, +metrics, and both relay logs for disclosure. + ## Scenarios | ID | Expected outcome | @@ -41,6 +54,10 @@ green result. | O501 | Explicit authenticated composition reaches list, preview, revoke, and rotate; outbox rollback, ordered retry, quarantine, restoration, and capacity cases execute against PostgreSQL. | | P01 | Planted token, JWT, issuer, JWKS-body, display-claim, and private-identifier canaries are absent from client errors, tracing, metrics, immutable audit, export bytes, and dead-letter evidence. | +The machine-readable topology summary also names `M01` (migration checksums), +`H01` (HTTP/media), `G01` (Git), and `AU01` (audio) so a consumer can distinguish +the live client surfaces from the focused contract cards above. + Run one scenario directly with: ```sh @@ -64,10 +81,13 @@ and emits a separate bounded control signal. ## Data and cleanup -The playground binds only to local high ports: PostgreSQL `5546`, Redis `6546`, -and MinIO `9546`/`9547`. Its credentials are fixed synthetic test strings. Each -O5 PostgreSQL test creates a uniquely named disposable database, applies -migrations `0001` through `0050`, and drops that database after success. +The playground binds only to local high ports: relay A `3301`, relay B `3302`, +health `8301`/`8302`, metrics `9301`/`9302`, PostgreSQL `5546`, Redis `6546`, and +MinIO `9546`/`9547`. Its credentials are fixed synthetic test strings. Each O5 +PostgreSQL test creates a uniquely named disposable database, applies migrations +`0001` through `0050`, and drops that database after success. The live topology +uses the Compose project's synthetic `buzz` database and verifies every applied +checksum before driving clients. Use `just oss-e2e-stop` to stop services while retaining synthetic volumes, or `just oss-e2e-reset` for a destructive reset limited to this Compose project. diff --git a/scripts/oss-e2e.sh b/scripts/oss-e2e.sh index 30943e1222..1453caffaf 100755 --- a/scripts/oss-e2e.sh +++ b/scripts/oss-e2e.sh @@ -4,17 +4,39 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" compose_file="${repo_root}/docker-compose.oss-e2e.yml" project="buzz-oss-e2e" +state_dir="${TMPDIR:-/tmp}/buzz-oss-e2e-${UID}" +relay_bin="${repo_root}/target/debug/buzz-relay" +git_helper="${repo_root}/target/debug/git-credential-nostr" +relay_identity="ws://127.0.0.1:3301" +tenant_host="127.0.0.1:3301" scenario_ids=(A01 D01 D02 D03 D04 L01 L02 L03 R01 O501 P01) +completed_scenarios=() +failed_scenario="" export DATABASE_URL="postgres://buzz:buzz_oss_e2e@127.0.0.1:5546/buzz" # sadscan:disable np.postgres.1 export BUZZ_TEST_DATABASE_URL="${DATABASE_URL}" export REDIS_URL="redis://127.0.0.1:6546" -export S3_ENDPOINT="http://127.0.0.1:9546" -export S3_ACCESS_KEY="buzz_oss_e2e" -export S3_SECRET_KEY="buzz_oss_e2e_synthetic_secret" -export S3_BUCKET="buzz-media" +export BUZZ_S3_ENDPOINT="http://127.0.0.1:9546" +export BUZZ_S3_ACCESS_KEY="buzz_oss_e2e" +export BUZZ_S3_SECRET_KEY="buzz_oss_e2e_synthetic_secret" +export BUZZ_S3_BUCKET="buzz-media" +export BUZZ_S3_REGION="us-east-1" +export BUZZ_S3_ADDRESSING_STYLE="path" export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" export RUST_TEST_THREADS="${RUST_TEST_THREADS:-2}" +export OSS_E2E_RELAY_IDENTITY="${relay_identity}" +export OSS_E2E_RELAY_A_WS="ws://127.0.0.1:3301" +export OSS_E2E_RELAY_B_WS="ws://127.0.0.1:3302" +export OSS_E2E_RELAY_A_HTTP="http://127.0.0.1:3301" +export OSS_E2E_RELAY_B_HTTP="http://127.0.0.1:3302" +export OSS_E2E_RELAY_A_METRICS="http://127.0.0.1:9301/metrics" +export OSS_E2E_RELAY_B_METRICS="http://127.0.0.1:9302/metrics" +export OSS_E2E_TENANT_HOST="${tenant_host}" +export OSS_E2E_RELAY_A_LOG="${state_dir}/relay-a.log" +export OSS_E2E_RELAY_B_LOG="${state_dir}/relay-b.log" +export OSS_E2E_RESTART_STATE="${state_dir}/restart-state.json" +export OSS_E2E_SUMMARY="${state_dir}/summary.json" +export GIT_CREDENTIAL_NOSTR_BIN="${git_helper}" compose() { docker compose --project-name "${project}" --file "${compose_file}" "$@" @@ -24,10 +46,232 @@ cargo_test() { "${repo_root}/bin/cargo" test "$@" } -setup() { +pid_file() { + printf '%s/%s.pid' "${state_dir}" "${1:?relay name is required}" +} + +relay_log() { + printf '%s/%s.log' "${state_dir}" "${1:?relay name is required}" +} + +relay_command() { + ps -p "${1:?pid is required}" -o command= +} + +stop_relay() { + local relay_name="${1:?relay name is required}" + local file + file="$(pid_file "${relay_name}")" + if [[ ! -f "${file}" ]]; then + return 0 + fi + local relay_pid + relay_pid="$(<"${file}")" + if [[ ! "${relay_pid}" =~ ^[0-9]+$ ]]; then + printf 'refusing ambiguous %s pid file: %s\n' "${relay_name}" "${file}" >&2 + return 1 + fi + if ! kill -0 "${relay_pid}" 2>/dev/null; then + rm -f "${file}" + return 0 + fi + local command_line + command_line="$(relay_command "${relay_pid}")" + if [[ "${command_line}" != *"${relay_bin}"* ]]; then + printf 'refusing to stop unowned pid %s for %s: %s\n' \ + "${relay_pid}" "${relay_name}" "${command_line}" >&2 + return 1 + fi + kill "${relay_pid}" + local attempt + for attempt in $(seq 1 40); do + if ! kill -0 "${relay_pid}" 2>/dev/null; then + rm -f "${file}" + return 0 + fi + sleep 0.25 + done + printf 'owned %s pid %s did not stop after SIGTERM\n' "${relay_name}" "${relay_pid}" >&2 + return 1 +} + +wait_readiness() { + local relay_name="${1:?relay name is required}" + local health_port="${2:?health port is required}" + local file + file="$(pid_file "${relay_name}")" + local attempt + for attempt in $(seq 1 90); do + local relay_pid + relay_pid="$(<"${file}")" + if ! kill -0 "${relay_pid}" 2>/dev/null; then + printf '%s exited before readiness\n' "${relay_name}" >&2 + tail -n 120 "$(relay_log "${relay_name}")" >&2 + return 1 + fi + local status_code + status_code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${health_port}/_readiness" || true)" + if [[ "${status_code}" == "200" ]]; then + return 0 + fi + sleep 1 + done + printf '%s did not become ready\n' "${relay_name}" >&2 + tail -n 120 "$(relay_log "${relay_name}")" >&2 + return 1 +} + +start_relay() { + local relay_name="${1:?relay name is required}" + local bind_port="${2:?bind port is required}" + local health_port="${3:?health port is required}" + local metrics_port="${4:?metrics port is required}" + local auto_migrate="${5:?auto migrate flag is required}" + local file + file="$(pid_file "${relay_name}")" + if [[ -f "${file}" ]]; then + printf '%s already has a pid file; run stop before start\n' "${relay_name}" >&2 + return 1 + fi + nohup env \ + DATABASE_URL="${DATABASE_URL}" \ + REDIS_URL="${REDIS_URL}" \ + RELAY_URL="${relay_identity}" \ + BUZZ_BIND_ADDR="127.0.0.1:${bind_port}" \ + BUZZ_HEALTH_PORT="${health_port}" \ + BUZZ_METRICS_PORT="${metrics_port}" \ + BUZZ_AUTO_MIGRATE="${auto_migrate}" \ + BUZZ_DB_POOL_SIZE=8 \ + BUZZ_REDIS_POOL_SIZE=8 \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_REQUIRE_RELAY_MEMBERSHIP=false \ + BUZZ_HUDDLE_AUDIO_AVAILABLE=true \ + BUZZ_USAGE_METRICS_PER_COMMUNITY=off \ + BUZZ_MESH=off \ + BUZZ_S3_ENDPOINT="${BUZZ_S3_ENDPOINT}" \ + BUZZ_S3_ACCESS_KEY="${BUZZ_S3_ACCESS_KEY}" \ + BUZZ_S3_SECRET_KEY="${BUZZ_S3_SECRET_KEY}" \ + BUZZ_S3_BUCKET="${BUZZ_S3_BUCKET}" \ + BUZZ_S3_REGION="${BUZZ_S3_REGION}" \ + BUZZ_S3_ADDRESSING_STYLE="${BUZZ_S3_ADDRESSING_STYLE}" \ + BUZZ_MEDIA_BASE_URL="http://127.0.0.1:${bind_port}/media" \ + RUST_LOG=buzz_relay=info \ + "${relay_bin}" >>"$(relay_log "${relay_name}")" 2>&1 & + local relay_pid=$! + printf '%s\n' "${relay_pid}" >"${file}" + wait_readiness "${relay_name}" "${health_port}" +} + +build_binaries() { + "${repo_root}/bin/cargo" build -p buzz-relay -p git-credential-nostr + [[ -x "${relay_bin}" ]] + [[ -x "${git_helper}" ]] +} + +setup_dependencies() { compose up --detach --wait postgres redis minio compose run --rm minio-init +} + +setup() { + mkdir -p "${state_dir}" + setup_dependencies + build_binaries + stop_relay relay-b + stop_relay relay-a + : >"${OSS_E2E_RELAY_A_LOG}" + : >"${OSS_E2E_RELAY_B_LOG}" + rm -f "${OSS_E2E_RESTART_STATE}" "${OSS_E2E_SUMMARY}" + start_relay relay-a 3301 8301 9301 true + start_relay relay-b 3302 8302 9302 false + status +} + +restart_relay_b() { + stop_relay relay-b + start_relay relay-b 3302 8302 9302 false +} + +status_relay() { + local relay_name="${1:?relay name is required}" + local file + file="$(pid_file "${relay_name}")" + if [[ ! -f "${file}" ]]; then + printf '%s: stopped\n' "${relay_name}" + return 0 + fi + local relay_pid + relay_pid="$(<"${file}")" + if kill -0 "${relay_pid}" 2>/dev/null; then + printf '%s: running pid=%s\n' "${relay_name}" "${relay_pid}" + else + printf '%s: stale pid=%s\n' "${relay_name}" "${relay_pid}" + return 1 + fi +} + +status() { compose ps + status_relay relay-a + status_relay relay-b +} + +stop() { + stop_relay relay-b + stop_relay relay-a + compose down --remove-orphans +} + +cleanup_after_run() { + local command_rc=$? + trap - EXIT + stop_relay relay-b || command_rc=$? + stop_relay relay-a || command_rc=$? + compose down --remove-orphans || command_rc=$? + exit "${command_rc}" +} + +write_summary() { + local overall="${1:?overall result is required}" + local temporary="${OSS_E2E_SUMMARY}.tmp" + local head + head="$(git -C "${repo_root}" rev-parse HEAD)" + { + printf '{\n' + printf ' "schema": "buzz.v1.oss-only-e2e-summary.v1",\n' + printf ' "source_head": "%s",\n' "${head}" + printf ' "overall": "%s",\n' "${overall}" + printf ' "executed_scenario_count": %s,\n' "${#completed_scenarios[@]}" + printf ' "scenarios": [' + local separator="" + local scenario_id + for scenario_id in "${completed_scenarios[@]}"; do + printf '%s{"id":"%s","status":"PASS"}' "${separator}" "${scenario_id}" + separator="," + done + if [[ -n "${failed_scenario}" ]]; then + printf '%s{"id":"%s","status":"FAIL"}' "${separator}" "${failed_scenario}" + fi + printf ']\n' + printf '}\n' + } >"${temporary}" + mv "${temporary}" "${OSS_E2E_SUMMARY}" +} + +run_live_topology() { + if ! cargo_test -p buzz-relay --test oss_only_e2e \ + live_two_relay_clients_and_migrations -- --ignored --exact --nocapture; then + failed_scenario="TOPOLOGY_PRE_RESTART" + return 1 + fi + completed_scenarios+=(M01 A01 H01 G01 AU01 P01) + restart_relay_b + if ! cargo_test -p buzz-relay --test oss_only_e2e \ + restarted_relay_restores_persisted_event -- --ignored --exact --nocapture; then + failed_scenario="R01" + return 1 + fi + completed_scenarios+=(R01) } run_scenario() { @@ -69,20 +313,63 @@ run_scenario() { cargo_test -p buzz-db postgres_operator_lifecycle_is_atomic_idempotent_and_serialized ;; *) - printf 'unknown scenario: %s\nvalid scenarios: %s\n' \ + printf 'unknown scenario: %s\nvalid scenarios: TOPOLOGY %s\n' \ "${scenario_id}" "${scenario_ids[*]}" >&2 return 64 ;; esac } +run_all() { + setup + trap cleanup_after_run EXIT + if ! run_live_topology; then + write_summary FAIL + return 1 + fi + local scenario_id + for scenario_id in "${scenario_ids[@]}"; do + if ! run_scenario "${scenario_id}"; then + failed_scenario="${scenario_id}" + write_summary FAIL + return 1 + fi + completed_scenarios+=("${scenario_id}") + done + write_summary PASS + printf '%s\n' "${OSS_E2E_SUMMARY}" +} + +run_one() { + local scenario_id="${1:?scenario ID is required}" + setup + trap cleanup_after_run EXIT + if [[ "${scenario_id}" == "TOPOLOGY" ]]; then + if ! run_live_topology; then + write_summary FAIL + return 1 + fi + elif ! run_scenario "${scenario_id}"; then + failed_scenario="${scenario_id}" + write_summary FAIL + return 1 + else + completed_scenarios+=("${scenario_id}") + fi + write_summary PASS + printf '%s\n' "${OSS_E2E_SUMMARY}" +} + usage() { cat <<'USAGE' usage: scripts/oss-e2e.sh setup|run|reset|stop|status|scenario ID +setup starts PostgreSQL, Redis, MinIO, and two real stock relay processes. +run drives the live topology plus all focused contract cards and then cleans up. +scenario TOPOLOGY runs only the live migration/client/restart matrix. + All services, credentials, fixtures, and identifiers are local and synthetic. -The lifecycle operator surface is constructed only inside its explicit tests; -the stock relay router remains unchanged. +The stock relay binary registers no O5 operator routes. USAGE } @@ -92,25 +379,22 @@ case "${command_name}" in setup ;; run) - setup - for scenario_id in "${scenario_ids[@]}"; do - printf '\n[oss-e2e] scenario %s\n' "${scenario_id}" - run_scenario "${scenario_id}" - done + run_all ;; reset) + stop_relay relay-b + stop_relay relay-a compose down --volumes --remove-orphans setup ;; stop) - compose down --remove-orphans + stop ;; status) - compose ps + status ;; scenario) - setup - run_scenario "${2:-}" + run_one "${2:-}" ;; *) usage >&2 From 098d04a138549064d92a9237f0c3a8da1a494090 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:55:37 -0500 Subject: [PATCH 07/18] test: await Redis fanout subscription Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/tests/oss_only_e2e.rs | 37 ++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index 7ebaee6224..bea6e80c2d 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -41,6 +41,7 @@ type WsStream = WebSocketStream>; #[derive(Clone)] struct LiveConfig { database_url: String, + redis_url: String, relay_identity: String, relay_a_ws: String, relay_b_ws: String, @@ -60,6 +61,7 @@ impl LiveConfig { let relay_identity = env_or("OSS_E2E_RELAY_IDENTITY", "ws://127.0.0.1:3301"); Ok(Self { database_url: required_env("BUZZ_TEST_DATABASE_URL")?, + redis_url: required_env("REDIS_URL")?, relay_a_ws: env_or("OSS_E2E_RELAY_A_WS", "ws://127.0.0.1:3301"), relay_b_ws: env_or("OSS_E2E_RELAY_B_WS", "ws://127.0.0.1:3302"), relay_a_http: env_or("OSS_E2E_RELAY_A_HTTP", "http://127.0.0.1:3301"), @@ -154,10 +156,6 @@ impl RelaySocket { ])) .await?; self.wait_for_eose(subscription_id).await?; - // Topic retention is demand-driven and the Redis PSUBSCRIBE command is - // asynchronous. Match the repository's Redis round-trip proof by - // giving that acknowledgement one bounded scheduling window. - tokio::time::sleep(Duration::from_millis(200)).await; Ok(()) } @@ -392,6 +390,7 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { relay_b .subscribe_channel(subscription_id, channel_id, 1) .await?; + wait_for_redis_subscription(&config.redis_url, channel_id).await?; let event = EventBuilder::new(Kind::TextNote, "synthetic cross-relay fan-out") .tags([Tag::parse(["h", &channel_id.to_string()]).context("message h tag")?]) .sign_with_keys(&owner) @@ -412,6 +411,36 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { Ok(LiveScenario { owner, channel_id }) } +async fn wait_for_redis_subscription(redis_url: &str, channel_id: Uuid) -> Result<()> { + let client = redis::Client::open(redis_url).context("open live OSS Redis client")?; + let mut connection = client + .get_multiplexed_async_connection() + .await + .context("connect live OSS Redis client")?; + let pattern = format!("buzz:*:channel:{channel_id}"); + let expected_suffix = format!(":channel:{channel_id}"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + + loop { + let channels: Vec = redis::cmd("PUBSUB") + .arg("CHANNELS") + .arg(&pattern) + .query_async(&mut connection) + .await + .context("query live Redis subscriptions")?; + if channels + .iter() + .any(|channel| channel.ends_with(&expected_suffix)) + { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + bail!("relay B did not acknowledge scoped Redis subscription {pattern}"); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + async fn post_event(config: &LiveConfig, event: &Event) -> Result<()> { let response = Client::new() .post(format!("{}/events", config.relay_a_http)) From e9638be15761d76bcdc964cf36c0788a0adfc9ad Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:37:32 -0500 Subject: [PATCH 08/18] test: diagnose cross-relay fanout boundary Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/tests/oss_only_e2e.rs | 82 ++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index bea6e80c2d..09458e9b21 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -390,14 +390,44 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { relay_b .subscribe_channel(subscription_id, channel_id, 1) .await?; - wait_for_redis_subscription(&config.redis_url, channel_id).await?; + let redis_channel = wait_for_redis_subscription(&config.redis_url, channel_id).await?; + let redis_client = redis::Client::open(config.redis_url.as_str()) + .context("open live OSS Redis diagnostic client")?; + let mut redis_probe = redis_client + .get_async_pubsub() + .await + .context("connect live OSS Redis diagnostic subscriber")?; + redis_probe + .subscribe(&redis_channel) + .await + .context("subscribe live OSS Redis diagnostic subscriber")?; let event = EventBuilder::new(Kind::TextNote, "synthetic cross-relay fan-out") .tags([Tag::parse(["h", &channel_id.to_string()]).context("message h tag")?]) .sign_with_keys(&owner) .context("sign synthetic fan-out event")?; let event_id = event.id.to_hex(); relay_a.send_event(&event).await?; - relay_b.wait_for_event(subscription_id, &event_id).await?; + let mut redis_messages = redis_probe.on_message(); + let redis_message = tokio::time::timeout(Duration::from_secs(5), redis_messages.next()) + .await + .context("Redis did not publish the synthetic cross-relay event")? + .context("Redis diagnostic subscription ended before publication")?; + let redis_payload: String = redis_message + .get_payload() + .context("decode synthetic Redis publication")?; + let redis_event = + Event::from_json(&redis_payload).context("parse synthetic event from Redis publication")?; + ensure!( + redis_event.id.to_hex() == event_id, + "Redis diagnostic subscriber observed the wrong event" + ); + if let Err(error) = relay_b.wait_for_event(subscription_id, &event_id).await { + let metrics = diagnostic_metric_lines(&config.relay_b_metrics).await; + bail!( + "relay B missed event {event_id} after Redis published it on {redis_channel}: \ + {error:#}; relay-B metrics: {metrics}" + ); + } let restart_state = RestartState { channel_id, @@ -411,7 +441,7 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { Ok(LiveScenario { owner, channel_id }) } -async fn wait_for_redis_subscription(redis_url: &str, channel_id: Uuid) -> Result<()> { +async fn wait_for_redis_subscription(redis_url: &str, channel_id: Uuid) -> Result { let client = redis::Client::open(redis_url).context("open live OSS Redis client")?; let mut connection = client .get_multiplexed_async_connection() @@ -428,11 +458,23 @@ async fn wait_for_redis_subscription(redis_url: &str, channel_id: Uuid) -> Resul .query_async(&mut connection) .await .context("query live Redis subscriptions")?; - if channels - .iter() - .any(|channel| channel.ends_with(&expected_suffix)) + if let Some(channel) = channels + .into_iter() + .find(|channel| channel.ends_with(&expected_suffix)) { - return Ok(()); + let subscribers: Vec<(String, u64)> = redis::cmd("PUBSUB") + .arg("NUMSUB") + .arg(&channel) + .query_async(&mut connection) + .await + .context("query live Redis subscriber count")?; + ensure!( + subscribers + .iter() + .any(|(observed, count)| observed == &channel && *count >= 1), + "Redis reported the scoped channel without a live subscriber" + ); + return Ok(channel); } if tokio::time::Instant::now() >= deadline { bail!("relay B did not acknowledge scoped Redis subscription {pattern}"); @@ -441,6 +483,32 @@ async fn wait_for_redis_subscription(redis_url: &str, channel_id: Uuid) -> Resul } } +async fn diagnostic_metric_lines(metrics_url: &str) -> String { + const NAMES: [&str; 3] = [ + "buzz_multinode_fanout_total", + "buzz_multinode_fanout_lag_total", + "buzz_subscriptions_active", + ]; + match Client::new().get(metrics_url).send().await { + Ok(response) => match response.text().await { + Ok(body) => { + let selected: Vec<_> = body + .lines() + .filter(|line| !line.starts_with('#')) + .filter(|line| NAMES.iter().any(|name| line.starts_with(name))) + .collect(); + if selected.is_empty() { + "requested metrics absent".to_owned() + } else { + selected.join(" | ") + } + } + Err(error) => format!("metrics body unavailable: {error}"), + }, + Err(error) => format!("metrics endpoint unavailable: {error}"), + } +} + async fn post_event(config: &LiveConfig, event: &Event) -> Result<()> { let response = Client::new() .post(format!("{}/events", config.relay_a_http)) From 5b8671ad5e94ee2ee67eb50f9d60f65f934251d5 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:41:49 -0500 Subject: [PATCH 09/18] test: use NIP-29 stream message in topology Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/tests/oss_only_e2e.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index 09458e9b21..525f615c05 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -387,8 +387,9 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { ) .await?; let subscription_id = "oss-live-fanout"; + let stream_kind = buzz_core::kind::KIND_STREAM_MESSAGE as u16; relay_b - .subscribe_channel(subscription_id, channel_id, 1) + .subscribe_channel(subscription_id, channel_id, stream_kind) .await?; let redis_channel = wait_for_redis_subscription(&config.redis_url, channel_id).await?; let redis_client = redis::Client::open(config.redis_url.as_str()) @@ -401,7 +402,7 @@ async fn websocket_http_fanout(config: &LiveConfig) -> Result { .subscribe(&redis_channel) .await .context("subscribe live OSS Redis diagnostic subscriber")?; - let event = EventBuilder::new(Kind::TextNote, "synthetic cross-relay fan-out") + let event = EventBuilder::new(Kind::Custom(stream_kind), "synthetic cross-relay fan-out") .tags([Tag::parse(["h", &channel_id.to_string()]).context("message h tag")?]) .sign_with_keys(&owner) .context("sign synthetic fan-out event")?; From eadbc8178f5af63c6c27c07d5a7412de9e189246 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:45:56 -0500 Subject: [PATCH 10/18] test: query persisted NIP-29 stream event Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/tests/oss_only_e2e.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index 525f615c05..6ed8cada28 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -301,7 +301,7 @@ async fn restarted_relay_restores_persisted_event() { .send_json(&json!([ "REQ", subscription_id, - {"kinds": [1], "#h": [state.channel_id.to_string()]} + {"kinds": [buzz_core::kind::KIND_STREAM_MESSAGE], "#h": [state.channel_id.to_string()]} ])) .await .expect("query restarted relay"); From 99f45545df501ebbeb67c0c069827b47b8b16e6a Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:02:57 -0500 Subject: [PATCH 11/18] test: cover ambiguous evidence in topology Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/tests/oss_only_e2e.rs | 102 +++++++++++++++++++++++- scripts/oss-e2e.sh | 2 +- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index 6ed8cada28..528843b899 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -9,14 +9,30 @@ use std::{ path::{Path, PathBuf}, process::{Command, Output}, + sync::Arc, time::Duration, }; use anyhow::{bail, ensure, Context, Result}; +use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_auth::{ + AuthTransport, AuthorizationCapability, AuthorizationClock, AuthorizationClockError, + AuthorizationTime, VerifiedEvidenceAdapter, +}; +use buzz_core::CommunityId; +use buzz_relay::authorization_runtime::{ + finalization::AuthorizationMode, + transport::{ + DomainTransportPolicy, ProtectedAuthorizationResolver, ProtectedOperationRequest, + ProtectedResolution, ProtectedResolutionError, ProtectedTransportError, + ProtectedTransportRuntime, VerifiedProviderEvidenceResolution, + VerifiedProviderEvidenceResolutionError, VerifiedProviderEvidenceResolver, + }, +}; use buzz_ws_client::{build_auth_event, parse_relay_message, RelayMessage}; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp, ToBech32}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, RelayUrl, Tag, Timestamp, ToBech32}; use reqwest::{header, Client}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -255,6 +271,40 @@ struct LiveScenario { channel_id: Uuid, } +struct UnreachableAuthorizationResolver; + +#[async_trait] +impl ProtectedAuthorizationResolver for UnreachableAuthorizationResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> std::result::Result { + panic!("ambiguous provider evidence must deny before authority resolution") + } +} + +struct AmbiguousProviderEvidenceResolver; + +impl VerifiedProviderEvidenceResolver for AmbiguousProviderEvidenceResolver { + fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> std::result::Result< + VerifiedProviderEvidenceResolution, + VerifiedProviderEvidenceResolutionError, + > { + Ok(VerifiedProviderEvidenceResolution::Ambiguous) + } +} + +struct FixedAuthorizationClock; + +impl AuthorizationClock for FixedAuthorizationClock { + fn now(&self) -> std::result::Result { + Ok(AuthorizationTime::from_unix_seconds(1)) + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires the repository-managed two-relay OSS topology"] async fn live_two_relay_clients_and_migrations() { @@ -262,6 +312,9 @@ async fn live_two_relay_clients_and_migrations() { verify_exact_migration_chain(&config) .await .expect("M01 exact SQLx migration chain"); + ambiguous_provider_evidence_denies_runtime(&config) + .await + .expect("D02 ambiguous typed provider evidence denies in the relay runtime"); let scenario = websocket_http_fanout(&config) .await .expect("A01 real WebSocket/HTTP cross-relay fan-out"); @@ -279,6 +332,53 @@ async fn live_two_relay_clients_and_migrations() { .expect("P01 runtime logs, errors, and metrics redact planted canaries"); } +async fn ambiguous_provider_evidence_denies_runtime(config: &LiveConfig) -> Result<()> { + let authorization_domain = CommunityId::from_uuid(Uuid::new_v4()); + let keys = Keys::generate(); + let challenge = "oss-e2e-d02-ambiguous-provider-evidence"; + let relay_url = RelayUrl::parse(&config.relay_identity).context("parse relay identity")?; + let auth_event = EventBuilder::auth(challenge, relay_url) + .sign_with_keys(&keys) + .context("sign D02 synthetic NIP-42 proof")?; + let proof = VerifiedEvidenceAdapter::new() + .verify_nip42( + authorization_domain, + AuthTransport::RelayWebSocket, + &auth_event, + challenge, + &config.relay_identity, + None, + ) + .context("verify D02 synthetic NIP-42 proof")?; + let request = ProtectedOperationRequest::new( + Arc::new(proof), + None, + AuthorizationCapability::CommunityRead, + Uuid::new_v4(), + "ws_req", + ) + .context("construct D02 typed protected request")?; + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + authorization_domain, + AuthorizationMode::Enforce, + )], + Arc::new(UnreachableAuthorizationResolver), + Arc::new(FixedAuthorizationClock), + ) + .context("construct D02 relay authorization runtime")? + .with_provider_evidence_resolver(Arc::new(AmbiguousProviderEvidenceResolver)); + + ensure!( + matches!( + runtime.authorize(&request).await, + Err(ProtectedTransportError::AmbiguousProviderEvidence) + ), + "D02 ambiguous typed provider evidence did not fail closed" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires relay B to have been restarted by the repository wrapper"] async fn restarted_relay_restores_persisted_event() { diff --git a/scripts/oss-e2e.sh b/scripts/oss-e2e.sh index 1453caffaf..fc5ed80c7c 100755 --- a/scripts/oss-e2e.sh +++ b/scripts/oss-e2e.sh @@ -264,7 +264,7 @@ run_live_topology() { failed_scenario="TOPOLOGY_PRE_RESTART" return 1 fi - completed_scenarios+=(M01 A01 H01 G01 AU01 P01) + completed_scenarios+=(M01 A01 D02 H01 G01 AU01 P01) restart_relay_b if ! cargo_test -p buzz-relay --test oss_only_e2e \ restarted_relay_restores_persisted_event -- --ignored --exact --nocapture; then From 01974d70544db0a5fc76bb30cde5a55ee8336ade Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:13:16 -0500 Subject: [PATCH 12/18] fix: deny absent provider evidence in enforce Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../src/authorization_runtime/transport.rs | 8 +++ crates/buzz-relay/tests/oss_only_e2e.rs | 61 +++++++++++++++++-- scripts/oss-e2e.sh | 2 +- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs index 25ba06dd35..eb1a1f3021 100644 --- a/crates/buzz-relay/src/authorization_runtime/transport.rs +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -606,6 +606,14 @@ impl ProtectedTransportRuntime { .resolve(request) .map_err(|_| ProtectedTransportError::ProviderEvidenceUnavailable)?; match resolution { + VerifiedProviderEvidenceResolution::Absent + if request.verified_assertion().is_none() + && request.provider_evidence().is_none() => + { + Err(ProtectedTransportError::Resolution( + ProtectedResolutionError::new("provider_evidence_missing"), + )) + } VerifiedProviderEvidenceResolution::Absent => Ok(request.clone()), VerifiedProviderEvidenceResolution::Ambiguous => { Err(ProtectedTransportError::AmbiguousProviderEvidence) diff --git a/crates/buzz-relay/tests/oss_only_e2e.rs b/crates/buzz-relay/tests/oss_only_e2e.rs index 528843b899..93c48ee346 100644 --- a/crates/buzz-relay/tests/oss_only_e2e.rs +++ b/crates/buzz-relay/tests/oss_only_e2e.rs @@ -297,6 +297,20 @@ impl VerifiedProviderEvidenceResolver for AmbiguousProviderEvidenceResolver { } } +struct AbsentProviderEvidenceResolver; + +impl VerifiedProviderEvidenceResolver for AbsentProviderEvidenceResolver { + fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> std::result::Result< + VerifiedProviderEvidenceResolution, + VerifiedProviderEvidenceResolutionError, + > { + Ok(VerifiedProviderEvidenceResolution::Absent) + } +} + struct FixedAuthorizationClock; impl AuthorizationClock for FixedAuthorizationClock { @@ -312,6 +326,9 @@ async fn live_two_relay_clients_and_migrations() { verify_exact_migration_chain(&config) .await .expect("M01 exact SQLx migration chain"); + absent_provider_evidence_denies_runtime(&config) + .await + .expect("D01 absent typed provider evidence denies before authority resolution"); ambiguous_provider_evidence_denies_runtime(&config) .await .expect("D02 ambiguous typed provider evidence denies in the relay runtime"); @@ -332,14 +349,16 @@ async fn live_two_relay_clients_and_migrations() { .expect("P01 runtime logs, errors, and metrics redact planted canaries"); } -async fn ambiguous_provider_evidence_denies_runtime(config: &LiveConfig) -> Result<()> { +fn verified_direct_request( + config: &LiveConfig, + challenge: &'static str, +) -> Result { let authorization_domain = CommunityId::from_uuid(Uuid::new_v4()); let keys = Keys::generate(); - let challenge = "oss-e2e-d02-ambiguous-provider-evidence"; let relay_url = RelayUrl::parse(&config.relay_identity).context("parse relay identity")?; let auth_event = EventBuilder::auth(challenge, relay_url) .sign_with_keys(&keys) - .context("sign D02 synthetic NIP-42 proof")?; + .context("sign synthetic direct-origin NIP-42 proof")?; let proof = VerifiedEvidenceAdapter::new() .verify_nip42( authorization_domain, @@ -349,15 +368,45 @@ async fn ambiguous_provider_evidence_denies_runtime(config: &LiveConfig) -> Resu &config.relay_identity, None, ) - .context("verify D02 synthetic NIP-42 proof")?; - let request = ProtectedOperationRequest::new( + .context("verify synthetic direct-origin NIP-42 proof")?; + ProtectedOperationRequest::new( Arc::new(proof), None, AuthorizationCapability::CommunityRead, Uuid::new_v4(), "ws_req", ) - .context("construct D02 typed protected request")?; + .context("construct typed direct-origin protected request") +} + +async fn absent_provider_evidence_denies_runtime(config: &LiveConfig) -> Result<()> { + let request = verified_direct_request(config, "oss-e2e-d01-absent-provider-evidence")?; + let authorization_domain = request.authorization_domain(); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + authorization_domain, + AuthorizationMode::Enforce, + )], + Arc::new(UnreachableAuthorizationResolver), + Arc::new(FixedAuthorizationClock), + ) + .context("construct D01 relay authorization runtime")? + .with_provider_evidence_resolver(Arc::new(AbsentProviderEvidenceResolver)); + + match runtime.authorize(&request).await { + Err(ProtectedTransportError::Resolution(error)) => ensure!( + error.code() == "provider_evidence_missing", + "D01 absent evidence returned the wrong denial class" + ), + Err(error) => bail!("D01 absent evidence returned the wrong denial: {error}"), + Ok(_) => bail!("D01 absent evidence reached an authority grant"), + } + Ok(()) +} + +async fn ambiguous_provider_evidence_denies_runtime(config: &LiveConfig) -> Result<()> { + let request = verified_direct_request(config, "oss-e2e-d02-ambiguous-provider-evidence")?; + let authorization_domain = request.authorization_domain(); let runtime = ProtectedTransportRuntime::new( [DomainTransportPolicy::from_server_configuration( authorization_domain, diff --git a/scripts/oss-e2e.sh b/scripts/oss-e2e.sh index fc5ed80c7c..88b61c9865 100755 --- a/scripts/oss-e2e.sh +++ b/scripts/oss-e2e.sh @@ -264,7 +264,7 @@ run_live_topology() { failed_scenario="TOPOLOGY_PRE_RESTART" return 1 fi - completed_scenarios+=(M01 A01 D02 H01 G01 AU01 P01) + completed_scenarios+=(M01 A01 D01 D02 H01 G01 AU01 P01) restart_relay_b if ! cargo_test -p buzz-relay --test oss_only_e2e \ restarted_relay_restores_persisted_event -- --ignored --exact --nocapture; then From 1c89600b90cfd7b5c66fdef45271b0481cf3ac3b Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:42:30 -0500 Subject: [PATCH 13/18] ci: provide postgres for o5 database tests Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .github/workflows/ci.yml | 16 ++++++++++++++++ Justfile | 13 ++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e71ee37ba..92059227c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,20 @@ jobs: name: Unit Tests runs-on: ubuntu-latest timeout-minutes: 30 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: buzz_dev + POSTGRES_DB: buzz + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d buzz" + --health-interval 2s + --health-timeout 3s + --health-retries 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: @@ -129,6 +143,8 @@ jobs: tool: cargo-nextest@0.9.136 - name: Unit tests run: just test-unit + env: + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz desktop-core: name: Desktop Core diff --git a/Justfile b/Justfile index c9f1efdbc0..34761b8b7c 100644 --- a/Justfile +++ b/Justfile @@ -301,7 +301,7 @@ ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri test: ./scripts/run-tests.sh all -# Run unit tests only (no infra needed) +# Run unit tests. The O5 buzz-db gates require a reachable test PostgreSQL. test-unit: #!/usr/bin/env bash set -euo pipefail @@ -309,12 +309,11 @@ test-unit: cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli - # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). - # They guard the embedded-migrator invariant (exactly the consolidated - # 0001; cutover/backfill stays an operator script, not startup state) - # and the tenant-scoping lints. The Postgres-backed buzz-db tests are - # #[ignore]d, so --lib runs only the infra-free set. Without this gate a - # stray file in migrations/ or a broken lint ships green. + # buzz-db includes pure migration/lint tests and non-vacuous O5 + # PostgreSQL atomicity/concurrency gates. CI supplies an isolated + # service; local callers must provide BUZZ_TEST_DATABASE_URL or the + # documented localhost test database. Without this gate, migration + # drift or an unreachable database could ship green. cargo nextest run -p buzz-db --lib # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace From 3b828ae0d58a8f1ebeafd2486aaca14f20ed01f2 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:27:30 -0500 Subject: [PATCH 14/18] fix(db): preserve legacy revocation scope constraint Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/operator_lifecycle.rs | 8 ++++---- migrations/0049_authorization_operator_lifecycle.sql | 7 ------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/buzz-db/src/operator_lifecycle.rs b/crates/buzz-db/src/operator_lifecycle.rs index d3c14e71c4..e7fa4c91f3 100644 --- a/crates/buzz-db/src/operator_lifecycle.rs +++ b/crates/buzz-db/src/operator_lifecycle.rs @@ -856,7 +856,7 @@ async fn revoke_tx( let updated = sqlx::query( "UPDATE identity_bindings SET binding_version=$3,binding_state='revoked', \ revoked_at=clock_timestamp(),revoked_by=$4,revoked_reason=$5, \ - revocation_scope='binding',updated_at=clock_timestamp() \ + revocation_scope='key',updated_at=clock_timestamp() \ WHERE community_id=$1 AND binding_id=$2 AND binding_state='active' \ AND revoked_at IS NULL AND binding_version=$6", ) @@ -2101,8 +2101,8 @@ mod tests { )) )); - let state: (String, i64) = sqlx::query_as( - "SELECT binding_state,binding_version FROM identity_bindings \ + let state: (String, i64, String) = sqlx::query_as( + "SELECT binding_state,binding_version,revocation_scope FROM identity_bindings \ WHERE community_id=$1 AND binding_id=$2", ) .bind(domain.as_uuid()) @@ -2110,7 +2110,7 @@ mod tests { .fetch_one(&fixture.pool) .await .expect("inspect revoked binding"); - assert_eq!(state, ("revoked".into(), 2)); + assert_eq!(state, ("revoked".into(), 2, "key".into())); let receipt_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM authorization_operator_operation_receipts WHERE community_id=$1", ) diff --git a/migrations/0049_authorization_operator_lifecycle.sql b/migrations/0049_authorization_operator_lifecycle.sql index 1a70877067..69a22775a6 100644 --- a/migrations/0049_authorization_operator_lifecycle.sql +++ b/migrations/0049_authorization_operator_lifecycle.sql @@ -129,13 +129,6 @@ CREATE TABLE authorization_operator_effects ( ON DELETE RESTRICT ); -ALTER TABLE identity_bindings - DROP CONSTRAINT identity_bindings_revocation_scope_check; -ALTER TABLE identity_bindings - ADD CONSTRAINT identity_bindings_revocation_scope_check CHECK ( - revocation_scope IN ('principal', 'key', 'rotation', 'binding') - ); - ALTER TABLE identity_binding_history DROP CONSTRAINT identity_binding_history_transition_kind_check; ALTER TABLE identity_binding_history From 8b3fc283e294d9b6b004d9562f5640d840705bff Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:48:59 -0500 Subject: [PATCH 15/18] fix(operator): validate exact replacement eligibility Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/operator_lifecycle.rs | 36 ++++++++---- crates/buzz-relay/src/operator_runtime.rs | 14 +++-- .../buzz-relay/tests/o5_operator_postgres.rs | 58 +++++++++++++++++-- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/crates/buzz-db/src/operator_lifecycle.rs b/crates/buzz-db/src/operator_lifecycle.rs index e7fa4c91f3..7125de8118 100644 --- a/crates/buzz-db/src/operator_lifecycle.rs +++ b/crates/buzz-db/src/operator_lifecycle.rs @@ -488,7 +488,7 @@ fn validate_command( command.target_reference.is_none() || command.target_pseudonym.is_none() || command.replacement_reference.is_none() - || command.replacement.is_some() + || command.replacement.is_none() || command.list_limit != 1 || command.list_after.is_some() } @@ -773,11 +773,15 @@ async fn preview_tx( revision: u64, ) -> Result { let target = command.target_reference.expect("validated preview target"); - if resolve_active_binding_tx(tx, command.domain, target) - .await? - .is_none() - { + let replacement = command + .replacement + .as_ref() + .expect("validated preview replacement proof"); + let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + }; + if replacement_ineligible_tx(tx, command.domain, &binding, &replacement.pubkey).await? { + return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); } let result = OperatorLifecycleResult { operation_id: command.operation_id, @@ -914,10 +918,7 @@ async fn rotate_tx( let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); }; - if binding.pubkey.as_slice() == replacement.pubkey - || has_pending_lineage_tx(tx, command.domain, &binding).await? - || replacement_denied_tx(tx, command.domain, &binding, &replacement.pubkey).await? - { + if replacement_ineligible_tx(tx, command.domain, &binding, &replacement.pubkey).await? { return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); } let next_version = binding @@ -1595,7 +1596,11 @@ async fn replacement_denied_tx( EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$4) OR \ EXISTS(SELECT 1 FROM identity_migration_denied_keys WHERE community_id=$1 AND pubkey=$4) OR \ EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$4 \ - AND binding_state='active' AND revoked_at IS NULL)", + AND binding_state='active' AND revoked_at IS NULL) OR \ + EXISTS(SELECT 1 FROM identity_retired_pairs WHERE community_id=$1 \ + AND issuer=$2 AND subject=$3 AND pubkey=$4) OR \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 \ + AND issuer=$2 AND uid=$3 AND pubkey=$4 AND revoked_at IS NOT NULL)", ) .bind(domain.as_uuid()) .bind(&binding.issuer) @@ -1605,6 +1610,17 @@ async fn replacement_denied_tx( .await?) } +async fn replacement_ineligible_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + binding: &BindingRow, + replacement: &[u8; 32], +) -> Result { + Ok(binding.pubkey.as_slice() == replacement + || has_pending_lineage_tx(tx, domain, binding).await? + || replacement_denied_tx(tx, domain, binding, replacement).await?) +} + async fn retire_pair_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, diff --git a/crates/buzz-relay/src/operator_runtime.rs b/crates/buzz-relay/src/operator_runtime.rs index e4fd0c2904..304e3c857e 100644 --- a/crates/buzz-relay/src/operator_runtime.rs +++ b/crates/buzz-relay/src/operator_runtime.rs @@ -417,7 +417,8 @@ pub struct OperatorAuthorizationRequest { impl OperatorAuthorizationRequest { fn from_invocation(invocation: &OperatorInvocation) -> Self { let replacement_reference = match invocation.intent { - OperatorIntent::Rotate { replacement, .. } => Some(replacement), + OperatorIntent::Preview { replacement, .. } + | OperatorIntent::Rotate { replacement, .. } => Some(replacement), _ => None, }; Self { @@ -449,13 +450,13 @@ impl OperatorAuthorizationRequest { self.intent_fingerprint } - /// Requested replacement reference when a rotation needs fresh proof. + /// Requested replacement reference when a preview or rotation needs fresh proof. pub const fn replacement_reference(self) -> Option { self.replacement_reference } } -/// Fresh replacement material supplied only by an authenticated rotation grant. +/// Fresh replacement material supplied by an authenticated preview or rotation grant. #[derive(Clone, Copy, PartialEq, Eq)] pub struct GrantedOperatorReplacement { reference: OpaqueOperatorReference, @@ -518,7 +519,7 @@ pub trait GrantedOperatorCapability: Send + Sync { fn provenance_reference(&self) -> OpaqueOperatorReference; /// Single-use approval evidence identities, parallel to request approvals. fn approval_evidence_ids(&self) -> &[Uuid]; - /// Fresh replacement proof for an exact rotate intent, if any. + /// Fresh replacement proof for an exact preview or rotate intent, if any. fn replacement(&self) -> Option; /// Exclusive trusted expiry in Unix seconds. fn expires_at_unix_seconds(&self) -> u64; @@ -586,7 +587,7 @@ impl AuthorizedOperatorOperation { self.expires_at_unix_seconds } - /// Fresh replacement material for a rotate operation. + /// Fresh replacement material for a preview or rotate operation. pub const fn replacement(&self) -> Option { self.replacement } @@ -853,7 +854,8 @@ impl OperatorRuntime { } let replacement = grant.replacement(); let expected_replacement = match invocation.intent { - OperatorIntent::Rotate { replacement, .. } => Some(replacement), + OperatorIntent::Preview { replacement, .. } + | OperatorIntent::Rotate { replacement, .. } => Some(replacement), _ => None, }; if replacement.map(GrantedOperatorReplacement::reference) != expected_replacement { diff --git a/crates/buzz-relay/tests/o5_operator_postgres.rs b/crates/buzz-relay/tests/o5_operator_postgres.rs index 94938faf1a..e4a0db1150 100644 --- a/crates/buzz-relay/tests/o5_operator_postgres.rs +++ b/crates/buzz-relay/tests/o5_operator_postgres.rs @@ -160,7 +160,9 @@ impl OperatorAuthenticator for Authenticator { assert_eq!(credential.expose_to_authenticator(), CREDENTIAL.as_bytes()); let replacement = request .replacement_reference() - .map(|reference| GrantedOperatorReplacement::new(reference, [77; 32], [78; 32])) + .map(|reference| { + GrantedOperatorReplacement::new(reference, [reference.digest()[0]; 32], [78; 32]) + }) .transpose()?; let now = SystemClock.now_unix_seconds()?; Ok(Box::new(Grant { @@ -289,11 +291,51 @@ async fn explicitly_composed_routes_reach_real_postgres_list_preview_revoke_and_ let mut rotate = common_body(domain, Uuid::new_v4(), 2); rotate["target"] = json!(second); rotate["replacement"] = json!(reference(71)); - let (status, rotated) = post(runtime, "/operator/v1/lifecycle/rotate", rotate).await; + let (status, rotated) = post(runtime.clone(), "/operator/v1/lifecycle/rotate", rotate).await; assert_eq!(status, StatusCode::OK, "rotate response: {rotated}"); assert_eq!(rotated["lifecycle_revision"], 3); scenarios += 1; + let mut current = common_body(domain, Uuid::new_v4(), 3); + current["limit"] = json!(10); + let (status, current) = post(runtime.clone(), "/operator/v1/lifecycle/list", current).await; + assert_eq!(status, StatusCode::OK, "current list response: {current}"); + let current_target = current["records"] + .as_array() + .expect("current redacted list records") + .iter() + .find(|record| record["state"] == "active") + .and_then(|record| record["reference"].as_str()) + .expect("current active replacement reference"); + scenarios += 1; + + let mut retired_preview = common_body(domain, Uuid::new_v4(), 3); + retired_preview["target"] = json!(current_target); + retired_preview["replacement"] = json!(reference(32)); + let (status, denied_preview) = post( + runtime.clone(), + "/operator/v1/lifecycle/preview", + retired_preview, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "retired-key preview must fail closed: {denied_preview}" + ); + scenarios += 1; + + let mut rotate_back = common_body(domain, Uuid::new_v4(), 3); + rotate_back["target"] = json!(current_target); + rotate_back["replacement"] = json!(reference(32)); + let (status, denied_rotate) = post(runtime, "/operator/v1/lifecycle/rotate", rotate_back).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "retired-key rotation must fail closed: {denied_rotate}" + ); + scenarios += 1; + let receipts: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM authorization_operator_operation_receipts WHERE community_id=$1", ) @@ -308,9 +350,17 @@ async fn explicitly_composed_routes_reach_real_postgres_list_preview_revoke_and_ .fetch_one(&fixture.pool) .await .expect("count reachable operator effects"); - assert_eq!(receipts, 4); + let previews: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_lifecycle_previews WHERE community_id=$1", + ) + .bind(domain) + .fetch_one(&fixture.pool) + .await + .expect("count reachable operator previews"); + assert_eq!(receipts, 7); assert_eq!(effects, 2); - assert_eq!(scenarios, 4, "every reachable route scenario executed"); + assert_eq!(previews, 1, "denied preview cannot persist an impact plan"); + assert_eq!(scenarios, 7, "every reachable route scenario executed"); fixture.cleanup().await; } From c6dd3c3b6d66133ff8792ed846266d132e0179d9 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:31:17 -0500 Subject: [PATCH 16/18] fix(o5): reject retired replacement reuse Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/operator_lifecycle.rs | 6 ++++-- scripts/oss-e2e.sh | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/buzz-db/src/operator_lifecycle.rs b/crates/buzz-db/src/operator_lifecycle.rs index 7125de8118..f151b1e167 100644 --- a/crates/buzz-db/src/operator_lifecycle.rs +++ b/crates/buzz-db/src/operator_lifecycle.rs @@ -1587,6 +1587,8 @@ async fn replacement_denied_tx( binding: &BindingRow, replacement: &[u8; 32], ) -> Result { + // Replacement keys are community-global credentials. A retired or revoked + // key cannot become fresh merely by moving it to another principal. Ok(sqlx::query_scalar( "SELECT \ EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 \ @@ -1598,9 +1600,9 @@ async fn replacement_denied_tx( EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$4 \ AND binding_state='active' AND revoked_at IS NULL) OR \ EXISTS(SELECT 1 FROM identity_retired_pairs WHERE community_id=$1 \ - AND issuer=$2 AND subject=$3 AND pubkey=$4) OR \ + AND pubkey=$4) OR \ EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 \ - AND issuer=$2 AND uid=$3 AND pubkey=$4 AND revoked_at IS NOT NULL)", + AND pubkey=$4 AND revoked_at IS NOT NULL)", ) .bind(domain.as_uuid()) .bind(&binding.issuer) diff --git a/scripts/oss-e2e.sh b/scripts/oss-e2e.sh index 88b61c9865..e4db381fee 100755 --- a/scripts/oss-e2e.sh +++ b/scripts/oss-e2e.sh @@ -305,11 +305,11 @@ run_scenario() { cargo_test -p buzz-relay restart_bootstraps_full_state_before_readiness ;; O501) - cargo_test -p buzz-relay --test o5_operator_postgres + cargo_test -p buzz-relay --test o5_operator_postgres && cargo_test -p buzz-db postgres_o5_outbox_rollback_delivery_restore_and_capacity_are_non_vacuous ;; P01) - cargo_test -p buzz-relay --test o5_operator_surface planted_canaries_never_cross_response_logs_or_metrics + cargo_test -p buzz-relay --test o5_operator_surface planted_canaries_never_cross_response_logs_or_metrics && cargo_test -p buzz-db postgres_operator_lifecycle_is_atomic_idempotent_and_serialized ;; *) From 7294428c8342ab5e0179421ab4bcc80d604e7214 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:57:32 -0500 Subject: [PATCH 17/18] fix(o5): unify operator lifecycle planning Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/operator_lifecycle.rs | 1369 +++++++++++++++++++--- 1 file changed, 1200 insertions(+), 169 deletions(-) diff --git a/crates/buzz-db/src/operator_lifecycle.rs b/crates/buzz-db/src/operator_lifecycle.rs index f151b1e167..810e1ea6a1 100644 --- a/crates/buzz-db/src/operator_lifecycle.rs +++ b/crates/buzz-db/src/operator_lifecycle.rs @@ -29,6 +29,10 @@ use crate::authorization_invalidation::{ authorization_invalidation_request_fingerprint, AuthorizationInvalidationEntry, AuthorizationInvalidationRequest, }; +use crate::identity_binding::{ + binding_lock_coordinate, key_lock_coordinate, lock_identity_coordinates_tx, + operation_lock_coordinate, principal_lock_coordinate, +}; use crate::{Db, DbError, Result}; const MAX_RECORDS: usize = 100; @@ -55,14 +59,17 @@ impl OperatorReferenceKey { self.epoch } - fn derive(&self, domain: CommunityId, binding_id: Uuid) -> [u8; 32] { - let mut mac = as KeyInit>::new_from_slice(&self.bytes) - .expect("HMAC accepts a 32-byte key"); + fn derive( + &self, + domain: CommunityId, + binding_id: Uuid, + ) -> std::result::Result<[u8; 32], hmac::digest::InvalidLength> { + let mut mac = as KeyInit>::new_from_slice(&self.bytes)?; Mac::update(&mut mac, b"buzz-operator-binding-reference-v1"); Mac::update(&mut mac, domain.as_uuid().as_bytes()); Mac::update(&mut mac, &self.epoch.to_be_bytes()); Mac::update(&mut mac, binding_id.as_bytes()); - mac.finalize().into_bytes().into() + Ok(mac.finalize().into_bytes().into()) } } @@ -344,8 +351,9 @@ impl Db { key: &OperatorReferenceKey, command: &OperatorLifecycleCommand, ) -> std::result::Result { - validate_command(command)?; + let validated_action = validate_command(command)?; let mut tx = self.pool.begin().await.map_err(DbError::from)?; + set_lifecycle_lock_timeout_tx(&mut tx).await?; ensure_revision_tx(&mut tx, command.domain).await?; let revision = lock_revision_tx(&mut tx, command.domain).await?; @@ -394,11 +402,19 @@ impl Db { return Err(OperatorLifecycleFailure::Denied(reason)); } - let outcome = match command.action { - OperatorLifecycleAction::List => list_tx(&mut tx, key, command, revision).await?, - OperatorLifecycleAction::Preview => preview_tx(&mut tx, command, revision).await?, - OperatorLifecycleAction::Revoke => revoke_tx(&mut tx, key, command, revision).await?, - OperatorLifecycleAction::Rotate => rotate_tx(&mut tx, key, command, revision).await?, + let outcome = match validated_action { + ValidatedOperatorAction::List { limit, after } => { + list_tx(&mut tx, key, command, revision, limit, after).await? + } + ValidatedOperatorAction::Preview(rotation) => { + preview_tx(&mut tx, command, revision, rotation).await? + } + ValidatedOperatorAction::Revoke(target) => { + revoke_tx(&mut tx, key, command, revision, target).await? + } + ValidatedOperatorAction::Rotate(rotation) => { + rotate_tx(&mut tx, key, command, revision, rotation).await? + } }; let outcome = match outcome { OperationAttempt::Applied(value) => value, @@ -419,6 +435,7 @@ impl Db { ) -> std::result::Result<(), OperatorLifecycleFailure> { validate_denial_attempt(attempt)?; let mut tx = self.pool.begin().await.map_err(DbError::from)?; + set_lifecycle_lock_timeout_tx(&mut tx).await?; ensure_revision_tx(&mut tx, attempt.domain).await?; let revision = lock_revision_tx(&mut tx, attempt.domain).await?; if let Some(existing_fingerprint) = existing_denial_receipt_tx(&mut tx, attempt).await? { @@ -444,9 +461,53 @@ enum OperationAttempt { Denied(DecisionReason), } +#[derive(Clone, Copy)] +struct ValidatedTarget { + reference: [u8; 32], + pseudonym: PseudonymousReference, +} + +#[derive(Clone, Copy)] +struct ValidatedRotation<'a> { + target: ValidatedTarget, + replacement_reference: [u8; 32], + replacement: &'a VerifiedOperatorReplacement, +} + +#[derive(Clone, Copy)] +struct PlannedLifecycleEffect { + target: ValidatedTarget, + binding_id: Uuid, + previous_version: u64, + current_version: u64, +} + +struct RotationPlan<'a> { + replacement_reference: [u8; 32], + replacement: &'a VerifiedOperatorReplacement, + source: BindingRow, + replacement_binding_id: Uuid, + lifecycle_revision_precondition: u64, + effects: [PlannedLifecycleEffect; 1], +} + +impl RotationPlan<'_> { + fn affected_count(&self) -> Result { + u32::try_from(self.effects.len()) + .map_err(|_| DbError::InvalidData("operator affected count exceeded".into())) + } +} + +enum ValidatedOperatorAction<'a> { + List { limit: u16, after: Option<[u8; 32]> }, + Preview(ValidatedRotation<'a>), + Revoke(ValidatedTarget), + Rotate(ValidatedRotation<'a>), +} + fn validate_command( command: &OperatorLifecycleCommand, -) -> std::result::Result<(), OperatorLifecycleFailure> { +) -> std::result::Result, OperatorLifecycleFailure> { let mut approver_independence = command.authority.approver_independence_references.clone(); approver_independence.sort_unstable(); let invalid_common = command.operation_id.is_nil() @@ -475,41 +536,7 @@ fn validate_command( .approvers .iter() .any(|value| value.kind() != ReferenceKind::Approver); - let invalid_shape = match command.action { - OperatorLifecycleAction::List => { - command.list_limit == 0 - || usize::from(command.list_limit) > MAX_RECORDS - || command.target_reference.is_some() - || command.target_pseudonym.is_some() - || command.replacement_reference.is_some() - || command.replacement.is_some() - } - OperatorLifecycleAction::Preview => { - command.target_reference.is_none() - || command.target_pseudonym.is_none() - || command.replacement_reference.is_none() - || command.replacement.is_none() - || command.list_limit != 1 - || command.list_after.is_some() - } - OperatorLifecycleAction::Revoke => { - command.target_reference.is_none() - || command.target_pseudonym.is_none() - || command.replacement_reference.is_some() - || command.replacement.is_some() - || command.list_limit != 1 - || command.list_after.is_some() - } - OperatorLifecycleAction::Rotate => { - command.target_reference.is_none() - || command.target_pseudonym.is_none() - || command.replacement_reference.is_none() - || command.replacement.is_none() - || command.list_limit != 1 - || command.list_after.is_some() - } - }; - if invalid_common || invalid_shape { + if invalid_common { return Err(OperatorLifecycleFailure::Denied( DecisionReason::EvidenceInvalid, )); @@ -522,14 +549,86 @@ fn validate_command( DecisionReason::EvidenceInvalid, )); } - if let Some(replacement) = &command.replacement { - if Some(replacement.reference) != command.replacement_reference { - return Err(OperatorLifecycleFailure::Denied( - DecisionReason::IntentConflict, - )); + match command.action { + OperatorLifecycleAction::List => { + if command.list_limit == 0 + || usize::from(command.list_limit) > MAX_RECORDS + || command.target_reference.is_some() + || command.target_pseudonym.is_some() + || command.replacement_reference.is_some() + || command.replacement.is_some() + { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + Ok(ValidatedOperatorAction::List { + limit: command.list_limit, + after: command.list_after, + }) + } + OperatorLifecycleAction::Preview | OperatorLifecycleAction::Rotate => { + let (Some(target_reference), Some(target_pseudonym)) = + (command.target_reference, command.target_pseudonym) + else { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + }; + let (Some(replacement_reference), Some(replacement)) = + (command.replacement_reference, command.replacement.as_ref()) + else { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + }; + if command.list_limit != 1 || command.list_after.is_some() { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + if replacement.reference != replacement_reference { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::IntentConflict, + )); + } + let rotation = ValidatedRotation { + target: ValidatedTarget { + reference: target_reference, + pseudonym: target_pseudonym, + }, + replacement_reference, + replacement, + }; + if command.action == OperatorLifecycleAction::Preview { + Ok(ValidatedOperatorAction::Preview(rotation)) + } else { + Ok(ValidatedOperatorAction::Rotate(rotation)) + } + } + OperatorLifecycleAction::Revoke => { + let (Some(reference), Some(pseudonym)) = + (command.target_reference, command.target_pseudonym) + else { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + }; + if command.replacement_reference.is_some() + || command.replacement.is_some() + || command.list_limit != 1 + || command.list_after.is_some() + { + return Err(OperatorLifecycleFailure::Denied( + DecisionReason::EvidenceInvalid, + )); + } + Ok(ValidatedOperatorAction::Revoke(ValidatedTarget { + reference, + pseudonym, + })) } } - Ok(()) } fn validate_denial_attempt( @@ -555,6 +654,13 @@ fn validate_denial_attempt( Ok(()) } +async fn set_lifecycle_lock_timeout_tx(tx: &mut Transaction<'_, Postgres>) -> Result<()> { + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut **tx) + .await?; + Ok(()) +} + async fn ensure_revision_tx(tx: &mut Transaction<'_, Postgres>, domain: CommunityId) -> Result<()> { sqlx::query( "INSERT INTO authorization_operator_lifecycle_revisions (community_id) \ @@ -728,8 +834,10 @@ async fn list_tx( key: &OperatorReferenceKey, command: &OperatorLifecycleCommand, revision: u64, + limit: u16, + after: Option<[u8; 32]>, ) -> Result { - let after_binding = match command.list_after { + let after_binding = match after { Some(reference) => resolve_binding_id_tx(tx, command.domain, reference).await?, None => None, }; @@ -740,7 +848,7 @@ async fn list_tx( ) .bind(command.domain.as_uuid()) .bind(after_binding) - .bind(i64::from(command.list_limit)) + .bind(i64::from(limit)) .fetch_all(&mut **tx) .await?; let mut records = Vec::with_capacity(rows.len()); @@ -763,7 +871,7 @@ async fn list_tx( lifecycle_revision: revision, records, }; - accept_success_tx(tx, command, &result, None, None).await?; + accept_success_tx(tx, command, &result, None).await?; Ok(OperationAttempt::Applied(result)) } @@ -771,24 +879,19 @@ async fn preview_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, revision: u64, + input: ValidatedRotation<'_>, ) -> Result { - let target = command.target_reference.expect("validated preview target"); - let replacement = command - .replacement - .as_ref() - .expect("validated preview replacement proof"); - let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { - return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + let plan = match plan_rotation_tx(tx, command, revision, input).await? { + Ok(plan) => plan, + Err(reason) => return Ok(OperationAttempt::Denied(reason)), }; - if replacement_ineligible_tx(tx, command.domain, &binding, &replacement.pubkey).await? { - return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); - } + let affected_count = plan.affected_count()?; let result = OperatorLifecycleResult { operation_id: command.operation_id, correlation_id: command.correlation_id, action: command.action, status: OperatorLifecycleStatus::Previewed, - affected_count: 1, + affected_count, lifecycle_revision: revision, records: Vec::new(), }; @@ -800,36 +903,37 @@ async fn preview_tx( result: EventResult::Previewed, reason: DecisionReason::PreviewOnly, payload: None, - summary: Some((1, command.semantic_fingerprint)), + summary: Some((affected_count, command.semantic_fingerprint)), binding_version: None, invalidation_generation: None, }, )?; let accepted = append_decision_tx(tx, &decision_event, CapacityClass::NonessentialRead).await?; + let [effect] = &plan.effects; sqlx::query( "INSERT INTO authorization_lifecycle_previews \ (community_id,preview_digest,operation_id,target_reference,replacement_reference, \ lifecycle_revision,affected_count,expires_at,decision_event_id) \ - VALUES ($1,$2,$3,$4,$5,$6,1,clock_timestamp()+INTERVAL '5 minutes',$7)", + VALUES ($1,$2,$3,$4,$5,$6,$7,clock_timestamp()+INTERVAL '5 minutes',$8)", ) .bind(command.domain.as_uuid()) .bind(command.semantic_fingerprint.as_slice()) .bind(command.operation_id) - .bind(target.as_slice()) + .bind(effect.target.reference.as_slice()) + .bind(plan.replacement_reference.as_slice()) + .bind(i64_revision(revision)?) .bind( - command - .replacement_reference - .expect("validated preview replacement") - .as_slice(), + i32::try_from(affected_count) + .map_err(|_| DbError::InvalidData("operator affected count is out of range".into()))?, ) - .bind(i64_revision(revision)?) .bind(accepted.event_id.as_uuid()) .execute(&mut **tx) .await?; - accept_success_tx(tx, command, &result, None, None).await?; + accept_success_tx(tx, command, &result, None).await?; Ok(OperationAttempt::Applied(result)) } +#[derive(PartialEq, Eq)] struct BindingRow { binding_id: Uuid, issuer: String, @@ -839,14 +943,90 @@ struct BindingRow { provenance: String, } +async fn plan_rotation_tx<'a>( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + revision: u64, + input: ValidatedRotation<'a>, +) -> Result, DecisionReason>> { + let Some(candidate) = + resolve_active_binding_candidate_tx(tx, command.domain, input.target.reference).await? + else { + return Ok(Err(DecisionReason::TargetMismatch)); + }; + lock_identity_coordinates_tx( + tx, + vec![ + operation_lock_coordinate(command.domain, command.operation_id), + principal_lock_coordinate(command.domain, &candidate.issuer, &candidate.subject), + key_lock_coordinate(command.domain, &candidate.pubkey), + key_lock_coordinate(command.domain, &input.replacement.pubkey), + binding_lock_coordinate(command.domain, candidate.binding_id), + ], + ) + .await?; + let Some(source) = + resolve_active_binding_tx(tx, command.domain, input.target.reference).await? + else { + return Ok(Err(DecisionReason::StaleExpectedState)); + }; + if source != candidate + || rotation_ineligible_tx(tx, command.domain, &source, &input.replacement.pubkey).await? + { + return Ok(Err(DecisionReason::StaleExpectedState)); + } + let current_version = source + .version + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("binding revision exhausted".into()))?; + Ok(Ok(RotationPlan { + replacement_reference: input.replacement_reference, + replacement: input.replacement, + effects: [PlannedLifecycleEffect { + target: input.target, + binding_id: source.binding_id, + previous_version: source.version, + current_version, + }], + source, + replacement_binding_id: Uuid::new_v4(), + lifecycle_revision_precondition: revision, + })) +} + +async fn rotation_plan_is_current_tx( + tx: &mut Transaction<'_, Postgres>, + command: &OperatorLifecycleCommand, + plan: &RotationPlan<'_>, +) -> Result { + if lock_revision_tx(tx, command.domain).await? != plan.lifecycle_revision_precondition { + return Ok(false); + } + let [effect] = &plan.effects; + let Some(source) = + resolve_active_binding_tx(tx, command.domain, effect.target.reference).await? + else { + return Ok(false); + }; + if source != plan.source + || effect.binding_id != source.binding_id + || effect.previous_version != source.version + || source.version.checked_add(1) != Some(effect.current_version) + { + return Ok(false); + } + Ok(!rotation_ineligible_tx(tx, command.domain, &source, &plan.replacement.pubkey).await?) +} + async fn revoke_tx( tx: &mut Transaction<'_, Postgres>, _key: &OperatorReferenceKey, command: &OperatorLifecycleCommand, revision: u64, + target: ValidatedTarget, ) -> Result { - let target = command.target_reference.expect("validated revoke target"); - let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { + let Some(binding) = resolve_active_binding_tx(tx, command.domain, target.reference).await? + else { return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); }; if has_pending_lineage_tx(tx, command.domain, &binding).await? { @@ -856,7 +1036,6 @@ async fn revoke_tx( .version .checked_add(1) .ok_or_else(|| DbError::InvalidData("binding revision exhausted".into()))?; - retire_pair_tx(tx, command, &binding, next_version).await?; let updated = sqlx::query( "UPDATE identity_bindings SET binding_version=$3,binding_state='revoked', \ revoked_at=clock_timestamp(),revoked_by=$4,revoked_reason=$5, \ @@ -875,11 +1054,13 @@ async fn revoke_tx( if updated.rows_affected() != 1 { return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); } + retire_pair_tx(tx, command, &binding, binding.binding_id, next_version).await?; insert_pending_tx(tx, command, &binding, next_version).await?; append_binding_history_tx( tx, command, &binding, + binding.binding_id, next_version, "revoked", "revoke_binding", @@ -887,6 +1068,12 @@ async fn revoke_tx( ) .await?; let lifecycle_revision = advance_revision_tx(tx, command.domain, revision).await?; + let effect = PlannedLifecycleEffect { + target, + binding_id: binding.binding_id, + previous_version: binding.version, + current_version: next_version, + }; let result = OperatorLifecycleResult { operation_id: command.operation_id, correlation_id: command.correlation_id, @@ -896,14 +1083,7 @@ async fn revoke_tx( lifecycle_revision, records: Vec::new(), }; - accept_success_tx( - tx, - command, - &result, - Some((target, binding.version, next_version)), - Some(binding.binding_id), - ) - .await?; + accept_success_tx(tx, command, &result, Some(effect)).await?; Ok(OperationAttempt::Applied(result)) } @@ -912,21 +1092,33 @@ async fn rotate_tx( key: &OperatorReferenceKey, command: &OperatorLifecycleCommand, revision: u64, + input: ValidatedRotation<'_>, ) -> Result { - let target = command.target_reference.expect("validated rotate target"); - let replacement = command.replacement.as_ref().expect("validated replacement"); - let Some(binding) = resolve_active_binding_tx(tx, command.domain, target).await? else { - return Ok(OperationAttempt::Denied(DecisionReason::TargetMismatch)); + let plan = match plan_rotation_tx(tx, command, revision, input).await? { + Ok(plan) => plan, + Err(reason) => return Ok(OperationAttempt::Denied(reason)), }; - if replacement_ineligible_tx(tx, command.domain, &binding, &replacement.pubkey).await? { + if !rotation_plan_is_current_tx(tx, command, &plan).await? { return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); } - let next_version = binding - .version - .checked_add(1) - .ok_or_else(|| DbError::InvalidData("binding revision exhausted".into()))?; - let replacement_binding_id = Uuid::new_v4(); - retire_pair_tx(tx, command, &binding, next_version).await?; + apply_rotation_plan_tx(tx, key, command, plan).await +} + +async fn apply_rotation_plan_tx( + tx: &mut Transaction<'_, Postgres>, + key: &OperatorReferenceKey, + command: &OperatorLifecycleCommand, + plan: RotationPlan<'_>, +) -> Result { + let affected_count = plan.affected_count()?; + let RotationPlan { + replacement_reference: _, + replacement, + source: binding, + replacement_binding_id, + lifecycle_revision_precondition, + effects: [effect], + } = plan; let updated = sqlx::query( "UPDATE identity_bindings SET binding_version=$3,binding_state='rotated', \ revoked_at=clock_timestamp(),revoked_by=$4,revoked_reason=$5, \ @@ -937,18 +1129,26 @@ async fn rotate_tx( AND revoked_at IS NULL AND binding_version=$8", ) .bind(command.domain.as_uuid()) - .bind(binding.binding_id) - .bind(i64_revision(next_version)?) + .bind(effect.binding_id) + .bind(i64_revision(effect.current_version)?) .bind(command.authority.actor.digest().as_slice()) .bind(reason_code(command.reason_code)) .bind(replacement.pubkey.as_slice()) .bind(replacement_binding_id) - .bind(i64_revision(binding.version)?) + .bind(i64_revision(effect.previous_version)?) .execute(&mut **tx) .await?; if updated.rows_affected() != 1 { return Ok(OperationAttempt::Denied(DecisionReason::StaleExpectedState)); } + retire_pair_tx( + tx, + command, + &binding, + effect.binding_id, + effect.current_version, + ) + .await?; let policy = hex::encode(replacement.policy_digest); sqlx::query( "INSERT INTO identity_bindings \ @@ -970,7 +1170,7 @@ async fn rotate_tx( (community_id,predecessor_binding_id,successor_binding_id) VALUES ($1,$2,$3)", ) .bind(command.domain.as_uuid()) - .bind(binding.binding_id) + .bind(effect.binding_id) .bind(replacement_binding_id) .execute(&mut **tx) .await?; @@ -978,7 +1178,8 @@ async fn rotate_tx( tx, command, &binding, - next_version, + effect.binding_id, + effect.current_version, "rotated", "rotate", Some(replacement_binding_id), @@ -992,26 +1193,30 @@ async fn rotate_tx( version: 1, provenance: "provisioned".into(), }; - append_binding_history_tx(tx, command, &replacement_row, 1, "active", "rotate", None).await?; + append_binding_history_tx( + tx, + command, + &replacement_row, + replacement_binding_id, + 1, + "active", + "rotate", + None, + ) + .await?; let _ = binding_reference_tx(tx, key, command.domain, replacement_binding_id).await?; - let lifecycle_revision = advance_revision_tx(tx, command.domain, revision).await?; + let lifecycle_revision = + advance_revision_tx(tx, command.domain, lifecycle_revision_precondition).await?; let result = OperatorLifecycleResult { operation_id: command.operation_id, correlation_id: command.correlation_id, action: command.action, status: OperatorLifecycleStatus::Rotated, - affected_count: 1, + affected_count, lifecycle_revision, records: Vec::new(), }; - accept_success_tx( - tx, - command, - &result, - Some((target, binding.version, next_version)), - Some(binding.binding_id), - ) - .await?; + accept_success_tx(tx, command, &result, Some(effect)).await?; Ok(OperationAttempt::Applied(result)) } @@ -1132,41 +1337,33 @@ async fn accept_success_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, result: &OperatorLifecycleResult, - lifecycle: Option<([u8; 32], u64, u64)>, - binding_id: Option, + effect: Option, ) -> Result<()> { - let invalidation_generation = match (lifecycle, binding_id) { - (Some((_, _, current)), Some(binding_id)) => { - Some(apply_binding_invalidation_tx(tx, command, binding_id, current).await?) - } - (None, None) => None, - _ => { - return Err(DbError::InvalidData( - "operator lifecycle invalidation target is incomplete".into(), - )); - } + let invalidation_generation = match effect { + Some(effect) => Some( + apply_binding_invalidation_tx(tx, command, effect.binding_id, effect.current_version) + .await?, + ), + None => None, }; - let effect_id = lifecycle.map(|_| EffectId::generate()); - let payload = lifecycle - .map(|(reference, previous, current)| { - let target = command_target_pseudonym(command, reference)?; - LifecycleEvidenceV1::new( - target, - Some(previous), - Some(current), - None, - effect_id, - invalidation_generation, - None, - ) - .map(EventPayloadV1::Lifecycle) - .map_err(|error| DbError::InvalidData(error.to_string())) - }) - .transpose()? - .unwrap_or(EventPayloadV1::BoundedSummary { + let effect_id = effect.map(|_| EffectId::generate()); + let payload = match effect { + Some(effect) => LifecycleEvidenceV1::new( + effect.target.pseudonym, + Some(effect.previous_version), + Some(effect.current_version), + None, + effect_id, + invalidation_generation, + None, + ) + .map(EventPayloadV1::Lifecycle) + .map_err(|error| DbError::InvalidData(error.to_string())), + None => Ok(EventPayloadV1::BoundedSummary { count: result.affected_count, snapshot_digest: command.semantic_fingerprint, - }); + }), + }?; let event = operator_event( command, result.lifecycle_revision, @@ -1183,7 +1380,7 @@ async fn accept_success_tx( reason: DecisionReason::Applied, payload: Some(payload), summary: None, - binding_version: lifecycle.map(|(_, _, current)| current), + binding_version: effect.map(|effect| effect.current_version), invalidation_generation, }, )?; @@ -1201,7 +1398,7 @@ async fn accept_success_tx( event.event_id(), ) .await?; - if let (Some(effect_id), Some((target, _, _))) = (effect_id, lifecycle) { + if let (Some(effect_id), Some(effect)) = (effect_id, effect) { sqlx::query( "INSERT INTO authorization_operator_effects \ (community_id,effect_id,operation_id,effect_kind,target_reference, \ @@ -1215,7 +1412,7 @@ async fn accept_success_tx( OperatorLifecycleAction::Rotate => 2_i16, _ => return Err(DbError::InvalidData("unexpected operator effect".into())), }) - .bind(target.as_slice()) + .bind(effect.target.reference.as_slice()) .bind(i64_revision(result.lifecycle_revision)?) .bind(event.event_id().as_uuid()) .execute(&mut **tx) @@ -1316,17 +1513,6 @@ fn operator_event( )) } -fn command_target_pseudonym( - command: &OperatorLifecycleCommand, - reference: [u8; 32], -) -> Result { - command - .target_reference - .filter(|value| *value == reference) - .and(command.target_pseudonym) - .ok_or_else(|| DbError::InvalidData("operator target evidence mismatch".into())) -} - async fn insert_receipt_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, @@ -1501,7 +1687,9 @@ async fn binding_reference_tx( { return digest(reference); } - let reference = key.derive(domain, binding_id); + let reference = key + .derive(domain, binding_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?; sqlx::query( "INSERT INTO authorization_operator_binding_refs \ (community_id,binding_reference,binding_id,key_epoch) VALUES ($1,$2,$3,$4)", @@ -1565,6 +1753,37 @@ async fn resolve_active_binding_tx( .transpose() } +async fn resolve_active_binding_candidate_tx( + tx: &mut Transaction<'_, Postgres>, + domain: CommunityId, + reference: [u8; 32], +) -> Result> { + let row = sqlx::query( + "SELECT binding.binding_id,binding.issuer,binding.uid,binding.pubkey, \ + binding.binding_version,binding.binding_provenance \ + FROM authorization_operator_binding_refs reference \ + JOIN identity_bindings binding ON binding.community_id=reference.community_id \ + AND binding.binding_id=reference.binding_id \ + WHERE reference.community_id=$1 AND reference.binding_reference=$2 \ + AND binding.binding_state='active' AND binding.revoked_at IS NULL", + ) + .bind(domain.as_uuid()) + .bind(reference.as_slice()) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(BindingRow { + binding_id: row.try_get("binding_id")?, + issuer: row.try_get("issuer")?, + subject: row.try_get("uid")?, + pubkey: row.try_get("pubkey")?, + version: positive_u64(row.try_get("binding_version")?, "binding version")?, + provenance: row.try_get("binding_provenance")?, + }) + }) + .transpose() +} + async fn has_pending_lineage_tx( tx: &mut Transaction<'_, Postgres>, domain: CommunityId, @@ -1581,14 +1800,14 @@ async fn has_pending_lineage_tx( .await?) } -async fn replacement_denied_tx( +async fn rotation_state_denied_tx( tx: &mut Transaction<'_, Postgres>, domain: CommunityId, binding: &BindingRow, replacement: &[u8; 32], ) -> Result { - // Replacement keys are community-global credentials. A retired or revoked - // key cannot become fresh merely by moving it to another principal. + // Keys are community-global credentials. Neither an ineligible source nor + // a retired or revoked replacement can become fresh through rotation. Ok(sqlx::query_scalar( "SELECT \ EXISTS(SELECT 1 FROM identity_principals WHERE community_id=$1 \ @@ -1597,22 +1816,25 @@ async fn replacement_denied_tx( AND issuer=$2 AND subject=$3) OR \ EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$4) OR \ EXISTS(SELECT 1 FROM identity_migration_denied_keys WHERE community_id=$1 AND pubkey=$4) OR \ - EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$4 \ + EXISTS(SELECT 1 FROM identity_revoked_keys WHERE community_id=$1 AND pubkey=$5) OR \ + EXISTS(SELECT 1 FROM identity_migration_denied_keys WHERE community_id=$1 AND pubkey=$5) OR \ + EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 AND pubkey=$5 \ AND binding_state='active' AND revoked_at IS NULL) OR \ EXISTS(SELECT 1 FROM identity_retired_pairs WHERE community_id=$1 \ - AND pubkey=$4) OR \ + AND pubkey=$5) OR \ EXISTS(SELECT 1 FROM identity_bindings WHERE community_id=$1 \ - AND pubkey=$4 AND revoked_at IS NOT NULL)", + AND pubkey=$5 AND revoked_at IS NOT NULL)", ) .bind(domain.as_uuid()) .bind(&binding.issuer) .bind(&binding.subject) + .bind(&binding.pubkey) .bind(replacement.as_slice()) .fetch_one(&mut **tx) .await?) } -async fn replacement_ineligible_tx( +async fn rotation_ineligible_tx( tx: &mut Transaction<'_, Postgres>, domain: CommunityId, binding: &BindingRow, @@ -1620,13 +1842,14 @@ async fn replacement_ineligible_tx( ) -> Result { Ok(binding.pubkey.as_slice() == replacement || has_pending_lineage_tx(tx, domain, binding).await? - || replacement_denied_tx(tx, domain, binding, replacement).await?) + || rotation_state_denied_tx(tx, domain, binding, replacement).await?) } async fn retire_pair_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, binding: &BindingRow, + binding_id: Uuid, version: u64, ) -> Result<()> { sqlx::query( @@ -1640,7 +1863,7 @@ async fn retire_pair_tx( .bind(&binding.issuer) .bind(&binding.subject) .bind(&binding.pubkey) - .bind(binding.binding_id) + .bind(binding_id) .bind(i64_revision(version)?) .bind(command.authority.actor.digest().as_slice()) .bind(reason_code(command.reason_code)) @@ -1687,6 +1910,7 @@ async fn append_binding_history_tx( tx: &mut Transaction<'_, Postgres>, command: &OperatorLifecycleCommand, binding: &BindingRow, + binding_id: Uuid, version: u64, state: &str, transition: &str, @@ -1699,7 +1923,7 @@ async fn append_binding_history_tx( VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", ) .bind(command.domain.as_uuid()) - .bind(binding.binding_id) + .bind(binding_id) .bind(i64_revision(version)?) .bind(&binding.issuer) .bind(&binding.subject) @@ -1879,6 +2103,329 @@ mod tests { } } + #[derive(Debug, PartialEq, Eq)] + struct LifecycleCounts { + bindings: i64, + retired_pairs: i64, + pending_replacements: i64, + history: i64, + lineage: i64, + effects: i64, + invalidation_domains: i64, + invalidation_receipts: i64, + invalidation_floors: i64, + generic_receipts: i64, + } + + impl LifecycleCounts { + fn one_active_binding() -> Self { + Self { + bindings: 1, + retired_pairs: 0, + pending_replacements: 0, + history: 0, + lineage: 0, + effects: 0, + invalidation_domains: 0, + invalidation_receipts: 0, + invalidation_floors: 0, + generic_receipts: 0, + } + } + + fn one_rotation() -> Self { + Self { + bindings: 2, + retired_pairs: 1, + pending_replacements: 0, + history: 2, + lineage: 1, + effects: 1, + invalidation_domains: 1, + invalidation_receipts: 1, + invalidation_floors: 2, + generic_receipts: 1, + } + } + } + + async fn lifecycle_counts(pool: &sqlx::PgPool, domain: CommunityId) -> LifecycleCounts { + let row: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_bindings WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_retired_pairs WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_pending_replacements WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_history WHERE community_id=$1), \ + (SELECT COUNT(*) FROM identity_binding_lineage WHERE community_id=$1), \ + (SELECT COUNT(*) FROM authorization_operator_effects WHERE community_id=$1), \ + (SELECT COUNT(*) FROM authorization_invalidation_domains WHERE community_id=$1), \ + (SELECT COUNT(*) FROM authorization_invalidation_receipts WHERE community_id=$1), \ + (SELECT COUNT(*) FROM authorization_invalidation_floors WHERE community_id=$1), \ + (SELECT COUNT(*) FROM authorization_operation_receipts WHERE community_id=$1)", + ) + .bind(domain.as_uuid()) + .fetch_one(pool) + .await + .expect("count lifecycle state"); + LifecycleCounts { + bindings: row.0, + retired_pairs: row.1, + pending_replacements: row.2, + history: row.3, + lineage: row.4, + effects: row.5, + invalidation_domains: row.6, + invalidation_receipts: row.7, + invalidation_floors: row.8, + generic_receipts: row.9, + } + } + + async fn seed_active_binding( + fixture: &IsolatedPostgres, + key: &OperatorReferenceKey, + label: &str, + pubkey: [u8; 32], + ) -> (CommunityId, Uuid, [u8; 32]) { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let binding_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain.as_uuid()) + .bind(format!("{}.{}.o5.test", domain.as_uuid(), label)) + .execute(&fixture.pool) + .await + .expect("insert lifecycle test domain"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,binding_id,creation_attribution_kind) \ + VALUES ($1,$2,$3,$4,'db_binding',$5,'legacy_unknown')", + ) + .bind(domain.as_uuid()) + .bind(format!("https://{label}.issuer.invalid")) + .bind(format!("{label}-subject")) + .bind(pubkey.as_slice()) + .bind(binding_id) + .execute(&fixture.pool) + .await + .expect("insert lifecycle test binding"); + let mut tx = fixture.pool.begin().await.expect("begin reference setup"); + let reference = binding_reference_tx(&mut tx, key, domain, binding_id) + .await + .expect("derive lifecycle target reference"); + tx.commit().await.expect("commit reference setup"); + (domain, binding_id, reference) + } + + #[allow(clippy::too_many_arguments)] + fn rotation_command( + pseudonymizer: &Pseudonymizer, + domain: CommunityId, + action: OperatorLifecycleAction, + expected_revision: u64, + target: [u8; 32], + replacement_reference: [u8; 32], + replacement_pubkey: [u8; 32], + seed: u8, + ) -> OperatorLifecycleCommand { + OperatorLifecycleCommand { + domain, + operation_id: Uuid::new_v4(), + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [seed; 32], + expected_revision, + action, + reason_code: 1, + target_reference: Some(target), + target_pseudonym: Some( + pseudonymizer + .derive(domain, ReferenceKind::Binding, &target) + .expect("derive lifecycle target pseudonym"), + ), + replacement_reference: Some(replacement_reference), + replacement: Some( + VerifiedOperatorReplacement::new( + replacement_reference, + replacement_pubkey, + [77; 32], + ) + .expect("build verified lifecycle replacement"), + ), + list_limit: 1, + list_after: None, + authority: authority(pseudonymizer, domain, [97; 32], Some([98; 32])), + } + } + + #[derive(Clone, Copy)] + enum SourceKeySelector { + Revoked, + MigrationDenied, + } + + async fn assert_source_key_selector_blocks_rotation( + selector: SourceKeySelector, + label: &str, + source_key: [u8; 32], + replacement_key: [u8; 32], + ) { + let fixture = IsolatedPostgres::migrated(label).await; + let reference_key = OperatorReferenceKey::new([43; 32], 1).unwrap(); + let pseudonymizer = + Pseudonymizer::new(PseudonymKey::new([53; 32]).expect("pseudonym key"), 1); + let (domain, binding_id, target) = + seed_active_binding(&fixture, &reference_key, label, source_key).await; + match selector { + SourceKeySelector::Revoked => { + sqlx::query( + "INSERT INTO identity_revoked_keys (community_id,pubkey,reason) \ + VALUES ($1,$2,'legacy active overlap')", + ) + .bind(domain.as_uuid()) + .bind(source_key.as_slice()) + .execute(&fixture.pool) + .await + .expect("insert reachable legacy source-key tombstone"); + } + SourceKeySelector::MigrationDenied => { + sqlx::query( + "INSERT INTO identity_migration_denied_keys (community_id,pubkey,reason) \ + VALUES ($1,$2,'ambiguous legacy source key')", + ) + .bind(domain.as_uuid()) + .bind(source_key.as_slice()) + .execute(&fixture.pool) + .await + .expect("insert reachable migrated source-key denial"); + } + } + let selector_facts: (i64, i64, i64, i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM identity_revoked_keys \ + WHERE community_id=$1 AND pubkey=$2), \ + (SELECT COUNT(*) FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$2), \ + (SELECT COUNT(*) FROM identity_revoked_keys \ + WHERE community_id=$1 AND pubkey=$3), \ + (SELECT COUNT(*) FROM identity_migration_denied_keys \ + WHERE community_id=$1 AND pubkey=$3), \ + (SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id=$1 AND pubkey=$3), \ + (SELECT COUNT(*) FROM identity_retired_pairs \ + WHERE community_id=$1 AND pubkey=$3)", + ) + .bind(domain.as_uuid()) + .bind(source_key.as_slice()) + .bind(replacement_key.as_slice()) + .fetch_one(&fixture.pool) + .await + .expect("inspect source selector and fresh replacement"); + let baseline = lifecycle_counts(&fixture.pool, domain).await; + + let preview = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Preview, + 1, + target, + [84; 32], + replacement_key, + 151, + ); + let preview_stale = matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &preview) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ); + let after_preview = lifecycle_counts(&fixture.pool, domain).await; + let preview_facts: (String, i64, bool, i64, i64) = sqlx::query_as( + "SELECT binding.binding_state,binding.binding_version,binding.revoked_at IS NULL, \ + revision.revision, \ + (SELECT COUNT(*) FROM authorization_lifecycle_previews \ + WHERE community_id=$1) \ + FROM identity_bindings binding \ + JOIN authorization_operator_lifecycle_revisions revision \ + ON revision.community_id=binding.community_id \ + WHERE binding.community_id=$1 AND binding.binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect source-key denial after preview"); + + let rotate = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Rotate, + 1, + target, + [84; 32], + replacement_key, + 161, + ); + let rotate_stale = matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &rotate) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ); + let final_counts = lifecycle_counts(&fixture.pool, domain).await; + let final_facts: (String, i64, bool, i64, i64) = sqlx::query_as( + "SELECT binding.binding_state,binding.binding_version,binding.revoked_at IS NULL, \ + revision.revision, \ + (SELECT COUNT(*) FROM authorization_lifecycle_previews \ + WHERE community_id=$1) \ + FROM identity_bindings binding \ + JOIN authorization_operator_lifecycle_revisions revision \ + ON revision.community_id=binding.community_id \ + WHERE binding.community_id=$1 AND binding.binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect source-key denial after rotation"); + + fixture.cleanup().await; + + assert_ne!(source_key, replacement_key); + assert_eq!( + selector_facts, + match selector { + SourceKeySelector::Revoked => (1, 0, 0, 0, 0, 0), + SourceKeySelector::MigrationDenied => (0, 1, 0, 0, 0, 0), + }, + "only the active source key is selected; the replacement stays fresh" + ); + assert_eq!(baseline, LifecycleCounts::one_active_binding()); + assert_eq!( + ( + preview_stale, + after_preview, + preview_facts, + rotate_stale, + final_counts, + final_facts, + ), + ( + true, + LifecycleCounts::one_active_binding(), + ("active".into(), 1, true, 1, 0), + true, + LifecycleCounts::one_active_binding(), + ("active".into(), 1, true, 1, 0), + ), + "source-key selectors deny preview and rotation without lifecycle mutation" + ); + } + #[test] fn reference_key_is_domain_and_epoch_separated_and_redacted() { let first = OperatorReferenceKey::new([7; 32], 1).unwrap(); @@ -1903,6 +2450,490 @@ mod tests { assert!(previews.contains("authorization_decision_events")); } + #[tokio::test] + async fn postgres_record_denial_lock_timeout_is_bounded_and_atomic() { + let fixture = IsolatedPostgres::migrated("operator_denial_timeout").await; + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let operation_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(domain.as_uuid()) + .bind(format!( + "{}.operator-denial-timeout.o5.test", + domain.as_uuid() + )) + .execute(&fixture.pool) + .await + .expect("insert denial timeout test domain"); + sqlx::query( + "INSERT INTO authorization_operator_lifecycle_revisions (community_id,revision) \ + VALUES ($1,1)", + ) + .bind(domain.as_uuid()) + .execute(&fixture.pool) + .await + .expect("insert denial timeout lifecycle revision"); + + let pseudonymizer = + Pseudonymizer::new(PseudonymKey::new([54; 32]).expect("pseudonym key"), 1); + let attempt = OperatorLifecycleDenialAttempt { + domain, + operation_id, + correlation_id: Uuid::new_v4(), + semantic_fingerprint: [91; 32], + expected_revision: 1, + action: OperatorLifecycleAction::Revoke, + reason_code: 2, + actor: pseudonymizer + .derive(domain, ReferenceKind::Actor, &[93; 32]) + .expect("derive denial actor pseudonym"), + provenance_reference: [92; 32], + approvers: Vec::new(), + denial_reason: DecisionReason::EvidenceInvalid, + }; + let mut holder = fixture + .pool + .begin() + .await + .expect("begin revision lock holder"); + let held_revision: i64 = sqlx::query_scalar( + "SELECT revision FROM authorization_operator_lifecycle_revisions \ + WHERE community_id=$1 FOR UPDATE", + ) + .bind(domain.as_uuid()) + .fetch_one(&mut *holder) + .await + .expect("lock exact lifecycle revision row"); + + let failure = tokio::time::timeout( + Duration::from_secs(8), + fixture.db.record_operator_lifecycle_denial(&attempt), + ) + .await + .expect("PostgreSQL lock timeout must bound denial recording") + .expect_err("revision lock contention must fail closed"); + assert!( + matches!( + failure, + OperatorLifecycleFailure::Storage(DbError::Sqlx(sqlx::Error::Database( + ref error + ))) if error.code().as_deref() == Some("55P03") + ), + "the held revision row must surface PostgreSQL lock timeout: {failure:?}" + ); + let partial_writes: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT COUNT(*) FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2), \ + (SELECT COUNT(*) FROM authorization_audit_outbox \ + WHERE community_id=$1 AND operation_id=$2)", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect denial timeout rollback"); + + holder + .rollback() + .await + .expect("release revision lock holder"); + fixture.cleanup().await; + + assert_eq!(held_revision, 1, "the test holds the exact revision row"); + assert_eq!( + partial_writes, + (0, 0), + "lock timeout must leave no denial receipt or audit outbox event" + ); + } + + #[tokio::test] + async fn postgres_preview_and_rotate_share_impact_and_reject_rotate_back() { + let fixture = IsolatedPostgres::migrated("operator_plan").await; + let reference_key = OperatorReferenceKey::new([41; 32], 1).unwrap(); + let pseudonymizer = + Pseudonymizer::new(PseudonymKey::new([51; 32]).expect("pseudonym key"), 1); + let original_key = [31_u8; 32]; + let replacement_key = [32_u8; 32]; + let replacement_reference = [81_u8; 32]; + let (domain, binding_id, target) = + seed_active_binding(&fixture, &reference_key, "plan", original_key).await; + + let preview = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Preview, + 1, + target, + replacement_reference, + replacement_key, + 61, + ); + let preview_operation_id = preview.operation_id; + let previewed = fixture + .db + .execute_operator_lifecycle(&reference_key, &preview) + .await + .expect("preview canonical rotation plan"); + let preview_row: (Vec, Vec, i64, i32) = sqlx::query_as( + "SELECT target_reference,replacement_reference,lifecycle_revision,affected_count \ + FROM authorization_lifecycle_previews \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(domain.as_uuid()) + .bind(preview_operation_id) + .fetch_one(&fixture.pool) + .await + .expect("read persisted preview plan"); + let preview_binding: (String, i64, bool) = sqlx::query_as( + "SELECT binding_state,binding_version,revoked_at IS NULL \ + FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect binding after preview"); + let preview_revision: i64 = sqlx::query_scalar( + "SELECT revision FROM authorization_operator_lifecycle_revisions \ + WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("read lifecycle revision after preview"); + let preview_lifecycle_counts = lifecycle_counts(&fixture.pool, domain).await; + + let rotate = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Rotate, + 1, + target, + replacement_reference, + replacement_key, + 71, + ); + let rotate_operation_id = rotate.operation_id; + let rotated = fixture + .db + .execute_operator_lifecycle(&reference_key, &rotate) + .await + .expect("apply canonical rotation plan"); + let committed_effects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM authorization_operator_effects \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(domain.as_uuid()) + .bind(rotate_operation_id) + .fetch_one(&fixture.pool) + .await + .expect("count committed rotation effects"); + let accepted_receipts: Vec<(i32, i16)> = sqlx::query_as( + "SELECT affected_count,decision_reason \ + FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id IN ($2,$3) ORDER BY action", + ) + .bind(domain.as_uuid()) + .bind(preview_operation_id) + .bind(rotate_operation_id) + .fetch_all(&fixture.pool) + .await + .expect("read accepted preview and rotate receipts"); + let successor_reference: Vec = sqlx::query_scalar( + "SELECT reference.binding_reference \ + FROM authorization_operator_binding_refs reference \ + JOIN identity_bindings binding \ + ON binding.community_id=reference.community_id \ + AND binding.binding_id=reference.binding_id \ + WHERE binding.community_id=$1 AND binding.pubkey=$2 \ + AND binding.binding_state='active' AND binding.revoked_at IS NULL", + ) + .bind(domain.as_uuid()) + .bind(replacement_key.as_slice()) + .fetch_one(&fixture.pool) + .await + .expect("resolve active successor reference"); + let successor_reference = digest(successor_reference).expect("valid successor reference"); + + let rotate_back_preview = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Preview, + 2, + successor_reference, + [82; 32], + original_key, + 91, + ); + let rotate_back_preview_id = rotate_back_preview.operation_id; + let rotate_back_preview_denied = matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &rotate_back_preview) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ); + let rotate_back = rotation_command( + &pseudonymizer, + domain, + OperatorLifecycleAction::Rotate, + 2, + successor_reference, + [82; 32], + original_key, + 101, + ); + let rotate_back_id = rotate_back.operation_id; + let rotate_back_denied = matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &rotate_back) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ); + let denied_receipts: Vec<(i32, i16, i64)> = sqlx::query_as( + "SELECT affected_count,decision_reason,lifecycle_revision \ + FROM authorization_operator_operation_receipts \ + WHERE community_id=$1 AND operation_id IN ($2,$3) ORDER BY action", + ) + .bind(domain.as_uuid()) + .bind(rotate_back_preview_id) + .bind(rotate_back_id) + .fetch_all(&fixture.pool) + .await + .expect("read rejected preview and rotate receipts"); + let final_binding_states: Vec<(Vec, String, i64)> = sqlx::query_as( + "SELECT pubkey,binding_state,binding_version FROM identity_bindings \ + WHERE community_id=$1 ORDER BY pubkey", + ) + .bind(domain.as_uuid()) + .fetch_all(&fixture.pool) + .await + .expect("read final rotation lineage"); + let final_facts: (i64, i64) = sqlx::query_as( + "SELECT revision, \ + (SELECT COUNT(*) FROM authorization_lifecycle_previews \ + WHERE community_id=$1) \ + FROM authorization_operator_lifecycle_revisions WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("read final lifecycle facts"); + let final_lifecycle_counts = lifecycle_counts(&fixture.pool, domain).await; + + fixture.cleanup().await; + + assert_eq!(previewed.status, OperatorLifecycleStatus::Previewed); + assert_eq!(previewed.lifecycle_revision, 1); + assert_eq!(preview_binding, ("active".into(), 1, true)); + assert_eq!(preview_revision, 1); + assert_eq!(preview_row.0, target); + assert_eq!(preview_row.1, replacement_reference); + assert_eq!(preview_row.2, 1); + assert_eq!( + preview_lifecycle_counts, + LifecycleCounts::one_active_binding(), + "preview must not commit any lifecycle mutation or invalidation" + ); + assert_eq!(rotated.status, OperatorLifecycleStatus::Rotated); + assert_eq!(rotated.lifecycle_revision, 2); + assert!(rotate_back_preview_denied); + assert!(rotate_back_denied); + assert_eq!(denied_receipts.len(), 2); + assert!(denied_receipts.iter().all(|row| row.0 == 0 && row.2 == 2)); + assert_eq!(denied_receipts[0].1, denied_receipts[1].1); + assert_eq!( + denied_receipts[0].1, + DecisionReason::StaleExpectedState.discriminant() as i16 + ); + assert_eq!( + final_binding_states, + vec![ + (original_key.to_vec(), "rotated".into(), 2), + (replacement_key.to_vec(), "active".into(), 1), + ] + ); + assert_eq!(final_facts, (2, 1), "denied rotate-back adds no preview"); + assert_eq!( + final_lifecycle_counts, + LifecycleCounts::one_rotation(), + "rotate-back denials must leave the accepted rotation unchanged" + ); + assert_eq!(accepted_receipts.len(), 2); + assert_eq!(accepted_receipts[0].1, accepted_receipts[1].1); + assert_eq!( + accepted_receipts[0].1, + DecisionReason::Applied.discriminant() as i16 + ); + assert_eq!(committed_effects, 1, "rotation applies one target effect"); + assert_eq!( + previewed.affected_count, rotated.affected_count, + "preview and mutation must report the same planned target impact" + ); + assert_eq!(previewed.affected_count, 1); + assert_eq!( + rotated.affected_count, + u32::try_from(committed_effects).unwrap(), + "the result count agrees with the committed target effect" + ); + assert_eq!( + preview_row.3, + i32::try_from(previewed.affected_count).unwrap() + ); + assert!( + accepted_receipts + .iter() + .all(|row| row.0 == i32::try_from(previewed.affected_count).unwrap()), + "preview and mutation receipts retain the same planned affected count" + ); + } + + #[tokio::test] + async fn postgres_revoked_active_source_key_denies_preview_and_rotate() { + assert_source_key_selector_blocks_rotation( + SourceKeySelector::Revoked, + "operator_revoked_source", + [41; 32], + [42; 32], + ) + .await; + } + + #[tokio::test] + async fn postgres_migration_denied_active_source_key_denies_preview_and_rotate() { + assert_source_key_selector_blocks_rotation( + SourceKeySelector::MigrationDenied, + "operator_denied_source", + [44; 32], + [45; 32], + ) + .await; + } + + async fn stale_apply_facts( + action: OperatorLifecycleAction, + label: &str, + source_key: [u8; 32], + seed: u8, + ) -> (bool, (String, i64, bool), i64, LifecycleCounts) { + let fixture = IsolatedPostgres::migrated(label).await; + let reference_key = OperatorReferenceKey::new([42; 32], 1).unwrap(); + let pseudonymizer = + Pseudonymizer::new(PseudonymKey::new([52; 32]).expect("pseudonym key"), 1); + + let (domain, binding_id, target) = + seed_active_binding(&fixture, &reference_key, label, source_key).await; + sqlx::query( + "CREATE FUNCTION o5_reject_lifecycle_update() RETURNS trigger \ + LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF OLD.binding_state='active' \ + AND NEW.binding_state IN ('rotated','revoked') THEN \ + RETURN NULL; \ + END IF; \ + RETURN NEW; \ + END $$", + ) + .execute(&fixture.pool) + .await + .expect("install deterministic lifecycle CAS fault function"); + sqlx::query( + "CREATE TRIGGER o5_reject_lifecycle_update \ + BEFORE UPDATE ON identity_bindings FOR EACH ROW \ + EXECUTE FUNCTION o5_reject_lifecycle_update()", + ) + .execute(&fixture.pool) + .await + .expect("install deterministic lifecycle CAS fault trigger"); + let mut command = rotation_command( + &pseudonymizer, + domain, + action, + 1, + target, + [83; 32], + [source_key[0].wrapping_add(1); 32], + seed, + ); + if action == OperatorLifecycleAction::Revoke { + command.replacement_reference = None; + command.replacement = None; + } + let denied = matches!( + fixture + .db + .execute_operator_lifecycle(&reference_key, &command) + .await, + Err(OperatorLifecycleFailure::Denied( + DecisionReason::StaleExpectedState + )) + ); + let binding: (String, i64, bool) = sqlx::query_as( + "SELECT binding_state,binding_version,revoked_at IS NULL \ + FROM identity_bindings WHERE community_id=$1 AND binding_id=$2", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .fetch_one(&fixture.pool) + .await + .expect("inspect binding after stale rotation apply"); + let revision: i64 = sqlx::query_scalar( + "SELECT revision FROM authorization_operator_lifecycle_revisions \ + WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_one(&fixture.pool) + .await + .expect("read stale-apply lifecycle revision"); + let counts = lifecycle_counts(&fixture.pool, domain).await; + + fixture.cleanup().await; + + (denied, binding, revision, counts) + } + + #[tokio::test] + async fn postgres_zero_row_lifecycle_apply_commits_no_partial_plan() { + let rotate = stale_apply_facts( + OperatorLifecycleAction::Rotate, + "operator_stale_rotate", + [33; 32], + 111, + ) + .await; + let revoke = stale_apply_facts( + OperatorLifecycleAction::Revoke, + "operator_stale_revoke", + [35; 32], + 121, + ) + .await; + assert_eq!( + (rotate, revoke), + ( + ( + true, + ("active".into(), 1, true), + 1, + LifecycleCounts::one_active_binding(), + ), + ( + true, + ("active".into(), 1, true), + 1, + LifecycleCounts::one_active_binding(), + ), + ), + "zero-row rotate and revoke must commit no planned lifecycle side effect" + ); + } + #[tokio::test] async fn postgres_operator_lifecycle_is_atomic_idempotent_and_serialized() { const RAW_ISSUER_CANARY: &str = "https://issuer-canary.invalid/private"; From 42bf4c4027f8bd657bba6497f27954ded4bb1d9a Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:51:05 -0500 Subject: [PATCH 18/18] docs(auth): document federated deployment Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- README.md | 1 + docs/CORPORATE_IDENTITY.md | 4 + docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md | 384 +++++++++++++++++++++ docs/NIP_FI_RUNTIME_OPERATIONS.md | 4 + 4 files changed, 393 insertions(+) create mode 100644 docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md diff --git a/README.md b/README.md index 56439f00bc..8966b6f02a 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH - **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs - **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries - **[TESTING.md](TESTING.md)** — multi-agent E2E test suite +- **[Federated authorization deployment](docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md)** — custom providers, protected communities, lifecycle operations, and rollout safety - **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
diff --git a/docs/CORPORATE_IDENTITY.md b/docs/CORPORATE_IDENTITY.md index 00b47b42bd..ff99fe0469 100644 --- a/docs/CORPORATE_IDENTITY.md +++ b/docs/CORPORATE_IDENTITY.md @@ -5,6 +5,10 @@ Corporate identity is an optional relay policy enabled with after the request proves control of a Nostr key, then admits the request only when the existing community policy also succeeds. +For custom providers, exact per-community authorization modes, restore +protection, and the optional lifecycle operator surface, see +[FEDERATED_AUTHORIZATION_DEPLOYMENT.md](FEDERATED_AUTHORIZATION_DEPLOYMENT.md). + ## Required JWT policy - `BUZZ_CORPORATE_IDENTITY_JWKS_URI` must be HTTPS and contain no credentials. diff --git a/docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md b/docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md new file mode 100644 index 0000000000..492f45d4c4 --- /dev/null +++ b/docs/FEDERATED_AUTHORIZATION_DEPLOYMENT.md @@ -0,0 +1,384 @@ +# Federated authorization deployment + +Buzz ships its provider-neutral authorization runtime inactive. The stock +`buzz-relay` binary does not contain an identity-provider integration and does +not register the lifecycle operator API. This is deliberate: an OSS deployment +must supply its own verified identity input, provider policy, and operator +authentication instead of inheriting a permissive example. + +This guide explains the integration required to deploy that runtime. It is for +relay deployers building a deployment-specific relay binary or composition +crate. It is not necessary for a normal local Buzz installation. + +## Choose the integration model + +Buzz has two related, but different, identity paths: + +| Model | Appropriate when | Activation | +| --- | --- | --- | +| Relay-verified corporate identity | One deployment uses an asymmetric JWT issuer and the built-in binding policy | Configure the stock relay as described in [CORPORATE_IDENTITY.md](CORPORATE_IDENTITY.md) | +| Provider-neutral authorization | A deployment needs its own policy provider, exact per-community modes, bounded authorization leases, durable invalidation, and optional lifecycle operations | Build a deployment composition and follow this guide | + +Do not turn on both paths independently for the same request and then choose the +more permissive answer. A provider-neutral deployment must have one explicit +source of verified evidence and one provider policy for each evaluating +community. + +The client binding indicator is also separate. Authorization works without a +client indicator, and an indicator must never grant access. The stock desktop +client and relay do not gain a presentation surface merely because protected +authorization is installed. + +## What happens on a protected request + +For each request, the relay: + +1. Resolves the community from trusted host state. The request cannot choose + its authorization community or provider profile. +2. Verifies the Nostr proof and obtains verified federated evidence from the + configured deployment adapter. +3. Calls the provider registered for that exact community and capability. +4. Joins the provider decision with authoritative binding and policy state. +5. Issues a bounded lease only in `enforce` mode. +6. Rechecks durable invalidation state while the lease is in use. +7. Denies the operation if any required component is absent, stale, + unavailable, ambiguous, or inconsistent. + +The same policy boundary covers WebSocket operations, the HTTP event/query +bridge, protected media, Git, audio, moderation, and invite operations. Public +health, readiness, and discovery routes retain their documented exemptions. + +## Prerequisites + +Before writing the deployment adapter: + +- Run the database migrations from the exact relay revision being deployed. +- Create every protected community through the normal provisioning path. Each + configured community UUID must exist in the database host map. +- Configure durable PostgreSQL, Redis, and object storage. Restore protection + uses object storage as an independent witness for PostgreSQL high-water + state. +- Configure a stable relay signing key and production TLS. +- Choose either direct assertion delivery or a trusted-proxy design. A trusted + proxy must strip every inbound copy of the assertion header, inject exactly + one verified value, and prevent clients from reaching the relay directly. +- Define the provider's authoritative policy source, outage behavior, and + maximum acceptable invalidation delay. +- Put all pseudonymization keys and provider credentials in a secret manager. + Do not place them in source control or logs. + +## Build the deployment composition + +The deployment composition replaces two stock startup choices: the empty +provider registry and, if lifecycle operations are required, the absence of an +operator router. + +### 1. Implement an authorization provider + +Implement `buzz_auth::AuthorizationProvider` for the deployment's policy +adapter. The implementation must: + +- return a profile ID fixed by trusted server configuration; +- evaluate only the exact typed request it receives; +- perform no binding, membership, or lifecycle mutation; +- return an explicit allow, deny, or unavailable decision; +- use bounded, cancellation-safe asynchronous I/O; and +- treat malformed, conflicting, stale, or incomplete upstream state as deny or + unavailable, never allow. + +Provider cache updates may be used, but each update must become visible +atomically. Dropping a timed-out provider future must not leave a partial +policy mutation behind. + +The OSS repository intentionally contains no production provider. Test +providers are examples of the trait shape only and must not be used in a real +deployment. + +### 2. Supply verified identity evidence + +There are two supported composition shapes: + +- Use the built-in asymmetric JWT verifier and its trusted assertion + provenance. Configure it according to [CORPORATE_IDENTITY.md](CORPORATE_IDENTITY.md), + then install the provider-neutral runtime without a separate evidence + resolver. +- Install a deployment-owned `VerifiedProviderEvidenceResolver`. The resolver + may expose only evidence that was already verified and bound to trusted + request or connection state. It must return `Ambiguous` for multiple or + conflicting sources. + +The resolver is not a shortcut for parsing an arbitrary header. Raw tokens, +untrusted transport classifications, and client-selected domains cannot +construct verified provider evidence. + +### 3. Register providers by exact community + +Construct one `ProductionProviderRegistry` entry for every community using +`shadow`, `verify_only`, or `enforce`. Duplicate entries and missing providers +stop startup. + +The essential installation boundary looks like this: + +```rust +use std::sync::Arc; + +use buzz_auth::AuthorizationProvider; +use buzz_core::CommunityId; +use buzz_relay::authorization_runtime::production::{ + install_from_environment_with_providers_and_evidence, + ProductionProviderRegistry, +}; +use buzz_relay::authorization_runtime::transport::VerifiedProviderEvidenceResolver; + +async fn install_deployment_authorization( + state: &Arc, + community: CommunityId, + provider: Arc, + evidence: Option>, +) -> anyhow::Result<()> { + let providers = ProductionProviderRegistry::new([(community, provider)])?; + + install_from_environment_with_providers_and_evidence( + state, + providers, + evidence, + ) + .await?; + + Ok(()) +} +``` + +For multiple communities, add one exact `(CommunityId, provider)` entry for +each evaluating community. Do not install a global fallback provider. + +Call this function after constructing `AppState` and before building or +serving the Axum router. Installation initializes restore and invalidation +state before protected transports become reachable. A partial installation is +a startup error. + +The stock `main.rs` calls `install_from_environment` with an empty registry. +Setting a non-`off` evaluating mode on the stock binary therefore fails startup +instead of activating an incomplete deployment. + +## Configure community modes + +`BUZZ_PROTECTED_AUTHORIZATION_DOMAINS` is a comma-separated list of +`:` entries: + +```dotenv +BUZZ_PROTECTED_AUTHORIZATION_DOMAINS=\ +11111111-1111-4111-8111-111111111111:shadow,\ +22222222-2222-4222-8222-222222222222:deny_protected +``` + +| Mode | Provider evaluated | Protected access behavior | +| --- | --- | --- | +| `off` | No | Legacy behavior; use only before the community has been durably activated | +| `shadow` | Yes | Observe provider decisions without granting authority or protecting surfaces | +| `verify_only` | Yes | Produce a bounded display-only verification result; it grants no access | +| `enforce` | Yes | Require a successful final decision and issue a bounded access lease | +| `deny_protected` | No | Keep protected surfaces active while denying all protected access | + +An absent or blank domain list leaves the provider-neutral runtime inactive. +After a community has been activated in `enforce` or `deny_protected`, startup +rejects removing it or changing it to `off`, `shadow`, or `verify_only`. This +prevents a stale configuration or rollback from silently reopening protected +surfaces. Use `deny_protected` as the emergency fail-closed state. + +Additional runtime configuration: + +| Variable | Required | Meaning | +| --- | --- | --- | +| `BUZZ_PROTECTED_AUTHORIZATION_PROFILE` | No | Trusted provider profile ID; defaults to `current-membership-v1` | +| `BUZZ_PROTECTED_AUTHORIZATION_LEASE_SECONDS` | No | Positive maximum lease duration; defaults to 300 seconds | +| `BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_HEX` | For evaluating modes | Dedicated 32-byte hex key for audit-only pseudonymous evidence | +| `BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_EPOCH` | For evaluating modes | Positive integer identifying the pseudonymization-key epoch | +| `BUZZ_PROTECTED_AUTHORIZATION_RESTORE_BOOTSTRAPS` | For protected modes | Exact comma-separated `=` mappings | + +For example: + +```dotenv +BUZZ_PROTECTED_AUTHORIZATION_PROFILE=current-membership-v1 +BUZZ_PROTECTED_AUTHORIZATION_LEASE_SECONDS=300 +BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_HEX=<64 lowercase hex characters> +BUZZ_AUTHORIZATION_AUDIT_PSEUDONYM_KEY_EPOCH=1 +BUZZ_PROTECTED_AUTHORIZATION_RESTORE_BOOTSTRAPS=\ +22222222-2222-4222-8222-222222222222= +``` + +Generate each pseudonymization key independently. Do not reuse a JWT signing +key, relay key, operator key, or client-status privacy key. + +## Provision restore protection + +Before a community first enters `enforce` or `deny_protected`, provision its +object-store checkpoint exactly once. Choose a new non-nil bootstrap UUID, +record it in durable deployment configuration, and call: + +```rust +use buzz_relay::authorization_runtime::restore::RestoreProtectionRuntime; + +RestoreProtectionRuntime::provision_domain( + &state.db, + &state.git_store, + community, + bootstrap_id, +) +.await?; +``` + +Provisioning must be an explicit administrative step, not ordinary startup. +It uses a create-only object-store write and fails if the community was already +provisioned. Never generate a new bootstrap UUID on restart. + +At subsequent startups, the configured UUID must match the checkpoint and the +database version vector must be at least as current as the witnessed floor. +Missing checkpoints, stale restores, unwitnessed authority advances, and +ambiguous interrupted commits all stop protected startup. + +Back up both the authoritative database state and the independent checkpoint +store. Restoring only one side is not a supported recovery procedure. + +## Stage the rollout + +Use a separate deployment and evidence record for each stage: + +1. **Disabled:** deploy the custom binary with no configured protected domains. + Confirm ordinary relay behavior is unchanged. +2. **Shadow:** register the provider and observe bounded decision categories. + Compare results with the authoritative policy source without changing + access. +3. **Verify only:** validate exact identity-to-key matching and expiry behavior. + Treat any client-visible status as presentation only. +4. **Provision:** create the independent restore checkpoint and record its + immutable bootstrap UUID. +5. **Enforce:** activate one canary community, validate every protected surface, + then expand only after the canary evidence passes. +6. **Advertise:** publish NIP-FI discovery only after complete, same-revision + conformance and deployment checks succeed. + +Do not use successful `shadow` observations as evidence that enforcement or +restore behavior works. Do not publish discovery merely because one process is +running in `enforce` mode. + +## Install lifecycle operator routes when needed + +Provider-neutral authorization does not require exposing lifecycle HTTP +routes. If a deployment needs list, preview, revoke, or rotate operations, it +must separately provide: + +- an `OperatorAuthenticator` that returns short-lived, intent-bound grants; +- a `DurableOperatorExecutor`, normally `PostgresOperatorExecutor`, configured + with dedicated operator-reference and audit pseudonymization keys; and +- an `OperatorClock` from trusted deployment time. + +Then construct and merge the router explicitly: + +```rust +use std::sync::Arc; + +use buzz_relay::api::operator::lifecycle_router; +use buzz_relay::operator_runtime::OperatorRuntime; +use buzz_relay::router::build_router; + +let operator_runtime = Arc::new(OperatorRuntime::new( + operator_authenticator, + durable_operator_executor, + operator_clock, +)); + +let app = build_router(Arc::clone(&state)) + .merge(lifecycle_router(operator_runtime)); +``` + +The stock router does not register these endpoints: + +- `POST /operator/v1/lifecycle/list` +- `POST /operator/v1/lifecycle/preview` +- `POST /operator/v1/lifecycle/revoke` +- `POST /operator/v1/lifecycle/rotate` + +The authenticator must bind its grant to the exact domain, operation UUID, +intent fingerprint, capability, expiry, actor, credential provenance, and any +independent approvals. Mutations are idempotent by operation UUID and intent; +reusing an operation UUID for different intent must be rejected. Do not expose +these routes until ingress authentication, rate limits, no-store response +handling, and audit retention have been reviewed for the deployment. + +## Discovery and client presentation + +NIP-11 discovery is a claim about a complete deployment, not an enablement +flag. A deployment must construct `ConformanceReadyNipFiDiscovery` from a +complete-stack conformance source and install it into `AppState`. There is no +environment-variable shortcut. + +If trusted-proxy transport is advertised, the conformance source must include +origin-isolation evidence plus negative tests showing that direct relay access +and client-supplied header copies cannot bypass the proxy. + +Relay-authenticated client status has an additional typed approval and +dedicated transport gate. Keep it inactive until its deployment, privacy, and +client compatibility checks pass. A missing, withdrawn, expired, or invalid +status must display as no indicator and must never affect authorization. + +See [NIP_FI_RUNTIME_OPERATIONS.md](NIP_FI_RUNTIME_OPERATIONS.md) for session, +upgrade, rollback, restore, and privacy behavior, and +[nips/NIP-FI-RUNTIME-CONFORMANCE.md](nips/NIP-FI-RUNTIME-CONFORMANCE.md) for the +runtime evidence matrix. + +## Pre-enforcement checklist + +Before changing any community to `enforce`, verify all of the following on the +exact candidate revision: + +```shell +cargo test -p buzz-auth +cargo test -p buzz-relay authorization_runtime +cargo test -p buzz-relay --test nip_fi_runtime_conformance +``` + +- Database migrations and startup reconciliation completed successfully. +- The host resolves to the intended community and cannot be selected by a + client-controlled field. +- Exactly one provider is registered for the community and its profile matches + server configuration. +- Verified evidence is bound to the Nostr signer or explicitly validated + delegated owner as intended. +- Missing, expired, future, malformed, duplicated, and conflicting assertions + are denied. +- Provider timeout, outage, cancellation, and stale-policy cases fail closed. +- WebSocket, HTTP bridge, media `GET`/`HEAD`/upload, Git read/write, audio, + moderation, and invite paths have the expected decisions. +- Disconnect, logout, revocation, rotation, and dependency invalidation end + affected sessions within the documented bound. +- A rotate-back attempt cannot reactivate a retired key pair. +- The restore checkpoint exists, matches its immutable bootstrap UUID, and + rejects a staged stale-database restore. +- Trusted-proxy deployments pass direct-bypass and inbound-header-copy + negative tests. +- Logs, metrics, traces, fixtures, and alerts contain no raw assertions, + issuer-qualified subjects, display names, emails, or provider-private data. +- Discovery remains absent until every applicable conformance row passes at + the same revision. + +## Failure and rollback behavior + +Treat startup refusal as a security control. Do not bypass failures for missing +providers, evidence provenance, verifiers, community host mappings, audit +keys, restore checkpoints, or worker initialization. + +For an incident: + +1. Remove NIP-FI discovery before or with the first rollback. +2. Change affected activated communities to `deny_protected` if access must be + stopped. +3. Preserve binding, lifecycle, invalidation, audit, and restore-witness state. +4. Roll back only to a revision that understands the durable protected state; + an activated community cannot safely return to legacy behavior by deleting + configuration. +5. Re-run same-revision conformance before restoring discovery or client + presentation. + +Detailed restart and restore requirements are in +[NIP_FI_RUNTIME_OPERATIONS.md](NIP_FI_RUNTIME_OPERATIONS.md). diff --git a/docs/NIP_FI_RUNTIME_OPERATIONS.md b/docs/NIP_FI_RUNTIME_OPERATIONS.md index f9f1a1fd57..937592f380 100644 --- a/docs/NIP_FI_RUNTIME_OPERATIONS.md +++ b/docs/NIP_FI_RUNTIME_OPERATIONS.md @@ -5,6 +5,10 @@ separate disabled relay-authenticated client-status contract. It does not authorize enabling a provider, a client presentation surface, or a conformance claim. +For the deployment composition, provider registration, configuration, staged +activation, and pre-enforcement checks, see +[FEDERATED_AUTHORIZATION_DEPLOYMENT.md](FEDERATED_AUTHORIZATION_DEPLOYMENT.md). + ## Session and reconnect behavior For WebSocket authorization, the assertion belongs on the upgrade request and