diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index 262a9f1c04..df7b0fea5a 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -185,7 +185,6 @@ impl ResolvedFederatedPolicy { pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { Self { stamp } } - #[cfg(test)] pub(crate) fn not_required(authorization_domain: CommunityId) -> Self { Self::from_authoritative_resolution( @@ -735,7 +734,7 @@ impl VersionedBindingRef { } /// Stable reason proven by the authoritative binding lifecycle result. - pub(super) const fn authorization_reason(&self) -> AuthorizationReason { + pub(crate) const fn authorization_reason(&self) -> AuthorizationReason { self.resolution_reason } } diff --git a/crates/buzz-auth/src/finalization.rs b/crates/buzz-auth/src/finalization.rs new file mode 100644 index 0000000000..acd1725df1 --- /dev/null +++ b/crates/buzz-auth/src/finalization.rs @@ -0,0 +1,1055 @@ +//! Federated authorization finalization. +//! +//! This is the only crate-owned path from a validated provider capability +//! snapshot to an access lease. Display-only verification is returned as a +//! separate type that cannot be converted into an [`crate::AuthContext`] or +//! consumed by protected-operation lease validation. + +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{ + validate_context_evidence, AuthContext, AuthContextError, AuthContextInput, BindingVersion, + FederatedAuthorization, FederatedIdentityRequirement, ResolvedFederatedPolicy, + VersionedBindingRef, + }, + lease::{ + conservative_expiry, AccessLeasePolicy, AuthorizationClockError, AuthorizationLease, + AuthorizationTime, BindingLeaseBound, LeaseIssueError, LeaseVersion, + SharedAuthorizationClock, VerificationStatusPolicy, + }, + provider::{AuthorizationProfileId, CapabilitySnapshot, DecisionSource, PolicyVersion}, +}; + +/// Finalizer using one centrally injected clock for all authorization time. +#[derive(Clone)] +pub struct AuthorizationFinalizer { + clock: SharedAuthorizationClock, +} + +impl AuthorizationFinalizer { + /// Create a finalizer backed by the supplied central authorization clock. + pub fn new(clock: SharedAuthorizationClock) -> Self { + Self { clock } + } + + /// Read current time from the injected authorization clock. + pub fn now(&self) -> Result { + self.clock.now() + } + + /// Finalize enforcing federated authority and issue one bounded lease. + /// + /// The provider snapshot must be the current allow decision for the exact + /// domain, actor, transport, principal, profile, and correlation ID. The + /// resulting lease expires at the earliest provider/identity bound, + /// binding freshness bound, or configured application maximum, shortened + /// by the explicit conservative clock skew. + #[allow(clippy::too_many_arguments)] + pub fn finalize_access( + &self, + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + expected_profile: &AuthorizationProfileId, + binding_bound: BindingLeaseBound, + lease_policy: AccessLeasePolicy, + lease_version: LeaseVersion, + ) -> Result { + let now = self.now()?; + validate_context_evidence( + &input, + &federated_policy, + &authorization, + now.unix_seconds(), + )?; + let active_binding = validate_provider_evidence( + &input, + &federated_policy, + &authorization, + &snapshot, + expected_profile, + &binding_bound, + now, + )?; + let lease = AuthorizationLease::issue( + lease_version, + snapshot.authorization_domain(), + snapshot.transport(), + snapshot.actor_pubkey(), + snapshot.owner_pubkey(), + active_binding, + binding_bound, + snapshot.profile_id().clone(), + snapshot.policy_version().clone(), + snapshot.capabilities().clone(), + snapshot.effective_until(), + now, + lease_policy, + snapshot.correlation_id(), + )?; + Ok(AuthContext::finalize_v1_with_lease( + input, + federated_policy, + authorization, + lease, + now.unix_seconds(), + )?) + } + + /// Finalize display-only verification without issuing access authority. + /// + /// This path requires a direct active binding for the event-author key. + /// The returned type carries no capability set, access lease, membership, + /// or conversion into an authorized context. + #[allow(clippy::too_many_arguments)] + pub fn finalize_verification_only( + &self, + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + expected_profile: &AuthorizationProfileId, + binding_bound: BindingLeaseBound, + status_policy: VerificationStatusPolicy, + ) -> Result { + let now = self.now()?; + validate_context_evidence( + &input, + &federated_policy, + &authorization, + now.unix_seconds(), + )?; + let active_binding = validate_provider_evidence( + &input, + &federated_policy, + &authorization, + &snapshot, + expected_profile, + &binding_bound, + now, + )?; + if !matches!(authorization, FederatedAuthorization::Direct { .. }) { + return Err(FinalizationError::VerificationRequiresDirectBinding); + } + let expires_at = conservative_expiry( + now, + snapshot.effective_until(), + binding_bound.valid_until(), + status_policy.application_limit(), + status_policy.clock_skew(), + )?; + Ok(VerificationOnlyDisposition { + authorization_domain: snapshot.authorization_domain(), + actor_pubkey: snapshot.actor_pubkey(), + binding_id: active_binding.binding_id(), + binding_version: active_binding.binding_version(), + profile_id: snapshot.profile_id().clone(), + policy_version: snapshot.policy_version().clone(), + correlation_id: snapshot.correlation_id(), + issued_at: now.unix_seconds(), + expires_at, + }) + } +} + +impl fmt::Debug for AuthorizationFinalizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationFinalizer") + .field("clock", &"[injected]") + .finish() + } +} + +/// Short-lived, display-only proof of a current direct binding. +/// +/// This type is deliberately not an authorization context or lease. Protected +/// operations accept [`AuthorizationLease`], so a +/// verification-only result cannot grant access even if a caller retains it. +#[must_use] +#[derive(PartialEq, Eq)] +pub struct VerificationOnlyDisposition { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + correlation_id: Uuid, + issued_at: u64, + expires_at: u64, +} + +impl VerificationOnlyDisposition { + /// Exact authorization domain represented by the display status. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Event-author key whose direct active binding was verified. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Stable direct binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact active binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Current opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Correlation identifier for the display-only decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Central issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Conservative display-status expiry in Unix seconds. + pub const fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Check display freshness using the supplied central authorization clock. + /// + /// This check only controls presentation and is not an access decision. + pub fn is_current( + &self, + clock: &dyn crate::AuthorizationClock, + ) -> Result { + let now = clock.now()?; + Ok(now.unix_seconds() >= self.issued_at && now.unix_seconds() < self.expires_at) + } +} + +impl fmt::Debug for VerificationOnlyDisposition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerificationOnlyDisposition") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +fn validate_provider_evidence<'a>( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + authorization: &'a FederatedAuthorization, + snapshot: &CapabilitySnapshot, + expected_profile: &AuthorizationProfileId, + binding_bound: &BindingLeaseBound, + now: AuthorizationTime, +) -> Result<&'a VersionedBindingRef, FinalizationError> { + if !matches!( + federated_policy.requirement(), + FederatedIdentityRequirement::Required(_) + ) { + return Err(FinalizationError::FederatedPolicyRequired); + } + let domain = input.tenant().community(); + let proof = input.nostr_proof(); + if federated_policy.authorization_domain() != domain + || !snapshot.is_bound_to_federated_policy(federated_policy) + || snapshot.authorization_domain() != domain + || snapshot.transport() != proof.authorized_transport() + || snapshot.actor_pubkey() != proof.actor_pubkey() + || snapshot.proof_method() != proof.proof_method() + || snapshot.correlation_id() != input.correlation_id() + || snapshot.profile_id() != expected_profile + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + if snapshot.issued_at() > now.unix_seconds() + || snapshot.fresh_until() <= now.unix_seconds() + || snapshot.effective_until() <= now.unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceStale); + } + let active_binding = authorization + .active_binding() + .ok_or(FinalizationError::FederatedAuthorizationRequired)?; + if active_binding.binding_id() != binding_bound.binding_id() + || active_binding.binding_version() != binding_bound.binding_version() + { + return Err(FinalizationError::BindingEvidenceMismatch); + } + match authorization { + FederatedAuthorization::NotRequired => { + return Err(FinalizationError::FederatedAuthorizationRequired); + } + FederatedAuthorization::Direct { binding, assertion } => { + if snapshot.decision_source() != DecisionSource::DirectAssertion + || snapshot.owner_pubkey().is_some() + || snapshot.binding_id().is_some() + || snapshot.binding_version().is_some() + || snapshot.principal() != binding.principal() + || snapshot.principal() != assertion.principal() + || snapshot.effective_until() > assertion.expires_at().unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + } + FederatedAuthorization::Delegated { owner, admission } => { + if !proof + .verified_delegation() + .is_some_and(|delegation| delegation.capability().is_transport_wide()) + { + return Err(FinalizationError::UnsupportedDelegationScope); + } + if snapshot.decision_source() != DecisionSource::DelegatedOwnerBinding + || snapshot.owner_pubkey() != Some(owner.bound_pubkey()) + || snapshot.binding_id() != Some(owner.binding_id()) + || snapshot.binding_version() != Some(owner.binding_version()) + || snapshot.principal() != owner.principal() + || snapshot.principal() != admission.principal() + || snapshot.fresh_until() != admission.fresh_until().unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + if let Some(delegation_expiry) = proof + .verified_delegation() + .and_then(|delegation| delegation.expires_at()) + { + if snapshot.effective_until() > delegation_expiry.unix_seconds() { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + } + } + } + Ok(active_binding) +} + +/// Fail-closed federated finalization error. +#[derive(Debug, Error)] +pub enum FinalizationError { + /// The central authorization clock failed. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// Existing context evidence was invalid. + #[error(transparent)] + Context(#[from] AuthContextError), + /// A bounded lease or display expiry could not be issued. + #[error(transparent)] + Lease(#[from] LeaseIssueError), + /// The server-resolved policy did not require federated authorization. + #[error("federated finalization requires server-resolved federated policy")] + FederatedPolicyRequired, + /// No direct or delegated active binding evidence was supplied. + #[error("federated finalization requires active binding authorization")] + FederatedAuthorizationRequired, + /// The provider snapshot did not match the exact finalization evidence. + #[error("provider decision does not match finalization evidence")] + ProviderEvidenceMismatch, + /// The provider snapshot was future-issued, stale, or expired. + #[error("provider decision is no longer current")] + ProviderEvidenceStale, + /// Binding freshness evidence named another binding or version. + #[error("binding freshness evidence does not match active binding")] + BindingEvidenceMismatch, + /// Display verification was attempted for delegated rather than direct authority. + #[error("verification-only display requires the event author's direct active binding")] + VerificationRequiresDirectBinding, + /// Narrower delegation evidence reached the transport-wide finalizer. + #[error("operation-bound delegation cannot be promoted to transport-wide authority")] + UnsupportedDelegationScope, +} + +impl FinalizationError { + /// Stable audit and metric code. + pub fn code(&self) -> &'static str { + match self { + Self::Clock(_) => "authorization_finalize_001", + Self::Context(error) => error.code(), + Self::Lease(error) => error.code(), + Self::FederatedPolicyRequired => "authorization_finalize_002", + Self::FederatedAuthorizationRequired => "authorization_finalize_003", + Self::ProviderEvidenceMismatch => "authorization_finalize_004", + Self::ProviderEvidenceStale => "authorization_finalize_005", + Self::BindingEvidenceMismatch => "authorization_finalize_006", + Self::VerificationRequiresDirectBinding => "authorization_finalize_007", + Self::UnsupportedDelegationScope => "authorization_finalize_008", + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + use std::time::Duration; + + use nostr::Keys; + + use super::*; + use crate::{ + context::{ + AssertionExpiry, AssertionTransport, AuthContextVersion, AuthMethod, AuthTransport, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, + AuthorizedCommunityAccess, BindingSource, DelegationExpiry, EnrollmentMode, + FederatedPolicyStamp, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedTransportDelegation, + }, + lease::{ + ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockSkew, + AuthorizationLeaseValidator, LeaseRenewalAction, LeaseRenewalLeadTime, + LeaseUseRequirement, + }, + provider::{ + resolve_authorization, AuthorizationCapability, AuthorizationOutcome, + AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, + CapabilitySet, ProviderAllow, ProviderDecision, ProviderTimeout, + }, + Scope, + }; + + struct FixedClock(AtomicU64); + + impl FixedClock { + fn new(now: u64) -> Self { + Self(AtomicU64::new(now)) + } + + fn set(&self, now: u64) { + self.0.store(now, Ordering::SeqCst); + } + } + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds( + self.0.load(Ordering::SeqCst), + )) + } + } + + impl crate::provider::AuthorizationClock for FixedClock { + fn now_unix_seconds(&self) -> Option { + Some(self.0.load(Ordering::SeqCst)) + } + } + + struct AllowProvider { + issued_at: u64, + fresh_until: u64, + } + + impl AuthorizationProvider for AllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + self.profile_id(), + request.requested_capabilities().clone(), + PolicyVersion::new("policy.synthetic.example") + .expect("synthetic policy version is valid"), + self.issued_at, + self.fresh_until, + ) + .expect("synthetic provider result is valid"); + Box::pin(std::future::ready(ProviderDecision::Allow(allow))) + } + } + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0x100)) + } + + fn profile() -> AuthorizationProfileId { + AuthorizationProfileId::from_server_configuration("profile.synthetic.example") + .expect("synthetic profile is valid") + } + + fn required_policy( + correlation_id: Uuid, + enrollment_mode: EnrollmentMode, + ) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(), + Uuid::from_u128(0x40), + 1, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + struct DirectFixture { + input: AuthContextInput, + policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + binding_bound: BindingLeaseBound, + binding_id: Uuid, + binding_version: BindingVersion, + actor_pubkey: PublicKey, + } + + fn direct_fixture(assertion_expiry: u64, binding_expiry: u64) -> DirectFixture { + let actor = Keys::generate().public_key(); + let principal = + crate::FederatedPrincipal::new("https://issuer.synthetic.example", "subject-synthetic") + .expect("synthetic principal is valid"); + let proof = VerifiedNostrProof::new( + domain(), + AuthTransport::RelayWebSocket, + actor, + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(), + AuthTransport::RelayWebSocket, + principal.clone(), + Some(VerifiedKeyAttestation::new(actor)), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(assertion_expiry).expect("synthetic expiry is valid"), + ); + let binding_id = Uuid::from_u128(0x200); + let binding_version = BindingVersion::new(7).expect("synthetic version is valid"); + let binding = VersionedBindingRef::new_existing_active_for_test( + domain(), + binding_id, + principal, + actor, + binding_version, + None, + BindingSource::AttestedKey, + ) + .expect("synthetic binding is valid"); + let binding_bound = BindingLeaseBound::new(&binding, binding_expiry) + .expect("synthetic binding bound is valid"); + let tenant = + buzz_core::tenant::TenantContext::resolved(domain(), "relay.synthetic.example"); + DirectFixture { + input: AuthContextInput::new( + tenant, + Uuid::from_u128(0x300), + proof, + AuthorizedCommunityAccess::new(domain(), vec![Scope::MessagesRead], None), + ), + policy: required_policy(Uuid::from_u128(0x300), EnrollmentMode::AttestedKey), + authorization: FederatedAuthorization::Direct { binding, assertion }, + binding_bound, + binding_id, + binding_version, + actor_pubkey: actor, + } + } + + async fn direct_snapshot( + fixture: &DirectFixture, + clock: &FixedClock, + provider_fresh_until: u64, + ) -> Box { + let FederatedAuthorization::Direct { assertion, .. } = &fixture.authorization else { + panic!("direct fixture must contain direct authorization"); + }; + let request = AuthorizationRequest::direct( + fixture.input.nostr_proof(), + assertion, + required_policy(fixture.input.correlation_id(), EnrollmentMode::AttestedKey), + CapabilitySet::single(AuthorizationCapability::CommunityRead), + fixture.input.correlation_id(), + 1_000, + ) + .expect("synthetic request is valid"); + let outcome = resolve_authorization( + &AllowProvider { + issued_at: 999, + fresh_until: provider_fresh_until, + }, + &request, + clock, + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), + ) + .await; + match outcome { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic provider must allow, got {other:?}"), + } + } + + fn access_policy(limit_seconds: u64) -> AccessLeasePolicy { + AccessLeasePolicy::new( + ApplicationLeaseLimit::from_seconds(limit_seconds) + .expect("synthetic application limit is valid"), + AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"), + ) + } + + #[tokio::test] + async fn direct_lease_carries_binding_and_earliest_application_expiry() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context + .authorization_lease() + .expect("enforcing context carries a lease"); + assert_eq!(lease.binding_id(), fixture.binding_id); + assert_eq!(lease.binding_version(), fixture.binding_version); + assert_eq!(lease.expires_at(), 1_095); + assert_eq!( + lease.capabilities().as_slice(), + &[AuthorizationCapability::CommunityRead] + ); + } + + #[test] + fn earliest_expiry_includes_provider_binding_application_and_skew() { + let now = AuthorizationTime::from_unix_seconds(1_000); + let limit = ApplicationLeaseLimit::from_seconds(500).expect("synthetic limit is valid"); + let skew = AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"); + assert_eq!( + conservative_expiry(now, 1_100, 1_200, limit, skew), + Ok(1_095) + ); + assert_eq!( + conservative_expiry(now, 1_300, 1_080, limit, skew), + Ok(1_075) + ); + let short_limit = + ApplicationLeaseLimit::from_seconds(60).expect("synthetic limit is valid"); + assert_eq!( + conservative_expiry(now, 1_300, 1_200, short_limit, skew), + Ok(1_055) + ); + } + + #[tokio::test] + async fn operation_guard_is_per_capability_and_revalidates_at_commit() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let validator = AuthorizationLeaseValidator::new(clock.clone()); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + let guard = context + .operation_guard(&validator, requirement) + .expect("exact capability creates an operation guard"); + guard + .revalidate() + .expect("guard remains valid before commit boundary"); + clock.set(lease.expires_at()); + assert!(matches!( + guard.revalidate(), + Err(crate::LeaseValidationError::Expired) + )); + } + + #[tokio::test] + async fn same_version_different_binding_is_denied() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id: Uuid::from_u128(0x201), + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + + assert_eq!( + AuthorizationLeaseValidator::new(clock).authorize(lease, &requirement), + Err(crate::LeaseValidationError::BindingIdMismatch) + ); + } + + #[tokio::test] + async fn same_policy_version_different_profile_is_denied() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: AuthorizationProfileId::from_server_configuration( + "other-profile.synthetic.example", + ) + .expect("synthetic profile is valid"), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + + assert_eq!( + AuthorizationLeaseValidator::new(clock).authorize(lease, &requirement), + Err(crate::LeaseValidationError::AuthorizationProfileMismatch) + ); + } + + #[tokio::test] + async fn typed_version_policy_and_capability_mismatches_fail_closed() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let validator = AuthorizationLeaseValidator::new(clock); + let mut requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::new(2).expect("synthetic version is valid"), + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::LeaseVersionMismatch) + )); + requirement.lease_version = LeaseVersion::INITIAL; + requirement.policy_version = + PolicyVersion::new("changed.synthetic.example").expect("synthetic version is valid"); + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::PolicyVersionMismatch) + )); + requirement.policy_version = lease.policy_version().clone(); + requirement.capability = AuthorizationCapability::CommunityWrite; + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::MissingCapability) + )); + } + + #[tokio::test] + async fn verification_only_is_short_lived_and_has_no_access_context() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let status = finalizer + .finalize_verification_only( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + VerificationStatusPolicy::new( + ApplicationLeaseLimit::from_seconds(30) + .expect("synthetic status limit is valid"), + AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"), + ), + ) + .expect("complete current direct evidence produces display status"); + assert_eq!(status.expires_at(), 1_025); + assert!(status + .is_current(clock.as_ref()) + .expect("clock is available")); + } + + #[tokio::test] + async fn renewal_hooks_expose_renew_and_hard_expiry_boundaries() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let schedule = lease.renewal_schedule( + LeaseRenewalLeadTime::from_seconds(20).expect("synthetic lead is valid"), + ); + let validator = AuthorizationLeaseValidator::new(clock.clone()); + assert_eq!(schedule.renew_at(), 1_075); + assert_eq!(schedule.expires_at(), 1_095); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::Current + ); + clock.set(schedule.renew_at()); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::RenewNow + ); + clock.set(schedule.expires_at()); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::Expired + ); + } + + #[tokio::test] + async fn delegated_lease_is_bounded_by_delegation_and_owner_binding() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let owner = Keys::generate().public_key(); + let delegate = Keys::generate().public_key(); + let principal = crate::FederatedPrincipal::new( + "https://issuer.synthetic.example", + "owner-subject-synthetic", + ) + .expect("synthetic principal is valid"); + let proof = VerifiedNostrProof::new( + domain(), + AuthTransport::RelayWebSocket, + delegate, + AuthMethod::Nip42, + Some( + VerifiedTransportDelegation::new_unrestricted( + owner, + delegate, + Some( + DelegationExpiry::new(1_050).expect("synthetic delegation expiry is valid"), + ), + ) + .expect("synthetic delegation is valid"), + ), + ) + .expect("synthetic proof is valid"); + let binding_id = Uuid::from_u128(0x400); + let binding_version = BindingVersion::new(9).expect("synthetic version is valid"); + let owner_binding = VersionedBindingRef::new_existing_active_for_test( + domain(), + binding_id, + principal.clone(), + owner, + binding_version, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding is valid"); + let owner_resolution = AuthoritativeBindingResolution::existing_active( + AuthoritativeBindingEvidence::new( + domain(), + binding_id, + principal, + owner, + binding_version, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding resolution is valid"), + ); + let request = AuthorizationRequest::delegated( + &proof, + &owner_resolution, + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), + CapabilitySet::single(AuthorizationCapability::CommunityWrite), + Uuid::from_u128(0x500), + 1_000, + ) + .expect("synthetic delegated request is valid"); + let snapshot = match resolve_authorization( + &AllowProvider { + issued_at: 999, + fresh_until: 1_300, + }, + &request, + clock.as_ref(), + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), + ) + .await + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic provider must allow, got {other:?}"), + }; + assert_eq!(snapshot.effective_until(), 1_050); + let binding_bound = BindingLeaseBound::new(&owner_binding, 1_400) + .expect("synthetic binding bound is valid"); + let admission = snapshot + .verified_owner_admission(&owner_binding) + .expect("delegated snapshot matches the exact owner binding"); + let input = AuthContextInput::new( + buzz_core::tenant::TenantContext::resolved(domain(), "relay.synthetic.example"), + Uuid::from_u128(0x500), + proof, + AuthorizedCommunityAccess::new(domain(), vec![Scope::MessagesWrite], None), + ); + let authorization = FederatedAuthorization::Delegated { + owner: owner_binding, + admission, + }; + let context = finalizer + .finalize_access( + input, + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), + authorization, + snapshot, + &expected_profile, + binding_bound, + access_policy(500), + LeaseVersion::INITIAL, + ) + .expect("delegated evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + assert_eq!(lease.owner_pubkey(), Some(owner)); + assert_eq!(lease.binding_id(), binding_id); + assert_eq!(lease.binding_version(), binding_version); + assert_eq!(lease.expires_at(), 1_045); + } +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 31577f8f62..06b7c4005d 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -25,6 +25,8 @@ pub mod context; pub mod error; /// Trusted-workspace adapter for sealed verifier and binding evidence. pub mod evidence_adapter; +/// Federated provider-evidence finalization. +pub mod finalization; /// Bounded, versioned authorization leases. pub mod lease; /// NIP-42 challenge–response authentication. @@ -51,7 +53,8 @@ pub use context::{ ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError, ResolvedFederatedPolicy, VerifiedFederatedAssertion, - VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedProviderEvidence, + VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOperationBinding, + VerifiedOperationBindingKind, VerifiedOwnerAdmission, VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, }; pub use error::AuthError; @@ -59,17 +62,7 @@ pub use evidence_adapter::{ ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput, VerifiedEvidenceAdapter, }; - -/// Opaque display-only result used by the protected-transport interface. -/// -/// The finalization slice replaces this compatibility type with the complete -/// current-binding disposition. It intentionally has no public constructor so -/// this earlier review unit cannot mint verification status or authority. -#[must_use] -#[derive(PartialEq, Eq)] -pub struct VerificationOnlyDisposition { - _private: (), -} +pub use finalization::{AuthorizationFinalizer, FinalizationError, VerificationOnlyDisposition}; pub use lease::{ AccessLeasePolicy, ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockError, AuthorizationClockSkew, AuthorizationLease, AuthorizationLeaseValidator, @@ -86,14 +79,14 @@ pub use nip98_replay::{ MAX_REPLAY_TTL_SECS, }; pub use provider::{ - AuthorizationAuthority, AuthorizationCapability, + resolve_authorization, AuthorizationAuthority, AuthorizationCapability, AuthorizationClock as ProviderAuthorizationClock, AuthorizationDenial, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, - CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, - ProviderAuthorizationError, ProviderContractError, ProviderDecision, ProviderTimeout, - ProviderUnavailable, ProviderUnavailableReason, RetryAfter, MAX_PROVIDER_FRESHNESS_SECONDS, - MAX_PROVIDER_TIMEOUT, + CapabilitySnapshot, DecisionSource, OwnerAdmissionError, PolicyVersion, ProviderAllow, + ProviderAllowReason, ProviderAuthorizationError, ProviderContractError, ProviderDecision, + ProviderTimeout, ProviderUnavailable, ProviderUnavailableReason, RetryAfter, + MAX_PROVIDER_FRESHNESS_SECONDS, MAX_PROVIDER_TIMEOUT, }; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 4db5d56d21..499e7be7d1 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -15,10 +15,10 @@ use crate::context::{ authority::{resolve_direct_binding, resolve_existing_binding}, resolve_current_federated_policy, AdmissionExpiry, AssertionTransport, AuthContext, AuthContextError, AuthContextInput, AuthMethod, AuthTransport, AuthoritativeBindingResolution, - AuthoritativeFederatedResolution, AuthorityAdapterError, BindingVersion, + AuthoritativeFederatedResolution, AuthorityAdapterError, AuthorizationReason, BindingVersion, CapabilityFinalizationSeal, FederatedAuthorityAdapter, FederatedPolicyStamp, FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, - VerifiedOwnerAdmission, + VerifiedOwnerAdmission, VersionedBindingRef, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -68,6 +68,11 @@ impl fmt::Debug for AuthorizationCapability { pub struct CapabilitySet(Vec); impl CapabilitySet { + /// Build the exact one-capability request used by on-demand authorization. + pub fn single(capability: AuthorizationCapability) -> Self { + Self(vec![capability]) + } + /// Build a non-empty set, sorting and removing duplicate capabilities. pub fn new( mut capabilities: Vec, @@ -129,6 +134,11 @@ impl AuthorizationProfileId { } Ok(Self(value)) } + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn new(value: impl Into) -> Result { + Self::from_server_configuration(value) + } /// Exact profile identifier for provider routing. pub fn as_str(&self) -> &str { &self.0 @@ -359,6 +369,90 @@ impl AuthorizationRequest { let Some(delegation) = proof.verified_delegation() else { return Err(ProviderContractError::DelegationRequired); }; + if !delegation.capability().is_transport_wide() { + return Err(ProviderContractError::UnsupportedDelegationScope); + } + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(ProviderContractError::DelegatedOwnerMismatch); + } + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::DelegationExpired); + } + if owner + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::BindingExpired); + } + let evidence_valid_from = federated_policy.stamp().effective_from(); + let mut evidence_valid_until = federated_policy.stamp().effective_until(); + if let Some(delegation) = delegation.expires_at() { + evidence_valid_until = evidence_valid_until.min(delegation.unix_seconds()); + } + if let Some(binding) = owner.expires_at() { + evidence_valid_until = evidence_valid_until.min(binding.unix_seconds()); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Delegated { + owner_pubkey: owner.bound_pubkey(), + binding_id: owner.binding_id(), + binding_version: owner.binding_version(), + }, + principal: owner.principal().clone(), + key_attested: false, + assertion_transport: None, + assertion_not_before: None, + assertion_expires_at: None, + federated_policy: federated_policy.into_stamp(), + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DelegatedOwnerBinding, + evidence_valid_from, + evidence_valid_until, + }) + } + + /// Build a delegated request from a sealed active binding-store record. + /// + /// The evidence adapter can create this record only from typed current + /// storage output. Enrolled-in-request bindings are rejected so delegated + /// authorization retains O3's existing-active requirement. + pub fn delegated_from_active_binding( + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; + if proof.authorization_domain() != owner.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if owner.authorization_reason() != AuthorizationReason::ExistingBinding { + return Err(ProviderContractError::DelegatedBindingNotExistingActive); + } + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired); + }; + if !delegation.capability().is_transport_wide() { + return Err(ProviderContractError::UnsupportedDelegationScope); + } if delegation.owner_pubkey() != owner.bound_pubkey() { return Err(ProviderContractError::DelegatedOwnerMismatch); } @@ -1138,7 +1232,6 @@ impl CapabilitySnapshot { pub const fn reason(&self) -> ProviderAllowReason { self.reason } - /// Consume a direct capability decision and finalize authoritative context. /// /// The current enrollment policy is reread after provider I/O, then the @@ -1368,6 +1461,38 @@ impl CapabilitySnapshot { } Ok(()) } + + /// Derive current delegated-owner admission for one exact active binding. + pub fn verified_owner_admission( + &self, + owner: &VersionedBindingRef, + ) -> Result { + if self.decision_source != DecisionSource::DelegatedOwnerBinding { + return Err(OwnerAdmissionError::NotDelegatedSnapshot); + } + if self.authorization_domain != owner.authorization_domain() { + return Err(OwnerAdmissionError::AuthorizationDomainMismatch); + } + if self.owner_pubkey != Some(owner.bound_pubkey()) { + return Err(OwnerAdmissionError::OwnerKeyMismatch); + } + if self.principal != *owner.principal() { + return Err(OwnerAdmissionError::PrincipalMismatch); + } + if self.binding_id != Some(owner.binding_id()) { + return Err(OwnerAdmissionError::BindingIdMismatch); + } + if self.binding_version != Some(owner.binding_version()) { + return Err(OwnerAdmissionError::BindingVersionMismatch); + } + let fresh_until = AdmissionExpiry::new(self.fresh_until) + .map_err(|_| OwnerAdmissionError::InvalidFreshnessBound)?; + Ok(VerifiedOwnerAdmission::from_capability_snapshot( + self.authorization_domain, + self.principal.clone(), + fresh_until, + )) + } } fn finalization_time( @@ -1440,6 +1565,48 @@ where } } +/// Failure to derive delegated-owner admission from a capability snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum OwnerAdmissionError { + /// The snapshot does not represent delegated owner authority. + #[error("capability snapshot does not carry delegated owner authority")] + NotDelegatedSnapshot, + /// The snapshot and owner binding belong to different domains. + #[error("capability snapshot and owner binding domains do not match")] + AuthorizationDomainMismatch, + /// The snapshot and owner binding identify different owner keys. + #[error("capability snapshot and owner binding keys do not match")] + OwnerKeyMismatch, + /// The snapshot and owner binding identify different principals. + #[error("capability snapshot and owner binding principals do not match")] + PrincipalMismatch, + /// The snapshot and owner binding carry different binding identifiers. + #[error("capability snapshot and owner binding identifiers do not match")] + BindingIdMismatch, + /// The snapshot and owner binding carry different binding generations. + #[error("capability snapshot and owner binding versions do not match")] + BindingVersionMismatch, + /// The snapshot's admission freshness bound is absent or invalid. + #[error("capability snapshot has an invalid admission freshness bound")] + InvalidFreshnessBound, +} + +impl OwnerAdmissionError { + /// Stable provider-neutral diagnostic code for the admission failure. + pub const fn code(self) -> &'static str { + match self { + Self::NotDelegatedSnapshot => "authorization_owner_admission_001", + Self::AuthorizationDomainMismatch => "authorization_owner_admission_002", + Self::OwnerKeyMismatch => "authorization_owner_admission_003", + Self::PrincipalMismatch => "authorization_owner_admission_004", + Self::BindingIdMismatch => "authorization_owner_admission_005", + Self::BindingVersionMismatch => "authorization_owner_admission_006", + Self::InvalidFreshnessBound => "authorization_owner_admission_007", + } + } +} + impl fmt::Debug for CapabilitySnapshot { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -1501,7 +1668,7 @@ impl fmt::Debug for AuthorizationOutcome { /// completes, an allowed decision is checked against exactly one fresh sample. /// Provider freshness and all effective evidence bounds use that same value; /// callers must not precompute and pass a decision-start timestamp. -async fn resolve_authorization( +pub async fn resolve_authorization( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, clock: &dyn AuthorizationClock, @@ -1721,6 +1888,9 @@ pub enum ProviderContractError { /// A capability snapshot was presented to a different configured runtime. #[error("provider capability snapshot does not belong to this authorization runtime")] AuthorizationRuntimeMismatch, + /// A narrower operation-bound delegation reached a transport-wide request path. + #[error("delegation scope is not valid for transport-wide authorization")] + UnsupportedDelegationScope, } impl ProviderContractError { @@ -1761,6 +1931,7 @@ impl ProviderContractError { Self::CapabilityBindingChanged => "authorization_provider_contract_032", Self::FederatedPolicyChanged => "authorization_provider_contract_033", Self::AuthorizationRuntimeMismatch => "authorization_provider_contract_034", + Self::UnsupportedDelegationScope => "authorization_provider_contract_035", } } } diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 71bbfca200..ecbc9936c8 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -342,7 +342,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { } } -fn all_contract_errors() -> [ProviderContractError; 34] { +fn all_contract_errors() -> [ProviderContractError; 35] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -378,6 +378,7 @@ fn all_contract_errors() -> [ProviderContractError; 34] { ProviderContractError::CapabilityBindingChanged, ProviderContractError::FederatedPolicyChanged, ProviderContractError::AuthorizationRuntimeMismatch, + ProviderContractError::UnsupportedDelegationScope, ] } @@ -1835,6 +1836,22 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); + let owner_binding = VersionedBindingRef::new_existing_active_for_test( + domain(1), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding is valid"); + let admission = snapshot + .verified_owner_admission(&owner_binding) + .expect("delegated snapshot matches the exact owner binding"); + assert_eq!(admission.authorization_domain(), domain(1)); + assert_eq!(admission.principal(), request.principal()); + assert_eq!(admission.fresh_until().unix_seconds(), 180); } #[tokio::test] diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 941c0fc735..296ab89a68 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -7,7 +7,7 @@ use buzz_core::CommunityId; use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use crate::error::Result; @@ -76,6 +76,36 @@ pub async fn archive( Ok(result.rows_affected() > 0) } +/// Transaction-owned identity archive mutation. +#[allow(clippy::too_many_arguments)] +pub async fn archive_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO archived_identities \ + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(consent_path) + .bind(actor) + .bind(reason) + .bind(replaced_by) + .bind(request_event_id) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Unarchives an identity from `community_id`. /// /// Returns `true` if a row was deleted, `false` if the identity was not archived @@ -91,6 +121,21 @@ pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) - Ok(result.rows_affected() > 0) } +/// Transaction-owned identity unarchive mutation. +pub async fn unarchive_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &str, +) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Returns all identities archived in `community_id`, ordered by archive time ascending. pub async fn list_archived( pool: &PgPool, diff --git a/crates/buzz-db/src/authorization_invalidation.rs b/crates/buzz-db/src/authorization_invalidation.rs new file mode 100644 index 0000000000..7e71e3716e --- /dev/null +++ b/crates/buzz-db/src/authorization_invalidation.rs @@ -0,0 +1,1044 @@ +//! Durable provider-neutral authorization invalidation state. +//! +//! Postgres is the authority. Every committed event receives one strictly +//! increasing generation inside its authorization domain. Redis may advertise +//! that generation, but consumers always reconcile selector floors here. + +use std::collections::BTreeMap; +use std::fmt; + +use buzz_core::CommunityId; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +/// Maximum selectors accepted in one atomic invalidation event. +pub const MAX_INVALIDATION_SELECTORS: usize = 64; + +/// Provider-neutral selector classes understood by the authorization runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AuthorizationSelectorKind { + /// Exact issuer-qualified principal fingerprint. + PrincipalFingerprint, + /// Exact Nostr public key. + NostrKey, + /// Stable binding ID together with an invalid-through version. + Binding, + /// Exact runtime session ID. + Session, + /// Entire authorization domain. + Domain, + /// Exact opaque provider policy version. + PolicyVersion, + /// Exact delegated owner Nostr key. + DelegatedOwner, +} + +impl AuthorizationSelectorKind { + /// Stable storage label. + pub const fn as_str(self) -> &'static str { + match self { + Self::PrincipalFingerprint => "principal_fingerprint", + Self::NostrKey => "nostr_key", + Self::Binding => "binding", + Self::Session => "session", + Self::Domain => "domain", + Self::PolicyVersion => "policy_version", + Self::DelegatedOwner => "delegated_owner", + } + } + + fn parse(value: &str) -> Result { + match value { + "principal_fingerprint" => Ok(Self::PrincipalFingerprint), + "nostr_key" => Ok(Self::NostrKey), + "binding" => Ok(Self::Binding), + "session" => Ok(Self::Session), + "domain" => Ok(Self::Domain), + "policy_version" => Ok(Self::PolicyVersion), + "delegated_owner" => Ok(Self::DelegatedOwner), + _ => Err(DbError::InvalidData( + "authorization invalidation selector kind is invalid".into(), + )), + } + } +} + +/// A typed selector for one authorization dependency. +#[derive(Clone, PartialEq, Eq)] +pub enum AuthorizationSelector { + /// Already-derived issuer-qualified principal fingerprint. + PrincipalFingerprint([u8; 32]), + /// Exact Nostr actor key. + NostrKey([u8; 32]), + /// Stable binding ID and highest invalid version. + Binding { + /// Stable binding identifier. + binding_id: Uuid, + /// All binding versions through this value are invalid. + invalid_through: u64, + }, + /// Exact runtime session. + Session(Uuid), + /// Entire authorization domain. + Domain, + /// Exact opaque provider policy version. + PolicyVersion(String), + /// Exact delegated owner key. + DelegatedOwner([u8; 32]), +} + +impl fmt::Debug for AuthorizationSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationSelector") + .field("kind", &self.kind()) + .field("value", &"[redacted]") + .finish() + } +} + +impl AuthorizationSelector { + /// Derive a fingerprint from exact validated issuer and subject bytes. + pub fn principal(issuer: &str, subject: &str) -> Result { + if issuer.is_empty() || subject.is_empty() { + return Err(DbError::InvalidData( + "authorization principal must be exact and non-empty".into(), + )); + } + Ok(Self::PrincipalFingerprint(tagged_fingerprint( + b"principal", + &[issuer.as_bytes(), subject.as_bytes()], + ))) + } + + /// Preserve a previously derived principal fingerprint. + pub const fn principal_fingerprint(fingerprint: [u8; 32]) -> Self { + Self::PrincipalFingerprint(fingerprint) + } + + /// Select an exact Nostr actor key. + pub const fn nostr_key(key: [u8; 32]) -> Self { + Self::NostrKey(key) + } + + /// Select a binding and all of its versions through `invalid_through`. + pub fn binding(binding_id: Uuid, invalid_through: u64) -> Result { + if binding_id.is_nil() || invalid_through == 0 { + return Err(DbError::InvalidData( + "authorization binding selector requires a non-nil ID and positive version".into(), + )); + } + Ok(Self::Binding { + binding_id, + invalid_through, + }) + } + + /// Select an exact non-nil runtime session. + pub fn session(session_id: Uuid) -> Result { + if session_id.is_nil() { + return Err(DbError::InvalidData( + "authorization session selector must not be nil".into(), + )); + } + Ok(Self::Session(session_id)) + } + + /// Select the entire authorization domain. + pub const fn domain() -> Self { + Self::Domain + } + + /// Select an exact non-empty provider policy version. + pub fn policy_version(version: impl Into) -> Result { + let version = version.into(); + if version.is_empty() { + return Err(DbError::InvalidData( + "authorization policy version must not be empty".into(), + )); + } + Ok(Self::PolicyVersion(version)) + } + + /// Select an exact delegated owner key. + pub const fn delegated_owner(key: [u8; 32]) -> Self { + Self::DelegatedOwner(key) + } + + /// Selector class. + pub const fn kind(&self) -> AuthorizationSelectorKind { + match self { + Self::PrincipalFingerprint(_) => AuthorizationSelectorKind::PrincipalFingerprint, + Self::NostrKey(_) => AuthorizationSelectorKind::NostrKey, + Self::Binding { .. } => AuthorizationSelectorKind::Binding, + Self::Session(_) => AuthorizationSelectorKind::Session, + Self::Domain => AuthorizationSelectorKind::Domain, + Self::PolicyVersion(_) => AuthorizationSelectorKind::PolicyVersion, + Self::DelegatedOwner(_) => AuthorizationSelectorKind::DelegatedOwner, + } + } + + /// Redaction-safe stable selector fingerprint. + pub fn fingerprint(&self) -> [u8; 32] { + match self { + Self::PrincipalFingerprint(value) => *value, + Self::NostrKey(key) => tagged_fingerprint(b"nostr-key", &[key]), + Self::Binding { binding_id, .. } => { + tagged_fingerprint(b"binding", &[binding_id.as_bytes()]) + } + Self::Session(session_id) => tagged_fingerprint(b"session", &[session_id.as_bytes()]), + Self::Domain => tagged_fingerprint(b"domain", &[]), + Self::PolicyVersion(version) => { + tagged_fingerprint(b"policy-version", &[version.as_bytes()]) + } + Self::DelegatedOwner(key) => tagged_fingerprint(b"delegated-owner", &[key]), + } + } + + /// Invalid-through binding version, when this is a binding selector. + pub const fn binding_version_floor(&self) -> Option { + match self { + Self::Binding { + invalid_through, .. + } => Some(*invalid_through), + _ => None, + } + } +} + +/// Effect retained for a selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthorizationInvalidationEffect { + /// Fence evaluations captured before this event's generation. + Fence, + /// Deny this selector until an explicit future recovery mechanism changes authority. + StickyDeny, + /// Permanently deny binding versions through the selector's version floor. + BindingVersionFloor, +} + +impl AuthorizationInvalidationEffect { + const fn is_sticky(self) -> bool { + matches!(self, Self::StickyDeny) + } +} + +/// One selector and effect in an invalidation event. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationEntry { + selector: AuthorizationSelector, + effect: AuthorizationInvalidationEffect, +} + +impl AuthorizationInvalidationEntry { + /// Permanently deny one exact selector until a separately designed and + /// authorized recovery mechanism changes authority. + pub fn sticky_deny(selector: AuthorizationSelector) -> Result { + if selector.kind() == AuthorizationSelectorKind::Binding { + return Err(DbError::InvalidData( + "binding invalidation requires a version-floor entry".into(), + )); + } + Ok(Self { + selector, + effect: AuthorizationInvalidationEffect::StickyDeny, + }) + } + + /// Fence all evaluations already in flight in one domain while allowing + /// later evaluations to resolve fresh policy and binding state. + pub const fn domain_fence() -> Self { + Self { + selector: AuthorizationSelector::Domain, + effect: AuthorizationInvalidationEffect::Fence, + } + } + + /// Fence authority captured before reversible admission loss for one exact + /// principal, Nostr key, or delegated owner. + /// + /// This is intentionally unavailable for bindings, sessions, domains, and + /// policy versions. Binding invalidation uses a monotonic version floor, + /// while domain fencing remains an explicit separate operation. + pub fn admission_loss_fence(selector: AuthorizationSelector) -> Result { + if !matches!( + selector.kind(), + AuthorizationSelectorKind::PrincipalFingerprint + | AuthorizationSelectorKind::NostrKey + | AuthorizationSelectorKind::DelegatedOwner + ) { + return Err(DbError::InvalidData( + "authorization admission loss requires a principal, key, or delegated owner".into(), + )); + } + Ok(Self { + selector, + effect: AuthorizationInvalidationEffect::Fence, + }) + } + + /// Permanently deny one binding ID through a positive version, while + /// permitting a later version of that same stable binding ID. + pub fn binding_version_floor(binding_id: Uuid, invalid_through: u64) -> Result { + Ok(Self { + selector: AuthorizationSelector::binding(binding_id, invalid_through)?, + effect: AuthorizationInvalidationEffect::BindingVersionFloor, + }) + } + + /// Selected dependency. + pub const fn selector(&self) -> &AuthorizationSelector { + &self.selector + } + + /// Retained effect. + pub const fn effect(&self) -> AuthorizationInvalidationEffect { + self.effect + } +} + +impl fmt::Debug for AuthorizationInvalidationEntry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationEntry") + .field("selector", &self.selector) + .field("effect", &self.effect) + .finish() + } +} + +/// Atomic idempotent invalidation request. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationRequest { + event_id: Uuid, + entries: Vec, +} + +impl AuthorizationInvalidationRequest { + /// Validate a non-empty, bounded event. + pub fn new(event_id: Uuid, entries: Vec) -> Result { + if event_id.is_nil() { + return Err(DbError::InvalidData( + "authorization invalidation event ID must not be nil".into(), + )); + } + if entries.is_empty() || entries.len() > MAX_INVALIDATION_SELECTORS { + return Err(DbError::InvalidData( + "authorization invalidation selector count is out of bounds".into(), + )); + } + if entries.iter().any(|entry| { + let kind = entry.selector.kind(); + !match (kind, entry.effect) { + ( + AuthorizationSelectorKind::PrincipalFingerprint + | AuthorizationSelectorKind::NostrKey + | AuthorizationSelectorKind::DelegatedOwner + | AuthorizationSelectorKind::Domain, + AuthorizationInvalidationEffect::Fence, + ) + | ( + AuthorizationSelectorKind::Binding, + AuthorizationInvalidationEffect::BindingVersionFloor, + ) => true, + (_, AuthorizationInvalidationEffect::StickyDeny) => { + kind != AuthorizationSelectorKind::Binding + } + _ => false, + } + }) { + return Err(DbError::InvalidData( + "authorization invalidation selector and effect are incompatible".into(), + )); + } + Ok(Self { event_id, entries }) + } + + /// Idempotency identifier. + pub const fn event_id(&self) -> Uuid { + self.event_id + } + + /// Requested selectors and effects. + pub fn entries(&self) -> &[AuthorizationInvalidationEntry] { + &self.entries + } +} + +impl fmt::Debug for AuthorizationInvalidationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationRequest") + .field("event_id", &"[redacted]") + .field("selector_count", &self.entries.len()) + .finish() + } +} + +/// Redaction-safe receipt for a committed invalidation event. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationInvalidationReceipt { + /// Domain in which the event committed. + pub community_id: CommunityId, + /// Idempotency identifier. + pub event_id: Uuid, + /// Strictly increasing durable generation. + pub generation: u64, +} + +impl fmt::Debug for AuthorizationInvalidationReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationReceipt") + .field("community_id", &"[redacted]") + .field("event_id", &"[redacted]") + .field("generation", &self.generation) + .finish() + } +} + +/// Whether a request committed now or replayed its identical receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthorizationInvalidationResult { + /// This transaction committed the event. + Applied(AuthorizationInvalidationReceipt), + /// An identical request had already committed. + AlreadyApplied(AuthorizationInvalidationReceipt), +} + +impl AuthorizationInvalidationResult { + /// Whether this call committed a new durable invalidation. + pub const fn committed_now(&self) -> bool { + matches!(self, Self::Applied(_)) + } + + /// Durable receipt in either outcome. + pub const fn receipt(self) -> AuthorizationInvalidationReceipt { + match self { + Self::Applied(receipt) | Self::AlreadyApplied(receipt) => receipt, + } + } +} + +/// One durable selector floor returned by reconciliation. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationFloor { + /// Selector class. + pub kind: AuthorizationSelectorKind, + /// Redaction-safe stable selector fingerprint. + pub fingerprint: [u8; 32], + /// Most recent generation that touched the floor. + pub generation: u64, + /// Whether the selector remains denied independently of capture generation. + pub sticky_deny: bool, + /// Highest invalid binding version, for binding selectors only. + pub binding_version_floor: Option, +} + +impl fmt::Debug for AuthorizationInvalidationFloor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationFloor") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .field("generation", &self.generation) + .field("sticky_deny", &self.sticky_deny) + .field("binding_version_floor", &self.binding_version_floor) + .finish() + } +} + +/// Consistent durable generation and selector-floor view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorizationInvalidationSnapshot { + /// Domain read from the writer database. + pub community_id: CommunityId, + /// Durable generation at this snapshot. + pub generation: u64, + /// Full floors, or floors changed after the requested delta generation. + pub floors: Vec, +} + +#[derive(Clone)] +struct NormalizedEntry { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + sticky_deny: bool, + binding_version_floor: Option, +} + +fn tagged_fingerprint(tag: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((tag.len() as u64).to_be_bytes()); + digest.update(tag); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().into() +} + +fn normalized_entries(request: &AuthorizationInvalidationRequest) -> Vec { + let mut entries = BTreeMap::new(); + for entry in request.entries() { + let selector = entry.selector(); + let key = (selector.kind(), selector.fingerprint()); + let value = entries.entry(key).or_insert(NormalizedEntry { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + sticky_deny: false, + binding_version_floor: None, + }); + value.sticky_deny |= entry.effect().is_sticky(); + value.binding_version_floor = match ( + value.binding_version_floor, + selector.binding_version_floor(), + ) { + (Some(current), Some(candidate)) => Some(current.max(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + } + entries.into_values().collect() +} + +fn request_fingerprint( + community_id: CommunityId, + event_id: Uuid, + entries: &[NormalizedEntry], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-authorization-invalidation-v1"); + digest.update(community_id.as_uuid().as_bytes()); + digest.update(event_id.as_bytes()); + for entry in entries { + digest.update((entry.kind.as_str().len() as u64).to_be_bytes()); + digest.update(entry.kind.as_str().as_bytes()); + digest.update(entry.fingerprint); + digest.update([u8::from(entry.sticky_deny)]); + digest.update( + entry + .binding_version_floor + .unwrap_or_default() + .to_be_bytes(), + ); + } + digest.finalize().into() +} + +/// Deterministic fingerprint shared by invalidation and restore receipts. +pub fn authorization_invalidation_request_fingerprint( + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, +) -> [u8; 32] { + request_fingerprint( + community_id, + request.event_id(), + &normalized_entries(request), + ) +} + +fn positive_u64(value: i64, label: &str) -> Result { + u64::try_from(value).map_err(|_| { + DbError::InvalidData(format!( + "authorization invalidation {label} is outside the supported range" + )) + }) +} + +fn fingerprint_array(value: Vec) -> Result<[u8; 32]> { + value.try_into().map_err(|_| { + DbError::InvalidData("authorization invalidation fingerprint has invalid length".into()) + }) +} + +impl Db { + /// Atomically allocate a generation and retain the strongest selector floors. + pub async fn apply_authorization_invalidation( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + let entries = normalized_entries(request); + let fingerprint = request_fingerprint(community_id, request.event_id(), &entries); + let mut tx = self.pool.begin().await?; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community_id.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(community_id.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(community_id.as_uuid()) + .bind(request.event_id()) + .fetch_optional(&mut *tx) + .await? + { + let stored: Vec = row.try_get("request_fingerprint")?; + if stored.as_slice() != fingerprint { + return Err(DbError::InvalidData( + "authorization invalidation event ID was reused with different input".into(), + )); + } + let receipt = AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: positive_u64(row.try_get("generation")?, "generation")?, + }; + tx.commit().await?; + return Ok(AuthorizationInvalidationResult::AlreadyApplied(receipt)); + } + + let next_generation = generation.checked_add(1).ok_or_else(|| { + DbError::InvalidData("authorization invalidation generation exhausted".into()) + })?; + sqlx::query( + "INSERT INTO authorization_invalidation_receipts \ + (community_id, event_id, generation, request_fingerprint) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(community_id.as_uuid()) + .bind(request.event_id()) + .bind(next_generation) + .bind(fingerprint.as_slice()) + .execute(&mut *tx) + .await?; + + for entry in entries { + let binding_floor = entry + .binding_version_floor + .map(i64::try_from) + .transpose() + .map_err(|_| { + DbError::InvalidData( + "authorization 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, $5, $6) \ + ON CONFLICT (community_id, selector_kind, selector_fingerprint) DO UPDATE SET \ + generation = EXCLUDED.generation, \ + sticky_deny = authorization_invalidation_floors.sticky_deny \ + OR EXCLUDED.sticky_deny, \ + 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(community_id.as_uuid()) + .bind(entry.kind.as_str()) + .bind(entry.fingerprint.as_slice()) + .bind(next_generation) + .bind(entry.sticky_deny) + .bind(binding_floor) + .execute(&mut *tx) + .await?; + } + + sqlx::query( + "UPDATE authorization_invalidation_domains \ + SET generation = $2, updated_at = NOW() WHERE community_id = $1", + ) + .bind(community_id.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(community_id.as_uuid()) + .bind(request.event_id()) + .bind(fingerprint.as_slice()) + .bind(next_generation.to_be_bytes().as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + Ok(AuthorizationInvalidationResult::Applied( + AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: positive_u64(next_generation, "generation")?, + }, + )) + } + + /// Read a consistent full snapshot from the writer database. + pub async fn authorization_invalidation_snapshot( + &self, + community_id: CommunityId, + ) -> Result { + self.authorization_invalidation_read(community_id, None) + .await + } + + /// Read floors changed after `after_generation` plus the current generation. + pub async fn authorization_invalidation_delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + self.authorization_invalidation_read(community_id, Some(after_generation)) + .await + } + + async fn authorization_invalidation_read( + &self, + community_id: CommunityId, + after_generation: Option, + ) -> Result { + let after_generation = after_generation + .map(i64::try_from) + .transpose() + .map_err(|_| { + DbError::InvalidData( + "authorization invalidation generation exceeds database range".into(), + ) + })?; + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id = $1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *tx) + .await?; + let generation = generation.unwrap_or_default(); + let rows = sqlx::query( + "SELECT selector_kind, selector_fingerprint, generation, sticky_deny, \ + binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND generation <= $2 \ + AND ($3::BIGINT IS NULL OR generation > $3) \ + ORDER BY generation, selector_kind, selector_fingerprint", + ) + .bind(community_id.as_uuid()) + .bind(generation) + .bind(after_generation) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + let floors = rows + .into_iter() + .map(|row| { + let binding_version: Option = row.try_get("binding_version_floor")?; + Ok(AuthorizationInvalidationFloor { + kind: AuthorizationSelectorKind::parse(row.try_get("selector_kind")?)?, + fingerprint: fingerprint_array(row.try_get("selector_fingerprint")?)?, + generation: positive_u64(row.try_get("generation")?, "floor generation")?, + sticky_deny: row.try_get("sticky_deny")?, + binding_version_floor: binding_version + .map(|value| positive_u64(value, "binding version")) + .transpose()?, + }) + }) + .collect::>>()?; + + Ok(AuthorizationInvalidationSnapshot { + community_id, + generation: positive_u64(generation, "generation")?, + floors, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DbConfig; + + fn request( + event_id: Uuid, + selector: AuthorizationSelector, + ) -> AuthorizationInvalidationRequest { + let entry = match selector { + AuthorizationSelector::Binding { + binding_id, + invalid_through, + } => AuthorizationInvalidationEntry::binding_version_floor(binding_id, invalid_through), + selector => AuthorizationInvalidationEntry::sticky_deny(selector), + } + .expect("test invalidation entry is valid"); + AuthorizationInvalidationRequest::new(event_id, vec![entry]) + .expect("test invalidation request is valid") + } + + #[test] + fn principal_fingerprints_are_exact_and_domain_neutral() { + let a = AuthorizationSelector::principal("issuer-a", "subject").expect("valid principal"); + let b = AuthorizationSelector::principal("issuer-b", "subject").expect("valid principal"); + assert_ne!(a.fingerprint(), b.fingerprint()); + assert_eq!( + a.fingerprint(), + AuthorizationSelector::principal("issuer-a", "subject") + .expect("valid principal") + .fingerprint() + ); + assert!(!format!("{a:?}").contains("issuer-a")); + } + + #[test] + fn duplicate_binding_entries_retain_strongest_version_floor() { + let binding_id = Uuid::new_v4(); + let request = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![ + AuthorizationInvalidationEntry::binding_version_floor(binding_id, 2) + .expect("valid binding floor"), + AuthorizationInvalidationEntry::binding_version_floor(binding_id, 5) + .expect("valid binding floor"), + ], + ) + .expect("valid request"); + let normalized = normalized_entries(&request); + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].binding_version_floor, Some(5)); + assert!(!normalized[0].sticky_deny); + } + + #[test] + fn transient_admission_fences_are_exact_and_never_bindings() { + let allowed = [ + AuthorizationSelector::principal("issuer", "subject").expect("valid principal"), + AuthorizationSelector::nostr_key([1_u8; 32]), + AuthorizationSelector::delegated_owner([2_u8; 32]), + ]; + let entries = allowed + .into_iter() + .map(AuthorizationInvalidationEntry::admission_loss_fence) + .collect::>>() + .expect("exact admission selectors are valid"); + let request = AuthorizationInvalidationRequest::new(Uuid::new_v4(), entries) + .expect("admission-loss request is valid"); + assert!(normalized_entries(&request) + .iter() + .all(|entry| !entry.sticky_deny && entry.binding_version_floor.is_none())); + + for selector in [ + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version("policy").expect("valid policy"), + ] { + assert!(AuthorizationInvalidationEntry::admission_loss_fence(selector).is_err()); + } + let forged_binding_fence = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![AuthorizationInvalidationEntry { + selector: AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + effect: AuthorizationInvalidationEffect::Fence, + }], + ); + assert!(matches!(forged_binding_fence, Err(DbError::InvalidData(_)))); + assert!(AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![AuthorizationInvalidationEntry::domain_fence()], + ) + .is_ok()); + assert!(AuthorizationInvalidationEntry::sticky_deny( + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding") + ) + .is_err()); + } + + #[test] + fn request_debug_redacts_identifiers() { + let event_id = Uuid::new_v4(); + let request = request( + event_id, + AuthorizationSelector::policy_version("private-policy").expect("valid policy"), + ); + let debug = format!("{request:?}"); + assert!(!debug.contains(&event_id.to_string())); + assert!(!debug.contains("private-policy")); + } + + async fn integration_db() -> Db { + let mut config = DbConfig::default(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or(config.database_url); + config.min_connections = 0; + let db = Db::new(&config) + .await + .expect("connect integration database"); + db.migrate().await.expect("run integration migrations"); + db + } + + async fn integration_community(db: &Db) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!( + "authorization-invalidation-{}.example", + id.simple() + )) + .execute(&db.pool) + .await + .expect("insert integration community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn durable_idempotency_concurrency_and_delta_converge() { + let db = integration_db().await; + let community_id = integration_community(&db).await; + let binding_id = Uuid::new_v4(); + let first_id = Uuid::new_v4(); + let first = request( + first_id, + AuthorizationSelector::binding(binding_id, 1).expect("valid binding"), + ); + let first_receipt = db + .apply_authorization_invalidation(community_id, &first) + .await + .expect("first event commits"); + assert_eq!(first_receipt.receipt().generation, 1); + assert!(matches!( + db.apply_authorization_invalidation(community_id, &first) + .await + .expect("identical retry resolves"), + AuthorizationInvalidationResult::AlreadyApplied(_) + )); + + let conflicting_retry = request( + first_id, + AuthorizationSelector::policy_version("different").expect("valid policy"), + ); + assert!(matches!( + db.apply_authorization_invalidation(community_id, &conflicting_retry) + .await, + Err(DbError::InvalidData(_)) + )); + + let second = request( + Uuid::new_v4(), + AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + ); + let third = request( + Uuid::new_v4(), + AuthorizationSelector::policy_version("old-policy").expect("valid policy"), + ); + let db_a = db.clone(); + let db_b = db.clone(); + let (second_result, third_result) = tokio::join!( + db_a.apply_authorization_invalidation(community_id, &second), + db_b.apply_authorization_invalidation(community_id, &third), + ); + let mut generations = [ + second_result + .expect("second event commits") + .receipt() + .generation, + third_result + .expect("third event commits") + .receipt() + .generation, + ]; + generations.sort_unstable(); + assert_eq!(generations, [2, 3]); + + let binding_advance = request( + Uuid::new_v4(), + AuthorizationSelector::binding(binding_id, 5).expect("valid binding"), + ); + assert_eq!( + db.apply_authorization_invalidation(community_id, &binding_advance) + .await + .expect("binding floor advances") + .receipt() + .generation, + 4 + ); + let snapshot = db + .authorization_invalidation_snapshot(community_id) + .await + .expect("full snapshot reads"); + assert_eq!(snapshot.generation, 4); + let binding_floor = snapshot + .floors + .iter() + .find(|floor| floor.kind == AuthorizationSelectorKind::Binding) + .expect("binding floor present"); + assert_eq!(binding_floor.binding_version_floor, Some(5)); + assert!(!binding_floor.sticky_deny); + + let delta = db + .authorization_invalidation_delta(community_id, 3) + .await + .expect("delta reads"); + assert_eq!(delta.generation, 4); + assert_eq!(delta.floors.len(), 1); + assert_eq!(delta.floors[0].kind, AuthorizationSelectorKind::Binding); + + let principal = AuthorizationSelector::principal("admission-issuer", "admission-subject") + .expect("valid principal"); + let admission_loss = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![ + AuthorizationInvalidationEntry::admission_loss_fence(principal.clone()) + .expect("valid admission-loss fence"), + ], + ) + .expect("valid admission-loss request"); + assert_eq!( + db.apply_authorization_invalidation(community_id, &admission_loss) + .await + .expect("admission-loss fence commits") + .receipt() + .generation, + 5 + ); + let delta = db + .authorization_invalidation_delta(community_id, 4) + .await + .expect("admission-loss delta reads"); + assert_eq!(delta.generation, 5); + assert_eq!(delta.floors.len(), 1); + assert_eq!( + delta.floors[0].kind, + AuthorizationSelectorKind::PrincipalFingerprint + ); + assert_eq!(delta.floors[0].fingerprint, principal.fingerprint()); + assert!(!delta.floors[0].sticky_deny); + assert_eq!(delta.floors[0].binding_version_floor, None); + // Activated domains are intentionally one-way in V1. This isolated + // integration database is discarded by the test harness rather than + // bypassing the production marker guard for cleanup. + } +} diff --git a/crates/buzz-db/src/authorization_version.rs b/crates/buzz-db/src/authorization_version.rs new file mode 100644 index 0000000000..5899f51f18 --- /dev/null +++ b/crates/buzz-db/src/authorization_version.rs @@ -0,0 +1,470 @@ +//! Restore-independent monotonic version snapshot for protected authority. + +use std::collections::BTreeMap; + +use buzz_core::CommunityId; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{Db, Result}; + +/// Hashed per-resource high-water marks safe for an external checkpoint. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AuthorizationVersionVector { + /// Issuer-qualified binding selector fingerprint to maximum version. + pub bindings: BTreeMap, + /// Git repository selector fingerprint to publication version. + pub git_publications: BTreeMap, + /// Media-object selector fingerprint to publication version. + pub media_publications: BTreeMap, + /// Protected object surface to cutover generation. + pub object_authority: BTreeMap, + /// Durable invalidation generation. + pub invalidation_generation: u64, + /// Complete PostgreSQL authority epoch, including tombstones and membership. + pub authority_epoch: u64, + /// Durable current-only client-status revision floor. + pub status_revision: u64, +} + +impl AuthorizationVersionVector { + /// True when every recorded floor exists at an equal or higher version. + pub fn dominates(&self, floor: &Self) -> bool { + dominates_map(&self.bindings, &floor.bindings) + && dominates_map(&self.git_publications, &floor.git_publications) + && dominates_map(&self.media_publications, &floor.media_publications) + && dominates_map(&self.object_authority, &floor.object_authority) + && self.invalidation_generation >= floor.invalidation_generation + && self.authority_epoch >= floor.authority_epoch + && self.status_revision >= floor.status_revision + } +} + +fn dominates_map(current: &BTreeMap, floor: &BTreeMap) -> bool { + floor + .iter() + .all(|(selector, version)| current.get(selector).is_some_and(|value| value >= version)) +} + +fn selector_fingerprint(namespace: &[u8], parts: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-authorization-version-selector-v1"); + digest.update((namespace.len() as u64).to_be_bytes()); + digest.update(namespace); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + hex::encode(digest.finalize()) +} + +impl Db { + /// Exact domains that have crossed the one-way Enforce activation boundary. + pub async fn activated_authorization_domains(&self) -> Result> { + let rows: Vec = sqlx::query_scalar( + "SELECT community_id FROM authorization_invalidation_domains ORDER BY community_id", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(CommunityId::from_uuid).collect()) + } + + /// Idempotently activate one Enforce invalidation domain and commit the + /// exact receipt in the same transaction. + /// + /// Production construction witnesses this mutation independently before + /// making the protected transport reachable. Observational modes never + /// call this API. + pub async fn activate_authorization_domain( + &self, + community_id: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + ) -> Result<()> { + const OPERATION_KIND: &str = "authorization_domain_activate_v1"; + let mut tx = self.pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(operation_id.to_string()) + .execute(&mut *tx) + .await?; + if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await? + { + let kind: String = row.try_get("operation_kind")?; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + if kind != OPERATION_KIND || fingerprint.as_slice() != request_fingerprint { + return Err(crate::DbError::InvalidData( + "protected-domain activation retry conflicts with its durable receipt" + .to_owned(), + )); + } + tx.commit().await?; + return Ok(()); + } + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1) \ + ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1,$2,$3,$4,1,$5,clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(OPERATION_KIND) + .bind(request_fingerprint.as_slice()) + .bind([1_u8].as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + /// Whether a transaction-owned protected operation committed. + pub async fn has_authorization_operation_receipt( + &self, + community_id: CommunityId, + operation_id: uuid::Uuid, + ) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2)", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_one(&self.pool) + .await?; + Ok(exists) + } + + /// Return the exact request fingerprint for a committed protected operation. + pub async fn authorization_operation_receipt_fingerprint( + &self, + community_id: CommunityId, + operation_id: uuid::Uuid, + ) -> Result> { + let value: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&self.pool) + .await?; + value + .map(|bytes| { + bytes.try_into().map_err(|_| { + crate::DbError::InvalidData( + "authorization receipt fingerprint must be 32 bytes".to_owned(), + ) + }) + }) + .transpose() + } + + /// Read the complete protected-authority high-water vector from the writer. + pub async fn authorization_version_vector( + &self, + community_id: CommunityId, + ) -> Result { + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let vector = authorization_version_vector_tx(&mut tx, community_id).await?; + tx.commit().await?; + Ok(vector) + } + + /// Read an exact operation receipt and the complete authority vector from + /// one writer-consistent snapshot. Pending restore recovery must not join + /// a receipt from one database state to a vector from another. + pub async fn authorization_receipt_and_version_vector( + &self, + community_id: CommunityId, + operation_id: Uuid, + ) -> Result<(Option<[u8; 32]>, AuthorizationVersionVector)> { + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let fingerprint: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await?; + let fingerprint = fingerprint + .map(|bytes| { + bytes.try_into().map_err(|_| { + crate::DbError::InvalidData( + "authorization receipt fingerprint must be 32 bytes".to_owned(), + ) + }) + }) + .transpose()?; + let vector = authorization_version_vector_tx(&mut tx, community_id).await?; + tx.commit().await?; + Ok((fingerprint, vector)) + } +} + +async fn authorization_version_vector_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, +) -> Result { + let mut vector = AuthorizationVersionVector::default(); + for row in sqlx::query( + "SELECT issuer, uid AS subject, max(binding_version) AS version \ + FROM identity_bindings WHERE community_id=$1 \ + GROUP BY issuer, uid", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let issuer: String = row.try_get("issuer")?; + let subject: String = row.try_get("subject")?; + let version: i64 = row.try_get("version")?; + if let Ok(version) = u64::try_from(version) { + vector.bindings.insert( + selector_fingerprint(b"binding", &[issuer.as_bytes(), subject.as_bytes()]), + version, + ); + } + } + for row in sqlx::query( + "SELECT repo_id, publication_version FROM git_repo_publications \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let repo_id: String = row.try_get("repo_id")?; + let version: i64 = row.try_get("publication_version")?; + if let Ok(version) = u64::try_from(version) { + vector + .git_publications + .insert(selector_fingerprint(b"git", &[repo_id.as_bytes()]), version); + } + } + for row in sqlx::query( + "SELECT sha256, publication_version FROM media_publications \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let digest: String = row.try_get("sha256")?; + let version: i64 = row.try_get("publication_version")?; + if let Ok(version) = u64::try_from(version) { + vector.media_publications.insert( + selector_fingerprint(b"media", &[digest.as_bytes()]), + version, + ); + } + } + for row in sqlx::query( + "SELECT surface, generation FROM protected_object_authority \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let surface: String = row.try_get("surface")?; + let generation: i64 = row.try_get("generation")?; + if let Ok(generation) = u64::try_from(generation) { + vector.object_authority.insert(surface, generation); + } + } + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + vector.invalidation_generation = generation + .and_then(|value| u64::try_from(value).ok()) + .unwrap_or_default(); + let epoch: Option<(i64, i64)> = sqlx::query_as( + "SELECT authority_epoch, status_revision \ + FROM authorization_authority_epochs WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + if let Some((authority_epoch, status_revision)) = epoch { + vector.authority_epoch = u64::try_from(authority_epoch).unwrap_or_default(); + vector.status_revision = u64::try_from(status_revision).unwrap_or_default(); + } + Ok(vector) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_or_lower_component_never_dominates() { + let mut floor = AuthorizationVersionVector::default(); + floor.bindings.insert("a".into(), 2); + let mut current = floor.clone(); + assert!(current.dominates(&floor)); + current.bindings.insert("a".into(), 1); + assert!(!current.dominates(&floor)); + current.bindings.clear(); + assert!(!current.dominates(&floor)); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn activation_precedes_first_mutation_and_makes_lifecycle_visible() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("activation-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + let operation_id = Uuid::new_v4(); + let fingerprint = [0x71; 32]; + + db.activate_authorization_domain(community, operation_id, fingerprint) + .await + .expect("domain activation"); + db.activate_authorization_domain(community, operation_id, fingerprint) + .await + .expect("activation retry"); + let before = db + .authorization_invalidation_snapshot(community) + .await + .expect("initial snapshot"); + + sqlx::query("INSERT INTO relay_members (community_id,pubkey,role) VALUES ($1,$2,'member')") + .bind(community.as_uuid()) + .bind("11".repeat(32)) + .execute(&db.pool) + .await + .expect("first protected lifecycle mutation"); + let after = db + .authorization_invalidation_snapshot(community) + .await + .expect("advanced snapshot"); + + assert_eq!(after.generation, before.generation + 1); + assert_eq!( + db.authorization_operation_receipt_fingerprint(community, operation_id) + .await + .expect("activation receipt"), + Some(fingerprint) + ); + let (atomic_receipt, atomic_vector) = db + .authorization_receipt_and_version_vector(community, operation_id) + .await + .expect("receipt and vector snapshot"); + assert_eq!(atomic_receipt, Some(fingerprint)); + assert_eq!( + atomic_vector, + db.authorization_version_vector(community) + .await + .expect("stable vector after snapshot") + ); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn git_policy_replacement_advances_restore_authority() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("git-policy-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("activate protected domain"); + let before = db + .authorization_version_vector(community) + .await + .expect("pre-policy vector"); + let event_id = Sha256::digest(Uuid::new_v4().as_bytes()).to_vec(); + let owner = [0x42_u8; 32]; + sqlx::query( + "INSERT INTO events \ + (community_id,id,pubkey,created_at,kind,tags,content,sig,d_tag) \ + VALUES ($1,$2,$3,clock_timestamp(),30617,$4,'',$5,'repo')", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .bind(owner.as_slice()) + .bind(serde_json::json!([["d", "repo"], ["protected", "true"]])) + .bind(vec![0_u8; 64]) + .execute(&db.pool) + .await + .expect("protected Git policy"); + let inserted = db + .authorization_version_vector(community) + .await + .expect("inserted policy vector"); + assert!(inserted.authority_epoch > before.authority_epoch); + assert!(inserted.dominates(&before)); + assert!(!before.dominates(&inserted)); + + sqlx::query( + "UPDATE events SET deleted_at=clock_timestamp() \ + WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .execute(&db.pool) + .await + .expect("retire protected Git policy"); + let retired = db + .authorization_version_vector(community) + .await + .expect("retired policy vector"); + assert!(retired.authority_epoch > inserted.authority_epoch); + assert!(!inserted.dominates(&retired)); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 2d116588ae..fe30dcc84a 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -183,6 +183,36 @@ pub async fn create_channel_with_id( description: Option<&str>, created_by: &[u8], ttl_seconds: Option, +) -> Result<(ChannelRecord, bool)> { + let mut tx = pool.begin().await?; + let result = create_channel_with_id_tx( + &mut tx, + community_id, + channel_id, + name, + channel_type, + visibility, + description, + created_by, + ttl_seconds, + ) + .await?; + tx.commit().await?; + Ok(result) +} + +/// Transaction-aware variant of [`create_channel_with_id`]. +#[allow(clippy::too_many_arguments)] +pub async fn create_channel_with_id_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + description: Option<&str>, + created_by: &[u8], + ttl_seconds: Option, ) -> Result<(ChannelRecord, bool)> { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -202,8 +232,6 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = pool.begin().await?; - let rows_affected = sqlx::query( r#" INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) @@ -220,7 +248,7 @@ pub async fn create_channel_with_id( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(&mut **tx) .await? .rows_affected(); @@ -242,7 +270,7 @@ pub async fn create_channel_with_id( .bind(channel_id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } @@ -260,11 +288,10 @@ pub async fn create_channel_with_id( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let record = row_to_channel_record(row)?; - tx.commit().await?; Ok((record, was_created)) } @@ -386,14 +413,7 @@ pub async fn add_member( role: MemberRole, invited_by: Option<&[u8]>, ) -> Result { - validate_member_pubkey(pubkey)?; - let mut tx = pool.begin().await?; - - // First statement: serialize the whole role-check / owner-count / upsert - // sequence against concurrent membership writes on this channel. - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; - let record = add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await?; tx.commit().await?; Ok(record) @@ -418,7 +438,7 @@ pub enum ChannelAdmissionOutcome { IdentityBindingRequired, } -/// Add a channel member and optional corporate identity binding in one transaction. +/// Add a channel member and optional relay-verified identity binding atomically. pub async fn add_member_with_identity( pool: &PgPool, community_id: CommunityId, @@ -436,11 +456,11 @@ pub async fn add_member_with_identity( } let mut tx = pool.begin().await?; - // Keep this first: every channel membership writer shares this lock order. acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; - let member = - match add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await { + match add_member_after_lock_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by) + .await + { Ok(member) => member, Err(error) => { tx.rollback().await?; @@ -491,7 +511,24 @@ fn validate_member_pubkey(pubkey: &[u8]) -> Result<()> { Ok(()) } -async fn add_member_tx( +/// Transaction-aware variant of [`add_member`]. +/// +/// This function owns the channel membership lock. Callers that already hold +/// it use the private after-lock helper so the lock order remains exact. +pub async fn add_member_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: MemberRole, + invited_by: Option<&[u8]>, +) -> Result { + validate_member_pubkey(pubkey)?; + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + add_member_after_lock_tx(tx, community_id, channel_id, pubkey, role, invited_by).await +} + +async fn add_member_after_lock_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, channel_id: Uuid, @@ -647,39 +684,54 @@ async fn add_member_tx( /// actor could commit after their role was read and this removal would proceed on /// a stale elevated role. /// -/// The `is_agent_owner` lookup deliberately runs *before* the transaction opens: -/// it borrows a second connection from `pool`, and issuing it while holding the -/// lock could deadlock against ourselves on a small pool. That is safe because -/// `agent_owner_pubkey` is immutable — [`crate::user::set_agent_owner`] only -/// updates it when it `IS NULL` (first-mint-wins), so its value cannot change -/// under us and needs no serialization. +/// The immutable agent-owner relationship is read on the caller-owned +/// transaction connection so this operation also composes with a sealed +/// authorization transaction without borrowing a second pool connection. pub async fn remove_member( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], +) -> Result<()> { + let mut tx = pool.begin().await?; + remove_member_tx(&mut tx, community_id, channel_id, pubkey, actor_pubkey).await?; + tx.commit().await?; + Ok(()) +} + +/// Transaction-aware variant of [`remove_member`]. +pub async fn remove_member_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + actor_pubkey: &[u8], ) -> Result<()> { let is_self_remove = pubkey == actor_pubkey; - // Immutable, and must not be queried while holding the lock (second pool - // connection). Resolved up front so every *mutable* authorization read can - // sit behind the serialization point below. let actor_is_agent_owner = if is_self_remove { false } else { - crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? + sqlx::query_scalar::<_, bool>( + "SELECT agent_owner_pubkey = $3 FROM users \ + WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(actor_pubkey) + .fetch_optional(&mut **tx) + .await? + .unwrap_or(false) }; - let mut tx = pool.begin().await?; - // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key // as `add_member`). - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx, community_id, channel_id).await?; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) + let actor_role_str = get_active_role_tx(tx, community_id, channel_id, actor_pubkey) .await? .ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?; let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { @@ -695,7 +747,7 @@ pub async fn remove_member( // Defense-in-depth: prevent removing the last owner regardless of caller. // Callers (REST handlers, NIP-29 handlers) also check this, but the DB // layer enforces it as the final safety net. - let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let target_role = get_active_role_tx(tx, community_id, channel_id, pubkey).await?; if target_role.as_deref() == Some("owner") { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members \ @@ -703,7 +755,7 @@ pub async fn remove_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -724,14 +776,13 @@ pub async fn remove_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(&mut *tx) + .execute(&mut **tx) .await?; if result.rows_affected() == 0 { return Err(DbError::MemberNotFound(channel_id)); } - tx.commit().await?; Ok(()) } @@ -873,6 +924,84 @@ pub async fn get_accessible_channel_ids( .collect() } +/// Revalidate one actor's current read access to a channel in one database +/// statement. Open channels are readable by any authenticated relay actor; +/// private channels require an active membership. Deleted channels deny. +/// +/// This intentionally bypasses application caches. Callers use it at an +/// outbound release boundary after asynchronous fetch or queueing work. +pub async fn channel_read_authorized( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + actor: &[u8], +) -> Result { + let allowed = sqlx::query_scalar::<_, bool>( + r#" + SELECT c.visibility::text <> 'private' + OR EXISTS ( + SELECT 1 + FROM channel_members cm + WHERE cm.community_id = c.community_id + AND cm.channel_id = c.id + AND cm.pubkey = $3 + AND cm.removed_at IS NULL + ) + FROM channels c + WHERE c.community_id = $1 + AND c.id = $2 + AND c.deleted_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_optional(pool) + .await?; + Ok(allowed.unwrap_or(false)) +} + +/// Revalidate uncached read access to an entire channel set in one database +/// statement. Aggregate disclosures use this at their final release boundary +/// so authority for an earlier channel cannot go stale while later channels +/// are checked one at a time. +pub async fn channel_set_read_authorized( + pool: &PgPool, + community_id: CommunityId, + channel_ids: &[Uuid], + actor: &[u8], +) -> Result { + if channel_ids.is_empty() { + return Ok(true); + } + let allowed = sqlx::query_scalar::<_, bool>( + r#" + SELECT COUNT(DISTINCT c.id) = cardinality($2::uuid[]) + FROM channels c + WHERE c.community_id = $1 + AND c.id = ANY($2::uuid[]) + AND c.deleted_at IS NULL + AND ( + c.visibility::text <> 'private' + OR EXISTS ( + SELECT 1 + FROM channel_members cm + WHERE cm.community_id = c.community_id + AND cm.channel_id = c.id + AND cm.pubkey = $3 + AND cm.removed_at IS NULL + ) + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_ids) + .bind(actor) + .fetch_one(pool) + .await?; + Ok(allowed) +} + /// Lists channels in a community, optionally filtered by visibility string. pub async fn list_channels( pool: &PgPool, @@ -1251,6 +1380,486 @@ pub struct ChannelUpdate { pub ttl_seconds: Option>, } +/// Transaction-owned NIP-29 channel mutation selected by the relay after +/// protocol-shape validation. Authorization is rechecked from locked rows in +/// [`apply_nip29_mutation_tx`]. +pub enum Nip29Mutation { + /// Create a channel and bootstrap the actor as its owner. + Create { + /// Stable client- or event-derived channel identifier. + channel_id: Uuid, + /// Canonical display name. + name: String, + /// Channel type. + channel_type: ChannelType, + /// Initial visibility. + visibility: ChannelVisibility, + /// Optional description. + description: Option, + /// Optional ephemeral lifetime. + ttl_seconds: Option, + }, + /// Add a member or change an active member's role. + PutUser { + /// Channel identifier. + channel_id: Uuid, + /// Target member key. + target: Vec, + /// Explicit role, or preserve/default when absent. + role: Option, + }, + /// Remove a member. + RemoveUser { + /// Channel identifier. + channel_id: Uuid, + /// Target member key. + target: Vec, + }, + /// Atomically edit channel metadata. + EditMetadata { + /// Channel identifier. + channel_id: Uuid, + /// Durable metadata columns. + updates: ChannelUpdate, + /// Optional topic replacement. + topic: Option, + /// Optional purpose replacement. + purpose: Option, + /// Optional archive transition. + archived: Option, + }, + /// Soft-delete a group and its relay-authored discovery rows. + DeleteGroup { + /// Channel identifier. + channel_id: Uuid, + /// Relay key used to scope discovery cleanup. + relay_pubkey: Vec, + }, + /// Join an open channel without changing an existing role. + Join { + /// Channel identifier. + channel_id: Uuid, + }, + /// Leave a channel without implicit membership creation. + Leave { + /// Channel identifier. + channel_id: Uuid, + }, +} + +/// Durable result of a transaction-owned NIP-29 projection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Nip29MutationOutcome { + /// Affected channel. + pub channel_id: Uuid, + /// Whether protected business state changed. + pub changed: bool, + /// Whether membership visibility changed and caches must be invalidated. + pub membership_changed: bool, + /// Whether channel visibility or lifecycle caches must be invalidated. + pub channel_changed: bool, +} + +async fn actor_owns_active_owner_agent_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + actor: &[u8], +) -> Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( \ + SELECT 1 FROM channel_members cm \ + JOIN users u ON u.community_id = cm.community_id AND u.pubkey = cm.pubkey \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 \ + AND cm.role = 'owner' AND cm.removed_at IS NULL \ + AND u.agent_owner_pubkey = $3 \ + )", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_one(&mut **tx) + .await?) +} + +async fn update_channel_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + mut updates: ChannelUpdate, +) -> Result<()> { + if let Some(name) = updates.name.as_mut() { + *name = buzz_core::channel::canonical_channel_name(name).to_owned(); + if name.is_empty() { + return Err(DbError::InvalidData("channel name is required".into())); + } + } + if updates.name.is_none() + && updates.description.is_none() + && updates.visibility.is_none() + && updates.ttl_seconds.is_none() + { + return Ok(()); + } + if updates.ttl_seconds.is_some() { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "buzz_channel_ttl:{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx) + .await?; + } + let result = sqlx::query( + "UPDATE channels SET \ + name = COALESCE($1, name), \ + description = COALESCE($2, description), \ + visibility = COALESCE($3::channel_visibility, visibility), \ + ttl_seconds = CASE WHEN $4 THEN $5 ELSE ttl_seconds END, \ + ttl_deadline = CASE WHEN $4 THEN CASE WHEN $5 IS NULL THEN NULL \ + ELSE NOW() + ($5 || ' seconds')::interval END ELSE ttl_deadline END, \ + updated_at = NOW() \ + WHERE community_id = $6 AND id = $7 AND deleted_at IS NULL", + ) + .bind(updates.name) + .bind(updates.description) + .bind(updates.visibility) + .bind(updates.ttl_seconds.is_some()) + .bind(updates.ttl_seconds.flatten()) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::ChannelNotFound(channel_id)); + } + Ok(()) +} + +/// Apply one NIP-29 durable projection on the same transaction that owns the +/// sealed authorization permit and event receipt. +pub async fn apply_nip29_mutation_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + actor: &[u8], + mutation: Nip29Mutation, +) -> Result { + if actor.len() != 32 { + return Err(DbError::InvalidData("actor pubkey must be 32 bytes".into())); + } + match mutation { + Nip29Mutation::Create { + channel_id, + name, + channel_type, + visibility, + description, + ttl_seconds, + } => { + let (_, changed) = create_channel_with_id_tx( + tx, + community_id, + channel_id, + &name, + channel_type, + visibility, + description.as_deref(), + actor, + ttl_seconds, + ) + .await?; + if !changed { + return Err(DbError::InvalidData("channel already exists".into())); + } + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: changed, + }) + } + Nip29Mutation::PutUser { + channel_id, + target, + role, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx, community_id, channel_id).await?; + let existing = get_active_role_tx(tx, community_id, channel_id, &target).await?; + let effective_role = match (role, existing.as_deref()) { + (Some(role), _) => role, + (None, Some(role)) => role.parse().map_err(|_| { + DbError::InvalidData(format!("invalid role in database: {role}")) + })?, + (None, None) => MemberRole::Member, + }; + if target != actor { + // Establish a row-level serialization point even when the + // target has never published a profile. Without this insert, + // `FOR SHARE` below cannot lock a missing row and a concurrent + // first profile could commit a restrictive policy before this + // membership transaction commits. + crate::user::ensure_user_tx(tx, community_id, &target).await?; + let policy = sqlx::query( + "SELECT channel_add_policy::text AS policy, agent_owner_pubkey \ + FROM users WHERE community_id = $1 AND pubkey = $2 \ + FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .fetch_optional(&mut **tx) + .await?; + if let Some(policy) = policy { + let value: String = policy.try_get("policy")?; + let owner: Option> = policy.try_get("agent_owner_pubkey")?; + match value.as_str() { + "owner_only" if owner.as_deref() != Some(actor) => { + return Err(DbError::AccessDenied( + "only the agent owner may add this member".into(), + )); + } + "nobody" => { + return Err(DbError::AccessDenied( + "this member has disabled external channel additions".into(), + )); + } + _ => {} + } + } + } + let before = existing; + // The lock is reentrant for this transaction; `add_member_tx` + // retains the complete role and last-owner checks. + add_member_tx( + tx, + community_id, + channel_id, + &target, + effective_role, + Some(actor), + ) + .await?; + let changed = before.as_deref() != Some(effective_role.as_str()); + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: channel.visibility == "open" && before.is_none(), + }) + } + Nip29Mutation::RemoveUser { channel_id, target } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + get_channel_tx(tx, community_id, channel_id).await?; + if target != actor + && get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .is_none() + { + return Err(DbError::AccessDenied( + "actor is not an active member".into(), + )); + } + remove_member_tx(tx, community_id, channel_id, &target, actor).await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + Nip29Mutation::EditMetadata { + channel_id, + updates, + topic, + purpose, + archived, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let privileged = updates.name.is_some() + || updates.description.is_some() + || updates.visibility.is_some() + || updates.ttl_seconds.is_some() + || archived.is_some(); + let role = get_active_role_tx(tx, community_id, channel_id, actor).await?; + if privileged { + let elevated = role + .as_deref() + .and_then(|role| role.parse::().ok()) + .is_some_and(|role| role.is_elevated()); + if !elevated + && !actor_owns_active_owner_agent_tx(tx, community_id, channel_id, actor) + .await? + { + return Err(DbError::AccessDenied( + "actor is not authorized to edit channel metadata".into(), + )); + } + } else if (topic.is_some() || purpose.is_some()) && role.is_none() { + return Err(DbError::AccessDenied( + "actor is not an active member".into(), + )); + } + update_channel_tx(tx, community_id, channel_id, updates).await?; + if let Some(topic) = topic { + sqlx::query( + "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", + ) + .bind(topic) + .bind(actor) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + } + if let Some(purpose) = purpose { + sqlx::query( + "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", + ) + .bind(purpose) + .bind(actor) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + } + if let Some(archived) = archived { + let result = if archived { + sqlx::query( + "UPDATE channels SET archived_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL AND archived_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + } else { + sqlx::query( + "UPDATE channels SET archived_at = NULL, ttl_deadline = CASE \ + WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \ + ELSE ttl_deadline END WHERE community_id = $1 AND id = $2 \ + AND deleted_at IS NULL AND archived_at IS NOT NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + }; + if result.rows_affected() == 0 { + return Err(DbError::AccessDenied( + "channel archive state did not permit the transition".into(), + )); + } + } + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: false, + channel_changed: true, + }) + } + Nip29Mutation::DeleteGroup { + channel_id, + relay_pubkey, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let owner = get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .as_deref() + == Some("owner"); + if !owner + && !actor_owns_active_owner_agent_tx(tx, community_id, channel_id, actor).await? + { + return Err(DbError::AccessDenied( + "only an owner may delete a group".into(), + )); + } + let changed = sqlx::query( + "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + .rows_affected() + > 0; + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL \ + AND kind IN (39000, 39001, 39002)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(relay_pubkey) + .execute(&mut **tx) + .await?; + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: changed, + }) + } + Nip29Mutation::Join { channel_id } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx, community_id, channel_id).await?; + if channel.visibility != "open" { + return Err(DbError::AccessDenied("channel is private".into())); + } + if get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .is_some() + { + return Ok(Nip29MutationOutcome { + channel_id, + changed: false, + membership_changed: false, + channel_changed: false, + }); + } + add_member_tx( + tx, + community_id, + channel_id, + actor, + MemberRole::Member, + None, + ) + .await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + Nip29Mutation::Leave { channel_id } => { + remove_member_tx(tx, community_id, channel_id, actor, actor).await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + } +} + /// Updates channel metadata dynamically. /// /// At least one field must be provided; returns `InvalidData` otherwise. @@ -1588,6 +2197,10 @@ pub async fn get_member_role( } /// Get the active role on the caller's transaction snapshot. +/// +/// Permission decisions that combine an event-owned binding with membership +/// use this together with [`crate::event::query_events_tx`] after selecting a +/// repeatable-read transaction, so both facts come from one database snapshot. pub async fn get_member_role_tx( transaction: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -1607,75 +2220,44 @@ pub async fn get_member_role_tx( Ok(row.map(|r| r.try_get("role")).transpose()?) } -/// Revalidate uncached access to one stored channel at a release boundary. -pub async fn channel_read_authorized( - pool: &PgPool, +/// Lock and revalidate the ordinary member-or-open channel write predicate in +/// the caller's authorization transaction. +pub async fn require_channel_write_authority_tx( + transaction: &mut Transaction<'_, Postgres>, community_id: CommunityId, channel_id: Uuid, actor: &[u8], -) -> Result { - let allowed = sqlx::query_scalar::<_, bool>( - r#" - SELECT c.visibility::text <> 'private' - OR EXISTS ( - SELECT 1 - FROM channel_members cm - WHERE cm.community_id = c.community_id - AND cm.channel_id = c.id - AND cm.pubkey = $3 - AND cm.removed_at IS NULL - ) - FROM channels c - WHERE c.community_id = $1 - AND c.id = $2 - AND c.deleted_at IS NULL - "#, +) -> Result<()> { + let row = sqlx::query( + "SELECT visibility::text AS visibility, archived_at FROM channels \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL FOR SHARE", ) .bind(community_id.as_uuid()) .bind(channel_id) - .bind(actor) - .fetch_optional(pool) - .await?; - Ok(allowed.unwrap_or(false)) -} - -/// Revalidate uncached read access to an entire channel set in one database -/// statement. -pub async fn channel_set_read_authorized( - pool: &PgPool, - community_id: CommunityId, - channel_ids: &[Uuid], - actor: &[u8], -) -> Result { - if channel_ids.is_empty() { - return Ok(true); + .fetch_optional(&mut **transaction) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let archived_at: Option> = row.try_get("archived_at")?; + if archived_at.is_some() { + return Err(DbError::AccessDenied("channel is archived".into())); } - let allowed = sqlx::query_scalar::<_, bool>( - r#" - SELECT COUNT(DISTINCT c.id) = cardinality($2::uuid[]) - FROM channels c - WHERE c.community_id = $1 - AND c.id = ANY($2::uuid[]) - AND c.deleted_at IS NULL - AND ( - c.visibility::text <> 'private' - OR EXISTS ( - SELECT 1 - FROM channel_members cm - WHERE cm.community_id = c.community_id - AND cm.channel_id = c.id - AND cm.pubkey = $3 - AND cm.removed_at IS NULL - ) - ) - "#, + let role: Option = sqlx::query_scalar( + "SELECT role::text FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL FOR SHARE", ) .bind(community_id.as_uuid()) - .bind(channel_ids) + .bind(channel_id) .bind(actor) - .fetch_one(pool) + .fetch_optional(&mut **transaction) .await?; - Ok(allowed) + let visibility: String = row.try_get("visibility")?; + if role.is_none() && visibility != "open" { + return Err(DbError::AccessDenied( + "actor is not a channel member".into(), + )); + } + Ok(()) } /// Archive ephemeral channels whose TTL deadline has passed. @@ -1684,10 +2266,22 @@ pub async fn channel_set_read_authorized( /// `archived_at IS NULL` guard prevents double-archiving even if called /// concurrently from multiple relay pods. pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { + reap_expired_ephemeral_channels_excluding(pool, &[]).await +} + +/// Archive expired ephemeral channels except exact protected domains. +/// +/// The exclusion predicate is part of the `UPDATE`, so an Enforce row cannot +/// be claimed and mutated between an application-side mode check and commit. +pub async fn reap_expired_ephemeral_channels_excluding( + pool: &PgPool, + excluded_communities: &[Uuid], +) -> Result> { let rows = sqlx::query( "UPDATE channels AS ch SET archived_at = NOW() \ FROM communities AS c \ WHERE ch.community_id = c.id \ + AND NOT (ch.community_id = ANY($1::uuid[])) \ AND ch.ttl_seconds IS NOT NULL \ AND ch.ttl_deadline < NOW() \ AND ch.archived_at IS NULL \ @@ -1695,6 +2289,7 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result PgPool { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + 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()); PgPool::connect(&database_url) .await .expect("connect to test DB") @@ -1805,15 +2404,6 @@ mod tests { } } - async fn trusted_assertion_count(pool: &PgPool, community: CommunityId) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND kind = $2") - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_USER_TRUSTED_ASSERTION as i32) - .fetch_one(pool) - .await - .expect("trusted assertion count") - } - async fn active_membership_count( pool: &PgPool, community: CommunityId, @@ -1840,7 +2430,6 @@ mod tests { let community_id = make_test_community(&pool).await; let community = CommunityId::from_uuid(community_id); let owner = random_pubkey(); - let non_member_inviter = random_pubkey(); let joiner = random_pubkey(); let channel = create_test_channel( &pool, @@ -1862,7 +2451,7 @@ mod tests { channel.id, &joiner, MemberRole::Member, - Some(&non_member_inviter), + Some(&random_pubkey()), Some(&identity), ) .await @@ -1880,7 +2469,6 @@ mod tests { .expect("binding lookup") .is_none() ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1942,7 +2530,6 @@ mod tests { active_membership_count(&pool, community, channel.id, &joiner).await, 0 ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1984,15 +2571,6 @@ mod tests { active_membership_count(&pool, community, channel.id, &joiner).await, 0 ); - assert!( - crate::identity_binding::get_active_identity_binding_by_pubkey( - &pool, community, &joiner, - ) - .await - .expect("binding lookup") - .is_none() - ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -2053,121 +2631,39 @@ mod tests { } )); - let retry = add_member_with_identity( - &pool, - community, - channel.id, - &joiner, - MemberRole::Member, - Some(&owner), - Some(&identity), - ) - .await - .expect("idempotent retry"); - assert!(matches!( - retry, - ChannelAdmissionOutcome::Joined { - identity_binding: Some(BindIdentityResult::Matched), - .. - } - )); - assert_eq!( - active_membership_count(&pool, community, channel.id, &joiner).await, - 1 - ); - let binding_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM identity_bindings \ - WHERE community_id = $1 AND pubkey = $2 \ - AND binding_state = 'active' AND revoked_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(&joiner) - .fetch_one(&pool) - .await - .expect("binding count"); - assert_eq!(binding_count, 1); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn existing_member_and_non_corporate_paths_remain_idempotent() { - let pool = setup_pool().await; - let community_id = make_test_community(&pool).await; - let community = CommunityId::from_uuid(community_id); - let owner = random_pubkey(); - let existing_member = random_pubkey(); - let non_corporate_joiner = random_pubkey(); - let channel = create_test_channel( - &pool, - community_id, - "unchanged-admission-paths", - ChannelType::Stream, - ChannelVisibility::Private, - None, - &owner, - Some(3600), - ) - .await - .expect("create private huddle"); - - add_member( - &pool, - community, - channel.id, - &existing_member, - MemberRole::Member, - Some(&owner), - ) - .await - .expect("existing member add"); - add_member( - &pool, - community, - channel.id, - &existing_member, - MemberRole::Member, - Some(&owner), - ) - .await - .expect("existing member retry"); - assert_eq!( - active_membership_count(&pool, community, channel.id, &existing_member).await, - 1 - ); - - let outcome = add_member_with_identity( + let retry = add_member_with_identity( &pool, community, channel.id, - &non_corporate_joiner, + &joiner, MemberRole::Member, Some(&owner), - None, + Some(&identity), ) .await - .expect("non-corporate admission"); + .expect("idempotent retry"); assert!(matches!( - outcome, + retry, ChannelAdmissionOutcome::Joined { - identity_binding: None, + identity_binding: Some(BindIdentityResult::Matched), .. } )); assert_eq!( - active_membership_count(&pool, community, channel.id, &non_corporate_joiner).await, + active_membership_count(&pool, community, channel.id, &joiner).await, 1 ); - assert!( - crate::identity_binding::get_active_identity_binding_by_pubkey( - &pool, - community, - &non_corporate_joiner, - ) - .await - .expect("binding lookup") - .is_none() - ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); + let binding_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id = $1 AND pubkey = $2 \ + AND binding_state = 'active' AND revoked_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&joiner) + .fetch_one(&pool) + .await + .expect("binding count"); + assert_eq!(binding_count, 1); } async fn insert_channel_with_id( @@ -2450,6 +2946,14 @@ mod tests { .await .expect("expire channel"); + let excluded = reap_expired_ephemeral_channels_excluding(&pool, &[community_id]) + .await + .expect("run excluded reaper"); + assert!( + !excluded.iter().any(|row| row.channel_id == channel.id), + "an excluded protected domain must remain untouched" + ); + let reaped = reap_expired_ephemeral_channels(&pool) .await .expect("run reaper"); @@ -3252,4 +3756,453 @@ mod tests { .expect("read role after restore"); assert_eq!(restored.as_deref(), Some("owner")); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_create_is_owned_by_the_callers_transaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let actor = random_pubkey(); + let channel_id = Uuid::new_v4(); + let mut tx = pool.begin().await.expect("begin caller transaction"); + let outcome = apply_nip29_mutation_tx( + &mut tx, + community, + &actor, + Nip29Mutation::Create { + channel_id, + name: "sealed-channel".into(), + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Private, + description: None, + ttl_seconds: None, + }, + ) + .await + .expect("create projection"); + assert!(outcome.changed); + tx.rollback().await.expect("authorization rollback"); + + assert!(matches!( + get_channel(&pool, community, channel_id).await, + Err(DbError::ChannelNotFound(_)) + )); + let membership_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM channel_members WHERE community_id = $1 AND channel_id = $2", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("count rolled-back members"); + assert_eq!(membership_count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_join_retry_is_idempotent_and_never_changes_an_existing_role() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let member = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-join", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + let mut tx = pool.begin().await.expect("begin first join"); + let first = apply_nip29_mutation_tx( + &mut tx, + community, + &member, + Nip29Mutation::Join { + channel_id: channel.id, + }, + ) + .await + .expect("first join"); + assert!(first.changed); + tx.commit().await.expect("commit first join"); + + let mut retry = pool.begin().await.expect("begin retry"); + let repeated = apply_nip29_mutation_tx( + &mut retry, + community, + &member, + Nip29Mutation::Join { + channel_id: channel.id, + }, + ) + .await + .expect("retry join"); + assert!(!repeated.changed); + retry.commit().await.expect("commit retry"); + + let members = get_members(&pool, community, channel.id) + .await + .expect("members"); + assert_eq!( + members + .iter() + .filter(|entry| entry.pubkey == member) + .count(), + 1 + ); + assert_eq!( + members + .iter() + .find(|entry| entry.pubkey == owner) + .map(|entry| entry.role.as_str()), + Some("owner") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_put_user_serializes_with_target_policy_updates() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let actor = random_pubkey(); + let target = random_pubkey(); + ensure_user(&pool, community, &actor) + .await + .expect("ensure actor"); + ensure_user(&pool, community, &target) + .await + .expect("ensure target"); + set_channel_add_policy(&pool, community, &target, "anyone") + .await + .expect("allow external additions"); + let channel = create_test_channel( + &pool, + community_id, + "sealed-target-policy-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &actor, + None, + ) + .await + .expect("create channel"); + + let mut operation = pool.begin().await.expect("begin protected put-user"); + apply_nip29_mutation_tx( + &mut operation, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await + .expect("authorize and stage target membership"); + + let update_pool = pool.clone(); + let update_target = target.clone(); + let mut policy_update = tokio::spawn(async move { + set_channel_add_policy(&update_pool, community, &update_target, "nobody").await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut policy_update) + .await + .is_err(), + "target policy update must wait for the transaction that authorized the addition" + ); + + operation + .commit() + .await + .expect("commit authorized addition before policy update"); + tokio::time::timeout(std::time::Duration::from_secs(10), policy_update) + .await + .expect("policy update proceeds after authorization transaction") + .expect("policy update task") + .expect("policy update succeeds"); + assert!( + is_member(&pool, community, channel.id, &target) + .await + .expect("membership after serialized commit"), + "the authorization transaction won the serialization order" + ); + + let denied_target = random_pubkey(); + ensure_user(&pool, community, &denied_target) + .await + .expect("ensure denied target"); + set_channel_add_policy(&pool, community, &denied_target, "nobody") + .await + .expect("deny external additions first"); + let mut denied = pool.begin().await.expect("begin denied put-user"); + let result = apply_nip29_mutation_tx( + &mut denied, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: denied_target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + denied.rollback().await.expect("rollback denied put-user"); + assert!( + !is_member(&pool, community, channel.id, &denied_target) + .await + .expect("denied target membership"), + "a policy update that commits first must deny without membership" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_put_user_serializes_with_first_target_profile() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let actor = random_pubkey(); + ensure_user(&pool, community, &actor) + .await + .expect("ensure actor"); + let channel = create_test_channel( + &pool, + community_id, + "sealed-first-profile-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &actor, + None, + ) + .await + .expect("create channel"); + + // PutUser wins: its create-or-conflict establishes the target row and + // retains that row through membership commit. The first restrictive + // profile must wait and therefore takes effect only afterward. + let target = random_pubkey(); + let mut operation = pool.begin().await.expect("begin protected put-user"); + apply_nip29_mutation_tx( + &mut operation, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await + .expect("stage absent target membership"); + + let update_pool = pool.clone(); + let update_target = target.clone(); + let mut first_profile = tokio::spawn(async move { + let mut profile_tx = update_pool.begin().await.expect("begin first profile"); + ensure_user_tx(&mut profile_tx, community, &update_target) + .await + .expect("create or observe target"); + set_channel_add_policy_tx(&mut profile_tx, community, &update_target, "nobody") + .await + .expect("set first profile policy"); + profile_tx.commit().await.expect("commit first profile") + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut first_profile) + .await + .is_err(), + "first target profile must wait for the earlier PutUser transaction" + ); + operation.commit().await.expect("commit absent-target add"); + tokio::time::timeout(std::time::Duration::from_secs(10), first_profile) + .await + .expect("first profile proceeds after membership commit") + .expect("first profile task"); + assert!(is_member(&pool, community, channel.id, &target) + .await + .expect("membership after PutUser-first order")); + + // Profile wins: hold its newly inserted `nobody` row open. PutUser must + // wait at create-or-conflict, then re-read the committed restriction + // and deny before adding membership. + let denied_target = random_pubkey(); + let mut profile_tx = pool.begin().await.expect("begin winning profile"); + ensure_user_tx(&mut profile_tx, community, &denied_target) + .await + .expect("stage first target profile"); + set_channel_add_policy_tx(&mut profile_tx, community, &denied_target, "nobody") + .await + .expect("stage restrictive policy"); + + let put_pool = pool.clone(); + let put_actor = actor.clone(); + let put_target = denied_target.clone(); + let channel_id = channel.id; + let mut put_user = tokio::spawn(async move { + let mut put_tx = put_pool.begin().await.expect("begin waiting put-user"); + let result = apply_nip29_mutation_tx( + &mut put_tx, + community, + &put_actor, + Nip29Mutation::PutUser { + channel_id, + target: put_target, + role: Some(MemberRole::Member), + }, + ) + .await; + match result { + Ok(outcome) => { + put_tx.commit().await.expect("commit unexpected add"); + Ok(outcome) + } + Err(error) => { + put_tx.rollback().await.expect("rollback denied add"); + Err(error) + } + } + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut put_user) + .await + .is_err(), + "PutUser must wait for the earlier first-profile transaction" + ); + profile_tx + .commit() + .await + .expect("commit restrictive profile"); + let result = tokio::time::timeout(std::time::Duration::from_secs(10), put_user) + .await + .expect("PutUser proceeds after first profile commit") + .expect("PutUser task"); + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + assert!( + !is_member(&pool, community, channel.id, &denied_target) + .await + .expect("membership after profile-first order"), + "a restrictive first profile must deny without membership" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removed_after_precheck_before_commit_denies_event() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let member = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-write-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + None, + ) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_id) + .bind(channel.id) + .bind(&member) + .bind(&owner) + .execute(&pool) + .await + .expect("add member"); + + let mut preflight = pool.begin().await.expect("begin preflight"); + require_channel_write_authority_tx(&mut preflight, community, channel.id, &member) + .await + .expect("member passes preflight"); + preflight.rollback().await.expect("release preflight locks"); + + sqlx::query( + "UPDATE channel_members SET removed_at = NOW() \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id) + .bind(channel.id) + .bind(&member) + .execute(&pool) + .await + .expect("remove member between boundaries"); + + let mut operation = pool.begin().await.expect("begin operation"); + let result = + require_channel_write_authority_tx(&mut operation, community, channel.id, &member) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + operation + .rollback() + .await + .expect("rollback denied operation"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn open_to_private_after_precheck_denies_nonmember() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let nonmember = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-visibility-race", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + let mut preflight = pool.begin().await.expect("begin preflight"); + require_channel_write_authority_tx(&mut preflight, community, channel.id, &nonmember) + .await + .expect("open channel passes preflight"); + preflight.rollback().await.expect("release preflight locks"); + + sqlx::query( + "UPDATE channels SET visibility = 'private'::channel_visibility \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(channel.id) + .execute(&pool) + .await + .expect("make channel private between boundaries"); + + let mut operation = pool.begin().await.expect("begin operation"); + let result = + require_channel_write_authority_tx(&mut operation, community, channel.id, &nonmember) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + operation + .rollback() + .await + .expect("rollback denied operation"); + } } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 4a0e9ea667..1bbb8df1a0 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -17,6 +17,10 @@ pub mod api_token; pub mod archived_identities; /// Transaction-owned admission records for protected audio sessions. pub mod audio_admission; +/// Durable provider-neutral invalidation selectors and generation floors. +pub mod authorization_invalidation; +/// Durable authorization version and operation-receipt state. +pub mod authorization_version; /// Channel and membership persistence. pub mod channel; /// Direct message channel persistence. @@ -180,6 +184,43 @@ pub async fn insert_mentions( Ok(()) } +/// Insert mention projections on the caller-owned protected transaction. +pub async fn insert_mentions_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + for pubkey in event.tags.iter().filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 + && parts[0] == "p" + && parts[1].len() == 64 + && parts[1] + .chars() + .all(|character| character.is_ascii_hexdigit())) + .then(|| parts[1].to_ascii_lowercase()) + }) { + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) \ + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(event.id.as_bytes().as_slice()) + .bind(created_at) + .bind(channel_id) + .bind(event.kind.as_u16() as i32) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -2475,15 +2516,6 @@ impl Db { channel::channel_set_read_authorized(&self.pool, community_id, channel_ids, pubkey).await } - /// Fail closed before the invalidation slice owns durable operation receipts. - pub async fn authorization_operation_receipt_fingerprint( - &self, - _community_id: CommunityId, - _operation_id: Uuid, - ) -> Result> { - Ok(None) - } - /// Archive ephemeral channels whose TTL deadline has passed. pub async fn reap_expired_ephemeral_channels( &self, @@ -4607,6 +4639,16 @@ impl Db { git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await } + /// Return a reservation's immutable protected publication origin. + pub async fn repo_publication_origin( + &self, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result> { + git_repo::repo_publication_origin(&self.pool, community_id, repo_id, owner_pubkey).await + } + /// Release a git repo name reservation held by `owner_pubkey` (rollback). /// /// Returns the number of rows removed (0 or 1). See [`git_repo::release_repo_name`]. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 50e2d5d002..c5e6c0fc87 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -566,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 39); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1007,6 +1007,64 @@ mod tests { "migration 0030 is missing {required}" ); } + + assert_eq!(migrations[35].version, 36); + let protected_domain_marker = migrations[35].sql.as_str(); + assert!(protected_domain_marker.contains("CREATE TABLE authorization_invalidation_domains")); + + assert_eq!(migrations[39].version, 40); + let invalidation = migrations[39].sql.as_str(); + assert!(invalidation.contains("CREATE TABLE authorization_invalidation_receipts")); + assert!(invalidation.contains("CREATE TABLE authorization_invalidation_floors")); + + assert_eq!(migrations[40].version, 41); + let operation_receipts = migrations[40].sql.as_str(); + assert!(operation_receipts.contains("CREATE TABLE authorization_operation_receipts")); + assert!(operation_receipts.contains("request_fingerprint")); + assert!(operation_receipts.contains("result_payload")); + assert!(operation_receipts.contains("authorization_operation_expiry_guard")); + + assert_eq!(migrations[41].version, 42); + let authority_epochs = migrations[41].sql.as_str(); + assert!(authority_epochs.contains("CREATE TABLE authorization_authority_epochs")); + assert!(authority_epochs.contains("CREATE TABLE client_status_revisions")); + assert!(authority_epochs.contains("advance_authorization_authority_epoch")); + assert!(authority_epochs.contains("IF TG_OP = 'DELETE'")); + assert!(authority_epochs.contains("domain_id := OLD.community_id")); + assert!(authority_epochs.contains("domain_id := NEW.community_id")); + assert!(authority_epochs.contains("pg_trigger_depth() > 1")); + assert!(authority_epochs.contains("ON DELETE CASCADE")); + let function_position = authority_epochs + .find("CREATE FUNCTION advance_authorization_authority_epoch") + .expect("authority epoch function exists"); + for git_policy_trigger in [ + "git_policy_insert_authority_epoch", + "git_policy_update_authority_epoch", + "git_policy_delete_authority_epoch", + ] { + let trigger_position = authority_epochs + .find(git_policy_trigger) + .expect("git policy authority trigger exists"); + assert!(function_position < trigger_position); + } + for protected_table in [ + "identity_bindings", + "identity_principals", + "identity_revoked_keys", + "identity_retired_pairs", + "relay_members", + "channel_members", + "community_bans", + "channels", + "users", + "authorization_invalidation_domains", + "git_repo_publications", + "media_publications", + "protected_object_authority", + "audio_session_admissions", + ] { + assert!(authority_epochs.contains(protected_table)); + } } fn additive_identity_executable_sql(sql: &str) -> String { @@ -2663,4 +2721,96 @@ mod tests { "fresh installs must default non-allowlisted kinds to NULL: {search_expression}" ); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authority_triggers_preserve_off_and_deny_unwitnessed_protected_teardown() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations(&pool).await.expect("apply all migrations"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "authority-trigger-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert legacy community"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')", + ) + .bind(community_id) + .bind("11".repeat(32)) + .execute(&pool) + .await + .expect("legacy membership remains writable"); + let legacy_domains: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read legacy authorization rows"); + assert_eq!(legacy_domains, 0, "Off must not acquire protected state"); + + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(community_id) + .execute(&pool) + .await + .expect("initialize protected domain"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')", + ) + .bind(community_id) + .bind("22".repeat(32)) + .execute(&pool) + .await + .expect("protected membership mutation"); + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read protected generation"); + assert_eq!(generation, 1, "one mutation advances generation once"); + + sqlx::query("DELETE FROM relay_members WHERE community_id=$1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete ordinary community-owned rows first"); + assert!(sqlx::query("DELETE FROM communities WHERE id=$1") + .bind(community_id) + .execute(&pool) + .await + .is_err()); + let retained: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_authority_epochs WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read retained authority state"); + assert_eq!(retained, 1, "denied teardown retains the monotonic floor"); + + assert!(sqlx::query( + "DELETE FROM authorization_invalidation_domains WHERE community_id=$1" + ) + .bind(community_id) + .execute(&pool) + .await + .is_err()); + let marker: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read retained activation marker"); + assert_eq!(marker, 1, "protected activation is a one-way cutover"); + } } diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index 7189a2381f..a79c9afcee 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/relay_invite.rs @@ -22,7 +22,8 @@ use buzz_core::invite::{ V2_SECRET_LEN, }; use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; +use uuid::Uuid; use crate::error::Result; use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; @@ -113,6 +114,21 @@ pub async fn mint_relay_invite( created_by: &str, ttl_secs: u64, max_uses: Option, +) -> Result { + let mut transaction = pool.begin().await?; + let invite = + mint_relay_invite_tx(&mut transaction, community, created_by, ttl_secs, max_uses).await?; + transaction.commit().await?; + Ok(invite) +} + +/// Mint an invite inside a caller-owned authorization transaction. +pub async fn mint_relay_invite_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, ) -> Result { validate_mint_inputs(ttl_secs, max_uses)?; @@ -133,7 +149,7 @@ pub async fn mint_relay_invite( .bind(max_uses) .bind(expires_at) .bind(created_by) - .fetch_one(pool) + .fetch_one(&mut **transaction) .await?; let invite_id: uuid::Uuid = row.try_get("id")?; @@ -147,6 +163,32 @@ pub async fn mint_relay_invite( }) } +/// Lock and validate the relay role that may mint an invite. +/// +/// Enforcing callers invoke this inside the same transaction that writes the +/// invite and its authorization receipt. Legacy callers retain their existing +/// authorization flow. +pub async fn validate_relay_invite_minter_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + created_by: &str, +) -> Result<()> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(created_by) + .fetch_optional(&mut **transaction) + .await?; + if !matches!(role.as_deref(), Some("owner" | "admin")) { + return Err(crate::error::DbError::InvalidData( + "invite mint authority changed before commit".into(), + )); + } + Ok(()) +} + fn log_claim_outcome( community: CommunityId, invite_id: Option, @@ -174,17 +216,28 @@ const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; /// expiry index makes old rows drain first without turning cleanup into an /// unbounded transaction. pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + reap_expired_relay_invites_excluding(pool, cutoff, &[]).await +} + +/// Delete expired invites outside exact protected Enforce domains. +pub async fn reap_expired_relay_invites_excluding( + pool: &PgPool, + cutoff: DateTime, + excluded_communities: &[Uuid], +) -> Result { let result = sqlx::query( "DELETE FROM relay_invites \ WHERE (community_id, id) IN (\ SELECT community_id, id FROM relay_invites \ WHERE expires_at < $1 \ + AND NOT (community_id = ANY($3::uuid[])) \ ORDER BY expires_at \ LIMIT $2\ )", ) .bind(cutoff) .bind(RETENTION_SWEEP_BATCH_SIZE) + .bind(excluded_communities) .execute(pool) .await?; @@ -217,11 +270,41 @@ pub async fn claim_relay_invite_with_identity( claimer_pubkey: &str, policy_version: Option<&str>, identity: Option<&IdentityBindingInput<'_>>, +) -> Result { + let mut transaction = pool.begin().await?; + let outcome = claim_relay_invite_with_identity_tx( + &mut transaction, + community, + token_hash, + claimer_pubkey, + policy_version, + identity, + ) + .await?; + if matches!( + outcome, + ClaimOutcome::Joined { .. } | ClaimOutcome::AlreadyMember { .. } + ) { + transaction.commit().await?; + } else { + transaction.rollback().await?; + } + Ok(outcome) +} + +/// Stage invite consumption, binding, membership, and policy evidence inside +/// a caller-owned authorization transaction. +pub async fn claim_relay_invite_with_identity_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + identity: Option<&IdentityBindingInput<'_>>, ) -> Result { crate::identity_binding::validate_membership_identity_key(claimer_pubkey, identity)?; - let mut tx = pool.begin().await?; sqlx::query("SET LOCAL lock_timeout = '3s'") - .execute(&mut *tx) + .execute(&mut **tx) .await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. @@ -233,12 +316,11 @@ pub async fn claim_relay_invite_with_identity( ) .bind(community.as_uuid()) .bind(token_hash) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; // 3. No matching invite. let Some(invite) = row else { - tx.rollback().await?; log_claim_outcome(community, None, "invalid", None, None); return Ok(ClaimOutcome::Invalid); }; @@ -252,7 +334,6 @@ pub async fn claim_relay_invite_with_identity( // not authorize fresh policy-acceptance evidence, even for an existing // member; exhausted-but-live invites remain valid for idempotent retries. if expires_at <= Utc::now() { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -264,12 +345,10 @@ pub async fn claim_relay_invite_with_identity( } let identity_binding = if let Some(identity) = identity { - match crate::identity_binding::bind_or_validate_identity_tx(&mut tx, community, identity) - .await? + match crate::identity_binding::bind_or_validate_identity_tx(tx, community, identity).await? { binding @ (BindIdentityResult::Created | BindIdentityResult::Matched) => Some(binding), BindIdentityResult::Conflict(conflict) => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -280,7 +359,6 @@ pub async fn claim_relay_invite_with_identity( return Ok(ClaimOutcome::IdentityConflict(conflict)); } BindIdentityResult::Revoked => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -291,7 +369,6 @@ pub async fn claim_relay_invite_with_identity( return Ok(ClaimOutcome::IdentityRevoked); } BindIdentityResult::BindingRequired => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -313,7 +390,7 @@ pub async fn claim_relay_invite_with_identity( sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(claimer_pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; if existing.is_some() { @@ -326,10 +403,9 @@ pub async fn claim_relay_invite_with_identity( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } - tx.commit().await?; log_claim_outcome( community, Some(invite_id), @@ -347,7 +423,6 @@ pub async fn claim_relay_invite_with_identity( // 7. Capacity check. if let Some(mu) = max_uses { if use_count >= mu { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -369,7 +444,7 @@ pub async fn claim_relay_invite_with_identity( ) .bind(community.as_uuid()) .bind(claimer_pubkey) - .execute(&mut *tx) + .execute(&mut **tx) .await? .rows_affected() > 0; @@ -384,12 +459,11 @@ pub async fn claim_relay_invite_with_identity( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } if !inserted { - tx.commit().await?; log_claim_outcome( community, Some(invite_id), @@ -410,12 +484,9 @@ pub async fn claim_relay_invite_with_identity( .bind(new_use_count) .bind(community.as_uuid()) .bind(invite_id) - .execute(&mut *tx) + .execute(&mut **tx) .await?; - // 11. Commit. - tx.commit().await?; - let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); log_claim_outcome( @@ -803,6 +874,12 @@ mod tests { .await .expect("age old invite"); + assert_eq!( + reap_expired_relay_invites_excluding(&pool, cutoff, &[*community.as_uuid()]) + .await + .expect("exclude protected invites"), + 0 + ); assert_eq!( reap_expired_relay_invites(&pool, cutoff) .await diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index affddd8177..3e7ffd92ea 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -7,7 +7,7 @@ //! lowercase hex strings. use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use crate::error::Result; use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; @@ -93,6 +93,35 @@ pub async fn get_relay_member( .map_err(crate::error::DbError::from) } +/// Return and share-lock a relay member inside a caller-owned authorization +/// transaction so a role decision remains stable through commit. +pub async fn get_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, +) -> Result> { + let row = sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at \ + FROM relay_members WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + + row.map(|r| -> std::result::Result { + Ok(RelayMember { + pubkey: r.try_get("pubkey")?, + role: r.try_get("role")?, + added_by: r.try_get("added_by")?, + created_at: r.try_get("created_at")?, + updated_at: r.try_get("updated_at")?, + }) + }) + .transpose() + .map_err(crate::error::DbError::from) +} + /// Returns all relay members of `community` ordered by `created_at` ascending. pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result> { let rows = sqlx::query( @@ -142,6 +171,27 @@ pub async fn add_relay_member( Ok(result.rows_affected() > 0) } +/// Transaction-owned relay member insertion. +pub async fn add_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + role: &str, + added_by: Option<&str>, +) -> Result { + let result = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(role) + .bind(added_by) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Claims relay membership via an invite and atomically persists policy evidence. /// /// Returns `true` when membership was inserted, or `false` when the pubkey was @@ -319,6 +369,35 @@ pub async fn remove_relay_member( } } +/// Transaction-owned relay member removal with owner protection. +pub async fn remove_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(if exists.is_some() { + RemoveResult::IsOwner + } else { + RemoveResult::NotFound + }) +} + /// Removes a relay member only if their current role matches `expected_role`. /// /// The delete and the role check are collapsed into a single @@ -375,6 +454,38 @@ pub async fn remove_relay_member_if_role( } } +/// Transaction-owned role-conditional relay member removal. +pub async fn remove_relay_member_if_role_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + expected_role: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .execute(&mut **transaction) + .await?; + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + let role = sqlx::query_scalar::<_, String>( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(match role.as_deref() { + None => RemoveResult::NotFound, + Some("owner") => RemoveResult::IsOwner, + Some(_) => RemoveResult::RoleMismatch, + }) +} + /// Updates the role of an existing relay member in `community`. Returns `true` /// if updated. pub async fn update_relay_member_role( @@ -395,6 +506,25 @@ pub async fn update_relay_member_role( Ok(result.rows_affected() > 0) } +/// Transaction-owned role update with owner protection. +pub async fn update_relay_member_role_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + new_role: &str, +) -> Result { + let result = sqlx::query( + "UPDATE relay_members SET role = $1, updated_at = now() \ + WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", + ) + .bind(new_role) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Ensures the configured owner pubkey holds the `"owner"` role *in /// `community`*, and demotes any other owners in that community to `"admin"`. /// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 066fb5f5c0..9902e9f003 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -4,6 +4,7 @@ use crate::error::Result; use buzz_core::CommunityId; use sqlx::PgPool; use sqlx::Row; +use sqlx::{Postgres, Transaction}; /// A user's profile fields. #[derive(Debug, Clone)] @@ -54,6 +55,63 @@ pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8] Ok(result.rows_affected() == 1) } +/// Ensure a user row exists inside a caller-owned transaction. +pub async fn ensure_user_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + let result = sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Apply absolute kind:0 profile state inside a caller-owned transaction. +/// A contested NIP-05 handle leaves the prior handle unchanged while updating +/// the remaining fields, matching the legacy compatibility behavior. +#[allow(clippy::too_many_arguments)] +pub async fn replace_user_profile_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], + display_name: &str, + avatar_url: &str, + about: &str, + nip05_handle: &str, +) -> Result<()> { + let contested: bool = !nip05_handle.is_empty() + && sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE community_id = $1 \ + AND LOWER(nip05_handle) = LOWER($2) AND pubkey <> $3)", + ) + .bind(community_id.as_uuid()) + .bind(nip05_handle) + .bind(pubkey) + .fetch_one(&mut **tx) + .await?; + sqlx::query( + "UPDATE users SET display_name = NULLIF($1, ''), avatar_url = NULLIF($2, ''), \ + about = NULLIF($3, ''), nip05_handle = CASE WHEN $4 THEN nip05_handle \ + ELSE NULLIF($5, '') END WHERE community_id = $6 AND pubkey = $7", + ) + .bind(display_name) + .bind(avatar_url) + .bind(about) + .bind(contested) + .bind(nip05_handle) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Get a single user record by pubkey. pub async fn get_user( pool: &PgPool, @@ -368,6 +426,26 @@ pub async fn is_agent_owner( Ok(row.unwrap_or(false)) } +/// Share-lock and validate an agent-owner relationship inside a caller-owned +/// authorization transaction. +pub async fn is_agent_owner_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], +) -> Result { + let owner = sqlx::query_scalar::<_, Vec>( + "SELECT agent_owner_pubkey FROM users \ + WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL \ + FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(target_pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(owner.is_some_and(|owner| owner == actor_pubkey)) +} + /// Set the channel_add_policy for a user. /// Returns an error if the pubkey is not found (rows_affected == 0). /// Returns an error if `policy` is not one of the valid ENUM values. @@ -398,6 +476,35 @@ pub async fn set_channel_add_policy( Ok(()) } +/// Set a channel-add policy inside a caller-owned transaction. +pub async fn set_channel_add_policy_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, +) -> Result<()> { + if !matches!(policy, "anyone" | "owner_only" | "nobody") { + return Err(crate::error::DbError::InvalidData(format!( + "invalid channel_add_policy: {policy}" + ))); + } + let result = sqlx::query( + "UPDATE users SET channel_add_policy = $1::channel_add_policy \ + WHERE community_id = $2 AND pubkey = $3", + ) + .bind(policy) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(crate::error::DbError::NotFound( + "pubkey not found in users table".into(), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-pubsub/src/authorization_invalidation.rs b/crates/buzz-pubsub/src/authorization_invalidation.rs new file mode 100644 index 0000000000..3894615b44 --- /dev/null +++ b/crates/buzz-pubsub/src/authorization_invalidation.rs @@ -0,0 +1,225 @@ +//! Provider-neutral authorization invalidation hints over Redis pub/sub. +//! +//! Hints contain no selector or identity data. The community is derived from +//! the server-owned Redis channel and the durable generation is reconciled +//! from Postgres by every consumer. + +use buzz_core::CommunityId; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::topic::BUZZ_PREFIX; + +/// Current provider-neutral hint wire version. +pub const AUTHORIZATION_INVALIDATION_WIRE_VERSION: u16 = 1; +/// Tenant-local Redis channel suffix. +pub const AUTHORIZATION_INVALIDATION_SUFFIX: &str = "authorization-invalidation"; +/// Pattern subscribed by relay nodes. +pub const AUTHORIZATION_INVALIDATION_PATTERN: &str = "buzz:*:authorization-invalidation"; + +/// Redis hint that a durable domain generation may have advanced. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthorizationInvalidationHint { + /// Envelope version. Unknown versions trigger a full durable reconcile. + pub wire_version: u16, + /// Highest generation known by the publisher after its commit. + pub generation: u64, +} + +impl AuthorizationInvalidationHint { + /// Construct a current-version hint for a positive durable generation. + pub const fn current(generation: u64) -> Self { + Self { + wire_version: AUTHORIZATION_INVALIDATION_WIRE_VERSION, + generation, + } + } +} + +/// A hint scoped by its server-owned Redis channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScopedAuthorizationInvalidationHint { + /// Authorization domain parsed from the channel. + pub community_id: CommunityId, + /// Provider-neutral durable-generation hint. + pub hint: AuthorizationInvalidationHint, +} + +/// Redis channel for one server-resolved authorization domain. +pub fn authorization_invalidation_channel(community_id: CommunityId) -> String { + format!("{BUZZ_PREFIX}:{community_id}:{AUTHORIZATION_INVALIDATION_SUFFIX}") +} + +/// Parse the exact authorization-invalidation channel shape. +pub fn parse_authorization_invalidation_channel(channel: &str) -> Option { + let mut parts = channel.split(':'); + if parts.next()? != BUZZ_PREFIX { + return None; + } + let community_id = Uuid::parse_str(parts.next()?).ok()?; + if parts.next()? != AUTHORIZATION_INVALIDATION_SUFFIX || parts.next().is_some() { + return None; + } + Some(CommunityId::from_uuid(community_id)) +} + +const BACKOFF_INITIAL_SECS: u64 = 1; +const BACKOFF_MAX_SECS: u64 = 30; + +/// Subscribe forever, reconnecting with bounded exponential backoff. +pub async fn run_authorization_invalidation_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + loop { + match connect_and_subscribe(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis authorization-invalidation stream ended; reconnecting in {backoff_secs}s" + ); + } + Err(error) => tracing::error!( + "Redis authorization-invalidation error: {error}; reconnecting in {backoff_secs}s" + ), + } + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + } +} + +async fn connect_and_subscribe( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut connection = client.get_async_pubsub().await?; + connection + .psubscribe(AUTHORIZATION_INVALIDATION_PATTERN) + .await?; + tracing::info!( + "Redis authorization-invalidation subscriber listening on {AUTHORIZATION_INVALIDATION_PATTERN}" + ); + + let mut stream = connection.on_message(); + while let Some(message) = stream.next().await { + let channel = message.get_channel_name(); + let Some(community_id) = parse_authorization_invalidation_channel(channel) else { + tracing::warn!("ignoring authorization-invalidation hint on unexpected channel"); + continue; + }; + let payload: String = match message.get_payload() { + Ok(payload) => payload, + Err(error) => { + tracing::warn!(%error, "ignoring unreadable authorization-invalidation hint"); + continue; + } + }; + let hint: AuthorizationInvalidationHint = match serde_json::from_str(&payload) { + Ok(hint) => hint, + Err(error) => { + tracing::warn!(%error, "ignoring malformed authorization-invalidation hint"); + continue; + } + }; + if broadcast_tx + .send(ScopedAuthorizationInvalidationHint { community_id, hint }) + .is_err() + { + tracing::trace!("no local authorization-invalidation receivers"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn channels_are_exactly_domain_scoped() { + let a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa)); + let b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb)); + assert_ne!( + authorization_invalidation_channel(a), + authorization_invalidation_channel(b) + ); + assert_eq!( + parse_authorization_invalidation_channel( + authorization_invalidation_channel(a).as_str() + ), + Some(a) + ); + } + + #[test] + fn rejects_ambiguous_channels() { + for channel in [ + "buzz:authorization-invalidation", + "buzz:not-a-uuid:authorization-invalidation", + "buzz:00000000-0000-0000-0000-00000000aaaa:authorization-invalidation:extra", + "other:00000000-0000-0000-0000-00000000aaaa:authorization-invalidation", + ] { + assert_eq!(parse_authorization_invalidation_channel(channel), None); + } + } + + #[test] + fn envelope_roundtrip_contains_only_version_and_generation() { + let payload = serde_json::to_string(&AuthorizationInvalidationHint::current(42)) + .expect("hint serializes"); + assert_eq!(payload, r#"{"wire_version":1,"generation":42}"#); + assert_eq!( + serde_json::from_str::(&payload) + .expect("hint deserializes"), + AuthorizationInvalidationHint::current(42) + ); + } + + #[test] + fn unknown_wire_version_remains_visible_to_reconciler() { + let hint: AuthorizationInvalidationHint = + serde_json::from_str(r#"{"wire_version":99,"generation":7}"#) + .expect("forward version remains decodable"); + assert_eq!(hint.wire_version, 99); + assert_eq!(hint.generation, 7); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_roundtrip_preserves_only_scoped_generation_hint() { + let pool = crate::test_util::make_test_pool(); + let manager = Arc::new( + crate::PubSubManager::new("redis://127.0.0.1:6379", pool) + .await + .expect("create pubsub manager"), + ); + let mut receiver = manager.subscribe_authorization_invalidations(); + let subscriber = manager.clone(); + let task = + tokio::spawn( + async move { subscriber.run_authorization_invalidation_subscriber().await }, + ); + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + manager + .publish_authorization_invalidation( + community_id, + AuthorizationInvalidationHint::current(11), + ) + .await + .expect("publish hint"); + let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), receiver.recv()) + .await + .expect("hint arrives") + .expect("broadcast remains open"); + assert_eq!(received.community_id, community_id); + assert_eq!(received.hint, AuthorizationInvalidationHint::current(11)); + task.abort(); + } +} diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 4f1690beef..3774e3172e 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -21,6 +21,8 @@ //! Pool connections handle all other commands. //! Lagged receivers get `RecvError::Lagged`. +/// Provider-neutral durable authorization invalidation hints. +pub mod authorization_invalidation; /// Cross-pod cache-key invalidation over Redis pub/sub. pub mod cache_invalidation; /// Cross-pod connection-control commands over Redis pub/sub. @@ -51,6 +53,10 @@ use buzz_core::TenantContext; use nostr::PublicKey; use tokio::sync::{broadcast, mpsc, Mutex}; +use crate::authorization_invalidation::{ + authorization_invalidation_channel, AuthorizationInvalidationHint, + ScopedAuthorizationInvalidationHint, +}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; @@ -66,6 +72,8 @@ pub struct ChannelEvent { pub topic: EventTopic, /// The Nostr event payload. pub event: nostr::Event, + /// Opaque relay-authenticated authority retained for protected ephemeral delivery. + pub authority: Option, } /// Configuration for the pub/sub subsystem. @@ -109,6 +117,7 @@ pub struct PubSubManager { subscription_rx: Mutex>>, broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, + authorization_invalidation_tx: broadcast::Sender, conn_control_tx: broadcast::Sender, } @@ -125,6 +134,7 @@ impl PubSubManager { ) -> Result { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); + let (authorization_invalidation_tx, _) = broadcast::channel(4096); let (conn_control_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); @@ -137,6 +147,7 @@ impl PubSubManager { subscription_rx: Mutex::new(Some(subscription_rx)), broadcast_tx, cache_invalidation_tx, + authorization_invalidation_tx, conn_control_tx, }) } @@ -170,6 +181,15 @@ impl PubSubManager { .await; } + /// Starts the authorization-invalidation hint subscriber with reconnects. + pub async fn run_authorization_invalidation_subscriber(self: Arc) { + authorization_invalidation::run_authorization_invalidation_subscriber( + self.redis_url.clone(), + self.authorization_invalidation_tx.clone(), + ) + .await; + } + /// Starts the connection-control subscriber loop with automatic /// reconnection. Runs forever — spawn this in a background task. pub async fn run_conn_control_subscriber(self: Arc) { @@ -260,6 +280,13 @@ impl PubSubManager { self.cache_invalidation_tx.subscribe() } + /// Returns a receiver for durable authorization-generation hints. + pub fn subscribe_authorization_invalidations( + &self, + ) -> broadcast::Receiver { + self.authorization_invalidation_tx.subscribe() + } + /// Returns a new broadcast receiver for cross-pod connection-control commands. pub fn subscribe_conn_control(&self) -> broadcast::Receiver { self.conn_control_tx.subscribe() @@ -284,6 +311,22 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a provider-neutral hint after a durable invalidation commit. + pub async fn publish_authorization_invalidation( + &self, + community_id: buzz_core::CommunityId, + hint: AuthorizationInvalidationHint, + ) -> Result { + let mut connection = self.pool.get().await?; + let payload = serde_json::to_string(&hint)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(authorization_invalidation_channel(community_id)) + .arg(payload) + .query_async(&mut connection) + .await?; + Ok(subscriber_count) + } + /// Publish a connection-control command to all pods. Used for live ban /// enforcement: the banning pod disconnects any local sockets synchronously /// and calls this to reach the banned member's sockets on other pods. The DB @@ -328,6 +371,17 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } + /// Publish an event with an opaque relay-owned authority envelope. + pub async fn publish_event_with_authority( + &self, + ctx: &TenantContext, + topic: EventTopic, + event: &nostr::Event, + authority: &str, + ) -> Result { + publisher::publish_event_with_authority(&self.pool, ctx, topic, event, authority).await + } + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, diff --git a/crates/buzz-pubsub/src/publisher.rs b/crates/buzz-pubsub/src/publisher.rs index 8ad06cc56b..c17a2b7f23 100644 --- a/crates/buzz-pubsub/src/publisher.rs +++ b/crates/buzz-pubsub/src/publisher.rs @@ -3,6 +3,7 @@ use buzz_core::TenantContext; use deadpool_redis::Pool; use nostr::JsonUtil; +use serde::Serialize; use uuid::Uuid; use crate::error::PubSubError; @@ -35,3 +36,28 @@ pub async fn publish_event( .await?; Ok(subscriber_count) } + +#[derive(Serialize)] +struct PublishedEventEnvelope<'a> { + event: &'a nostr::Event, + authority: &'a str, +} + +/// Publish one event with opaque relay-owned authority metadata. +pub async fn publish_event_with_authority( + pool: &Pool, + ctx: &TenantContext, + topic: EventTopic, + event: &nostr::Event, + authority: &str, +) -> Result { + let mut conn = pool.get().await?; + let key = crate::topic::EventTopicKey::from_context(ctx, topic).redis_channel(); + let payload = serde_json::to_string(&PublishedEventEnvelope { event, authority })?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(&key) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) +} diff --git a/crates/buzz-pubsub/src/subscriber.rs b/crates/buzz-pubsub/src/subscriber.rs index 88826ed99b..f7b4217044 100644 --- a/crates/buzz-pubsub/src/subscriber.rs +++ b/crates/buzz-pubsub/src/subscriber.rs @@ -6,11 +6,18 @@ use std::time::Duration; use futures_util::StreamExt; use nostr::JsonUtil; +use serde::Deserialize; use tokio::sync::{broadcast, mpsc, Mutex}; use crate::topic::EventTopicKey; use crate::ChannelEvent; +#[derive(Deserialize)] +struct PublishedEventEnvelope { + event: nostr::Event, + authority: String, +} + /// Initial reconnect backoff (1 second). const BACKOFF_INITIAL_SECS: u64 = 1; /// Maximum reconnect backoff (30 seconds). @@ -145,18 +152,22 @@ async fn connect_and_subscribe( } }; - let event = match nostr::Event::from_json(&payload) { - Ok(e) => e, - Err(e) => { - tracing::warn!("Failed to deserialize event from pub/sub: {e}"); - continue; - } + let (event, authority) = match serde_json::from_str::(&payload) { + Ok(envelope) => (envelope.event, Some(envelope.authority)), + Err(_) => match nostr::Event::from_json(&payload) { + Ok(event) => (event, None), + Err(e) => { + tracing::warn!("Failed to deserialize event from pub/sub: {e}"); + continue; + } + }, }; let channel_event = ChannelEvent { community_id: topic_key.community_id, topic: topic_key.topic, event, + authority, }; if let Err(_e) = broadcast_tx.send(channel_event) { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index a8ad966dc3..24f0129679 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -23,8 +23,17 @@ use axum::{ }; use serde::Deserialize; use serde_json::Value; +use sha2::{Digest, Sha256}; +use crate::authorization_runtime::executor::{ + begin_authorized_enrollment, begin_authorized_operation, AuthorizedEnrollmentStart, + AuthorizedOperationStart, ProtectedOperationId, +}; +use crate::authorization_runtime::finalization::AuthorizationMode; +use crate::authorization_runtime::transport::authorize_enrollment_if_configured; +use crate::authorization_runtime::transport::authorize_if_configured; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use buzz_auth::AuthorizationCapability; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -108,6 +117,16 @@ pub struct AcceptPolicyRequest { pub age_confirmed: bool, } +fn stable_correlation_from_proof(proof: &buzz_auth::VerifiedNostrProof) -> uuid::Uuid { + let fingerprint = proof.operation_binding().fingerprint(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&fingerprint[..16]); + if bytes == [0; 16] { + bytes[15] = 1; + } + uuid::Uuid::from_bytes(bytes) +} + /// Public join policy shared by every client-side join surface. pub async fn join_policy(State(state): State>) -> Json { match &state.config.join_policy { @@ -236,7 +255,9 @@ async fn authenticate( ( buzz_core::TenantContext, nostr::PublicKey, - crate::corporate_identity::CorporateIdentityProof, + Option, + Arc, + Option>, ), (StatusCode, Json), > { @@ -254,15 +275,27 @@ async fn authenticate( })?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let (pubkey, event_id_bytes, verified_proof) = bridge::verify_protected_bridge_auth( headers, "POST", &url, Some(body), true, // invites always require NIP-98; no X-Pubkey dev fallback true, // POST bodies must be covered by a payload tag + tenant.community(), )?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + let authorization_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())); + if authorization_mode == Some(AuthorizationMode::DenyProtected) { + return Err(api_error( + StatusCode::FORBIDDEN, + "protected authorization denied", + )); + } + if authorization_mode != Some(AuthorizationMode::Enforce) { + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + } let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( state, @@ -273,7 +306,9 @@ async fn authenticate( let auth_tag = headers .get("x-auth-tag") .and_then(|value| value.to_str().ok()); - let identity_proof = crate::corporate_identity::verify_corporate_identity( + let identity_lane = + crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()); + let identity_proof = match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, @@ -281,9 +316,53 @@ async fn authenticate( auth_tag, ) .await - .map_err(|error| error.into_api_error())?; + { + Ok(proof) => Some(proof), + Err(error) + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational invite identity verification unavailable"); + None + } + Err(error) => return Err(error.into_api_error()), + }; + + let verified_proof = bridge::retain_bridge_proof(verified_proof, auth_tag)? + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "NIP-98 evidence required"))?; + + let enrollment_assertion = if state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())) + == Some(AuthorizationMode::Enforce) + { + let now = state + .corporate_identity + .as_ref() + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "relay identity verification failed"))? + .authorization_now() + .map_err(|error| error.into_api_error())?; + crate::corporate_identity::verified_assertion_for_proof( + identity_proof.as_ref().ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "relay identity verification failed") + })?, + tenant.community(), + buzz_auth::AuthTransport::HttpBridge, + now, + ) + .map_err(|error| error.into_api_error())? + .map(Arc::new) + } else { + None + }; - Ok((tenant, pubkey, identity_proof)) + Ok(( + tenant, + pubkey, + identity_proof, + verified_proof, + enrollment_assertion, + )) } async fn record_atomic_identity_rejection( @@ -316,7 +395,7 @@ pub async fn mint_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey, identity_proof) = + let (tenant, pubkey, identity_proof, verified_proof, verified_assertion) = authenticate(&state, &headers, "/api/invites", &body).await?; // Authz mirrors kind:9030 (add member): owner or admin only. @@ -346,24 +425,33 @@ pub async fn mint_invite( }; let (ttl, max_uses) = validate_mint_request(&request)?; - crate::corporate_identity::finalize_corporate_identity( + let protected_authority = authorize_if_configured( &state, - tenant.community(), - pubkey, - identity_proof, + Arc::clone(&verified_proof), + verified_assertion, + AuthorizationCapability::InviteMint, + stable_correlation_from_proof(&verified_proof), + "invite.mint", ) .await - .map_err(|error| error.into_api_error())?; - - // Mint a v2 opaque, database-backed invite. - let invite = state - .db - .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) - .await - .map_err(|error| match error { - buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), - error => internal_error(&format!("invite mint: {error}")), - })?; + .map_err(|error| { + tracing::warn!(error = %error, "invite mint: protected authorization denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + if crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -373,25 +461,90 @@ pub async fn mint_invite( "http" }; - tracing::info!( - community = %tenant.community(), - minted_by = %sender_hex, - invite_id = %invite.invite_id, - expires_at = %invite.expires_at, - max_uses = ?invite.max_uses, - "relay invite minted" - ); + let build_response = |invite: buzz_db::relay_invite::MintedInvite| { + tracing::info!( + community = %tenant.community(), + minted_by = %sender_hex, + invite_id = %invite.invite_id, + expires_at = %invite.expires_at, + max_uses = ?invite.max_uses, + "relay invite minted" + ); + serde_json::json!({ + "code": invite.code, + "expires_at": invite.expires_at.timestamp() as u64, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), + }) + }; - // expires_at as unix seconds for the response contract. - let expires_at_unix = invite.expires_at.timestamp() as u64; + if protected_authority.is_enforcing() { + let operation_id = ProtectedOperationId::derive( + tenant.community(), + "invite.mint.v1", + &verified_proof.operation_binding().fingerprint(), + ) + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + let mut digest = Sha256::new(); + digest.update(b"buzz-invite-mint-v1"); + digest.update(ttl.to_be_bytes()); + digest.update(max_uses.unwrap_or_default().to_be_bytes()); + let permit = protected_authority + .seal_postgres_mutation(operation_id, "invite.mint.v1", digest.finalize().into()) + .map_err(|error| { + tracing::warn!(error = %error, "invite mint: protected authorization denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })? + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + let response = match begin_authorized_operation(&state, permit) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))? + { + AuthorizedOperationStart::Replay(payload) => serde_json::from_slice(&payload) + .map_err(|error| internal_error(&format!("invite mint replay: {error}")))?, + AuthorizedOperationStart::Execute(mut operation) => { + buzz_db::relay_invite::validate_relay_invite_minter_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ) + .await + .map_err(|error| { + tracing::warn!(error = %error, "invite mint authority changed before commit"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let invite = buzz_db::relay_invite::mint_relay_invite_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ttl, + max_uses, + ) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + let response = build_response(invite); + let payload = serde_json::to_vec(&response) + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + operation + .commit(&payload) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + response + } + }; + return Ok(Json(response)); + } - Ok(Json(serde_json::json!({ - "code": invite.code, - "expires_at": expires_at_unix, - "max_uses": invite.max_uses, - "uses_remaining": invite.uses_remaining, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), - }))) + let invite = state + .db + .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .await + .map_err(|error| match error { + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), + error => internal_error(&format!("invite mint: {error}")), + })?; + Ok(Json(build_response(invite))) } /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the @@ -405,9 +558,20 @@ pub async fn claim_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey, identity_proof) = + let (tenant, pubkey, identity_proof, verified_proof, enrollment_assertion) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + let authorization_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())); + if authorization_mode == Some(AuthorizationMode::DenyProtected) { + return Err(api_error( + StatusCode::FORBIDDEN, + "protected authorization denied", + )); + } + let enforcing = authorization_mode == Some(AuthorizationMode::Enforce); + if claim_rate_limited(&state, tenant.community(), &pubkey) { return Err(api_error( StatusCode::TOO_MANY_REQUESTS, @@ -420,7 +584,10 @@ pub async fn claim_invite( // Invite admission must be coupled to the identity being admitted. A // delegated owner proof can become stale between verification and the // invite transaction, so bootstrap claims require the joiner's direct JWT. - if crate::corporate_identity::proof_is_delegated(&identity_proof) { + if identity_proof + .as_ref() + .is_some_and(crate::corporate_identity::proof_is_delegated) + { return Err(api_error( StatusCode::FORBIDDEN, "direct relay identity required for invite claim", @@ -430,6 +597,13 @@ pub async fn claim_invite( let claimer_hex = pubkey.to_hex(); let key = invite_token::derive_invite_key(&state.relay_keypair); + if enforcing && !request.code.starts_with(V2_PREFIX) { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_unavailable", + )); + } + // --- v2 database-backed path --- // // Route by exact prefix: v2. codes use the durable invite table. No @@ -450,8 +624,141 @@ pub async fn claim_invite( } let token_hash = hash_v2_code(&request.code); - let identity_binding = - crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + if enforcing { + let assertion = enrollment_assertion.ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "relay identity verification failed") + })?; + let enrollment = authorize_enrollment_if_configured( + &state, + Arc::clone(&verified_proof), + assertion, + stable_correlation_from_proof(&verified_proof), + ) + .await + .map_err(|error| { + tracing::warn!(error = %error, "invite claim: protected enrollment denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let mut stable_key = Vec::with_capacity(64); + stable_key.extend_from_slice(&token_hash); + stable_key.extend_from_slice(pubkey.as_bytes()); + let operation_id = + ProtectedOperationId::derive(tenant.community(), "invite.claim.v1", &stable_key) + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + let mut digest = Sha256::new(); + digest.update(b"buzz-invite-claim-v1"); + digest.update(token_hash); + digest.update(pubkey.as_bytes()); + if let Some(policy) = &state.config.join_policy { + digest.update(policy.version.as_bytes()); + } + let request_fingerprint: [u8; 32] = digest.finalize().into(); + let permit = enrollment + .seal_postgres_enrollment(operation_id, "invite.claim.v1", request_fingerprint) + .map_err(|error| { + tracing::warn!(error = %error, "invite claim: protected enrollment stale"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })? + .ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let response = match begin_authorized_enrollment(&state, permit) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))? + { + AuthorizedEnrollmentStart::Replay(payload) => serde_json::from_slice(&payload) + .map_err(|error| internal_error(&format!("invite claim replay: {error}")))?, + AuthorizedEnrollmentStart::Execute(mut operation) => { + let issuer = operation.issuer().to_owned(); + let subject = operation.subject().to_owned(); + let actor = *operation.actor_pubkey(); + let identity = buzz_db::identity_binding::IdentityBindingInput { + issuer: &issuer, + uid: &subject, + pubkey: &actor, + display_name: None, + source: buzz_db::identity_binding::SOURCE_JWT_NPUB, + }; + let outcome = buzz_db::relay_invite::claim_relay_invite_with_identity_tx( + operation.transaction(), + tenant.community(), + &token_hash, + &claimer_hex, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + Some(&identity), + ) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + let response = match outcome { + buzz_db::relay_invite::ClaimOutcome::Joined { .. } => serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }), + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + serde_json::json!({ + "status": "already_member", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }) + } + buzz_db::relay_invite::ClaimOutcome::Expired => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_expired")); + } + buzz_db::relay_invite::ClaimOutcome::Exhausted => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")); + } + buzz_db::relay_invite::ClaimOutcome::Invalid => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + } + buzz_db::relay_invite::ClaimOutcome::IdentityConflict(_) => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding conflict", + )); + } + buzz_db::relay_invite::ClaimOutcome::IdentityRevoked => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding revoked", + )); + } + buzz_db::relay_invite::ClaimOutcome::IdentityBindingRequired => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding required", + )); + } + }; + let payload = serde_json::to_vec(&response) + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + operation + .commit(&payload) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + response + } + }; + return Ok(Json(response)); + } + let legacy_identity = crate::authorization_runtime::transport::legacy_identity_lane( + &state, + tenant.community(), + ) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy; + let identity_binding = if legacy_identity { + identity_proof.as_ref().and_then(|proof| { + crate::corporate_identity::binding_input_for_proof(proof, &pubkey) + }) + } else { + None + }; let outcome = state .db .claim_relay_invite_with_identity( @@ -472,15 +779,19 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Joined { identity_binding, .. } => { - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } tracing::info!( community = %tenant.community(), member = %claimer_hex, @@ -505,15 +816,19 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::AlreadyMember { identity_binding, .. } => { - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } Ok(Json(serde_json::json!({ "status": "already_member", "community_id": tenant.community().to_string(), @@ -531,6 +846,9 @@ pub async fn claim_invite( Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) } buzz_db::relay_invite::ClaimOutcome::IdentityConflict(conflict) => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -541,6 +859,9 @@ pub async fn claim_invite( .await) } buzz_db::relay_invite::ClaimOutcome::IdentityRevoked => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -551,6 +872,9 @@ pub async fn claim_invite( .await) } buzz_db::relay_invite::ClaimOutcome::IdentityBindingRequired => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -584,8 +908,16 @@ pub async fn claim_invite( .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; } - let identity_binding = - crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + let legacy_identity = + crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy; + let identity_binding = if legacy_identity { + identity_proof + .as_ref() + .and_then(|proof| crate::corporate_identity::binding_input_for_proof(proof, &pubkey)) + } else { + None + }; let claim_outcome = state .db .claim_relay_membership_with_identity( @@ -607,6 +939,9 @@ pub async fn claim_invite( identity_binding, } => (inserted, identity_binding), buzz_db::relay_members::MembershipClaimOutcome::IdentityConflict(conflict) => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -617,6 +952,9 @@ pub async fn claim_invite( .await); } buzz_db::relay_members::MembershipClaimOutcome::IdentityRevoked => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -627,6 +965,9 @@ pub async fn claim_invite( .await); } buzz_db::relay_members::MembershipClaimOutcome::IdentityBindingRequired => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -637,15 +978,19 @@ pub async fn claim_invite( .await); } }; - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } if was_inserted { tracing::info!( diff --git a/crates/buzz-relay/src/authorization_runtime/ephemeral.rs b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs index a4f7816d2f..a557c1fc70 100644 --- a/crates/buzz-relay/src/authorization_runtime/ephemeral.rs +++ b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs @@ -1,21 +1,23 @@ -//! Fail-closed ephemeral-authority interfaces for the lower review unit. -//! -//! The session-conformance slice installs signed, durable authority. Production -//! verification here always denies; test-only constructors preserve the -//! existing realtime fixture surface without creating production authority. +//! Relay-authenticated authority carried only across ephemeral Redis fan-out. use std::{fmt, sync::Arc}; +use async_trait::async_trait; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use buzz_core::CommunityId; +use hmac::{Hmac, KeyInit, Mac}; +use sha2::{Digest, Sha256}; use thiserror::Error; +use super::executor::{revalidate_ephemeral_claim, EphemeralAuthorityClaim}; use super::transport::ProtectedAuthorization; +use crate::connection::QueuedOutboundReleaseFence; use crate::state::AppState; +const DOMAIN_SEPARATOR: &[u8] = b"buzz-ephemeral-redis-authority-v1"; +type HmacSha256 = Hmac; const PRESENCE_PREFIX: &str = "pa1:"; -/// Protected presence envelope decoded before authority verification. #[derive(Clone, serde::Deserialize, serde::Serialize)] pub(crate) struct ProtectedPresenceValue { pub(crate) status: String, @@ -34,40 +36,119 @@ impl fmt::Debug for ProtectedPresenceValue { } } -/// Decode an envelope while retaining fail-closed authority verification. +pub(crate) fn encode_presence( + status: String, + context_id: [u8; 32], + authority: String, +) -> Result { + let value = serde_json::to_vec(&ProtectedPresenceValue { + status, + context_id, + authority, + })?; + Ok(format!( + "{PRESENCE_PREFIX}{}", + URL_SAFE_NO_PAD.encode(value) + )) +} + pub(crate) fn decode_presence( value: &str, ) -> Result, EphemeralAuthorityError> { let Some(value) = value.strip_prefix(PRESENCE_PREFIX) else { return Ok(None); }; - let bytes = URL_SAFE_NO_PAD + let value = URL_SAFE_NO_PAD .decode(value) .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; - Ok(Some(serde_json::from_slice(&bytes)?)) + Ok(Some(serde_json::from_slice(&value)?)) } -/// Refuse to seal ephemeral authority before the session slice is installed. +/// Seal one provider-neutral sender authority for trusted relay Redis fan-out. +pub(crate) fn seal( + state: &AppState, + authority: &ProtectedAuthorization, + event: &nostr::Event, +) -> Result { + seal_context(state, authority, event.id.to_bytes()) +} + +/// Seal enforcing authority for a non-event ephemeral effect boundary. pub(crate) fn seal_context( - _state: &AppState, - _authority: &ProtectedAuthorization, - _context_id: [u8; 32], + state: &AppState, + authority: &ProtectedAuthorization, + context_id: [u8; 32], +) -> Result { + let claim = authority + .seal_ephemeral_delivery(context_id)? + .ok_or(EphemeralAuthorityError::AuthorityRequired)?; + seal_claim(&signing_key(state), &claim) +} + +fn seal_claim( + signing_key: &[u8; 32], + claim: &EphemeralAuthorityClaim, ) -> Result { - Err(EphemeralAuthorityError::AuthorityRequired) + let payload = serde_json::to_vec(claim)?; + let mut mac = ::new_from_slice(signing_key) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + mac.update(DOMAIN_SEPARATOR); + mac.update(&(payload.len() as u64).to_be_bytes()); + mac.update(&payload); + let signature = mac.finalize().into_bytes(); + Ok(format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(payload), + URL_SAFE_NO_PAD.encode(signature) + )) } -/// Authority verifier that denies production input until its owning slice. +/// Verify and preflight a remote sender authority before any local fan-out. +pub(crate) async fn verify( + state: &AppState, + community_id: CommunityId, + event: &nostr::Event, + token: &str, +) -> Result, EphemeralAuthorityError> { + let claim = verify_claim(&signing_key(state), token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != event.id.to_bytes() + || claim.actor_pubkey != event.pubkey.to_bytes() + || claim.capability != "community_write" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + let authority = Arc::new(RemoteEphemeralAuthority { + db: state.db.clone(), + claim, + }); + if !authority.release().await { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(authority) +} + +/// Database/signing material retained by an internal cross-node effect owner. #[derive(Clone)] pub(crate) enum AuthorityTokenVerifier { - ProductionDeny, + Database { + db: buzz_db::Db, + signing_key: [u8; 32], + }, #[cfg(test)] - Test(Arc), + TestAllow, + #[cfg(test)] + TestDeny, + #[cfg(test)] + TestConditional(Arc), } -/// Test-compatible retained authority with bounded synchronous expiry checks. +/// Authority retained by an ephemeral effect owner through cleanup. The +/// absolute lease deadline is available to synchronous realtime boundaries; +/// invalidation and binding state remain asynchronously revalidated. #[derive(Clone)] pub(crate) struct RetainedEphemeralAuthority { - gate: Arc, + fence: Arc, expires_at: u64, } @@ -77,7 +158,7 @@ impl RetainedEphemeralAuthority { } pub(crate) async fn release(&self) -> bool { - self.is_time_valid() && self.gate.load(std::sync::atomic::Ordering::SeqCst) + self.is_time_valid() && self.fence.release().await } pub(crate) fn is_time_valid(&self) -> bool { @@ -88,72 +169,238 @@ impl RetainedEphemeralAuthority { } impl AuthorityTokenVerifier { - /// Build a production verifier that remains unavailable in this slice. - pub(crate) fn new(_db: buzz_db::Db, _relay_secret: &[u8]) -> Self { - Self::ProductionDeny + /// Build from the shared relay signing secret without retaining it. + pub(crate) fn new(db: buzz_db::Db, relay_secret: &[u8]) -> Self { + Self::Database { + db, + signing_key: signing_key_from_secret(relay_secret), + } } #[cfg(test)] pub(crate) fn allow_for_test() -> Self { - Self::Test(Arc::new(std::sync::atomic::AtomicBool::new(true))) + Self::TestAllow } #[cfg(test)] pub(crate) fn deny_for_test() -> Self { - Self::Test(Arc::new(std::sync::atomic::AtomicBool::new(false))) + Self::TestDeny } #[cfg(test)] pub(crate) fn conditional_for_test(gate: Arc) -> Self { - Self::Test(gate) + Self::TestConditional(gate) } + /// Verify exact context and current database authority at the effect owner. pub(crate) async fn verify_context( &self, - _community_id: CommunityId, - _context_id: [u8; 32], - _token: &str, + community_id: CommunityId, + context_id: [u8; 32], + token: &str, ) -> Result { - self.retained_test_authority() + match self { + Self::Database { db, signing_key } => { + let claim = verify_claim(signing_key, token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != context_id + || claim.capability != "audio_join" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + let expires_at = claim.expires_at; + let authority = RetainedEphemeralAuthority { + fence: Arc::new(RemoteEphemeralAuthority { + db: db.clone(), + claim, + }), + expires_at, + }; + if !authority.release().await { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(authority) + } + #[cfg(test)] + Self::TestAllow => Ok(RetainedEphemeralAuthority { + fence: Arc::new(TestConditionalAuthority { + gate: Arc::new(std::sync::atomic::AtomicBool::new(true)), + }), + expires_at: test_authority_expiry(), + }), + #[cfg(test)] + Self::TestDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + #[cfg(test)] + Self::TestConditional(gate) => { + if !gate.load(std::sync::atomic::Ordering::SeqCst) { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(RetainedEphemeralAuthority { + fence: Arc::new(TestConditionalAuthority { + gate: Arc::clone(gate), + }), + expires_at: test_authority_expiry(), + }) + } + } } + /// Verify exact context, actor, and current database authority. pub(crate) async fn verify_actor_context( &self, - _community_id: CommunityId, - _context_id: [u8; 32], - _actor_pubkey: [u8; 32], - _token: &str, - ) -> Result { - self.retained_test_authority() - } - - fn retained_test_authority( - &self, - ) -> Result { + community_id: CommunityId, + context_id: [u8; 32], + actor_pubkey: [u8; 32], + token: &str, + ) -> Result<(), EphemeralAuthorityError> { match self { - Self::ProductionDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), - #[cfg(test)] - Self::Test(gate) if gate.load(std::sync::atomic::Ordering::SeqCst) => { - Ok(RetainedEphemeralAuthority { - gate: Arc::clone(gate), - expires_at: u64::MAX, - }) + Self::Database { db, signing_key } => { + let claim = verify_claim(signing_key, token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != context_id + || claim.actor_pubkey != actor_pubkey + || claim.capability != "community_write" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + revalidate_ephemeral_claim(db, &claim) + .await + .map_err(|_| EphemeralAuthorityError::ExpiredOrInvalidated) } #[cfg(test)] - Self::Test(_) => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + Self::TestAllow => Ok(()), + #[cfg(test)] + Self::TestDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + #[cfg(test)] + Self::TestConditional(gate) => gate + .load(std::sync::atomic::Ordering::SeqCst) + .then_some(()) + .ok_or(EphemeralAuthorityError::ExpiredOrInvalidated), } } } -/// Fail-closed ephemeral-authority error. +#[cfg(test)] +fn test_authority_expiry() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test wall clock after Unix epoch") + .as_secs() + + 3_600 +} + +#[cfg(test)] +struct TestConditionalAuthority { + gate: Arc, +} + +#[cfg(test)] +#[async_trait] +impl QueuedOutboundReleaseFence for TestConditionalAuthority { + async fn release(&self) -> bool { + self.gate.load(std::sync::atomic::Ordering::SeqCst) + } +} + +fn verify_claim( + signing_key: &[u8; 32], + token: &str, +) -> Result { + let (payload, signature) = token + .split_once('.') + .ok_or(EphemeralAuthorityError::InvalidEnvelope)?; + let payload = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + let mut mac = ::new_from_slice(signing_key) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + mac.update(DOMAIN_SEPARATOR); + mac.update(&(payload.len() as u64).to_be_bytes()); + mac.update(&payload); + mac.verify_slice(&signature) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + Ok(serde_json::from_slice(&payload)?) +} + +fn signing_key(state: &AppState) -> [u8; 32] { + signing_key_from_secret(state.relay_keypair.secret_key().as_secret_bytes()) +} + +fn signing_key_from_secret(secret: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(DOMAIN_SEPARATOR); + digest.update(secret); + digest.finalize().into() +} + +struct RemoteEphemeralAuthority { + db: buzz_db::Db, + claim: EphemeralAuthorityClaim, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for RemoteEphemeralAuthority { + async fn release(&self) -> bool { + revalidate_ephemeral_claim(&self.db, &self.claim) + .await + .is_ok() + } +} + #[derive(Debug, Error)] pub(crate) enum EphemeralAuthorityError { #[error("ephemeral sender authority is required")] AuthorityRequired, #[error("ephemeral sender authority envelope is invalid")] InvalidEnvelope, + #[error("ephemeral sender authority signature is invalid")] + InvalidSignature, + #[error("ephemeral sender authority context does not match")] + ContextMismatch, #[error("ephemeral sender authority is expired or invalidated")] ExpiredOrInvalidated, + #[error("ephemeral sender authority could not be sealed")] + Transport(#[from] super::transport::ProtectedTransportError), #[error("ephemeral sender authority serialization failed")] Serialization(#[from] serde_json::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protected_presence_round_trips_exact_authority_context() { + let context_id = [0x5a; 32]; + let encoded = encode_presence("online".into(), context_id, "sealed-authority".into()) + .expect("encode presence"); + let decoded = decode_presence(&encoded) + .expect("decode presence") + .expect("protected value"); + assert_eq!(decoded.status, "online"); + assert_eq!(decoded.context_id, context_id); + assert_eq!(decoded.authority, "sealed-authority"); + let debug = format!("{decoded:?}"); + assert!(!debug.contains("online")); + assert!(!debug.contains("sealed-authority")); + assert!(!debug.contains("5a5a")); + } + + #[test] + fn legacy_presence_is_distinct_from_protected_presence() { + assert!(decode_presence("online") + .expect("legacy presence is valid") + .is_none()); + } + + #[test] + fn malformed_protected_presence_fails_closed() { + assert!(matches!( + decode_presence("pa1:not-base64***"), + Err(EphemeralAuthorityError::InvalidEnvelope) + )); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/executor.rs b/crates/buzz-relay/src/authorization_runtime/executor.rs index d4f9ffca24..e57573512c 100644 --- a/crates/buzz-relay/src/authorization_runtime/executor.rs +++ b/crates/buzz-relay/src/authorization_runtime/executor.rs @@ -1,50 +1,283 @@ -//! Fail-closed transaction interfaces for the pre-finalization stack. +//! Transaction-owned protected mutation execution. //! -//! Protected transports need stable types before the owning finalization and -//! invalidation slices land. This compatibility module never creates a permit -//! or starts a transaction; the later owning slice replaces it with the -//! transaction-owned implementation. +//! A sealed permit is derived only from finalized enforcing authority. The +//! executor locks the durable invalidation and binding authorities, validates +//! expiry with the database clock, and commits the mutation and idempotency +//! receipt in one PostgreSQL transaction. -use std::fmt; +use std::{fmt, sync::Arc}; +use buzz_auth::{AuthorizationCapability, FederatedAuthorization}; use buzz_core::CommunityId; +use buzz_db::authorization_invalidation::AuthorizationSelectorKind; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use sqlx::{Postgres, Transaction}; +use sqlx::{Postgres, Row, Transaction}; use thiserror::Error; use uuid::Uuid; use super::transport::{ - LeaseCurrentStateError, ProtectedEnrollmentAuthority, ProtectedOperationAuthority, - ProtectedTransportError, + LeaseCurrentStateError, ProtectedOperationAuthority, ProtectedTransportError, }; -/// Opaque commit fence that cannot be constructed in this review unit. +const MAX_RESULT_BYTES: usize = 65_536; +const RESULT_VERSION: i16 = 1; + +/// One exact durable invalidation dependency carried to the commit boundary. +#[derive(Clone, PartialEq, Eq)] +pub struct CommitDependency { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, +} + +impl CommitDependency { + /// Preserve a dependency produced by the trusted invalidation runtime. + pub fn from_trusted_runtime( + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, + ) -> Result { + if (kind == AuthorizationSelectorKind::Binding) != binding_version.is_some() + || binding_version == Some(0) + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + kind, + fingerprint, + binding_version, + }) + } +} + +impl fmt::Debug for CommitDependency { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CommitDependency") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .field("binding_version", &"[redacted]") + .finish() + } +} + +/// Captured generation and complete selector set for one finalized authority. #[derive(Clone, PartialEq, Eq)] pub struct AuthorizationCommitFence { - _private: (), + evaluation_generation: u64, + dependencies: Vec, +} + +impl AuthorizationCommitFence { + /// Construct a commit fence from trusted finalization/invalidation state. + pub fn from_trusted_runtime( + evaluation_generation: u64, + mut dependencies: Vec, + ) -> Result { + if dependencies.is_empty() { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + let has_domain = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::Domain); + let has_binding = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::Binding); + let has_policy = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::PolicyVersion); + if !(has_domain && has_binding && has_policy) { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + evaluation_generation, + dependencies, + }) + } + + /// Construct a binding-independent fence for direct first enrollment. + pub fn from_trusted_enrollment_runtime( + evaluation_generation: u64, + mut dependencies: Vec, + ) -> Result { + if dependencies.is_empty() { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + let has = |kind| { + dependencies + .iter() + .any(|dependency| dependency.kind == kind) + }; + if !has(AuthorizationSelectorKind::Domain) + || !has(AuthorizationSelectorKind::PrincipalFingerprint) + || !has(AuthorizationSelectorKind::NostrKey) + || !has(AuthorizationSelectorKind::PolicyVersion) + || has(AuthorizationSelectorKind::Binding) + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + evaluation_generation, + dependencies, + }) + } } impl fmt::Debug for AuthorizationCommitFence { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("AuthorizationCommitFence([unavailable])") + formatter + .debug_struct("AuthorizationCommitFence") + .field("evaluation_generation", &self.evaluation_generation) + .field("dependency_count", &self.dependencies.len()) + .finish() } } -/// Opaque ephemeral claim that cannot be constructed in this review unit. -#[allow(dead_code)] -#[derive(Clone, serde::Deserialize, serde::Serialize)] +/// Signed Redis-safe snapshot of one finalized ephemeral sender authority. +/// +/// It contains only opaque fingerprints and stable binding identifiers. Raw +/// issuer, subject, profile, and policy values never enter the fan-out wire. +#[derive(Clone, Serialize, Deserialize)] pub(crate) struct EphemeralAuthorityClaim { - _private: (), + pub(crate) community_id: Uuid, + pub(crate) event_id: [u8; 32], + pub(crate) actor_pubkey: [u8; 32], + pub(crate) bound_pubkey: [u8; 32], + pub(crate) binding_id: Uuid, + pub(crate) binding_version: u64, + pub(crate) expires_at: u64, + pub(crate) capability: String, + evaluation_generation: u64, + dependencies: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +struct EphemeralCommitDependency { + kind: String, + fingerprint: [u8; 32], + binding_version: Option, } -#[allow(dead_code)] impl EphemeralAuthorityClaim { pub(super) fn from_authority( - _authority: &ProtectedOperationAuthority, - _event_id: [u8; 32], + authority: &ProtectedOperationAuthority, + event_id: [u8; 32], ) -> Result { - Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + authority.revalidate()?; + let capability = ephemeral_capability_label(authority.capability()) + .ok_or(ProtectedTransportError::SurfaceCapabilityMismatch)?; + let context = authority.context(); + let lease = context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease)?; + let binding = match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + return Err(ProtectedTransportError::MissingAccessLease) + } + }; + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + Ok(Self { + community_id: *context.tenant().community().as_uuid(), + event_id, + actor_pubkey: context.pubkey().to_bytes(), + bound_pubkey: binding.bound_pubkey().to_bytes(), + binding_id: binding.binding_id(), + binding_version: binding.binding_version().get(), + expires_at: lease.expires_at(), + capability: capability.to_owned(), + evaluation_generation: fence.evaluation_generation, + dependencies: fence + .dependencies + .iter() + .map(|dependency| EphemeralCommitDependency { + kind: dependency.kind.as_str().to_owned(), + fingerprint: dependency.fingerprint, + binding_version: dependency.binding_version, + }) + .collect(), + }) + } +} + +/// Recheck a signed ephemeral claim against the writer database immediately +/// before a remote node releases it to a socket. +pub(crate) async fn revalidate_ephemeral_claim( + db: &buzz_db::Db, + claim: &EphemeralAuthorityClaim, +) -> Result<(), AuthorizationExecutionError> { + if claim.binding_version == 0 + || claim.dependencies.is_empty() + || !matches!(claim.capability.as_str(), "community_write" | "audio_join") + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + let mut transaction = db.begin_transaction().await?; + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(claim.community_id) + .fetch_optional(&mut *transaction) + .await?; + let generation = generation.ok_or(AuthorizationExecutionError::Invalidated)?; + if generation < 0 || (generation as u64) < claim.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + for dependency in &claim.dependencies { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = $2 \ + AND selector_fingerprint = $3 FOR SHARE", + ) + .bind(claim.community_id) + .bind(&dependency.kind) + .bind(dependency.fingerprint.as_slice()) + .fetch_optional(&mut *transaction) + .await?; + let Some(row) = row else { continue }; + let floor_generation: i64 = row.try_get("generation")?; + let sticky_deny: bool = row.try_get("sticky_deny")?; + let binding_floor: Option = row.try_get("binding_version_floor")?; + let fenced_after_evaluation = + floor_generation < 0 || floor_generation as u64 > claim.evaluation_generation; + let binding_denied = match (binding_floor, dependency.binding_version) { + (Some(floor), Some(version)) => floor < 0 || version <= floor as u64, + _ => false, + }; + if sticky_deny || fenced_after_evaluation || binding_denied { + return Err(AuthorizationExecutionError::Invalidated); + } } + let version = i64::try_from(claim.binding_version) + .map_err(|_| AuthorizationExecutionError::InvalidBinding)?; + let active: Option = sqlx::query_scalar( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 AND pubkey = $3 \ + AND binding_version = $4 AND binding_state = 'active' FOR SHARE", + ) + .bind(claim.community_id) + .bind(claim.binding_id) + .bind(claim.bound_pubkey.as_slice()) + .bind(version) + .fetch_optional(&mut *transaction) + .await?; + if active.is_none() { + return Err(AuthorizationExecutionError::InvalidBinding); + } + ensure_not_expired(&mut transaction, claim.expires_at).await?; + transaction.commit().await?; + Ok(()) } /// Stable retry identity for one protected operation. @@ -52,7 +285,7 @@ impl EphemeralAuthorityClaim { pub struct ProtectedOperationId(Uuid); impl ProtectedOperationId { - /// Derive a deterministic UUID from a domain-separated stable key. + /// Derive a deterministic UUID from a domain-separated stable operation key. pub fn derive( authorization_domain: CommunityId, operation_kind: &'static str, @@ -69,8 +302,9 @@ impl ProtectedOperationId { digest.update((stable_key.len() as u64).to_be_bytes()); digest.update(stable_key); let digest: [u8; 32] = digest.finalize().into(); - let mut bytes = [0_u8; 16]; + let mut bytes = [0u8; 16]; bytes.copy_from_slice(&digest[..16]); + // Mark the digest-derived value as an RFC 4122 variant/version-5 UUID. bytes[6] = (bytes[6] & 0x0f) | 0x50; bytes[8] = (bytes[8] & 0x3f) | 0x80; Ok(Self(Uuid::from_bytes(bytes))) @@ -87,115 +321,956 @@ impl fmt::Debug for ProtectedOperationId { } } -/// Permit placeholder that cannot be minted before finalization lands. +/// Permit sealed from a finalized enforcing authorization context. pub struct SealedOperationPermit { - _private: (), + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + actor_pubkey: [u8; 32], + bound_pubkey: [u8; 32], + binding_id: Uuid, + binding_version: u64, + issuer: String, + subject: String, + expires_at: u64, + fence: AuthorizationCommitFence, } impl SealedOperationPermit { pub(super) fn from_authority( - _authority: &ProtectedOperationAuthority, - _operation_id: ProtectedOperationId, - _operation_kind: &'static str, - _request_fingerprint: [u8; 32], + authority: &ProtectedOperationAuthority, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], ) -> Result { - Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + authority.revalidate()?; + let context = authority.context(); + let lease = context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease)?; + let binding = match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + return Err(ProtectedTransportError::MissingAccessLease) + } + }; + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-operation-request-v1"); + digest.update(context.tenant().community().as_uuid().as_bytes()); + digest.update(operation_id.as_uuid().as_bytes()); + digest.update((operation_kind.len() as u64).to_be_bytes()); + digest.update(operation_kind.as_bytes()); + let capability = capability_label(authority.capability()) + .ok_or(ProtectedTransportError::SurfaceCapabilityMismatch)?; + digest.update(capability.as_bytes()); + digest.update(context.pubkey().to_bytes()); + if let Some(owner) = context.agent_owner_pubkey() { + digest.update(owner.to_bytes()); + } + digest.update(lease.binding_id().as_bytes()); + digest.update(lease.binding_version().get().to_be_bytes()); + digest.update(lease.profile_id().as_str().as_bytes()); + digest.update(lease.policy_version().as_str().as_bytes()); + digest.update(lease.lease_version().get().to_be_bytes()); + digest.update(lease.expires_at().to_be_bytes()); + digest.update(request_fingerprint); + + Ok(Self { + community_id: context.tenant().community(), + operation_id, + operation_kind, + request_fingerprint: digest.finalize().into(), + actor_pubkey: context.pubkey().to_bytes(), + bound_pubkey: binding.bound_pubkey().to_bytes(), + binding_id: binding.binding_id(), + binding_version: binding.binding_version().get(), + issuer: binding.principal().issuer().to_owned(), + subject: binding.principal().subject().to_owned(), + expires_at: lease.expires_at(), + fence, + }) } } -/// Enrollment permit placeholder that cannot be minted before finalization. +impl fmt::Debug for SealedOperationPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SealedOperationPermit") + .field("community_id", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("operation_kind", &self.operation_kind) + .field("authority", &"[redacted]") + .finish() + } +} + +/// Binding-independent permit for one atomic direct first enrollment. pub struct SealedEnrollmentPermit { - _private: (), + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + actor_pubkey: [u8; 32], + issuer: String, + subject: String, + expires_at: u64, + fence: AuthorizationCommitFence, } impl SealedEnrollmentPermit { pub(super) fn from_authority( - _authority: &ProtectedEnrollmentAuthority, - _operation_id: ProtectedOperationId, - _operation_kind: &'static str, - _request_fingerprint: [u8; 32], + authority: &super::transport::ProtectedEnrollmentAuthority, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], ) -> Result { - Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + authority.revalidate()?; + let disposition = authority.disposition(); + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-enrollment-request-v1"); + digest.update(disposition.authorization_domain().as_uuid().as_bytes()); + digest.update(operation_id.as_uuid().as_bytes()); + digest.update(operation_kind.as_bytes()); + digest.update(disposition.actor_pubkey().to_bytes()); + digest.update(disposition.principal().issuer().as_bytes()); + digest.update(disposition.principal().subject().as_bytes()); + digest.update(disposition.profile_id().as_str().as_bytes()); + digest.update(disposition.policy_version().as_str().as_bytes()); + digest.update(disposition.expires_at().to_be_bytes()); + digest.update(request_fingerprint); + Ok(Self { + community_id: disposition.authorization_domain(), + operation_id, + operation_kind, + request_fingerprint: digest.finalize().into(), + actor_pubkey: disposition.actor_pubkey().to_bytes(), + issuer: disposition.principal().issuer().to_owned(), + subject: disposition.principal().subject().to_owned(), + expires_at: disposition.expires_at(), + fence, + }) } } -/// Start result retained for protected operation call-site compatibility. +/// Start result for an idempotent protected operation. pub enum AuthorizedOperationStart { - /// Previously committed bounded response. + /// The same operation already committed; return this original typed payload. Replay(Vec), - /// Transaction owned by the authorization executor. + /// The caller owns the only transaction in which effects may be written. Execute(Box), } -/// Transaction handle that can only be produced by the owning later slice. +/// Start result for an atomic direct first enrollment. +pub enum AuthorizedEnrollmentStart { + /// The exact enrollment already committed. + Replay(Vec), + /// The caller owns the sole enrollment transaction. + Execute(Box), +} + +/// Open binding-independent enrollment transaction. +pub struct AuthorizedEnrollmentOperation { + transaction: Transaction<'static, Postgres>, + permit: SealedEnrollmentPermit, + restore: Arc, +} + +impl AuthorizedEnrollmentOperation { + /// Transaction used to bind identity, consume the invite, and add membership. + pub fn transaction(&mut self) -> &mut Transaction<'static, Postgres> { + &mut self.transaction + } + + /// Direct actor key bound by the staged assertion and Nostr proof. + pub const fn actor_pubkey(&self) -> &[u8; 32] { + &self.permit.actor_pubkey + } + + /// Literal validated issuer. + pub fn issuer(&self) -> &str { + &self.permit.issuer + } + + /// Literal validated subject. + pub fn subject(&self) -> &str { + &self.permit.subject + } + + /// Commit the enrollment and its bounded idempotency result together. + pub async fn commit( + mut self, + result_payload: &[u8], + ) -> Result, AuthorizationExecutionError> { + if result_payload.len() > MAX_RESULT_BYTES { + return Err(AuthorizationExecutionError::ResultTooLarge); + } + ensure_not_expired(&mut self.transaction, self.permit.expires_at).await?; + insert_receipt( + &mut self.transaction, + self.permit.community_id, + self.permit.operation_id, + self.permit.operation_kind, + &self.permit.request_fingerprint, + self.permit.expires_at, + result_payload, + ) + .await?; + let witness = self + .restore + .begin( + self.permit.community_id, + self.permit.operation_id.as_uuid(), + self.permit.request_fingerprint, + ) + .await?; + let commit_result = self.transaction.commit().await; + let witness_result = witness.commit().await; + if let Err(error) = witness_result { + return Err(error.into()); + } + if let Err(error) = commit_result { + // RestoreMutationGuard::commit returned success only after reading + // the exact durable operation receipt back from PostgreSQL. Never + // turn a bare failed commit acknowledgement into success. + tracing::warn!(%error, "protected enrollment commit acknowledgement was ambiguous; exact durable receipt verified"); + } + Ok(result_payload.to_vec()) + } +} + +/// Open transaction that has validated and locked its authorization authority. pub struct AuthorizedOperation { transaction: Transaction<'static, Postgres>, + permit: SealedOperationPermit, + restore: Option>, } impl AuthorizedOperation { - /// Borrow the executor-owned transaction. + /// Transaction to pass to transaction-aware mutation APIs. pub fn transaction(&mut self) -> &mut Transaction<'static, Postgres> { &mut self.transaction } - /// Refuse to commit while the durable executor is unavailable. + /// Atomically commit a bounded replay result with all transaction effects. pub async fn commit( - self, - _result_payload: &[u8], + mut self, + result_payload: &[u8], ) -> Result, AuthorizationExecutionError> { - self.transaction.rollback().await?; - Err(AuthorizationExecutionError::RestoreUnavailable) + if result_payload.len() > MAX_RESULT_BYTES { + return Err(AuthorizationExecutionError::ResultTooLarge); + } + ensure_not_expired(&mut self.transaction, self.permit.expires_at).await?; + insert_receipt( + &mut self.transaction, + self.permit.community_id, + self.permit.operation_id, + self.permit.operation_kind, + &self.permit.request_fingerprint, + self.permit.expires_at, + result_payload, + ) + .await?; + let witness = match &self.restore { + Some(restore) => Some( + restore + .begin( + self.permit.community_id, + self.permit.operation_id.as_uuid(), + self.permit.request_fingerprint, + ) + .await?, + ), + #[cfg(test)] + None => None, + #[cfg(not(test))] + None => return Err(AuthorizationExecutionError::RestoreUnavailable), + }; + let commit_result = self.transaction.commit().await; + if let Some(witness) = witness { + witness.commit().await?; + if let Err(error) = commit_result { + // A successful witness commit proves that the exact receipt is + // durable. Without that proof the error above is returned. + tracing::warn!(%error, "protected operation commit acknowledgement was ambiguous; exact durable receipt verified"); + } + } else { + // Only test-only construction can omit restore protection. Keep + // even that path honest so a failed database commit is never + // reported as a successful protected effect. + commit_result?; + } + Ok(result_payload.to_vec()) } } -/// Fail closed until the finalization slice installs transaction execution. +#[allow(clippy::too_many_arguments)] +async fn insert_receipt( + transaction: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: &[u8; 32], + expires_at: u64, + result_payload: &[u8], +) -> Result<(), AuthorizationExecutionError> { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7::double precision))", + ) + .bind(community_id.as_uuid()) + .bind(operation_id.as_uuid()) + .bind(operation_kind) + .bind(request_fingerprint.as_slice()) + .bind(RESULT_VERSION) + .bind(result_payload) + .bind(expires_at as f64) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +/// Begin a transaction-owned protected mutation or replay its original result. pub async fn begin_authorized_operation( - _state: &crate::state::AppState, - _permit: SealedOperationPermit, + state: &crate::state::AppState, + permit: SealedOperationPermit, ) -> Result { - Err(AuthorizationExecutionError::RestoreUnavailable) + let restore = state + .restore_protection() + .ok_or(AuthorizationExecutionError::RestoreUnavailable)?; + begin_authorized_operation_inner(&state.db, Some(Arc::clone(restore)), permit).await +} + +async fn begin_authorized_operation_inner( + db: &buzz_db::Db, + restore: Option>, + permit: SealedOperationPermit, +) -> Result { + let mut transaction = db.begin_transaction().await?; + + // Serialize identical retries before reading the receipt. This avoids two + // first attempts executing the mutation body concurrently. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(permit.operation_id.as_uuid().to_string()) + .execute(&mut *transaction) + .await?; + + let committed_receipt = if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_version, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id = $1 AND operation_id = $2 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.operation_id.as_uuid()) + .fetch_optional(&mut *transaction) + .await? + { + let operation_kind: String = row.try_get("operation_kind")?; + let request_fingerprint: Vec = row.try_get("request_fingerprint")?; + let result_version: i16 = row.try_get("result_version")?; + let result_payload: Vec = row.try_get("result_payload")?; + if operation_kind != permit.operation_kind + || request_fingerprint.as_slice() != permit.request_fingerprint + || result_version != RESULT_VERSION + { + return Err(AuthorizationExecutionError::ConflictingRetry); + } + Some(result_payload) + } else { + None + }; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(permit.community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .fetch_one(&mut *transaction) + .await?; + if generation < 0 || (generation as u64) < permit.fence.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + + validate_dependency_floors(&mut transaction, permit.community_id, &permit.fence).await?; + validate_active_binding(&mut transaction, &permit).await?; + ensure_not_expired(&mut transaction, permit.expires_at).await?; + + if let Some(result_payload) = committed_receipt { + transaction.commit().await?; + return Ok(AuthorizedOperationStart::Replay(result_payload)); + } + + Ok(AuthorizedOperationStart::Execute(Box::new( + AuthorizedOperation { + transaction, + permit, + restore, + }, + ))) +} + +#[cfg(test)] +async fn begin_authorized_operation_for_test( + db: &buzz_db::Db, + permit: SealedOperationPermit, +) -> Result { + begin_authorized_operation_inner(db, None, permit).await +} + +/// Begin a binding-independent transaction that atomically creates first enrollment. +pub async fn begin_authorized_enrollment( + state: &crate::state::AppState, + permit: SealedEnrollmentPermit, +) -> Result { + let restore = state + .restore_protection() + .ok_or(AuthorizationExecutionError::RestoreUnavailable)?; + let mut transaction = state.db.begin_transaction().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(permit.operation_id.as_uuid().to_string()) + .execute(&mut *transaction) + .await?; + + let committed_receipt = if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_version, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id = $1 AND operation_id = $2 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.operation_id.as_uuid()) + .fetch_optional(&mut *transaction) + .await? + { + let operation_kind: String = row.try_get("operation_kind")?; + let request_fingerprint: Vec = row.try_get("request_fingerprint")?; + let result_version: i16 = row.try_get("result_version")?; + let result_payload: Vec = row.try_get("result_payload")?; + if operation_kind != permit.operation_kind + || request_fingerprint.as_slice() != permit.request_fingerprint + || result_version != RESULT_VERSION + { + return Err(AuthorizationExecutionError::ConflictingRetry); + } + Some(result_payload) + } else { + None + }; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(permit.community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .fetch_one(&mut *transaction) + .await?; + if generation < 0 || (generation as u64) < permit.fence.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + validate_dependency_floors(&mut transaction, permit.community_id, &permit.fence).await?; + ensure_not_expired(&mut transaction, permit.expires_at).await?; + + if let Some(result_payload) = committed_receipt { + transaction.commit().await?; + return Ok(AuthorizedEnrollmentStart::Replay(result_payload)); + } + Ok(AuthorizedEnrollmentStart::Execute(Box::new( + AuthorizedEnrollmentOperation { + transaction, + permit, + restore: Arc::clone(restore), + }, + ))) +} + +async fn validate_dependency_floors( + transaction: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + fence: &AuthorizationCommitFence, +) -> Result<(), AuthorizationExecutionError> { + for dependency in &fence.dependencies { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = $2 \ + AND selector_fingerprint = $3", + ) + .bind(community_id.as_uuid()) + .bind(dependency.kind.as_str()) + .bind(dependency.fingerprint.as_slice()) + .fetch_optional(&mut **transaction) + .await?; + let Some(row) = row else { continue }; + let generation: i64 = row.try_get("generation")?; + let sticky_deny: bool = row.try_get("sticky_deny")?; + let binding_floor: Option = row.try_get("binding_version_floor")?; + let fenced_after_evaluation = + generation < 0 || generation as u64 > fence.evaluation_generation; + let binding_denied = match (binding_floor, dependency.binding_version) { + (Some(floor), Some(version)) => floor < 0 || version <= floor as u64, + _ => false, + }; + if sticky_deny || fenced_after_evaluation || binding_denied { + return Err(AuthorizationExecutionError::Invalidated); + } + } + Ok(()) +} + +async fn validate_active_binding( + transaction: &mut Transaction<'static, Postgres>, + permit: &SealedOperationPermit, +) -> Result<(), AuthorizationExecutionError> { + let version = i64::try_from(permit.binding_version) + .map_err(|_| AuthorizationExecutionError::InvalidBinding)?; + let active: Option = sqlx::query_scalar( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 \ + AND issuer = $3 AND uid = $4 AND pubkey = $5 \ + AND binding_version = $6 AND binding_state = 'active' \ + FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.binding_id) + .bind(&permit.issuer) + .bind(&permit.subject) + .bind(permit.bound_pubkey.as_slice()) + .bind(version) + .fetch_optional(&mut **transaction) + .await?; + if active.is_none() { + return Err(AuthorizationExecutionError::InvalidBinding); + } + // The actor is included in the sealed fingerprint even when delegated; + // direct/owner agreement was already enforced by AuthContext finalization. + let _ = permit.actor_pubkey; + Ok(()) +} + +async fn ensure_not_expired( + transaction: &mut Transaction<'static, Postgres>, + expires_at: u64, +) -> Result<(), AuthorizationExecutionError> { + let current: f64 = + sqlx::query_scalar("SELECT EXTRACT(EPOCH FROM clock_timestamp())::double precision") + .fetch_one(&mut **transaction) + .await?; + if !current.is_finite() || current >= expires_at as f64 { + return Err(AuthorizationExecutionError::Expired); + } + Ok(()) +} + +const fn capability_label(capability: AuthorizationCapability) -> Option<&'static str> { + match capability { + AuthorizationCapability::CommunityRead => Some("community_read"), + AuthorizationCapability::CommunityWrite => Some("community_write"), + AuthorizationCapability::Moderate => Some("moderate"), + AuthorizationCapability::InviteMint => Some("invite_mint"), + AuthorizationCapability::InviteClaim => Some("invite_claim"), + AuthorizationCapability::MediaRead => Some("media_read"), + AuthorizationCapability::MediaWrite => Some("media_write"), + AuthorizationCapability::GitRead => Some("git_read"), + AuthorizationCapability::GitWrite => Some("git_write"), + AuthorizationCapability::AudioJoin => Some("audio_join"), + _ => None, + } +} + +const fn ephemeral_capability_label(capability: AuthorizationCapability) -> Option<&'static str> { + match capability { + AuthorizationCapability::CommunityWrite => Some("community_write"), + AuthorizationCapability::AudioJoin => Some("audio_join"), + _ => None, + } } /// Fail-closed protected mutation execution error. #[derive(Debug, Error)] pub enum AuthorizationExecutionError { - /// Database transaction failed. + /// Database operation failed. #[error("protected operation database transaction failed")] Database(#[from] sqlx::Error), - /// Database wrapper failed. + /// Database wrapper failed before the transaction began. #[error("protected operation database transaction failed")] Db(#[from] buzz_db::DbError), - /// Independent restore witness failed. + /// Independent restore witness was unavailable or rejected reconciliation. #[error("protected operation restore witness failed")] Restore(#[from] super::restore::RestoreProtectionError), - /// Mandatory restore witness is unavailable. + /// Production execution was attempted without the mandatory witness. #[error("protected operation restore witness is unavailable")] RestoreUnavailable, - /// Captured authorization fence is incomplete. + /// Captured generation or dependency set was incomplete. #[error("protected operation commit fence is invalid")] InvalidCommitFence, - /// Stable operation identity is malformed. + /// Stable operation identity was empty or malformed. #[error("protected operation identity is invalid")] InvalidOperationIdentity, - /// Stable identity was reused for different input. + /// The stable operation ID was reused for different input. #[error("protected operation retry conflicts with the committed request")] ConflictingRetry, - /// Durable invalidation state denies the operation. + /// Current durable invalidation state denies the operation. #[error("protected operation authority was invalidated")] Invalidated, - /// The active binding changed or disappeared. + /// The exact active binding changed or disappeared. #[error("protected operation binding is no longer active")] InvalidBinding, - /// Authorization expired before commit. + /// The lease expired before the commit boundary. #[error("protected operation authorization expired before commit")] Expired, - /// Replay result exceeded its bounded payload. + /// Replay result exceeded the bounded receipt payload. #[error("protected operation result is too large")] ResultTooLarge, } impl From for LeaseCurrentStateError { fn from(_error: AuthorizationExecutionError) -> Self { - Self::Unavailable + LeaseCurrentStateError::Unavailable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_ISSUER: &str = "https://idp.example"; + const TEST_SUBJECT: &str = "executor-subject"; + + fn dependency( + kind: AuthorizationSelectorKind, + marker: u8, + binding_version: Option, + ) -> CommitDependency { + CommitDependency::from_trusted_runtime(kind, [marker; 32], binding_version) + .expect("valid dependency") + } + + fn operation_fence() -> AuthorizationCommitFence { + AuthorizationCommitFence::from_trusted_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::Binding, 2, Some(1)), + dependency(AuthorizationSelectorKind::PolicyVersion, 3, None), + ], + ) + .expect("complete operation fence") + } + + fn epoch_after(seconds: u64) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_secs() + + seconds + } + + fn permit( + community_id: CommunityId, + binding_id: Uuid, + operation_id: ProtectedOperationId, + request_marker: u8, + expires_at: u64, + ) -> SealedOperationPermit { + SealedOperationPermit { + community_id, + operation_id, + operation_kind: "executor.test.v1", + request_fingerprint: [request_marker; 32], + actor_pubkey: [7; 32], + bound_pubkey: [7; 32], + binding_id, + binding_version: 1, + issuer: TEST_ISSUER.to_owned(), + subject: TEST_SUBJECT.to_owned(), + expires_at, + fence: operation_fence(), + } + } + + async fn integration_setup() -> (buzz_db::Db, CommunityId, Uuid) { + 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 pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("test migrations"); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id.as_uuid()) + .bind(format!( + "executor-{}.example", + community_id.as_uuid().simple() + )) + .execute(&pool) + .await + .expect("test community"); + let binding_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, binding_id, \ + binding_version, binding_state, binding_provenance) \ + VALUES ($1, $2, $3, $4, 'jwt_npub', $5, 1, 'active', 'attested_key')", + ) + .bind(community_id.as_uuid()) + .bind(TEST_ISSUER) + .bind(TEST_SUBJECT) + .bind([7_u8; 32].as_slice()) + .bind(binding_id) + .execute(&pool) + .await + .expect("active test binding"); + (buzz_db::Db::from_pool(pool), community_id, binding_id) + } + + #[test] + fn stable_operation_identity_is_domain_and_kind_separated() { + let first_domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let second_domain = CommunityId::from_uuid(Uuid::from_u128(2)); + let first = ProtectedOperationId::derive(first_domain, "event.ingest.v1", b"event") + .expect("operation id"); + assert_eq!( + first, + ProtectedOperationId::derive(first_domain, "event.ingest.v1", b"event") + .expect("stable operation id") + ); + assert_ne!( + first, + ProtectedOperationId::derive(second_domain, "event.ingest.v1", b"event") + .expect("domain-separated operation id") + ); + assert_ne!( + first, + ProtectedOperationId::derive(first_domain, "invite.mint.v1", b"event") + .expect("kind-separated operation id") + ); + } + + #[test] + fn operation_and_enrollment_fences_require_distinct_authority_sets() { + assert!(AuthorizationCommitFence::from_trusted_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 3, None), + ], + ) + .is_err()); + assert!(AuthorizationCommitFence::from_trusted_enrollment_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PrincipalFingerprint, 2, None,), + dependency(AuthorizationSelectorKind::NostrKey, 3, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 4, None), + dependency(AuthorizationSelectorKind::Binding, 5, Some(1)), + ], + ) + .is_err()); + assert!(AuthorizationCommitFence::from_trusted_enrollment_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PrincipalFingerprint, 2, None,), + dependency(AuthorizationSelectorKind::NostrKey, 3, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 4, None), + ], + ) + .is_ok()); + let _ = operation_fence(); + } + + #[test] + fn capability_receipt_labels_are_stable() { + assert_eq!( + capability_label(AuthorizationCapability::CommunityWrite), + Some("community_write") + ); + assert_eq!( + capability_label(AuthorizationCapability::AudioJoin), + Some("audio_join") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::CommunityWrite), + Some("community_write") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::AudioJoin), + Some("audio_join") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::MediaWrite), + None + ); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn postgres_executor_serializes_retries_rolls_back_and_checks_expiry() { + let (db, community_id, binding_id) = integration_setup().await; + let concurrent_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"concurrent") + .expect("operation id"); + let run = |permit| async { + match begin_authorized_operation_for_test(&db, permit) + .await + .expect("authorized operation") + { + AuthorizedOperationStart::Execute(operation) => { + operation.commit(b"committed-once").await.expect("commit"); + true + } + AuthorizedOperationStart::Replay(payload) => { + assert_eq!(payload, b"committed-once"); + false + } + } + }; + let (first, second) = tokio::join!( + run(permit( + community_id, + binding_id, + concurrent_id, + 1, + epoch_after(60), + )), + run(permit( + community_id, + binding_id, + concurrent_id, + 1, + epoch_after(60), + )), + ); + assert_ne!(first, second, "exactly one concurrent retry executes"); + + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, concurrent_id, 2, epoch_after(60),), + ) + .await, + Err(AuthorizationExecutionError::ConflictingRetry) + )); + + let rollback_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"rollback") + .expect("operation id"); + let AuthorizedOperationStart::Execute(rolled_back) = begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, rollback_id, 3, epoch_after(60)), + ) + .await + .expect("open rollback operation") else { + panic!("uncommitted operation cannot replay") + }; + rolled_back + .transaction + .rollback() + .await + .expect("explicit rollback"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, rollback_id, 3, epoch_after(60),), + ) + .await + .expect("retry after rollback"), + AuthorizedOperationStart::Execute(_) + )); + + let expired_id = ProtectedOperationId::derive(community_id, "executor.test.v1", b"expired") + .expect("operation id"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit( + community_id, + binding_id, + expired_id, + 4, + epoch_after(0).saturating_sub(1), + ), + ) + .await, + Err(AuthorizationExecutionError::Expired) + )); + + let invalidation_id = Uuid::new_v4(); + let mut invalidation = db.begin_transaction().await.expect("invalidation tx"); + let invalidation_generation: i64 = sqlx::query_scalar( + "UPDATE authorization_invalidation_domains \ + SET generation = generation + 1 WHERE community_id = $1 \ + RETURNING generation", + ) + .bind(community_id.as_uuid()) + .fetch_one(&mut *invalidation) + .await + .expect("advance invalidation generation"); + sqlx::query( + "INSERT INTO authorization_invalidation_receipts \ + (community_id, event_id, generation, request_fingerprint) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(community_id.as_uuid()) + .bind(invalidation_id) + .bind(invalidation_generation) + .bind([9_u8; 32].as_slice()) + .execute(&mut *invalidation) + .await + .expect("invalidation receipt"); + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, generation, sticky_deny) \ + VALUES ($1, 'domain', $2, $3, true)", + ) + .bind(community_id.as_uuid()) + .bind([1_u8; 32].as_slice()) + .bind(invalidation_generation) + .execute(&mut *invalidation) + .await + .expect("domain deny floor"); + invalidation.commit().await.expect("commit invalidation"); + + let invalidated_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"invalidated") + .expect("operation id"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, invalidated_id, 5, epoch_after(60),), + ) + .await, + Err(AuthorizationExecutionError::Invalidated) + )); } } diff --git a/crates/buzz-relay/src/authorization_runtime/finalization.rs b/crates/buzz-relay/src/authorization_runtime/finalization.rs index 06c7c14496..0f3522091c 100644 --- a/crates/buzz-relay/src/authorization_runtime/finalization.rs +++ b/crates/buzz-relay/src/authorization_runtime/finalization.rs @@ -1,14 +1,23 @@ -//! Compile-stable finalization types for the protected-transport slice. +//! Relay adapter for exact-domain authorization policy and finalization. //! -//! The invalidation slice installs the runtime finalizer. This module exposes -//! only the sealed types needed to compile transport coupling before that -//! implementation is present. +//! A request never selects its provider profile or activation mode. The relay +//! resolves one immutable policy from the row-zero [`TenantContext`] domain. +//! Missing and duplicate domain configuration fail closed without a global or +//! Nostr-only fallback. -use std::fmt; +use std::{collections::HashMap, fmt, sync::Arc}; -use buzz_auth::{AuthorizationProfileId, FederatedPrincipal, PolicyVersion}; -use buzz_core::CommunityId; -use nostr::PublicKey; +use buzz_auth::{ + resolve_authorization, AccessLeasePolicy, AuthorizationFinalizer, AuthorizationOutcome, + AuthorizationProfileId, AuthorizationProvider, AuthorizationRequest, BindingLeaseBound, + CapabilitySet, DecisionSource, EnrollmentMode, FederatedAuthorization, FederatedPrincipal, + FinalizationError, LeaseVersion, PolicyVersion, ProviderAuthorizationClock, + ProviderContractError, ProviderTimeout, ResolvedFederatedPolicy, SharedAuthorizationClock, + VerificationOnlyDisposition, VerificationStatusPolicy, VerifiedFederatedAssertion, + VerifiedNostrProof, VersionedBindingRef, +}; +use buzz_core::{tenant::TenantContext, CommunityId}; +use thiserror::Error; use uuid::Uuid; /// Server-owned activation mode for one exact authorization domain. @@ -16,19 +25,419 @@ use uuid::Uuid; pub enum AuthorizationMode { /// Do not evaluate federated identity or provider policy. Off, - /// Evaluate read-only provider policy without authority changes. + /// Evaluate read-only provider policy without binding or authority changes. Shadow, - /// Produce display-only output after full verification. + /// Produce a short-lived display-only result after full direct finalization. VerifyOnly, - /// Issue bounded protected access after finalization. + /// Issue bounded access leases after full direct or delegated finalization. Enforce, + /// Keep every protected surface active while denying all protected access. + DenyProtected, +} + +impl AuthorizationMode { + /// Whether this mode may evaluate the configured admission provider. + pub const fn evaluates_provider(self) -> bool { + matches!(self, Self::Shadow | Self::VerifyOnly | Self::Enforce) + } + + /// Whether this mode keeps the protected-surface inventory authoritative. + pub const fn protects_surfaces(self) -> bool { + matches!(self, Self::Enforce | Self::DenyProtected) + } +} + +/// Immutable server configuration for one exact authorization domain. +#[derive(Clone)] +pub struct DomainAuthorizationPolicy { + authorization_domain: CommunityId, + profile_id: AuthorizationProfileId, + provider: Arc, + enrollment_mode: EnrollmentMode, + mode: AuthorizationMode, + provider_timeout: ProviderTimeout, + access_lease_policy: AccessLeasePolicy, + verification_status_policy: VerificationStatusPolicy, +} + +impl DomainAuthorizationPolicy { + /// Build policy exclusively from trusted server configuration. + #[allow(clippy::too_many_arguments)] + pub fn from_server_configuration( + authorization_domain: CommunityId, + profile_id: impl Into, + provider: Arc, + enrollment_mode: EnrollmentMode, + mode: AuthorizationMode, + provider_timeout: ProviderTimeout, + access_lease_policy: AccessLeasePolicy, + verification_status_policy: VerificationStatusPolicy, + ) -> Result { + Ok(Self { + authorization_domain, + profile_id: AuthorizationProfileId::from_server_configuration(profile_id)?, + provider, + enrollment_mode, + mode, + provider_timeout, + access_lease_policy, + verification_status_policy, + }) + } + + /// Exact server-owned authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact server-owned activation mode. + pub const fn mode(&self) -> AuthorizationMode { + self.mode + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Server-resolved binding enrollment mode. + pub const fn enrollment_mode(&self) -> EnrollmentMode { + self.enrollment_mode + } +} + +impl fmt::Debug for DomainAuthorizationPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DomainAuthorizationPolicy") + .field("authorization_domain", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("provider", &"[configured]") + .field("enrollment_mode", &"[redacted]") + .field("mode", &self.mode) + .field("provider_timeout", &"[redacted]") + .field("access_lease_policy", &"[redacted]") + .field("verification_status_policy", &"[redacted]") + .finish() + } +} + +/// Immutable exact-domain provider selector. +#[derive(Clone)] +pub struct DomainProviderSelector { + policies: HashMap, +} + +impl DomainProviderSelector { + /// Build an exact-domain selector, rejecting every ambiguous duplicate. + pub fn new( + policies: impl IntoIterator, + ) -> Result { + let mut by_domain = HashMap::new(); + for policy in policies { + let domain = policy.authorization_domain; + if by_domain.insert(domain, policy).is_some() { + return Err(DomainPolicyError::AmbiguousDomainPolicy); + } + } + Ok(Self { + policies: by_domain, + }) + } + + /// Resolve policy only from the row-zero server tenant. + /// + /// No default provider exists. A federated authorization attempt for an + /// unconfigured domain is denied as missing policy. + pub fn resolve( + &self, + tenant: &TenantContext, + ) -> Result<&DomainAuthorizationPolicy, DomainPolicyError> { + self.policies + .get(&tenant.community()) + .ok_or(DomainPolicyError::MissingDomainPolicy) + } +} + +impl fmt::Debug for DomainProviderSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DomainProviderSelector") + .field("policies", &"[redacted]") + .finish() + } +} + +/// Fail-closed exact-domain policy resolution error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum DomainPolicyError { + /// More than one authorization policy named the same exact domain. + #[error("federated authorization policy is ambiguous for this domain")] + AmbiguousDomainPolicy, + /// No federated policy was configured for this exact domain. + #[error("federated authorization policy is missing for this domain")] + MissingDomainPolicy, +} + +/// Provider-neutral relay finalizer with immutable policy and injected time. +#[derive(Clone)] +pub struct RelayAuthorizationFinalizer { + selector: DomainProviderSelector, + finalizer: AuthorizationFinalizer, + clock: SharedAuthorizationClock, + runtime_binding: Uuid, +} + +struct RelayProviderClock<'a>(&'a dyn buzz_auth::AuthorizationClock); + +impl ProviderAuthorizationClock for RelayProviderClock<'_> { + fn now_unix_seconds(&self) -> Option { + self.0.now().ok().map(|value| value.unix_seconds()) + } +} + +impl RelayAuthorizationFinalizer { + /// Build a runtime that shares one central clock across evaluation and finalization. + pub fn new(selector: DomainProviderSelector, clock: SharedAuthorizationClock) -> Self { + Self { + selector, + finalizer: AuthorizationFinalizer::new(Arc::clone(&clock)), + clock, + runtime_binding: Uuid::new_v4(), + } + } + + /// Evaluate current direct provider admission for a server-resolved domain. + /// + /// Off mode performs no provider call. All other modes preserve provider + /// deny and unavailable outcomes without falling back to another policy. + #[allow(clippy::too_many_arguments)] + pub async fn evaluate_direct( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if !policy.mode.evaluates_provider() { + return Err(RelayFinalizationError::ModeDoesNotEvaluate); + } + let now = self.finalizer.now()?; + let request = AuthorizationRequest::direct( + proof, + assertion, + federated_policy, + requested_capabilities, + correlation_id, + now.unix_seconds(), + )?; + Ok(resolve_authorization( + policy.provider.as_ref(), + &request, + &RelayProviderClock(self.clock.as_ref()), + policy.provider_timeout, + self.runtime_binding, + ) + .await) + } + + /// Evaluate current delegated-owner provider admission for an exact domain. + pub async fn evaluate_delegated( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if !policy.mode.evaluates_provider() { + return Err(RelayFinalizationError::ModeDoesNotEvaluate); + } + let now = self.finalizer.now()?; + let request = AuthorizationRequest::delegated_from_active_binding( + proof, + owner, + federated_policy, + requested_capabilities, + correlation_id, + now.unix_seconds(), + )?; + Ok(resolve_authorization( + policy.provider.as_ref(), + &request, + &RelayProviderClock(self.clock.as_ref()), + policy.provider_timeout, + self.runtime_binding, + ) + .await) + } + + /// Finalize one validated allow snapshot according to server-owned mode. + /// + /// Off and shadow modes cannot finalize a binding, status, or access + /// context. Verify-only returns a distinct display type; enforce is the + /// only branch capable of returning an access context with a lease. + pub fn finalize_allowed( + &self, + input: buzz_auth::AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + binding_bound: BindingLeaseBound, + lease_version: LeaseVersion, + ) -> Result { + let policy = self.selector.resolve(input.tenant())?; + match policy.mode { + AuthorizationMode::Off + | AuthorizationMode::Shadow + | AuthorizationMode::DenyProtected => Err(RelayFinalizationError::ModeDoesNotFinalize), + AuthorizationMode::VerifyOnly => self + .finalizer + .finalize_verification_only( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.verification_status_policy, + ) + .map(RuntimeAuthorizationDisposition::VerificationOnly) + .map_err(Into::into), + AuthorizationMode::Enforce => self + .finalizer + .finalize_access( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.access_lease_policy, + lease_version, + ) + .map(|context| RuntimeAuthorizationDisposition::Access(Box::new(context))) + .map_err(Into::into), + } + } + + /// Finalize the same current direct evidence into a short-lived, + /// display-only status. The caller separately proves the presentation + /// gate; this method cannot issue access or a lease and performs no writes. + pub fn finalize_client_status( + &self, + input: buzz_auth::AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + binding_bound: BindingLeaseBound, + ) -> Result { + let policy = self.selector.resolve(input.tenant())?; + if !matches!( + policy.mode, + AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce + ) { + return Err(RelayFinalizationError::ModeDoesNotFinalize); + } + self.finalizer + .finalize_verification_only( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.verification_status_policy, + ) + .map_err(Into::into) + } + + /// Finalize direct first-enrollment evidence without creating a binding. + pub fn finalize_enrollment( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + federated_policy: ResolvedFederatedPolicy, + snapshot: Box, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if policy.mode != AuthorizationMode::Enforce { + return Err(RelayFinalizationError::ModeDoesNotFinalize); + } + if policy.enrollment_mode != EnrollmentMode::AttestedKey { + return Err(RelayFinalizationError::EnrollmentModeUnsupported); + } + let now = self.finalizer.now()?; + let key = assertion + .key_attestation() + .ok_or(RelayFinalizationError::EnrollmentEvidenceMismatch)?; + if proof.verified_delegation().is_some() + || proof.authorization_domain() != tenant.community() + || federated_policy.authorization_domain() != tenant.community() + || !snapshot.is_bound_to_federated_policy(&federated_policy) + || assertion.authorization_domain() != tenant.community() + || snapshot.authorization_domain() != tenant.community() + || proof.authorized_transport() != assertion.authorized_transport() + || snapshot.transport() != proof.authorized_transport() + || snapshot.actor_pubkey() != proof.actor_pubkey() + || key.pubkey() != proof.actor_pubkey() + || snapshot.owner_pubkey().is_some() + || snapshot.binding_id().is_some() + || snapshot.binding_version().is_some() + || snapshot.proof_method() != proof.proof_method() + || snapshot.principal() != assertion.principal() + || snapshot.profile_id() != &policy.profile_id + || snapshot.decision_source() != DecisionSource::DirectAssertion + || snapshot.correlation_id() != correlation_id + || !snapshot + .capabilities() + .contains(buzz_auth::AuthorizationCapability::InviteClaim) + || snapshot.issued_at() > now.unix_seconds() + || snapshot.fresh_until() <= now.unix_seconds() + || snapshot.effective_until() <= now.unix_seconds() + || assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now.unix_seconds())) + || assertion.expires_at().is_expired_at(now.unix_seconds()) + { + return Err(RelayFinalizationError::EnrollmentEvidenceMismatch); + } + let application_until = now + .unix_seconds() + .checked_add(policy.access_lease_policy.application_limit().seconds()) + .ok_or(RelayFinalizationError::EnrollmentEvidenceMismatch)?; + let expires_at = snapshot + .effective_until() + .min(assertion.expires_at().unix_seconds()) + .min(application_until) + .saturating_sub(policy.access_lease_policy.clock_skew().seconds()); + if expires_at <= now.unix_seconds() { + return Err(RelayFinalizationError::EnrollmentEvidenceMismatch); + } + Ok(EnrollmentDisposition { + authorization_domain: tenant.community(), + actor_pubkey: proof.actor_pubkey(), + principal: assertion.principal().clone(), + profile_id: policy.profile_id.clone(), + policy_version: snapshot.policy_version().clone(), + correlation_id, + expires_at, + }) + } } /// Direct provider decision sealed for atomic first enrollment. #[must_use] pub struct EnrollmentDisposition { authorization_domain: CommunityId, - actor_pubkey: PublicKey, + actor_pubkey: nostr::PublicKey, principal: FederatedPrincipal, profile_id: AuthorizationProfileId, policy_version: PolicyVersion, @@ -41,33 +450,27 @@ impl EnrollmentDisposition { pub const fn authorization_domain(&self) -> CommunityId { self.authorization_domain } - - /// Direct actor whose key was attested. - pub const fn actor_pubkey(&self) -> PublicKey { + /// Direct actor whose key is attested by the assertion. + pub const fn actor_pubkey(&self) -> nostr::PublicKey { self.actor_pubkey } - /// Literal issuer-qualified principal staged for enrollment. pub const fn principal(&self) -> &FederatedPrincipal { &self.principal } - - /// Installed provider profile that made the decision. + /// Server-selected authorization profile. pub const fn profile_id(&self) -> &AuthorizationProfileId { &self.profile_id } - - /// Provider policy revision that made the decision. + /// Provider policy version that authorized the enrollment. pub const fn policy_version(&self) -> &PolicyVersion { &self.policy_version } - /// Exact decision correlation identifier. pub const fn correlation_id(&self) -> Uuid { self.correlation_id } - - /// Earliest authoritative expiry bound for enrollment. + /// Earliest authoritative expiry bound for the enrollment transaction. pub const fn expires_at(&self) -> u64 { self.expires_at } @@ -81,3 +484,156 @@ impl fmt::Debug for EnrollmentDisposition { .finish() } } + +impl fmt::Debug for RelayAuthorizationFinalizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayAuthorizationFinalizer") + .field("selector", &self.selector) + .field("finalizer", &self.finalizer) + .finish() + } +} + +/// Typed result of server-mode finalization. +#[must_use] +pub enum RuntimeAuthorizationDisposition { + /// Enforcing authority carrying a bounded access lease. + Access(Box), + /// Display-only verification carrying no access authority. + VerificationOnly(VerificationOnlyDisposition), +} + +impl fmt::Debug for RuntimeAuthorizationDisposition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Access(_) => formatter + .debug_tuple("Access") + .field(&"[redacted]") + .finish(), + Self::VerificationOnly(_) => formatter + .debug_tuple("VerificationOnly") + .field(&"[redacted]") + .finish(), + } + } +} + +/// Fail-closed relay finalization adapter error. +#[derive(Debug, Error)] +pub enum RelayFinalizationError { + /// Exact-domain server policy could not be resolved. + #[error(transparent)] + DomainPolicy(#[from] DomainPolicyError), + /// Central authorization time was unavailable. + #[error(transparent)] + Clock(#[from] buzz_auth::AuthorizationClockError), + /// Provider request evidence was inconsistent. + #[error(transparent)] + ProviderContract(#[from] ProviderContractError), + /// Full provider/binding finalization failed. + #[error(transparent)] + Finalization(#[from] FinalizationError), + /// A non-evaluating mode was asked to evaluate federated policy. + #[error("server-resolved authorization mode does not evaluate federated policy")] + ModeDoesNotEvaluate, + /// A non-finalizing mode was asked to create status or authority. + #[error("server-resolved authorization mode does not permit finalization")] + ModeDoesNotFinalize, + /// First enrollment was configured for a non-attested mode. + #[error("server-resolved authorization policy does not permit direct enrollment")] + EnrollmentModeUnsupported, + /// Direct assertion, provider, proof, or expiry evidence did not match. + #[error("direct enrollment evidence is inconsistent or stale")] + EnrollmentEvidenceMismatch, +} + +#[cfg(test)] +mod tests { + use std::{future::ready, time::Duration}; + + use buzz_auth::{ + ApplicationLeaseLimit, AuthorizationClockSkew, AuthorizationDenial, + AuthorizationDenialReason, AuthorizationProviderFuture, ProviderDecision, + }; + use uuid::Uuid; + + use super::*; + + struct DenyProvider; + + impl AuthorizationProvider for DenyProvider { + fn profile_id(&self) -> buzz_auth::AuthorizationProfileId { + buzz_auth::AuthorizationProfileId::from_server_configuration( + "profile.synthetic-deny.example", + ) + .expect("synthetic profile is valid") + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(ready(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )))) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn policy(domain: CommunityId) -> DomainAuthorizationPolicy { + let application_limit = + ApplicationLeaseLimit::from_seconds(300).expect("synthetic limit is valid"); + let skew = AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"); + DomainAuthorizationPolicy::from_server_configuration( + domain, + "synthetic-provider.example", + Arc::new(DenyProvider), + EnrollmentMode::Provisioned, + AuthorizationMode::Enforce, + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + AccessLeasePolicy::new(application_limit, skew), + VerificationStatusPolicy::new(application_limit, skew), + ) + .expect("synthetic policy is valid") + } + + #[test] + fn duplicate_domain_policy_is_rejected_as_ambiguous() { + let exact_domain = domain(1); + let result = DomainProviderSelector::new([policy(exact_domain), policy(exact_domain)]); + assert!(matches!( + result, + Err(DomainPolicyError::AmbiguousDomainPolicy) + )); + } + + #[test] + fn missing_domain_has_no_default_provider_fallback() { + let configured = domain(1); + let missing = domain(2); + let selector = DomainProviderSelector::new([policy(configured)]) + .expect("one exact policy is unambiguous"); + let tenant = TenantContext::resolved(missing, "missing.authorization.example"); + assert!(matches!( + selector.resolve(&tenant), + Err(DomainPolicyError::MissingDomainPolicy) + )); + } + + #[test] + fn exact_server_tenant_selects_its_policy() { + let configured = domain(1); + let selector = DomainProviderSelector::new([policy(configured)]) + .expect("one exact policy is unambiguous"); + let tenant = TenantContext::resolved(configured, "configured.authorization.example"); + let resolved = selector + .resolve(&tenant) + .expect("exact domain is configured"); + assert_eq!(resolved.authorization_domain(), configured); + assert_eq!(resolved.mode(), AuthorizationMode::Enforce); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/invalidation.rs b/crates/buzz-relay/src/authorization_runtime/invalidation.rs new file mode 100644 index 0000000000..05c5b159e3 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/invalidation.rs @@ -0,0 +1,1995 @@ +//! Durable authorization invalidation, reconciliation, and use fences. +//! +//! Postgres is authoritative. Redis messages only accelerate reconciliation; +//! startup, polling, lag recovery, and every error path fail closed. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock, Weak}; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + AuthContext, FederatedAuthorization, FederatedIdentityRequirement, VerificationOnlyDisposition, +}; +use buzz_core::{CommunityId, TenantContext}; +use buzz_db::authorization_invalidation::{ + AuthorizationInvalidationEntry, AuthorizationInvalidationFloor, + AuthorizationInvalidationReceipt, AuthorizationInvalidationRequest, + AuthorizationInvalidationResult, AuthorizationInvalidationSnapshot, AuthorizationSelector, + AuthorizationSelectorKind, +}; +use buzz_db::{Db, DbError}; +use buzz_pubsub::authorization_invalidation::{ + AuthorizationInvalidationHint, ScopedAuthorizationInvalidationHint, + AUTHORIZATION_INVALIDATION_WIRE_VERSION, +}; +use buzz_pubsub::PubSubManager; +use dashmap::DashMap; +use thiserror::Error; +use tokio::sync::Mutex; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +/// Default interval between durable reconciliation reads. +pub const DEFAULT_INVALIDATION_POLL_INTERVAL: Duration = Duration::from_secs(5); +/// Default maximum age of a successful authority read. +pub const DEFAULT_INVALIDATION_MAX_STALENESS: Duration = Duration::from_secs(15); + +/// Runtime polling and freshness bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AuthorizationInvalidationConfig { + poll_interval: Duration, + max_staleness: Duration, +} + +impl AuthorizationInvalidationConfig { + /// Build non-zero polling and staleness bounds. + pub fn new( + poll_interval: Duration, + max_staleness: Duration, + ) -> Result { + if poll_interval.is_zero() || max_staleness.is_zero() { + return Err(AuthorizationInvalidationRuntimeError::InvalidConfiguration); + } + Ok(Self { + poll_interval, + max_staleness, + }) + } + + /// Durable reconciliation interval. + pub const fn poll_interval(self) -> Duration { + self.poll_interval + } + + /// Maximum permitted age of a successful writer-database read. + pub const fn max_staleness(self) -> Duration { + self.max_staleness + } +} + +impl Default for AuthorizationInvalidationConfig { + fn default() -> Self { + Self { + poll_interval: DEFAULT_INVALIDATION_POLL_INTERVAL, + max_staleness: DEFAULT_INVALIDATION_MAX_STALENESS, + } + } +} + +/// Fail-closed runtime failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)] +pub enum AuthorizationInvalidationRuntimeError { + /// Configuration contained a zero bound. + #[error("authorization invalidation configuration is invalid")] + InvalidConfiguration, + /// Lease dependencies could not be represented exactly. + #[error("authorization invalidation dependencies are invalid")] + InvalidDependencies, + /// Admission-loss selectors or idempotency evidence were invalid. + #[error("authorization admission-loss event is invalid")] + InvalidAdmissionLoss, + /// No successful startup/recovery snapshot is installed for the domain. + #[error("authorization invalidation domain is not ready")] + NotReady, + /// Durable authority was not read within the configured freshness bound. + #[error("authorization invalidation authority is stale")] + Stale, + /// A matching selector floor invalidated the observed authority. + #[error("authorization authority was invalidated")] + Invalidated, + /// Durable generation or floor state moved backwards. + #[error("authorization invalidation authority regressed")] + AuthorityRegressed, + /// The writer-database read failed. + #[error("authorization invalidation authority is unavailable")] + AuthorityUnavailable, + /// Redis hint publication failed after durable commit. + #[error("authorization invalidation hint publication failed")] + HintUnavailable, + /// The runtime no longer exists. + #[error("authorization invalidation runtime stopped")] + RuntimeStopped, +} + +/// Provider-neutral description of reversible admission loss. +/// +/// The event carries only selector material and an idempotency ID. Its exact +/// authorization domain is supplied separately by a server-resolved +/// [`TenantContext`] when the event is committed. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationAdmissionLoss { + request: AuthorizationInvalidationRequest, +} + +impl AuthorizationAdmissionLoss { + /// Build one bounded event for exact principal, Nostr-key, or + /// delegated-owner selectors. + pub fn new( + event_id: Uuid, + selectors: Vec, + ) -> Result { + let entries = selectors + .into_iter() + .map(AuthorizationInvalidationEntry::admission_loss_fence) + .collect::, _>>() + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss)?; + let request = AuthorizationInvalidationRequest::new(event_id, entries) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss)?; + Ok(Self { request }) + } + + /// Idempotency identifier retained by the durable invalidation authority. + pub const fn event_id(&self) -> Uuid { + self.request.event_id() + } +} + +impl fmt::Debug for AuthorizationAdmissionLoss { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationAdmissionLoss") + .field("event_id", &"[redacted]") + .field("selector_count", &self.request.entries().len()) + .finish() + } +} + +/// Generation captured immediately before provider evaluation. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationEvaluationFence { + community_id: CommunityId, + generation: u64, +} + +impl AuthorizationEvaluationFence { + /// Server-resolved authorization domain. + pub const fn community_id(self) -> CommunityId { + self.community_id + } + + /// Durable generation captured before evaluation. + pub const fn generation(self) -> u64 { + self.generation + } +} + +impl fmt::Debug for AuthorizationEvaluationFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationEvaluationFence") + .field("community_id", &"[redacted]") + .field("generation", &self.generation) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +struct Dependency { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, +} + +/// Exact selector dependencies represented by one finalized lease and session. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationDependencies { + community_id: CommunityId, + selectors: Vec, +} + +impl AuthorizationDependencies { + /// Derive exact invalidation dependencies for a display-only current + /// direct binding. This registration can withdraw presentation but cannot + /// authorize access or create a lease. + pub fn from_verification_only( + session_id: Uuid, + disposition: &VerificationOnlyDisposition, + ) -> Result { + Self::from_selectors( + disposition.authorization_domain(), + vec![ + AuthorizationSelector::nostr_key(disposition.actor_pubkey().to_bytes()), + AuthorizationSelector::binding( + disposition.binding_id(), + disposition.binding_version().get(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::session(session_id) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ], + ) + } + + /// Derive the pre-binding dependencies for one staged direct enrollment. + /// The transaction revalidates these selectors before it may create the + /// first binding or membership row. + pub fn from_enrollment( + session_id: Uuid, + disposition: &super::finalization::EnrollmentDisposition, + ) -> Result { + let actor = disposition.actor_pubkey().to_bytes(); + Self::from_selectors( + disposition.authorization_domain(), + vec![ + AuthorizationSelector::principal( + disposition.principal().issuer(), + disposition.principal().subject(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::nostr_key(actor), + AuthorizationSelector::session(session_id) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ], + ) + } + + /// Derive every invalidation dependency from one finalized enforcing + /// context and a server-owned non-nil session ID. + /// + /// The principal is read from the same active binding that the finalizer + /// bound to the lease. It is never accepted as an independent argument. + pub fn from_context( + session_id: Uuid, + context: &AuthContext, + ) -> Result { + let lease = context + .authorization_lease() + .ok_or(AuthorizationInvalidationRuntimeError::InvalidDependencies)?; + let (binding, delegated_owner) = match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => { + if lease.owner_pubkey().is_some() + || binding.bound_pubkey() != context.pubkey() + || context.agent_owner_pubkey().is_some() + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + (binding, None) + } + FederatedAuthorization::Delegated { owner, .. } => { + let owner_key = owner.bound_pubkey(); + if lease.owner_pubkey() != Some(owner_key) + || context.agent_owner_pubkey() != Some(owner_key) + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + (owner, Some(owner_key)) + } + FederatedAuthorization::NotRequired => { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + }; + if !matches!( + context.federated_policy().requirement(), + FederatedIdentityRequirement::Required(_) + ) || context.tenant().community() != lease.authorization_domain() + || context.federated_policy().authorization_domain() != lease.authorization_domain() + || context.transport() != lease.transport() + || context.pubkey() != lease.actor_pubkey() + || context.correlation_id() != lease.correlation_id() + || binding.authorization_domain() != lease.authorization_domain() + || binding.binding_id() != lease.binding_id() + || binding.binding_version() != lease.binding_version() + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + let actor = lease.actor_pubkey().to_bytes(); + let mut selectors = vec![ + AuthorizationSelector::principal( + binding.principal().issuer(), + binding.principal().subject(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::nostr_key(actor), + AuthorizationSelector::binding(lease.binding_id(), lease.binding_version().get()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::session(session_id) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(lease.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ]; + if let Some(owner) = delegated_owner { + selectors.extend(delegated_owner_selectors(owner.to_bytes())); + } + Self::from_selectors(lease.authorization_domain(), selectors) + } + + fn from_selectors( + community_id: CommunityId, + selectors: Vec, + ) -> Result { + if selectors.is_empty() { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + let mut dependencies = selectors + .into_iter() + .map(|selector| Dependency { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + binding_version: selector.binding_version_floor(), + }) + .collect::>(); + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + Ok(Self { + community_id, + selectors: dependencies, + }) + } + + /// Server-resolved domain represented by the dependencies. + pub const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Exact durable selector set carried into a PostgreSQL commit fence. + pub fn commit_dependencies( + &self, + ) -> Result, super::executor::AuthorizationExecutionError> + { + self.selectors + .iter() + .map(|dependency| { + super::executor::CommitDependency::from_trusted_runtime( + dependency.kind, + dependency.fingerprint, + dependency.binding_version, + ) + }) + .collect() + } +} + +impl fmt::Debug for AuthorizationDependencies { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationDependencies") + .field("community_id", &"[redacted]") + .field("selector_count", &self.selectors.len()) + .finish() + } +} + +type FloorKey = (AuthorizationSelectorKind, [u8; 32]); + +#[derive(Default)] +struct CachedDomain { + ready: bool, + generation: u64, + floors: BTreeMap, + last_success: Option, +} + +#[derive(Default)] +struct DomainState { + cached: RwLock, + reconcile_lock: Mutex<()>, +} + +#[derive(Clone)] +struct Registration { + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, + cancellation: Option, +} + +#[async_trait] +trait InvalidationStore: Send + Sync { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result; + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result; + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result; +} + +struct DbInvalidationStore(Db); + +#[async_trait] +impl InvalidationStore for DbInvalidationStore { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + self.0 + .apply_authorization_invalidation(community_id, request) + .await + } + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result { + self.0 + .authorization_invalidation_snapshot(community_id) + .await + } + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + self.0 + .authorization_invalidation_delta(community_id, after_generation) + .await + } +} + +struct RuntimeInner { + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + domains: DashMap>, + registrations: DashMap, + shutdown: CancellationToken, + healthy: AtomicBool, + restore: Option>, +} + +/// Cloneable fail-closed invalidation and reconciliation runtime. +#[derive(Clone)] +pub struct AuthorizationInvalidationRuntime { + inner: Arc, +} + +impl fmt::Debug for AuthorizationInvalidationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationRuntime") + .field("domains", &self.inner.domains.len()) + .field("registrations", &self.inner.registrations.len()) + .finish() + } +} + +impl AuthorizationInvalidationRuntime { + /// Build a runtime backed by the writer database and provider-neutral Redis hints. + pub fn new( + db: Db, + pubsub: Arc, + config: AuthorizationInvalidationConfig, + ) -> Self { + Self::with_store(Arc::new(DbInvalidationStore(db)), Some(pubsub), config) + } + + /// Build a production runtime whose invalidation commits participate in + /// the independent restore witness protocol. + pub fn new_with_restore( + db: Db, + pubsub: Arc, + config: AuthorizationInvalidationConfig, + restore: Arc, + ) -> Self { + Self::with_store_and_restore( + Arc::new(DbInvalidationStore(db)), + Some(pubsub), + config, + Some(restore), + ) + } + + fn with_store( + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + ) -> Self { + Self::with_store_and_restore(store, pubsub, config, None) + } + + fn with_store_and_restore( + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + restore: Option>, + ) -> Self { + Self { + inner: Arc::new(RuntimeInner { + store, + pubsub, + config, + domains: DashMap::new(), + registrations: DashMap::new(), + shutdown: CancellationToken::new(), + healthy: AtomicBool::new(true), + restore, + }), + } + } + + fn domain_state(&self, community_id: CommunityId) -> Arc { + self.inner + .domains + .entry(community_id) + .or_insert_with(|| Arc::new(DomainState::default())) + .clone() + } + + /// Install full durable snapshots before marking protected domains ready. + pub async fn initialize_domains( + &self, + domains: impl IntoIterator, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + for community_id in domains { + self.reconcile(community_id, true).await?; + } + Ok(()) + } + + /// Whether a domain has a fresh successful authority snapshot. + pub fn is_ready(&self, community_id: CommunityId) -> bool { + self.inner.check_ready(community_id).is_ok() + } + + /// Capture the durable generation immediately before provider evaluation. + /// A new domain is synchronously bootstrapped from the writer database. + pub async fn capture_before_evaluation( + &self, + community_id: CommunityId, + ) -> Result { + if !self.inner.domains.contains_key(&community_id) { + self.reconcile(community_id, true).await?; + } + let generation = self.inner.check_ready(community_id)?; + Ok(AuthorizationEvaluationFence { + community_id, + generation, + }) + } + + /// Recheck a read-only evaluation fence without registering authority. + /// This is the mutation-free Shadow/VerifyOnly path. + pub fn recheck_evaluation( + &self, + fence: AuthorizationEvaluationFence, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let current = self.inner.check_ready(fence.community_id)?; + if current != fence.generation { + return Err(AuthorizationInvalidationRuntimeError::Invalidated); + } + Ok(()) + } + + /// Register finalized authority and immediately recheck the pre-evaluation + /// fence, closing the race between provider evaluation and registration. + pub fn observe_authority( + &self, + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, + cancellation: Option, + ) -> Result { + if fence.community_id != dependencies.community_id { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + self.inner.check_observation(fence, &dependencies)?; + let registration_id = Uuid::new_v4(); + self.inner.registrations.insert( + registration_id, + Registration { + fence, + dependencies: dependencies.clone(), + cancellation, + }, + ); + if let Err(error) = self.inner.check_observation(fence, &dependencies) { + if let Some((_, registration)) = self.inner.registrations.remove(®istration_id) { + if let Some(token) = registration.cancellation { + token.cancel(); + } + } + return Err(error); + } + Ok(AuthorizationObserver { + inner: Arc::downgrade(&self.inner), + registration_id, + fence, + dependencies, + }) + } + + /// Force a full writer-database reconcile for one domain. + pub async fn reconcile_domain( + &self, + community_id: CommunityId, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.reconcile(community_id, true).await + } + + /// Reconcile local state and publish a hint after an already-durable commit. + pub async fn publish_committed( + &self, + receipt: AuthorizationInvalidationReceipt, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.reconcile(receipt.community_id, false).await?; + let Some(pubsub) = &self.inner.pubsub else { + return Ok(()); + }; + pubsub + .publish_authorization_invalidation( + receipt.community_id, + AuthorizationInvalidationHint::current(receipt.generation), + ) + .await + .map_err(|_| AuthorizationInvalidationRuntimeError::HintUnavailable)?; + Ok(()) + } + + /// Durably fence reversible admission loss, reconcile locally, and then + /// advertise the committed generation to other nodes. + /// + /// The tenant is the row-zero server-resolved domain boundary. No domain + /// value is accepted from the provider event itself. + pub async fn apply_admission_loss( + &self, + tenant: &TenantContext, + event: &AuthorizationAdmissionLoss, + ) -> Result { + let revocation_started = Instant::now(); + self.inner.check_health()?; + let community_id = tenant.community(); + let request_fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &event.request, + ); + let restore = match &self.inner.restore { + Some(runtime) => Some( + runtime + .begin(community_id, event.request.event_id(), request_fingerprint) + .await + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?, + ), + None => None, + }; + let result = match self.inner.store.apply(community_id, &event.request).await { + Ok(result) => result, + Err(error) => { + if let Some(restore) = restore { + let _ = restore.abort().await; + } + tracing::warn!(%error, "authorization admission-loss commit failed closed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + }; + if let Some(restore) = restore { + // Both variants prove the exact durable receipt. `AlreadyApplied` + // is a replay, not a rollback, so it must advance the Pending + // witness to Committed rather than attempting to abort it. + if let Err(error) = restore + .commit_invalidation(result.receipt().generation) + .await + { + tracing::warn!(%error, "authorization invalidation witness failed closed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + } + let receipt = result.receipt(); + self.publish_committed(receipt).await?; + // This is a conservative upper bound: the local cancellation happens + // during `publish_committed`'s reconcile, before the optional hint is + // published. No selector, principal, token, or private claim is a label. + metrics::histogram!("buzz_authorization_revocation_to_enforcement_seconds") + .record(revocation_started.elapsed().as_secs_f64()); + Ok(receipt) + } + + /// Poll every known domain once. Lost Redis hints converge here. + pub async fn poll_once(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + let domains = self + .inner + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + let mut first_error = None; + for community_id in domains { + if let Err(error) = self.reconcile(community_id, false).await { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) + } + + /// Run periodic reconciliation and optional Redis-hint consumption until shutdown. + /// Durable polling remains active when Redis is absent. + pub async fn run(&self) { + let mut hints = self + .inner + .pubsub + .as_ref() + .map(|pubsub| pubsub.subscribe_authorization_invalidations()); + let mut interval = tokio::time::interval(self.inner.config.poll_interval()); + loop { + tokio::select! { + _ = self.inner.shutdown.cancelled() => return, + _ = interval.tick() => { + if let Err(error) = self.poll_once().await { + tracing::warn!(%error, "authorization invalidation poll failed closed"); + } + } + received = next_hint(&mut hints) => match received { + Ok(scoped) => { + if let Err(error) = self.handle_hint(scoped).await { + tracing::warn!(%error, "authorization invalidation hint reconcile failed closed"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if let Err(error) = self.reconcile_all(true).await { + tracing::warn!(%error, "authorization invalidation lag recovery failed closed"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + self.inner.mark_all_failed(); + return; + } + } + } + } + } + + /// Stop the runtime loop. Existing observers fail closed once the runtime drops. + pub fn shutdown(&self) { + self.inner.shutdown.cancel(); + } + + /// Immediately make every installed domain unavailable. + /// + /// Production worker supervision calls this whenever a required worker + /// exits, including a panic. Existing and future Enforce observations then + /// fail closed instead of continuing with an unsupervised cache. + pub fn fail_closed(&self) { + self.inner.healthy.store(false, Ordering::SeqCst); + self.inner.mark_all_failed(); + } + + async fn handle_hint( + &self, + scoped: ScopedAuthorizationInvalidationHint, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let full = scoped.hint.wire_version != AUTHORIZATION_INVALIDATION_WIRE_VERSION; + if !full { + let current = self + .inner + .generation(scoped.community_id) + .unwrap_or_default(); + if scoped.hint.generation <= current { + return Ok(()); + } + } + self.reconcile(scoped.community_id, full).await + } + + async fn reconcile_all(&self, full: bool) -> Result<(), AuthorizationInvalidationRuntimeError> { + let domains = self + .inner + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + let mut first_error = None; + for community_id in domains { + if let Err(error) = self.reconcile(community_id, full).await { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) + } + + async fn reconcile( + &self, + community_id: CommunityId, + full: bool, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.inner.check_health()?; + let state = self.domain_state(community_id); + let _guard = state.reconcile_lock.lock().await; + self.inner.check_health()?; + let current = self.inner.generation(community_id).unwrap_or_default(); + let result = if full { + self.inner.store.snapshot(community_id).await + } else { + self.inner.store.delta(community_id, current).await + }; + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!(%error, "authorization invalidation writer read failed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + }; + self.inner.install_snapshot(snapshot, full)?; + self.inner.cancel_invalid(community_id); + Ok(()) + } +} + +fn delegated_owner_selectors(owner: [u8; 32]) -> [AuthorizationSelector; 2] { + [ + AuthorizationSelector::nostr_key(owner), + AuthorizationSelector::delegated_owner(owner), + ] +} + +async fn next_hint( + hints: &mut Option>, +) -> Result { + match hints { + Some(receiver) => receiver.recv().await, + None => std::future::pending().await, + } +} + +impl RuntimeInner { + fn check_health(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + if self.healthy.load(Ordering::SeqCst) { + Ok(()) + } else { + Err(AuthorizationInvalidationRuntimeError::NotReady) + } + } + + fn generation(&self, community_id: CommunityId) -> Option { + let state = self.domains.get(&community_id)?; + state.cached.read().ok().map(|cached| cached.generation) + } + + fn check_ready( + &self, + community_id: CommunityId, + ) -> Result { + self.check_health()?; + let state = self + .domains + .get(&community_id) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let cached = state + .cached + .read() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if !cached.ready { + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + let last_success = cached + .last_success + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + if Instant::now().saturating_duration_since(last_success) > self.config.max_staleness() { + return Err(AuthorizationInvalidationRuntimeError::Stale); + } + Ok(cached.generation) + } + + fn check_observation( + &self, + fence: AuthorizationEvaluationFence, + dependencies: &AuthorizationDependencies, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.check_health()?; + let state = self + .domains + .get(&fence.community_id) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let cached = state + .cached + .read() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if !cached.ready { + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + let last_success = cached + .last_success + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + if Instant::now().saturating_duration_since(last_success) > self.config.max_staleness() { + return Err(AuthorizationInvalidationRuntimeError::Stale); + } + if cached.generation < fence.generation { + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + for dependency in &dependencies.selectors { + let key = (dependency.kind, dependency.fingerprint); + let Some(floor) = cached.floors.get(&key) else { + continue; + }; + let binding_denied = match (floor.binding_version_floor, dependency.binding_version) { + (Some(floor_version), Some(version)) => version <= floor_version, + _ => false, + }; + if floor.sticky_deny || binding_denied || floor.generation > fence.generation { + return Err(AuthorizationInvalidationRuntimeError::Invalidated); + } + } + Ok(()) + } + + fn install_snapshot( + &self, + snapshot: AuthorizationInvalidationSnapshot, + full: bool, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let state = self + .domains + .get(&snapshot.community_id) + .map(|entry| entry.clone()) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let mut cached = state + .cached + .write() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if self.check_health().is_err() { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + if snapshot.generation < cached.generation + || snapshot + .floors + .iter() + .any(|floor| floor.generation > snapshot.generation) + { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + let incoming = snapshot + .floors + .into_iter() + .map(|floor| ((floor.kind, floor.fingerprint), floor)) + .collect::>(); + if full + && cached.ready + && cached.floors.iter().any(|(key, existing)| { + incoming.get(key).is_none_or(|next| { + next.generation < existing.generation + || (existing.sticky_deny && !next.sticky_deny) + || next.binding_version_floor < existing.binding_version_floor + }) + }) + { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + if full { + cached.floors = incoming; + } else { + for (key, floor) in incoming { + if cached.floors.get(&key).is_some_and(|existing| { + floor.generation < existing.generation + || (existing.sticky_deny && !floor.sticky_deny) + || floor.binding_version_floor < existing.binding_version_floor + }) { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + cached.floors.insert(key, floor); + } + } + cached.generation = snapshot.generation; + cached.last_success = Some(Instant::now()); + cached.ready = true; + Ok(()) + } + + fn mark_failed(&self, community_id: CommunityId) { + if let Some(state) = self.domains.get(&community_id) { + if let Ok(mut cached) = state.cached.write() { + cached.ready = false; + } + } + self.cancel_invalid(community_id); + } + + fn mark_all_failed(&self) { + let domains = self + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + for community_id in domains { + self.mark_failed(community_id); + } + } + + fn cancel_invalid(&self, community_id: CommunityId) { + let registrations = self + .registrations + .iter() + .filter(|entry| entry.value().fence.community_id == community_id) + .map(|entry| (*entry.key(), entry.value().clone())) + .collect::>(); + for (registration_id, registration) in registrations { + if self + .check_observation(registration.fence, ®istration.dependencies) + .is_err() + { + if let Some((_, removed)) = self.registrations.remove(®istration_id) { + if let Some(token) = removed.cancellation { + token.cancel(); + } + } + } + } + } +} + +/// Registered authority observer used for both pre-use and pre-commit checks. +pub struct AuthorizationObserver { + inner: Weak, + registration_id: Uuid, + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, +} + +impl AuthorizationObserver { + /// Fail closed unless the captured authority is still fresh and uninvalidated. + /// Call immediately before each protected use and again before commit. + pub fn recheck(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + let inner = self + .inner + .upgrade() + .ok_or(AuthorizationInvalidationRuntimeError::RuntimeStopped)?; + inner.check_observation(self.fence, &self.dependencies) + } + + /// Pre-evaluation fence retained by this observer. + pub const fn fence(&self) -> AuthorizationEvaluationFence { + self.fence + } + + /// Seal the durable generation and dependencies for transaction-owned use. + pub fn commit_fence( + &self, + ) -> Result< + super::executor::AuthorizationCommitFence, + super::executor::AuthorizationExecutionError, + > { + super::executor::AuthorizationCommitFence::from_trusted_runtime( + self.fence.generation(), + self.dependencies.commit_dependencies()?, + ) + } +} + +impl fmt::Debug for AuthorizationObserver { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationObserver") + .field("registration_id", &"[redacted]") + .field("fence", &self.fence) + .field("dependencies", &self.dependencies) + .finish() + } +} + +impl Drop for AuthorizationObserver { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + inner.registrations.remove(&self.registration_id); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + #[derive(Default)] + struct FakeStore { + snapshots: std::sync::Mutex>, + receipts: std::sync::Mutex< + HashMap< + (CommunityId, Uuid), + ( + AuthorizationInvalidationRequest, + AuthorizationInvalidationReceipt, + ), + >, + >, + fail: AtomicBool, + } + + impl FakeStore { + fn set(&self, snapshot: AuthorizationInvalidationSnapshot) { + self.snapshots + .lock() + .expect("fake store lock") + .insert(snapshot.community_id, snapshot); + } + } + + #[async_trait] + impl InvalidationStore for FakeStore { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(DbError::InvalidData("synthetic failure".into())); + } + let key = (community_id, request.event_id()); + let mut receipts = self.receipts.lock().expect("fake receipt lock"); + if let Some((stored, receipt)) = receipts.get(&key) { + if stored != request { + return Err(DbError::InvalidData("synthetic event ID collision".into())); + } + return Ok(AuthorizationInvalidationResult::AlreadyApplied(*receipt)); + } + + let mut snapshots = self.snapshots.lock().expect("fake store lock"); + let snapshot = + snapshots + .entry(community_id) + .or_insert(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + snapshot.generation = snapshot + .generation + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("synthetic generation exhausted".into()))?; + for entry in request.entries() { + let selector = entry.selector(); + let binding_version_floor = selector.binding_version_floor(); + if let Some(floor) = snapshot.floors.iter_mut().find(|floor| { + floor.kind == selector.kind() && floor.fingerprint == selector.fingerprint() + }) { + floor.generation = snapshot.generation; + floor.sticky_deny |= matches!( + entry.effect(), + buzz_db::authorization_invalidation::AuthorizationInvalidationEffect::StickyDeny + ); + floor.binding_version_floor = + match (floor.binding_version_floor, binding_version_floor) { + (Some(current), Some(candidate)) => Some(current.max(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + } else { + snapshot.floors.push(AuthorizationInvalidationFloor { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + generation: snapshot.generation, + sticky_deny: matches!( + entry.effect(), + buzz_db::authorization_invalidation::AuthorizationInvalidationEffect::StickyDeny + ), + binding_version_floor, + }); + } + } + let receipt = AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: snapshot.generation, + }; + receipts.insert(key, (request.clone(), receipt)); + Ok(AuthorizationInvalidationResult::Applied(receipt)) + } + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(DbError::InvalidData("synthetic failure".into())); + } + Ok(self + .snapshots + .lock() + .expect("fake store lock") + .get(&community_id) + .cloned() + .unwrap_or(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + })) + } + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + let mut snapshot = self.snapshot(community_id).await?; + snapshot + .floors + .retain(|floor| floor.generation > after_generation); + Ok(snapshot) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn runtime(store: Arc) -> AuthorizationInvalidationRuntime { + AuthorizationInvalidationRuntime::with_store( + store, + None, + AuthorizationInvalidationConfig::new(Duration::from_millis(5), Duration::from_secs(10)) + .expect("valid config"), + ) + } + + fn dependencies( + community_id: CommunityId, + selector: AuthorizationSelector, + ) -> AuthorizationDependencies { + dependencies_for(community_id, vec![selector]) + } + + fn dependencies_for( + community_id: CommunityId, + selectors: Vec, + ) -> AuthorizationDependencies { + let mut all = vec![AuthorizationSelector::domain()]; + all.extend(selectors); + AuthorizationDependencies::from_selectors(community_id, all).expect("valid dependencies") + } + + async fn observe( + runtime: &AuthorizationInvalidationRuntime, + community_id: CommunityId, + selectors: Vec, + cancellation: CancellationToken, + ) -> AuthorizationObserver { + let fence = runtime + .capture_before_evaluation(community_id) + .await + .expect("capture evaluation fence"); + runtime + .observe_authority( + fence, + dependencies_for(community_id, selectors), + Some(cancellation), + ) + .expect("observe authority") + } + + fn floor( + selector: &AuthorizationSelector, + generation: u64, + sticky_deny: bool, + ) -> AuthorizationInvalidationFloor { + AuthorizationInvalidationFloor { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + generation, + sticky_deny, + binding_version_floor: selector.binding_version_floor(), + } + } + + #[tokio::test] + async fn two_nodes_converge_after_lost_reordered_and_replayed_hints() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(1); + let selector = AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node_a = runtime(store.clone()); + let node_b = runtime(store.clone()); + node_a + .initialize_domains([community_id]) + .await + .expect("node A starts"); + node_b + .initialize_domains([community_id]) + .await + .expect("node B starts"); + let fence = node_b + .capture_before_evaluation(community_id) + .await + .expect("capture fence"); + let cancellation = CancellationToken::new(); + let observer = node_b + .observe_authority( + fence, + dependencies(community_id, selector.clone()), + Some(cancellation.clone()), + ) + .expect("observe authority"); + + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 2, + floors: vec![floor(&selector, 2, true)], + }); + node_a.poll_once().await.expect("node A reconciles"); + assert!( + observer.recheck().is_ok(), + "lost hint has not reached node B yet" + ); + node_b.poll_once().await.expect("poll heals lost hint"); + assert_eq!( + observer.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert!(cancellation.is_cancelled()); + + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("delayed hint is harmless"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(2), + }) + .await + .expect("replayed hint is harmless"); + assert_eq!(node_b.inner.generation(community_id), Some(2)); + } + + #[tokio::test] + async fn admission_loss_is_idempotent_and_cancels_two_nodes_before_readmission() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(7); + let tenant = TenantContext::resolved(community_id, "admission-loss.example"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node_a = runtime(store.clone()); + let node_b = runtime(store.clone()); + node_a + .initialize_domains([community_id]) + .await + .expect("node A starts"); + node_b + .initialize_domains([community_id]) + .await + .expect("node B starts"); + + let principal = + AuthorizationSelector::principal("issuer.example", "subject").expect("principal"); + let actor = AuthorizationSelector::nostr_key([7_u8; 32]); + let owner = AuthorizationSelector::delegated_owner([8_u8; 32]); + let direct_dependencies = vec![principal.clone(), actor.clone()]; + let delegated_dependencies = vec![principal.clone(), owner.clone()]; + let a_direct_cancel = CancellationToken::new(); + let a_delegated_cancel = CancellationToken::new(); + let b_direct_cancel = CancellationToken::new(); + let b_delegated_cancel = CancellationToken::new(); + let a_direct = observe( + &node_a, + community_id, + direct_dependencies.clone(), + a_direct_cancel.clone(), + ) + .await; + let a_delegated = observe( + &node_a, + community_id, + delegated_dependencies.clone(), + a_delegated_cancel.clone(), + ) + .await; + let b_direct = observe( + &node_b, + community_id, + direct_dependencies.clone(), + b_direct_cancel.clone(), + ) + .await; + let b_delegated = observe( + &node_b, + community_id, + delegated_dependencies.clone(), + b_delegated_cancel.clone(), + ) + .await; + + let event_id = Uuid::new_v4(); + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![principal.clone(), actor.clone(), owner.clone()], + ) + .expect("valid provider-neutral admission loss"); + let first = node_a + .apply_admission_loss(&tenant, &event) + .await + .expect("admission loss commits"); + assert_eq!(first.event_id, event_id); + assert_eq!(first.generation, 1); + assert!(a_direct_cancel.is_cancelled()); + assert!(a_delegated_cancel.is_cancelled()); + assert_eq!( + a_direct.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert_eq!( + a_delegated.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert!(!b_direct_cancel.is_cancelled()); + assert!(!b_delegated_cancel.is_cancelled()); + + let duplicate = node_a + .apply_admission_loss(&tenant, &event) + .await + .expect("duplicate event reconciles idempotently"); + assert_eq!(duplicate, first); + assert_eq!(store.snapshot(community_id).await.unwrap().generation, 1); + + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("current hint reconciles"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(0), + }) + .await + .expect("reordered delayed hint is harmless"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("replayed hint is harmless"); + assert!(b_direct_cancel.is_cancelled()); + assert!(b_delegated_cancel.is_cancelled()); + assert_eq!( + b_direct.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert_eq!( + b_delegated.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + + for (node, selectors) in [ + (&node_a, direct_dependencies), + (&node_b, delegated_dependencies), + ] { + let fence = node + .capture_before_evaluation(community_id) + .await + .expect("capture post-loss generation"); + assert_eq!(fence.generation(), 1); + let fresh = + node.observe_authority(fence, dependencies_for(community_id, selectors), None); + assert!( + fresh.is_ok(), + "fresh admission at the committed generation is permitted" + ); + } + } + + #[tokio::test] + #[ignore = "requires migrated Postgres and S3-compatible object storage"] + async fn restore_witnessed_admission_loss_replay_is_idempotent_and_fingerprint_bound() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("test migrations"); + let db = Db::from_pool(pool.clone()); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id.as_uuid()) + .bind(format!( + "restore-invalidation-{}.example", + Uuid::new_v4().simple() + )) + .execute(&pool) + .await + .expect("test community"); + + let endpoint = std::env::var("BUZZ_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_owned()); + let access_key = + std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_owned()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_owned()); + let bucket = std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-git".to_owned()); + let store = crate::api::git::store::GitStore::new( + &endpoint, + &access_key, + &secret_key, + &bucket, + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("test object store"); + let bootstrap_id = Uuid::new_v4(); + super::super::restore::RestoreProtectionRuntime::provision_domain( + &db, + &store, + community_id, + bootstrap_id, + ) + .await + .expect("provision restore witness"); + let restore = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("initialize restore witness"); + let tenant = TenantContext::resolved(community_id, "restore-invalidation.example"); + let event_id = Uuid::new_v4(); + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal("issuer.example", "subject-a").expect("principal"), + AuthorizationSelector::nostr_key([41_u8; 32]), + ], + ) + .expect("valid admission loss"); + let fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &event.request, + ); + + // Replica A writes Pending and commits PostgreSQL. Replica B then + // recovers the pending witness and replays the exact database effect + // before A attempts its now-stale ETag CAS. + let replica_a = restore + .begin(community_id, event_id, fingerprint) + .await + .expect("replica A begins invalidation"); + let first = db + .apply_authorization_invalidation(community_id, &event.request) + .await + .expect("database effect commits") + .receipt(); + let restore_b = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("replica B recovers replica A pending witness"); + let replica_b = restore_b + .begin(community_id, event_id, fingerprint) + .await + .expect("replica B recognizes exact durable replay"); + let replay = db + .apply_authorization_invalidation(community_id, &event.request) + .await + .expect("replica B replay converges"); + assert!(!replay.committed_now()); + replica_b + .commit_invalidation(first.generation) + .await + .expect("replica B observes committed witness"); + replica_a + .commit_invalidation(first.generation) + .await + .expect("replica A stale CAS converges idempotently"); + + let config = + AuthorizationInvalidationConfig::new(Duration::from_millis(5), Duration::from_secs(10)) + .expect("valid config"); + let runtime = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + config, + Some(restore_b), + ); + runtime + .initialize_domains([community_id]) + .await + .expect("initialize invalidation domain"); + let (duplicate_a, duplicate_b) = tokio::join!( + runtime.apply_admission_loss(&tenant, &event), + runtime.apply_admission_loss(&tenant, &event), + ); + assert_eq!(duplicate_a.expect("first replay"), first); + assert_eq!(duplicate_b.expect("concurrent replay"), first); + assert_eq!(first.generation, 1); + + let restore_after_replay = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("replay leaves a committed checkpoint"); + let restarted = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + AuthorizationInvalidationConfig::default(), + Some(restore_after_replay), + ); + restarted + .initialize_domains([community_id]) + .await + .expect("restart after replay"); + + let conflicting = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal("issuer.example", "subject-b").expect("principal"), + ], + ) + .expect("valid conflicting request"); + assert_eq!( + restarted.apply_admission_loss(&tenant, &conflicting).await, + Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable) + ); + let restore_after_conflict = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("fingerprint conflict cannot poison the checkpoint"); + let after_conflict = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + AuthorizationInvalidationConfig::default(), + Some(Arc::clone(&restore_after_conflict)), + ); + after_conflict + .initialize_domains([community_id]) + .await + .expect("restart after conflict"); + let next = AuthorizationAdmissionLoss::new( + Uuid::new_v4(), + vec![AuthorizationSelector::nostr_key([42_u8; 32])], + ) + .expect("valid next event"); + let next_receipt = after_conflict + .apply_admission_loss(&tenant, &next) + .await + .expect("later valid event commits"); + assert_eq!(next_receipt.generation, 2); + + // The generic witness commit used by audio reconciliation must have + // the same stale-CAS convergence property. Force a third durable + // operation through two independent restore runtimes, but commit the + // witness with `commit` rather than the invalidation specialization. + let generic = AuthorizationAdmissionLoss::new( + Uuid::new_v4(), + vec![AuthorizationSelector::nostr_key([43_u8; 32])], + ) + .expect("valid generic witness event"); + let generic_fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &generic.request, + ); + let generic_a = restore_after_conflict + .begin( + community_id, + generic.request.event_id(), + generic_fingerprint, + ) + .await + .expect("generic replica A begins"); + db.apply_authorization_invalidation(community_id, &generic.request) + .await + .expect("generic database effect commits"); + let generic_restore_b = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("generic replica B recovers pending witness"); + let generic_b = generic_restore_b + .begin( + community_id, + generic.request.event_id(), + generic_fingerprint, + ) + .await + .expect("generic replica B recognizes durable replay"); + generic_b.commit().await.expect("replica B commits witness"); + generic_a + .commit() + .await + .expect("replica A stale generic CAS converges"); + } + + #[tokio::test] + async fn admission_loss_uses_only_the_server_resolved_tenant_domain() { + let store = Arc::new(FakeStore::default()); + let selected_domain = domain(8); + let other_domain = domain(9); + for community_id in [selected_domain, other_domain] { + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + } + let runtime = runtime(store.clone()); + runtime + .initialize_domains([selected_domain, other_domain]) + .await + .expect("both domains start"); + let selector = AuthorizationSelector::nostr_key([9_u8; 32]); + let other_cancel = CancellationToken::new(); + let other_observer = observe( + &runtime, + other_domain, + vec![selector.clone()], + other_cancel.clone(), + ) + .await; + let event = AuthorizationAdmissionLoss::new(Uuid::new_v4(), vec![selector]) + .expect("valid admission loss"); + let tenant = TenantContext::resolved(selected_domain, "selected.example"); + let receipt = runtime + .apply_admission_loss(&tenant, &event) + .await + .expect("selected domain commits"); + + assert_eq!(receipt.community_id, selected_domain); + assert_eq!(store.snapshot(selected_domain).await.unwrap().generation, 1); + assert_eq!(store.snapshot(other_domain).await.unwrap().generation, 0); + assert!(!other_cancel.is_cancelled()); + assert!(other_observer.recheck().is_ok()); + } + + #[tokio::test] + async fn restart_bootstraps_full_state_before_readiness() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(2); + let selector = AuthorizationSelector::policy_version("old").expect("valid policy"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 7, + floors: vec![floor(&selector, 7, true)], + }); + let restarted = runtime(store); + assert!(!restarted.is_ready(community_id)); + let fence = restarted + .capture_before_evaluation(community_id) + .await + .expect("startup snapshot loads"); + assert_eq!(fence.generation(), 7); + assert!(matches!( + restarted.observe_authority(fence, dependencies(community_id, selector), None,), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + )); + } + + #[tokio::test] + async fn read_failure_denies_immediately_and_partition_heal_recovers() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(3); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + store.fail.store(true, Ordering::SeqCst); + assert_eq!( + node.poll_once().await, + Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable) + ); + assert!(!node.is_ready(community_id)); + store.fail.store(false, Ordering::SeqCst); + node.poll_once().await.expect("partition heals"); + assert!(node.is_ready(community_id)); + } + + #[tokio::test] + async fn durable_regression_never_restores_authority() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(4); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 4, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 3, + floors: Vec::new(), + }); + assert_eq!( + node.reconcile_domain(community_id).await, + Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed) + ); + assert!(!node.is_ready(community_id)); + } + + #[test] + fn debug_output_redacts_selector_material() { + let community_id = domain(5); + let private_policy = "private-policy-version"; + let dependencies = dependencies( + community_id, + AuthorizationSelector::policy_version(private_policy).expect("valid policy"), + ); + assert!(!format!("{dependencies:?}").contains(private_policy)); + } + + #[test] + fn admission_loss_rejects_unsafe_selectors_and_redacts_private_material() { + let event_id = Uuid::new_v4(); + let private_issuer = "private-issuer.example"; + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal(private_issuer, "private-subject") + .expect("valid principal"), + AuthorizationSelector::nostr_key([3_u8; 32]), + AuthorizationSelector::delegated_owner([4_u8; 32]), + ], + ) + .expect("safe admission-loss selectors"); + let debug = format!("{event:?}"); + assert!(!debug.contains(private_issuer)); + assert!(!debug.contains(&event_id.to_string())); + + for selector in [ + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version("private-policy").expect("valid policy"), + ] { + assert_eq!( + AuthorizationAdmissionLoss::new(Uuid::new_v4(), vec![selector]), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + } + assert_eq!( + AuthorizationAdmissionLoss::new(Uuid::new_v4(), Vec::new()), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + assert_eq!( + AuthorizationAdmissionLoss::new( + Uuid::nil(), + vec![AuthorizationSelector::nostr_key([5_u8; 32])], + ), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + } + + #[test] + fn delegated_owner_matches_generic_key_and_owner_selectors() { + let owner = [7_u8; 32]; + let selectors = delegated_owner_selectors(owner); + assert_eq!(selectors[0].kind(), AuthorizationSelectorKind::NostrKey); + assert_eq!( + selectors[1].kind(), + AuthorizationSelectorKind::DelegatedOwner + ); + assert_eq!( + selectors[0].fingerprint(), + AuthorizationSelector::nostr_key(owner).fingerprint() + ); + assert_eq!( + selectors[1].fingerprint(), + AuthorizationSelector::delegated_owner(owner).fingerprint() + ); + } + + #[tokio::test] + async fn runtime_polls_durable_authority_without_redis() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(6); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + let running = node.clone(); + let task = tokio::spawn(async move { running.run().await }); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 1, + floors: Vec::new(), + }); + tokio::time::timeout(Duration::from_secs(1), async { + while node.inner.generation(community_id) != Some(1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("polling converges without Redis"); + node.fail_closed(); + assert!(!node.is_ready(community_id)); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 2, + floors: Vec::new(), + }); + assert_eq!( + node.reconcile_domain(community_id).await, + Err(AuthorizationInvalidationRuntimeError::NotReady) + ); + assert!(!node.is_ready(community_id)); + node.shutdown(); + task.await.expect("runtime task exits"); + } + + #[test] + fn public_constructor_has_no_caller_supplied_principal_slot() { + fn assert_context_only_signature( + _constructor: fn( + Uuid, + &AuthContext, + ) -> Result< + AuthorizationDependencies, + AuthorizationInvalidationRuntimeError, + >, + ) { + } + + assert_context_only_signature(AuthorizationDependencies::from_context); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs index 5bda6bbebb..9b1fab6b90 100644 --- a/crates/buzz-relay/src/authorization_runtime/mod.rs +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -1,12 +1,15 @@ -//! Provider-neutral authorization interfaces available before runtime installation. +//! Provider-neutral runtime authorization seams. -/// Fail-closed ephemeral-authority interfaces installed by the session slice. -pub mod ephemeral; -/// Fail-closed transaction interfaces installed by the finalization slice. +pub(crate) mod ephemeral; +/// Transaction-owned protected mutation execution and idempotency. pub mod executor; -/// Activation and enrollment types consumed by protected transports. +/// Exact-domain provider selection and authorization finalization. pub mod finalization; -/// Fail-closed restore-witness interfaces installed by the invalidation slice. +/// Durable provider-neutral invalidation, reconciliation, and use fences. +pub mod invalidation; +/// Disabled-by-default production runtime construction. +pub mod production; +/// Independent high-water protection against stale PostgreSQL restoration. pub mod restore; /// Protected transport authorization and lease fencing. pub mod transport; diff --git a/crates/buzz-relay/src/authorization_runtime/production.rs b/crates/buzz-relay/src/authorization_runtime/production.rs new file mode 100644 index 0000000000..fe69cd9d21 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/production.rs @@ -0,0 +1,1467 @@ +//! Disabled-by-default production construction for protected authorization. +//! +//! Configuration names exact authorization domains. There is no default +//! domain, provider fallback, request-selected profile, or implicit Enforce. + +use std::{collections::HashMap, env, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use buzz_auth::{ + resolve_current_federated_policy, AccessLeasePolicy, ActiveBindingResolution, + ApplicationLeaseLimit, AuthContextInput, AuthorizationClockSkew, AuthorizationOutcome, + AuthorizationProvider, BindingLeaseBound, BindingSource, CapabilitySet, EnrollmentMode, + FederatedAuthorization, LeaseVersion, ProviderTimeout, ResolvedFederatedPolicy, Scope, + SharedAuthorizationClock, SystemAuthorizationClock, VerificationStatusPolicy, + VerifiedEvidenceAdapter, +}; +use buzz_core::{CommunityId, TenantContext}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + finalization::{ + AuthorizationMode, DomainAuthorizationPolicy, DomainProviderSelector, + RelayAuthorizationFinalizer, RuntimeAuthorizationDisposition, + }, + invalidation::{ + AuthorizationDependencies, AuthorizationInvalidationConfig, + AuthorizationInvalidationRuntime, AuthorizationObserver, + }, + transport::{ + DomainTransportPolicy, LeaseCurrentState, LeaseCurrentStateError, + LeaseCurrentStateObserver, ProtectedAuthorizationResolver, ProtectedOperationRequest, + ProtectedResolution, ProtectedResolutionError, ProtectedTransportRuntime, + }, +}; + +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 MAX_AUDIO_RECONCILIATION_SWEEPS: usize = 8; + +/// Runtime plus its durable invalidation worker. +pub struct InstalledProtectedRuntime { + /// Exact-domain transport runtime. + pub transport: Arc, + /// Durable invalidation runtime initialized before transport installation. + pub invalidation: AuthorizationInvalidationRuntime, + /// Independent high-water witness verified before transport installation. + pub restore: Arc, + /// Whether any exact domain is authoritative Enforce. + pub enforce_enabled: bool, + /// Exact authoritative domains whose crash remnants may be reconciled. + pub enforcing_domains: Vec, + /// Enforce domains whose optional public projection requires reconciliation. + pub projection_domains: Vec, +} + +/// Result of the single production installation boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedRuntimeInstallation { + /// No exact authorization domains were configured; legacy behavior remains. + Disabled, + /// Exact-domain transport, restore, and invalidation runtimes were installed. + Installed, +} + +/// Exact-domain provider registry supplied by the deployment adapter. +/// +/// The OSS runtime never invents a current-admission decision. Enforce +/// construction fails when a configured domain has no injected O2 provider. +#[derive(Default)] +pub struct ProductionProviderRegistry { + providers: HashMap>, +} + +impl ProductionProviderRegistry { + /// Build an exact registry, rejecting duplicate domain mappings. + pub fn new( + providers: impl IntoIterator)>, + ) -> Result { + let mut exact = HashMap::new(); + for (domain, provider) in providers { + if exact.insert(domain, provider).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + Ok(Self { providers: exact }) + } + + fn provider_for( + &self, + domain: CommunityId, + ) -> Result, ProductionRuntimeError> { + self.providers + .get(&domain) + .cloned() + .ok_or(ProductionRuntimeError::ProviderMissing) + } +} + +struct ProductionLeaseObserver { + invalidation: AuthorizationObserver, + current: LeaseCurrentState, +} + +impl LeaseCurrentStateObserver for ProductionLeaseObserver { + fn observe_current(&self) -> Result { + self.invalidation + .recheck() + .map_err(|_| LeaseCurrentStateError::Stale)?; + Ok(self.current.clone()) + } + + fn observe_commit_fence( + &self, + ) -> Result { + self.invalidation + .commit_fence() + .map_err(|_| LeaseCurrentStateError::Unavailable) + } +} + +struct ProductionResolver { + db: buzz_db::Db, + tenants: HashMap, + finalizer: RelayAuthorizationFinalizer, + invalidation: AuthorizationInvalidationRuntime, + clock: SharedAuthorizationClock, +} + +impl ProductionResolver { + fn tenant(&self, domain: CommunityId) -> Result<&TenantContext, ProtectedResolutionError> { + self.tenants + .get(&domain) + .ok_or(ProtectedResolutionError::new("configured_domain_missing")) + } + + async fn current_policy( + &self, + domain: CommunityId, + correlation_id: uuid::Uuid, + ) -> Result { + let now = self + .clock + .now() + .map_err(|_| ProtectedResolutionError::new("authorization_clock"))? + .unix_seconds(); + resolve_current_federated_policy( + &self.db.federated_authority_adapter(), + domain, + correlation_id, + now, + ) + .await + .map_err(|_| ProtectedResolutionError::new("federated_policy_unavailable")) + } + + async fn active_binding( + &self, + domain: CommunityId, + pubkey: nostr::PublicKey, + assertion: Option<&buzz_auth::VerifiedFederatedAssertion>, + ) -> Result { + let binding = self + .db + .get_active_identity_binding_by_pubkey(domain, pubkey.as_bytes()) + .await + .map_err(|_| ProtectedResolutionError::new("binding_unavailable"))? + .ok_or(ProtectedResolutionError::new("active_binding_required"))?; + let source = match binding.binding_provenance { + buzz_db::identity_binding::BindingProvenance::AttestedKey => BindingSource::AttestedKey, + buzz_db::identity_binding::BindingProvenance::Provisioned => BindingSource::Provisioned, + buzz_db::identity_binding::BindingProvenance::Tofu => BindingSource::Tofu, + }; + let expires_at = binding + .expires_at + .as_ref() + .map(|value| u64::try_from(value.timestamp())) + .transpose() + .map_err(|_| ProtectedResolutionError::new("binding_expiry_invalid"))?; + VerifiedEvidenceAdapter::new() + .active_binding_from_store( + domain, + domain, + binding.binding_id, + &binding.issuer, + &binding.uid, + pubkey, + binding.binding_version, + expires_at, + source, + ActiveBindingResolution::Existing, + assertion, + ) + .map_err(|_| ProtectedResolutionError::new("binding_evidence_mismatch")) + } + + async fn require_membership( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + let member = request + .owner_pubkey() + .unwrap_or_else(|| request.actor_pubkey()); + match self + .db + .is_relay_member(request.authorization_domain(), &member.to_hex()) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(ProtectedResolutionError::new("membership_required")), + Err(_) => Err(ProtectedResolutionError::new("membership_unavailable")), + } + } + + fn reseal_assertion( + &self, + assertion: &buzz_auth::VerifiedFederatedAssertion, + ) -> Result { + let now = self + .clock + .now() + .map_err(|_| ProtectedResolutionError::new("authorization_clock"))? + .unix_seconds(); + VerifiedEvidenceAdapter::new() + .federated_assertion_from_validated_claims( + assertion.authorization_domain(), + assertion.authorized_transport(), + assertion.principal().issuer(), + assertion.principal().subject(), + assertion.key_attestation().map(|key| key.pubkey()), + assertion.transport(), + assertion.not_before().map(|bound| bound.unix_seconds()), + assertion.expires_at().unix_seconds(), + now, + ) + .map_err(|_| ProtectedResolutionError::new("assertion_stale")) + } +} + +#[async_trait] +impl ProtectedAuthorizationResolver for ProductionResolver { + async fn observe( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + self.require_membership(request).await?; + let capabilities = CapabilitySet::single(request.capability()); + let federated_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let outcome = if let Some(owner) = request.owner_pubkey() { + let binding = self + .active_binding(request.authorization_domain(), owner, None) + .await?; + self.finalizer + .evaluate_delegated( + tenant, + request.verified_proof(), + &binding, + federated_policy, + capabilities, + request.correlation_id(), + ) + .await + } else { + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let _binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + self.finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + federated_policy, + capabilities, + request.correlation_id(), + ) + .await + } + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + if !matches!(outcome, AuthorizationOutcome::Allow(_)) { + return Err(ProtectedResolutionError::new("provider_denied")); + } + self.invalidation + .recheck_evaluation(fence) + .map_err(|_| ProtectedResolutionError::new("invalidation_race")) + } + + async fn present( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + if request.owner_pubkey().is_some() { + return Err(ProtectedResolutionError::new( + "client_status_requires_direct_binding", + )); + } + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + self.require_membership(request).await?; + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let snapshot = match self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + CapabilitySet::single(request.capability()), + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))? + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + _ => return Err(ProtectedResolutionError::new("provider_denied")), + }; + let binding_bound = BindingLeaseBound::new(&binding, snapshot.effective_until()) + .map_err(|_| ProtectedResolutionError::new("binding_bound_invalid"))?; + let authorization = FederatedAuthorization::Direct { + binding, + assertion: self.reseal_assertion(assertion)?, + }; + let access = VerifiedEvidenceAdapter::new() + .community_access_from_policy( + tenant, + request.authorization_domain(), + Scope::all_known(), + None, + ) + .map_err(|_| ProtectedResolutionError::new("community_access_invalid"))?; + let input = AuthContextInput::new( + tenant.clone(), + request.correlation_id(), + Arc::clone(request.verified_proof()), + access, + ); + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_client_status( + input, + finalization_policy, + authorization, + snapshot, + binding_bound, + ) + .map_err(|_| ProtectedResolutionError::new("status_finalization"))?; + let dependencies = AuthorizationDependencies::from_verification_only( + request + .session_id() + .unwrap_or_else(|| request.correlation_id()), + &disposition, + ) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let current = LeaseCurrentState::from_trusted_runtime( + disposition.binding_version(), + disposition.profile_id().clone(), + disposition.policy_version().clone(), + ); + Ok(super::transport::ProtectedStatusResolution::new( + disposition, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + fence.generation(), + )) + } + + async fn resolve( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + let capabilities = CapabilitySet::single(request.capability()); + + if let Some(assertion) = request.enrollment_assertion() { + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let outcome = self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_enrollment( + tenant, + request.verified_proof(), + assertion, + finalization_policy, + snapshot, + request.correlation_id(), + ) + .map_err(|_| ProtectedResolutionError::new("enrollment_finalization"))?; + let dependencies = AuthorizationDependencies::from_enrollment( + request + .session_id() + .unwrap_or_else(|| request.correlation_id()), + &disposition, + ) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let current = LeaseCurrentState::from_trusted_runtime( + buzz_auth::BindingVersion::INITIAL, + disposition.profile_id().clone(), + disposition.policy_version().clone(), + ); + return Ok(ProtectedResolution::enrollment( + disposition, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + )); + } + + self.require_membership(request).await?; + let adapter = VerifiedEvidenceAdapter::new(); + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let (authorization, snapshot) = if let Some(owner) = request.owner_pubkey() { + let binding = self + .active_binding(request.authorization_domain(), owner, None) + .await?; + let outcome = self + .finalizer + .evaluate_delegated( + tenant, + request.verified_proof(), + &binding, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let admission = snapshot + .verified_owner_admission(&binding) + .map_err(|_| ProtectedResolutionError::new("owner_admission_mismatch"))?; + ( + FederatedAuthorization::Delegated { + owner: binding, + admission, + }, + snapshot, + ) + } else { + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + let outcome = self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let owned_assertion = self.reseal_assertion(assertion)?; + ( + FederatedAuthorization::Direct { + binding, + assertion: owned_assertion, + }, + snapshot, + ) + }; + let binding = match &authorization { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + unreachable!("protected resolver requires identity") + } + }; + let binding_bound = BindingLeaseBound::new(binding, snapshot.effective_until()) + .map_err(|_| ProtectedResolutionError::new("binding_bound_invalid"))?; + let access = adapter + .community_access_from_policy( + tenant, + request.authorization_domain(), + Scope::all_known(), + None, + ) + .map_err(|_| ProtectedResolutionError::new("community_access_invalid"))?; + let input = AuthContextInput::new( + tenant.clone(), + request.correlation_id(), + Arc::clone(request.verified_proof()), + access, + ); + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_allowed( + input, + finalization_policy, + authorization, + snapshot, + binding_bound, + LeaseVersion::INITIAL, + ) + .map_err(|_| ProtectedResolutionError::new("authorization_finalization"))?; + let RuntimeAuthorizationDisposition::Access(context) = disposition else { + return Err(ProtectedResolutionError::new( + "non_authoritative_disposition", + )); + }; + let dependencies = AuthorizationDependencies::from_context( + request + .session_id() + .unwrap_or_else(|| request.correlation_id()), + &context, + ) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let lease = context + .authorization_lease() + .ok_or(ProtectedResolutionError::new("access_lease_missing"))?; + let current = LeaseCurrentState::from_trusted_runtime( + lease.binding_version(), + lease.profile_id().clone(), + lease.policy_version().clone(), + ); + Ok(ProtectedResolution::access( + context, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + )) + } +} + +/// Build the optional exact-domain runtime from provider-neutral environment +/// configuration. An absent or blank domain list installs nothing. +pub async fn build_from_environment( + state: &crate::state::AppState, +) -> Result, ProductionRuntimeError> { + build_from_environment_with_providers(state, ProductionProviderRegistry::default()).await +} + +/// Build and install the disabled-by-default stock runtime. +/// +/// The stock OSS binary has no admission provider and therefore fails closed +/// if a non-Off domain is configured. A deployment composition root with an +/// exact O2 provider calls [`install_from_environment_with_providers`] instead. +pub async fn install_from_environment( + state: &Arc, +) -> Result { + install_from_environment_with_providers(state, ProductionProviderRegistry::default()).await +} + +/// Build and install one exact provider-neutral production runtime. +/// +/// This is the sole production composition seam for externally supplied O2 +/// providers. Restore and invalidation state is initialized before either is +/// made reachable from `AppState`; missing providers and partial installation +/// fail startup rather than falling back to legacy authorization. +pub async fn install_from_environment_with_providers( + state: &Arc, + providers: ProductionProviderRegistry, +) -> Result { + if state.protected_transport().is_some() || state.restore_protection().is_some() { + return Err(ProductionRuntimeError::AlreadyInstalled); + } + let Some(installed) = build_from_environment_with_providers(state, providers).await? else { + return Ok(ProtectedRuntimeInstallation::Disabled); + }; + let InstalledProtectedRuntime { + transport, + invalidation, + restore, + enforce_enabled, + enforcing_domains, + projection_domains, + } = installed; + let audio_restore = Arc::clone(&restore); + state + .install_restore_protection(restore) + .map_err(|_| ProductionRuntimeError::AlreadyInstalled)?; + state + .install_protected_transport(transport) + .map_err(|_| ProductionRuntimeError::AlreadyInstalled)?; + + let invalidation_worker = invalidation.clone(); + let invalidation_failure = invalidation.clone(); + let authorization_hint_subscriber = Arc::clone(&state.pubsub); + let mut workers = tokio::task::JoinSet::new(); + workers.spawn(async move { invalidation_worker.run().await }); + workers.spawn(async move { + authorization_hint_subscriber + .run_authorization_invalidation_subscriber() + .await; + }); + if enforce_enabled { + workers.spawn(run_audio_reconciliation( + state.db.clone(), + audio_restore, + enforcing_domains, + )); + } + workers.spawn( + crate::corporate_identity::run_public_projection_retirement_reconciliation( + Arc::clone(state), + projection_domains, + ), + ); + // Any required worker exit is a runtime-health failure. A detached bare + // worker could otherwise panic while Enforce kept serving from stale + // authority. The supervisor first invalidates every domain, then aborts + // the remaining workers; all protected observations fail closed. + tokio::spawn(async move { + let completion = workers.join_next().await; + invalidation_failure.fail_closed(); + workers.abort_all(); + while workers.join_next().await.is_some() {} + match completion { + Some(Ok(())) => { + tracing::error!( + "required protected authorization worker exited; runtime failed closed" + ) + } + Some(Err(error)) => tracing::error!( + %error, + "required protected authorization worker failed; runtime failed closed" + ), + None => tracing::error!( + "protected authorization worker set was empty; runtime failed closed" + ), + } + }); + Ok(ProtectedRuntimeInstallation::Installed) +} + +/// Build the disabled-by-default runtime with exact provider implementations +/// supplied by the deployment adapter. The stock OSS binary supplies an empty +/// registry, so any configured non-Off domain fails closed instead of using a +/// permissive fallback. +pub async fn build_from_environment_with_providers( + state: &crate::state::AppState, + providers: ProductionProviderRegistry, +) -> Result, ProductionRuntimeError> { + let raw = env::var(DOMAINS_ENV).unwrap_or_default(); + let configured = parse_domains(&raw)?; + let activated = state.db.activated_authorization_domains().await?; + validate_activated_domain_configuration(&configured, activated.iter().copied())?; + if configured.is_empty() { + return Ok(None); + } + validate_provider_coverage(&configured, &providers)?; + if configured.values().any(|mode| mode.evaluates_provider()) + && state.identity_assertion_provenance().is_none() + { + return Err(ProductionRuntimeError::AssertionProvenanceMissing); + } + let protected_domains = protected_domains(&configured); + let enforcing_domains = enforcing_domains(&configured); + let projection_domains = projection_reconciliation_domains(&configured); + if !enforcing_domains.is_empty() && state.corporate_identity.is_none() { + return Err(ProductionRuntimeError::VerifierMissing); + } + let clock: SharedAuthorizationClock = Arc::new(SystemAuthorizationClock); + let restore_bootstraps = parse_restore_bootstraps( + &env::var(RESTORE_BOOTSTRAPS_ENV).unwrap_or_default(), + &protected_domains, + )?; + let profile = env::var(PROFILE_ENV).unwrap_or_else(|_| "current-membership-v1".to_owned()); + let lease_seconds = parse_positive_seconds(LEASE_SECONDS_ENV, 300)?; + let lease_limit = ApplicationLeaseLimit::from_seconds(lease_seconds)?; + let status_limit = ApplicationLeaseLimit::from_seconds(lease_seconds.min(60))?; + let skew = AuthorizationClockSkew::from_seconds(0)?; + let mut policies = Vec::with_capacity(configured.len()); + let mut transports = Vec::with_capacity(configured.len()); + for (domain, mode) in &configured { + transports.push(DomainTransportPolicy::from_server_configuration( + *domain, *mode, + )); + if !mode.evaluates_provider() { + continue; + } + let provider = providers.provider_for(*domain)?; + policies.push(DomainAuthorizationPolicy::from_server_configuration( + *domain, + profile.clone(), + provider, + EnrollmentMode::AttestedKey, + *mode, + ProviderTimeout::new(Duration::from_secs(2))?, + AccessLeasePolicy::new(lease_limit, skew), + VerificationStatusPolicy::new(status_limit, skew), + )?); + } + let hosts = state.db.usage_community_hosts().await?; + let host_map = hosts + .into_iter() + .map(|entry| { + ( + CommunityId::from_uuid(entry.id), + TenantContext::resolved(CommunityId::from_uuid(entry.id), entry.host), + ) + }) + .collect::>(); + for domain in configured.keys() { + if !host_map.contains_key(domain) { + return Err(ProductionRuntimeError::ConfiguredDomainMissing); + } + } + let restore = super::restore::RestoreProtectionRuntime::initialize( + state.db.clone(), + state.git_store.clone(), + restore_bootstraps, + ) + .await?; + activate_protected_domains(&state.db, &restore, protected_domains.iter().copied()).await?; + reconcile_audio_admissions_once(&state.db, &restore, enforcing_domains.iter().copied()).await?; + let invalidation = AuthorizationInvalidationRuntime::new_with_restore( + state.db.clone(), + Arc::clone(&state.pubsub), + AuthorizationInvalidationConfig::default(), + Arc::clone(&restore), + ); + invalidation + .initialize_domains(protected_domains.iter().copied()) + .await?; + crate::corporate_identity::reconcile_public_projection_retirements_startup( + state, + &projection_domains, + ) + .await + .map_err(|_| ProductionRuntimeError::PublicProjection)?; + let finalizer = RelayAuthorizationFinalizer::new( + DomainProviderSelector::new(policies)?, + Arc::clone(&clock), + ); + let resolver: Arc = Arc::new(ProductionResolver { + db: state.db.clone(), + tenants: host_map, + finalizer, + invalidation: invalidation.clone(), + clock: Arc::clone(&clock), + }); + let transport = Arc::new(ProtectedTransportRuntime::new(transports, resolver, clock)?); + Ok(Some(InstalledProtectedRuntime { + transport, + invalidation, + restore, + enforce_enabled: !enforcing_domains.is_empty(), + enforcing_domains, + projection_domains, + })) +} + +async fn activate_protected_domains( + db: &buzz_db::Db, + restore: &Arc, + domains: impl IntoIterator, +) -> Result<(), ProductionRuntimeError> { + for domain in domains { + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-domain-activation-v1"); + digest.update(domain.as_uuid().as_bytes()); + let fingerprint: [u8; 32] = digest.finalize().into(); + let operation_id = super::executor::ProtectedOperationId::derive( + domain, + "runtime.domain.activate.v1", + &fingerprint, + )? + .as_uuid(); + let witness = restore.begin(domain, operation_id, fingerprint).await?; + match db + .activate_authorization_domain(domain, operation_id, fingerprint) + .await + { + Ok(()) => witness.commit().await?, + Err(error) => { + let committed = db + .authorization_operation_receipt_fingerprint(domain, operation_id) + .await?; + if committed == Some(fingerprint) { + witness.commit().await?; + } else { + witness.abort().await?; + return Err(error.into()); + } + } + } + } + Ok(()) +} + +fn validate_provider_coverage( + configured: &HashMap, + providers: &ProductionProviderRegistry, +) -> Result<(), ProductionRuntimeError> { + for (domain, mode) in configured { + if mode.evaluates_provider() { + providers.provider_for(*domain)?; + } + } + Ok(()) +} + +fn validate_activated_domain_configuration( + configured: &HashMap, + activated: impl IntoIterator, +) -> Result<(), ProductionRuntimeError> { + if activated.into_iter().any(|domain| { + !configured + .get(&domain) + .is_some_and(|mode| mode.protects_surfaces()) + }) { + return Err(ProductionRuntimeError::ActivatedDomainDowngrade); + } + Ok(()) +} + +/// Reconcile expired durable audio attempts. Discovery is read-only; an +/// unexpired claimant remains exclusively owned by its healthy replica and +/// every orphan transition is independently witnessed. +pub async fn run_audio_reconciliation( + db: buzz_db::Db, + restore: Arc, + enforcing_domains: Vec, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + if let Err(error) = + reconcile_audio_admissions_once(&db, &restore, enforcing_domains.iter().copied()).await + { + tracing::warn!(%error, "durable audio admission reconciliation failed"); + } + } +} + +async fn reconcile_audio_admissions_once( + db: &buzz_db::Db, + restore: &Arc, + domains: impl IntoIterator, +) -> Result { + let mut reconciled = 0_u64; + let mut first_error: Option = None; + for domain in domains { + let mut domain_failed = false; + for sweep in 0..MAX_AUDIO_RECONCILIATION_SWEEPS { + let mut cursor = None; + loop { + let discovered = buzz_db::audio_admission::reconcilable_audio_admissions_after( + db, domain, cursor, + ) + .await; + let candidates = match discovered { + Ok(candidates) => candidates, + Err(error) => { + tracing::warn!(%error, "durable audio admission discovery failed for one domain"); + first_error.get_or_insert_with(|| error.into()); + domain_failed = true; + break; + } + }; + let Some(last) = candidates.last().map(|candidate| candidate.admission_id) else { + break; + }; + cursor = Some(last); + for candidate in candidates { + match reconcile_audio_admission(db, restore, domain, candidate).await { + Ok(true) => reconciled = reconciled.saturating_add(1), + Ok(false) => {} + Err(error) => { + tracing::warn!( + %error, + "durable audio admission candidate failed without starving later cleanup" + ); + first_error.get_or_insert(error); + domain_failed = true; + } + } + } + } + if domain_failed { + break; + } + let remaining = + buzz_db::audio_admission::reconcilable_audio_admissions(db, domain).await; + match remaining { + Ok(remaining) if remaining.is_empty() => break, + Ok(_) if sweep + 1 < MAX_AUDIO_RECONCILIATION_SWEEPS => continue, + Ok(_) => { + first_error + .get_or_insert(ProductionRuntimeError::AudioReconciliationIncomplete); + break; + } + Err(error) => { + first_error.get_or_insert_with(|| error.into()); + break; + } + } + } + } + match first_error { + Some(error) => Err(error), + None => Ok(reconciled), + } +} + +async fn reconcile_audio_admission( + db: &buzz_db::Db, + restore: &Arc, + domain: CommunityId, + candidate: buzz_db::audio_admission::AudioAdmissionReconciliationCandidate, +) -> Result { + use super::executor::ProtectedOperationId; + + // Reconciliation never proves a graceful disconnect. Even a formerly + // visible orphan is conservatively compensated as aborted after its + // durable ownership deadline and grace period. + let finished = false; + let terminal = b"aborted".as_slice(); + let mut stable = Sha256::new(); + stable.update(b"buzz-audio-admission-reconciliation-v1"); + stable.update(candidate.admission_id.as_bytes()); + stable.update(candidate.claimant_id.as_bytes()); + stable.update(candidate.source_state.as_str().as_bytes()); + stable.update(terminal); + stable.update(candidate.state_version.to_be_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation_id = + ProtectedOperationId::derive(domain, "audio.admission.reconcile.v1", &stable) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?; + let mut request = Sha256::new(); + request.update(b"buzz-audio-admission-reconciliation-request-v1"); + request.update(stable); + let request: [u8; 32] = request.finalize().into(); + let witness = restore + .begin(domain, operation_id.as_uuid(), request) + .await?; + match buzz_db::audio_admission::reconcile_claimed_audio_admission_with_receipt( + db, + domain, + candidate, + finished, + Some("orphaned_attachment"), + operation_id.as_uuid(), + request, + ) + .await + { + Ok(true) => witness.commit().await?, + Ok(false) => { + witness.abort().await?; + return Ok(false); + } + Err(error) => { + witness.abort().await?; + return Err(error.into()); + } + } + Ok(true) +} + +fn parse_positive_seconds(name: &'static str, default: u64) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or(ProductionRuntimeError::InvalidConfiguration), + Err(env::VarError::NotPresent) => Ok(default), + Err(env::VarError::NotUnicode(_)) => Err(ProductionRuntimeError::InvalidConfiguration), + } +} + +fn parse_domains( + raw: &str, +) -> Result, ProductionRuntimeError> { + let mut domains = HashMap::new(); + for item in raw + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + let (id, mode) = item + .split_once(':') + .ok_or(ProductionRuntimeError::InvalidConfiguration)?; + let domain = CommunityId::from_uuid( + uuid::Uuid::parse_str(id).map_err(|_| ProductionRuntimeError::InvalidConfiguration)?, + ); + let mode = match mode.trim().to_ascii_lowercase().as_str() { + "off" => AuthorizationMode::Off, + "shadow" => AuthorizationMode::Shadow, + "verify_only" => AuthorizationMode::VerifyOnly, + "enforce" => AuthorizationMode::Enforce, + "deny_protected" => AuthorizationMode::DenyProtected, + _ => return Err(ProductionRuntimeError::InvalidConfiguration), + }; + if domains.insert(domain, mode).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + Ok(domains) +} + +fn protected_domains(configured: &HashMap) -> Vec { + configured + .iter() + .filter_map(|(domain, mode)| mode.protects_surfaces().then_some(*domain)) + .collect() +} + +fn enforcing_domains(configured: &HashMap) -> Vec { + configured + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() +} + +fn projection_reconciliation_domains( + configured: &HashMap, +) -> Vec { + // Public projection retirement belongs to authoritative Enforce runtime. + // Observational modes must not queue or publish projection changes. + configured + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() +} + +fn parse_restore_bootstraps( + raw: &str, + protected_domains: &[CommunityId], +) -> Result, ProductionRuntimeError> { + let mut anchors = HashMap::new(); + for item in raw + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + let (domain, bootstrap) = item + .split_once('=') + .ok_or(ProductionRuntimeError::InvalidConfiguration)?; + let domain = CommunityId::from_uuid( + uuid::Uuid::parse_str(domain.trim()) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?, + ); + let bootstrap = uuid::Uuid::parse_str(bootstrap.trim()) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?; + if bootstrap.is_nil() || anchors.insert(domain, bootstrap).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + let mut result = Vec::with_capacity(protected_domains.len()); + for domain in protected_domains { + let bootstrap = anchors + .remove(domain) + .ok_or(ProductionRuntimeError::RestoreBootstrapMissing)?; + result.push((*domain, bootstrap)); + } + if !anchors.is_empty() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + Ok(result) +} + +/// Fail-closed production construction error. +#[derive(Debug, Error)] +pub enum ProductionRuntimeError { + /// Configuration was malformed or ambiguous. + #[error("protected authorization configuration is invalid")] + InvalidConfiguration, + /// An exact configured domain has no durable host mapping. + #[error("protected authorization domain is not present")] + ConfiguredDomainMissing, + /// Typed provider or finalization configuration was rejected. + #[error(transparent)] + Provider(#[from] buzz_auth::ProviderContractError), + /// A configured authoritative or observational domain has no exact O2 provider. + #[error("protected authorization provider is not configured for this domain")] + ProviderMissing, + /// Protected production state was already partially or fully installed. + #[error("protected authorization runtime is already installed")] + AlreadyInstalled, + /// Enforce was requested without a complete domain-usable assertion verifier. + #[error("protected authorization assertion verifier is not configured")] + VerifierMissing, + /// A protected domain was configured without deployment-verified ingress + /// provenance for its direct identity assertion. + #[error("protected authorization assertion provenance is not configured")] + AssertionProvenanceMissing, + /// A previously activated domain was omitted or configured non-authoritatively. + #[error("protected authorization domain cannot be downgraded after activation")] + ActivatedDomainDowngrade, + /// An Enforce domain has no exact externally provisioned restore anchor. + #[error("protected authorization restore bootstrap is not configured")] + RestoreBootstrapMissing, + /// Lease bounds were rejected. + #[error(transparent)] + Lease(#[from] buzz_auth::LeasePolicyError), + /// Durable domain construction failed. + #[error(transparent)] + Database(#[from] buzz_db::DbError), + /// Invalidation initialization failed. + #[error(transparent)] + Invalidation(#[from] super::invalidation::AuthorizationInvalidationRuntimeError), + /// Exact-domain policy construction failed. + #[error(transparent)] + DomainPolicy(#[from] super::finalization::DomainPolicyError), + /// Protected transport construction failed. + #[error(transparent)] + Transport(#[from] super::transport::ProtectedTransportError), + /// Independent restore witness initialization failed. + #[error(transparent)] + Restore(#[from] super::restore::RestoreProtectionError), + /// Stable operation construction failed before production activation. + #[error(transparent)] + Execution(#[from] super::executor::AuthorizationExecutionError), + /// Public projection startup reconciliation failed. + #[error("public identity projection reconciliation failed")] + PublicProjection, + /// Startup could not prove that all durable audio remnants were reconciled. + #[error("protected audio reconciliation did not reach a complete fixed point")] + AudioReconciliationIncomplete, +} + +#[cfg(test)] +mod tests { + use buzz_auth::{ + AuthorizationProviderFuture, AuthorizationRequest, ProviderDecision, ProviderUnavailable, + ProviderUnavailableReason, + }; + + use super::*; + + struct SyntheticUnavailableProvider; + + impl AuthorizationProvider for SyntheticUnavailableProvider { + fn profile_id(&self) -> buzz_auth::AuthorizationProfileId { + buzz_auth::AuthorizationProfileId::from_server_configuration( + "profile.synthetic-unavailable.example", + ) + .expect("synthetic profile is valid") + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async { + ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + }) + } + } + + #[test] + fn absent_configuration_is_disabled() { + assert!(parse_domains("").expect("empty is disabled").is_empty()); + } + + #[test] + fn exact_modes_parse_without_a_default() { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let third = uuid::Uuid::new_v4(); + let parsed = parse_domains(&format!( + "{first}:enforce,{second}:verify_only,{third}:deny_protected" + )) + .expect("valid exact domains"); + assert_eq!( + parsed.get(&CommunityId::from_uuid(first)), + Some(&AuthorizationMode::Enforce) + ); + assert_eq!( + parsed.get(&CommunityId::from_uuid(second)), + Some(&AuthorizationMode::VerifyOnly) + ); + assert_eq!( + parsed.get(&CommunityId::from_uuid(third)), + Some(&AuthorizationMode::DenyProtected) + ); + } + + #[test] + fn duplicate_or_unknown_domains_fail_closed() { + let id = uuid::Uuid::new_v4(); + assert!(parse_domains(&format!("{id}:enforce,{id}:shadow")).is_err()); + assert!(parse_domains(&format!("{id}:automatic")).is_err()); + } + + #[test] + fn observational_modes_have_no_durable_runtime_domains() { + let off = uuid::Uuid::new_v4(); + let shadow = uuid::Uuid::new_v4(); + let verify = uuid::Uuid::new_v4(); + let enforce = uuid::Uuid::new_v4(); + let deny = uuid::Uuid::new_v4(); + let parsed = parse_domains(&format!( + "{off}:off,{shadow}:shadow,{verify}:verify_only,{enforce}:enforce,{deny}:deny_protected" + )) + .expect("valid exact modes"); + let protected = protected_domains(&parsed) + .into_iter() + .collect::>(); + assert_eq!(protected.len(), 2); + assert!(protected.contains(&CommunityId::from_uuid(enforce))); + assert!(protected.contains(&CommunityId::from_uuid(deny))); + assert_eq!( + enforcing_domains(&parsed), + vec![CommunityId::from_uuid(enforce)] + ); + let projection_domains = projection_reconciliation_domains(&parsed) + .into_iter() + .collect::>(); + assert_eq!(projection_domains.len(), 1); + assert!(!projection_domains.contains(&CommunityId::from_uuid(off))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(shadow))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(verify))); + assert!(projection_domains.contains(&CommunityId::from_uuid(enforce))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(deny))); + } + + #[test] + fn activated_domain_cannot_be_omitted_or_downgraded() { + let activated = CommunityId::from_uuid(uuid::Uuid::new_v4()); + assert!(matches!( + validate_activated_domain_configuration(&HashMap::new(), [activated]), + Err(ProductionRuntimeError::ActivatedDomainDowngrade) + )); + for mode in [ + AuthorizationMode::Off, + AuthorizationMode::Shadow, + AuthorizationMode::VerifyOnly, + ] { + let configured = HashMap::from([(activated, mode)]); + assert!(matches!( + validate_activated_domain_configuration(&configured, [activated]), + Err(ProductionRuntimeError::ActivatedDomainDowngrade) + )); + } + let configured = HashMap::from([(activated, AuthorizationMode::Enforce)]); + validate_activated_domain_configuration(&configured, [activated]) + .expect("exact Enforce configuration preserves one-way activation"); + let configured = HashMap::from([(activated, AuthorizationMode::DenyProtected)]); + validate_activated_domain_configuration(&configured, [activated]) + .expect("deny-protected preserves the protected inventory after activation"); + } + + #[test] + fn exact_provider_registry_has_no_fallback() { + let configured = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let absent = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let provider: Arc = Arc::new(SyntheticUnavailableProvider); + let registry = ProductionProviderRegistry::new([(configured, provider)]) + .expect("exact provider registry"); + assert!(registry.provider_for(configured).is_ok()); + assert!(matches!( + registry.provider_for(absent), + Err(ProductionRuntimeError::ProviderMissing) + )); + } + + #[test] + fn exact_provider_coverage_makes_enforce_constructible_without_fallback() { + let enforce = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let off = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let deny = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let configured = parse_domains(&format!( + "{enforce}:enforce,{off}:off,{deny}:deny_protected" + )) + .expect("exact production configuration"); + assert!(matches!( + validate_provider_coverage(&configured, &ProductionProviderRegistry::default()), + Err(ProductionRuntimeError::ProviderMissing) + )); + let provider: Arc = Arc::new(SyntheticUnavailableProvider); + let providers = ProductionProviderRegistry::new([(enforce, provider)]) + .expect("exact provider registry"); + validate_provider_coverage(&configured, &providers) + .expect("an exact Enforce provider reaches production construction"); + } + + #[test] + fn stock_binary_uses_the_single_production_installation_boundary() { + let main = include_str!("../main.rs"); + assert!(main.contains("production::install_from_environment(&state)")); + assert!(!main.contains("production::build_from_environment(&state)")); + assert!(!main.contains("migration::prepare_postgres_authority(&state")); + let install = main + .find("production::install_from_environment(&state)") + .expect("single production installation"); + let cutover_verify = main + .find("migration::require_reconciled_authority(&state") + .expect("read-only cutover verification"); + assert!(install < cutover_verify); + } + + #[test] + fn enforce_domain_activation_precedes_snapshot_and_transport_reachability() { + let source = include_str!("production.rs"); + let activation = source + .find("activate_protected_domains(&state.db") + .expect("durable activation is part of construction"); + let snapshot = source + .find(".initialize_domains(protected_domains.iter().copied())") + .expect("invalidation snapshot is initialized"); + let transport = source + .find("ProtectedTransportRuntime::new(transports, resolver, clock)") + .expect("transport is constructed"); + assert!(activation < snapshot); + assert!(snapshot < transport); + } + + #[test] + fn production_supervises_cross_replica_authorization_hints() { + let source = include_str!("production.rs"); + let worker_set = source + .find("let mut workers = tokio::task::JoinSet::new()") + .expect("protected worker supervisor"); + let invalidation_runtime = source + .find("invalidation_worker.run().await") + .expect("durable invalidation runtime worker"); + let redis_subscriber = source + .find("run_authorization_invalidation_subscriber()") + .expect("cross-replica authorization hint subscriber"); + let supervisor = source + .find("let completion = workers.join_next().await") + .expect("fail-closed worker supervisor"); + + assert!(worker_set < invalidation_runtime); + assert!(worker_set < redis_subscriber); + assert!(invalidation_runtime < supervisor); + assert!(redis_subscriber < supervisor); + } + + #[test] + fn production_reconciles_projection_before_reachability_and_supervises_retry_worker() { + let source = include_str!("production.rs"); + let projection_domains = source + .find("let projection_domains = projection_reconciliation_domains(&configured)") + .expect("exact projection domain selection"); + let startup = source + .find("reconcile_public_projection_retirements_startup(") + .expect("startup reconciliation"); + let transport = source + .find("ProtectedTransportRuntime::new(transports, resolver, clock)") + .expect("transport construction"); + let worker_set = source + .find("let mut workers = tokio::task::JoinSet::new()") + .expect("protected worker supervisor"); + let retry_worker = source + .find("run_public_projection_retirement_reconciliation(") + .expect("continuous projection retry worker"); + let supervisor = source + .find("let completion = workers.join_next().await") + .expect("fail-closed worker supervisor"); + + assert!(projection_domains < startup); + assert!(startup < transport); + assert!(worker_set < retry_worker); + assert!(retry_worker < supervisor); + } + + #[test] + fn restore_bootstraps_are_exact_and_non_nil() { + let domain = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let bootstrap = uuid::Uuid::new_v4(); + assert_eq!( + parse_restore_bootstraps(&format!("{domain}={bootstrap}"), &[domain]) + .expect("exact bootstrap"), + vec![(domain, bootstrap)] + ); + assert!(matches!( + parse_restore_bootstraps("", &[domain]), + Err(ProductionRuntimeError::RestoreBootstrapMissing) + )); + assert!( + parse_restore_bootstraps(&format!("{domain}={}", uuid::Uuid::nil()), &[domain]) + .is_err() + ); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/restore.rs b/crates/buzz-relay/src/authorization_runtime/restore.rs index 48fea8cc0b..60fa8e2637 100644 --- a/crates/buzz-relay/src/authorization_runtime/restore.rs +++ b/crates/buzz-relay/src/authorization_runtime/restore.rs @@ -1,51 +1,872 @@ -//! Fail-closed restore-witness interfaces for the lower review unit. +//! Object-store witnessed high-water protection against stale PostgreSQL restore. //! -//! The invalidation slice replaces this module with the independent durable -//! witness. Until then no caller can begin, commit, or abort a protected -//! mutation through this interface. +//! The existing object store is the independent durability domain. A protected +//! mutation writes pending before its PostgreSQL commit and advances the +//! committed vector afterward. Startup refuses any database below a witnessed +//! floor and refuses an ambiguous pending checkpoint. + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use buzz_core::CommunityId; +use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::sync::{Mutex, OwnedMutexGuard}; use uuid::Uuid; -/// Disabled restore witness placeholder. +use crate::api::git::store::{CasOutcome, ETag, GitStore, Precond}; + +const FORMAT_VERSION: u32 = 2; +const BEGIN_RETRY_LIMIT: usize = 16; +const BEGIN_RETRY_DELAY: Duration = Duration::from_millis(10); + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct VersionVector { + bindings: BTreeMap, + git_publications: BTreeMap, + media_publications: BTreeMap, + object_authority: BTreeMap, + invalidation_generation: u64, + authority_epoch: u64, + status_revision: u64, +} + +impl From for VersionVector { + fn from(value: buzz_db::authorization_version::AuthorizationVersionVector) -> Self { + Self { + bindings: value.bindings, + git_publications: value.git_publications, + media_publications: value.media_publications, + object_authority: value.object_authority, + invalidation_generation: value.invalidation_generation, + authority_epoch: value.authority_epoch, + status_revision: value.status_revision, + } + } +} + +impl VersionVector { + fn to_db(&self) -> buzz_db::authorization_version::AuthorizationVersionVector { + buzz_db::authorization_version::AuthorizationVersionVector { + bindings: self.bindings.clone(), + git_publications: self.git_publications.clone(), + media_publications: self.media_publications.clone(), + object_authority: self.object_authority.clone(), + invalidation_generation: self.invalidation_generation, + authority_epoch: self.authority_epoch, + status_revision: self.status_revision, + } + } + + fn dominates(&self, floor: &Self) -> bool { + self.to_db().dominates(&floor.to_db()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum CheckpointState { + Committed, + Pending { + operation_id: Uuid, + request_fingerprint: [u8; 32], + previous: VersionVector, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct Checkpoint { + format_version: u32, + community_id: Uuid, + bootstrap_id: Uuid, + vector: VersionVector, + #[serde(flatten)] + state: CheckpointState, +} + +/// Disabled-by-default restore witness runtime. pub struct RestoreProtectionRuntime { - _private: (), + db: buzz_db::Db, + store: GitStore, + domains: BTreeMap>>, + bootstrap_ids: BTreeMap, } impl RestoreProtectionRuntime { - /// Refuse to begin a protected mutation before the witness is installed. - pub async fn begin( + /// Initialize exact configured domains and verify every external floor. + pub async fn initialize( + db: buzz_db::Db, + store: GitStore, + domains: impl IntoIterator, + ) -> Result, RestoreProtectionError> { + let configured = domains.into_iter().collect::>(); + if configured + .iter() + .any(|(domain, bootstrap)| domain.as_uuid().is_nil() || bootstrap.is_nil()) + { + return Err(RestoreProtectionError::InvalidBootstrap); + } + let runtime = Arc::new(Self { + db, + store, + domains: configured + .keys() + .copied() + .map(|domain| (domain, Arc::new(Mutex::new(())))) + .collect(), + bootstrap_ids: configured, + }); + for domain in runtime.domains.keys().copied().collect::>() { + runtime.verify_or_initialize(domain).await?; + } + Ok(runtime) + } + + /// Explicitly provision the independent witness before enabling Enforce. + /// Normal runtime startup never calls this method and never initializes a + /// missing checkpoint from potentially restored PostgreSQL state. + pub async fn provision_domain( + db: &buzz_db::Db, + store: &GitStore, + domain: CommunityId, + bootstrap_id: Uuid, + ) -> Result<(), RestoreProtectionError> { + if bootstrap_id.is_nil() { + return Err(RestoreProtectionError::InvalidBootstrap); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id, + vector: db.authorization_version_vector(domain).await?.into(), + state: CheckpointState::Committed, + }; + let body = serde_json::to_vec(&checkpoint) + .map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + match store + .put_pointer(&checkpoint_key(domain), &body, Precond::IfNoneMatchStar) + .await? + { + CasOutcome::Won(_) => Ok(()), + CasOutcome::LostRace => Err(RestoreProtectionError::AlreadyProvisioned), + } + } + + fn mutex(&self, domain: CommunityId) -> Result>, RestoreProtectionError> { + self.domains + .get(&domain) + .cloned() + .ok_or(RestoreProtectionError::DomainNotConfigured) + } + + fn bootstrap_id(&self, domain: CommunityId) -> Result { + self.bootstrap_ids + .get(&domain) + .copied() + .ok_or(RestoreProtectionError::DomainNotConfigured) + } + + async fn current(&self, domain: CommunityId) -> Result { + Ok(self.db.authorization_version_vector(domain).await?.into()) + } + + async fn read( + &self, + domain: CommunityId, + ) -> Result, RestoreProtectionError> { + let Some((etag, bytes)) = self.store.get_pointer(&checkpoint_key(domain)).await? else { + return Ok(None); + }; + let checkpoint: Checkpoint = serde_json::from_slice(&bytes) + .map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.format_version != FORMAT_VERSION + || checkpoint.community_id != *domain.as_uuid() + || matches!( + &checkpoint.state, + CheckpointState::Pending { + operation_id, + previous, + .. + } if operation_id.is_nil() || checkpoint.vector != *previous + ) + { + return Err(RestoreProtectionError::InvalidCheckpoint); + } + Ok(Some((etag, checkpoint))) + } + + async fn verify_or_initialize( &self, - _domain: CommunityId, - _operation_id: Uuid, - _request_fingerprint: [u8; 32], + domain: CommunityId, + ) -> Result<(), RestoreProtectionError> { + let _guard = self.mutex(domain)?.lock_owned().await; + for attempt in 0..=BEGIN_RETRY_LIMIT { + match self.read(domain).await? { + None => return Err(RestoreProtectionError::MissingCheckpoint), + Some((etag, checkpoint)) => { + if checkpoint.bootstrap_id != self.bootstrap_id(domain)? { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if let CheckpointState::Pending { + operation_id, + request_fingerprint, + previous, + } = &checkpoint.state + { + let (fingerprint, current) = self + .db + .authorization_receipt_and_version_vector(domain, *operation_id) + .await?; + let current = VersionVector::from(current); + let recovered_vector = match fingerprint { + Some(fingerprint) if fingerprint == *request_fingerprint => { + validate_recovered_vector(previous, ¤t)?; + current + } + // The receipt belongs to another request that reused + // this operation ID. If authority never moved, this + // pending request provably did not commit and an older + // poisoned checkpoint can be cleared safely. + Some(_) if current == *previous => previous.clone(), + _ => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + }; + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: recovered_vector, + state: CheckpointState::Committed, + }; + match put_exact(&self.store, domain, &recovered, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + } + } + let current = self.current(domain).await?; + if !current.dominates(&checkpoint.vector) { + return Err(RestoreProtectionError::StaleRestore); + } + if current == checkpoint.vector { + return Ok(()); + } + return Err(RestoreProtectionError::UnwitnessedAuthorityAdvance); + } + } + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } + + /// Witness intent before a PostgreSQL mutation that may advance protected + /// binding or publication versions. + pub async fn begin( + self: &Arc, + domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], ) -> Result { - Err(RestoreProtectionError::DomainNotConfigured) + if operation_id.is_nil() { + return Err(RestoreProtectionError::InvalidOperation); + } + let lock = self.mutex(domain)?.lock_owned().await; + for attempt in 0..=BEGIN_RETRY_LIMIT { + let (etag, checkpoint) = self + .read(domain) + .await? + .ok_or(RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.bootstrap_id != self.bootstrap_id(domain)? { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if let CheckpointState::Pending { + operation_id: pending_operation, + request_fingerprint: pending_fingerprint, + previous, + } = &checkpoint.state + { + let (fingerprint, committed) = self + .db + .authorization_receipt_and_version_vector(domain, *pending_operation) + .await?; + let committed = VersionVector::from(committed); + match fingerprint { + Some(fingerprint) if fingerprint == *pending_fingerprint => { + validate_recovered_vector(previous, &committed)?; + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: committed, + state: CheckpointState::Committed, + }; + match put_exact_returning(&self.store, domain, &recovered, etag).await { + Ok(_) | Err(RestoreProtectionError::ConcurrentCheckpoint) => continue, + Err(error) => return Err(error), + } + } + Some(_) if committed == *previous => { + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: previous.clone(), + state: CheckpointState::Committed, + }; + match put_exact_returning(&self.store, domain, &recovered, etag).await { + Ok(_) | Err(RestoreProtectionError::ConcurrentCheckpoint) => continue, + Err(error) => return Err(error), + } + } + Some(_) => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + None if attempt < BEGIN_RETRY_LIMIT => { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + None => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + } + } + let (receipt, current) = self + .db + .authorization_receipt_and_version_vector(domain, operation_id) + .await?; + let current = VersionVector::from(current); + if self + .read(domain) + .await? + .is_none_or(|(stable_etag, _)| stable_etag != etag) + { + if attempt < BEGIN_RETRY_LIMIT { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + return Err(RestoreProtectionError::ConcurrentCheckpoint); + } + if !current.dominates(&checkpoint.vector) { + return Err(RestoreProtectionError::StaleRestore); + } + if current != checkpoint.vector { + return Err(RestoreProtectionError::UnwitnessedAuthorityAdvance); + } + match receipt { + Some(fingerprint) if fingerprint != request_fingerprint => { + return Err(RestoreProtectionError::OperationIdentityConflict) + } + Some(_) => { + return Ok(RestoreMutationGuard { + runtime: Arc::clone(self), + domain, + operation_id, + request_fingerprint, + pending_etag: None, + previous: checkpoint.vector, + bootstrap_id: checkpoint.bootstrap_id, + _lock: lock, + }) + } + None => {} + } + let pending = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Pending { + operation_id, + request_fingerprint, + previous: current, + }, + }; + match put_exact_returning(&self.store, domain, &pending, etag).await { + Ok(etag) => { + return Ok(RestoreMutationGuard { + runtime: Arc::clone(self), + domain, + operation_id, + request_fingerprint, + pending_etag: Some(etag), + previous: checkpoint.vector, + bootstrap_id: checkpoint.bootstrap_id, + _lock: lock, + }) + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + } + Err(error) => return Err(error), + } + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } +} + +fn validate_recovered_vector( + previous: &VersionVector, + committed: &VersionVector, +) -> Result<(), RestoreProtectionError> { + if committed.dominates(previous) { + Ok(()) + } else { + Err(RestoreProtectionError::StaleRestore) } } -/// Unconstructible mutation-witness guard retained for call-site typing. +/// Serialized pending witness held until the PostgreSQL commit is durable. pub struct RestoreMutationGuard { - _private: (), + runtime: Arc, + domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + pending_etag: Option, + previous: VersionVector, + bootstrap_id: Uuid, + _lock: OwnedMutexGuard<()>, } impl RestoreMutationGuard { - /// Refuse a commit while the independent witness is absent. + /// Advance the independent committed floor after PostgreSQL commit. pub async fn commit(self) -> Result<(), RestoreProtectionError> { - Err(RestoreProtectionError::DomainNotConfigured) + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + if receipt != Some(self.request_fingerprint) { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let current = VersionVector::from(current); + if !current.dominates(&self.previous) { + return Err(RestoreProtectionError::StaleRestore); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Committed, + }; + let Some(etag) = self.pending_etag.clone() else { + return Ok(()); + }; + match put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await { + Ok(()) => Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) => { + self.converge_committed(¤t).await + } + Err(error) => Err(error), + } } - /// Refuse an abort while the independent witness is absent. + /// Advance an invalidation witness, converging after a stale CAS when a + /// competing replica already witnessed the exact durable generation. + pub async fn commit_invalidation(self, generation: u64) -> Result<(), RestoreProtectionError> { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + if receipt != Some(self.request_fingerprint) { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let current = VersionVector::from(current); + if !current.dominates(&self.previous) || current.invalidation_generation < generation { + return Err(RestoreProtectionError::StaleRestore); + } + if let Some(etag) = self.pending_etag.clone() { + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Committed, + }; + match put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) => {} + Err(error) => return Err(error), + } + } + self.converge_committed(¤t).await + } + + /// Converge a stale object-store CAS only when PostgreSQL still proves this + /// exact operation and the competing checkpoint covers the vector observed + /// after its durable commit. A different or regressed state never becomes + /// committed as a side effect of reconciliation. + async fn converge_committed( + &self, + committed_floor: &VersionVector, + ) -> Result<(), RestoreProtectionError> { + for attempt in 0..=BEGIN_RETRY_LIMIT { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + let current = VersionVector::from(current); + if receipt != Some(self.request_fingerprint) + || !current.dominates(&self.previous) + || !current.dominates(committed_floor) + { + return Err(RestoreProtectionError::StaleRestore); + } + let (etag, checkpoint) = self + .runtime + .read(self.domain) + .await? + .ok_or(RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.bootstrap_id != self.bootstrap_id { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if checkpoint_covers(&checkpoint, committed_floor) { + let Some(effective) = effective_checkpoint_vector(&checkpoint) else { + return Err(RestoreProtectionError::InvalidCheckpoint); + }; + if !current.dominates(effective) { + return Err(RestoreProtectionError::StaleRestore); + } + return Ok(()); + } + if let CheckpointState::Pending { + operation_id, + request_fingerprint, + previous, + } = &checkpoint.state + { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, *operation_id) + .await?; + let current = VersionVector::from(current); + if receipt == Some(*request_fingerprint) && current.dominates(previous) { + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current, + state: CheckpointState::Committed, + }; + if !checkpoint_covers(&recovered, committed_floor) { + return Err(RestoreProtectionError::StaleRestore); + } + match put_exact(&self.runtime.store, self.domain, &recovered, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + } + } + if receipt.is_none() && attempt < BEGIN_RETRY_LIMIT { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + } + return Err(RestoreProtectionError::ConcurrentCheckpoint); + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } + + /// Clear a pending witness only after PostgreSQL proved the transaction + /// rolled back. The previous committed vector is restored under the exact + /// pending ETag; a concurrent writer remains fail-closed. pub async fn abort(self) -> Result<(), RestoreProtectionError> { - Err(RestoreProtectionError::DomainNotConfigured) + if self + .runtime + .db + .authorization_operation_receipt_fingerprint(self.domain, self.operation_id) + .await? + .is_some_and(|fingerprint| fingerprint == self.request_fingerprint) + { + return Err(RestoreProtectionError::CommitAlreadyDurable); + } + let current = self.runtime.current(self.domain).await?; + if current != self.previous { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: self.previous, + state: CheckpointState::Committed, + }; + let Some(etag) = self.pending_etag.clone() else { + return Err(RestoreProtectionError::CommitAlreadyDurable); + }; + put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await + } +} + +fn checkpoint_covers(checkpoint: &Checkpoint, committed_floor: &VersionVector) -> bool { + effective_checkpoint_vector(checkpoint) + .is_some_and(|effective| effective.dominates(committed_floor)) +} + +fn effective_checkpoint_vector(checkpoint: &Checkpoint) -> Option<&VersionVector> { + Some(match &checkpoint.state { + CheckpointState::Committed => &checkpoint.vector, + CheckpointState::Pending { + previous: pending_previous, + .. + } if checkpoint.vector == *pending_previous => pending_previous, + CheckpointState::Pending { .. } => return None, + }) +} + +async fn put_exact( + store: &GitStore, + domain: CommunityId, + checkpoint: &Checkpoint, + etag: ETag, +) -> Result<(), RestoreProtectionError> { + put_exact_returning(store, domain, checkpoint, etag) + .await + .map(|_| ()) +} + +async fn put_exact_returning( + store: &GitStore, + domain: CommunityId, + checkpoint: &Checkpoint, + etag: ETag, +) -> Result { + let body = + serde_json::to_vec(checkpoint).map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + match store + .put_pointer(&checkpoint_key(domain), &body, Precond::IfMatch(etag)) + .await? + { + CasOutcome::Won(etag) => Ok(etag), + CasOutcome::LostRace => Err(RestoreProtectionError::ConcurrentCheckpoint), } } -/// Fail-closed restore-witness error. +fn checkpoint_key(domain: CommunityId) -> String { + format!("_authority/{domain}/authorization-version-v1.json") +} + +/// Fail-closed restore protection error. #[derive(Debug, Error)] pub enum RestoreProtectionError { - /// The exact domain has no installed witness. + /// The exact domain was not configured for protected authorization. #[error("restore protection domain is not configured")] DomainNotConfigured, + /// The immutable bootstrap identity was missing, nil, or mismatched. + #[error("restore protection bootstrap identity is invalid")] + InvalidBootstrap, + /// A protected domain has not been explicitly provisioned. + #[error("restore protection checkpoint is missing")] + MissingCheckpoint, + /// Provisioning raced an existing checkpoint. + #[error("restore protection checkpoint is already provisioned")] + AlreadyProvisioned, + /// A checkpoint was malformed or belonged to another domain. + #[error("restore protection checkpoint is invalid")] + InvalidCheckpoint, + /// The writer database is below an independently witnessed floor. + #[error("stale PostgreSQL restore detected")] + StaleRestore, + /// PostgreSQL authority advanced without a matching pending witness. + #[error("protected authority advanced without an independent witness")] + UnwitnessedAuthorityAdvance, + /// A crash left commit outcome ambiguous; serving is unsafe. + #[error("interrupted protected commit requires reconciliation")] + AmbiguousInterruptedCommit, + /// A caller attempted to clear a pending witness after its exact database + /// operation had already become durable. + #[error("protected commit is already durable; pending witness retained")] + CommitAlreadyDurable, + /// A stable operation ID was reused with different request bytes. + #[error("protected operation identity conflicts with an existing receipt")] + OperationIdentityConflict, + /// Another writer changed the checkpoint unexpectedly. + #[error("restore protection checkpoint changed concurrently")] + ConcurrentCheckpoint, + /// Operation identity was invalid. + #[error("restore protection operation identity is invalid")] + InvalidOperation, + /// Writer database access failed. + #[error(transparent)] + Database(#[from] buzz_db::DbError), + /// Independent object-store access failed. + #[error(transparent)] + Store(#[from] crate::api::git::store::StoreError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vector_rejects_missing_and_backward_versions() { + let mut floor = VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 2, + authority_epoch: 3, + status_revision: 4, + }; + floor.bindings.insert("principal".into(), 3); + let mut current = floor.clone(); + assert!(current.dominates(&floor)); + current.bindings.insert("principal".into(), 2); + assert!(!current.dominates(&floor)); + current.bindings.clear(); + assert!(!current.dominates(&floor)); + current = floor.clone(); + current.invalidation_generation = 1; + assert!(!current.dominates(&floor)); + } + + #[test] + fn every_publication_component_is_monotonic() { + let mut floor = VersionVector { + bindings: BTreeMap::from([("binding-fingerprint".into(), 3)]), + git_publications: BTreeMap::from([("git-fingerprint".into(), 4)]), + media_publications: BTreeMap::from([("media-fingerprint".into(), 5)]), + object_authority: BTreeMap::from([("git".into(), 6), ("media".into(), 7)]), + invalidation_generation: 8, + authority_epoch: 9, + status_revision: 10, + }; + let original = floor.clone(); + for component in [ + "git", + "media", + "object", + "invalidation", + "authority", + "status", + ] { + let mut current = original.clone(); + match component { + "git" => current.git_publications.clear(), + "media" => current.media_publications.clear(), + "object" => { + current.object_authority.insert("git".into(), 5); + } + "invalidation" => current.invalidation_generation = 7, + "authority" => current.authority_epoch = 8, + "status" => current.status_revision = 9, + _ => unreachable!(), + } + assert!(!current.dominates(&original), "{component} regressed"); + } + floor.bindings.insert("new-binding".into(), 1); + assert!(floor.dominates(&original)); + } + + #[test] + fn checkpoint_wire_contains_only_opaque_version_selectors() { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: Uuid::new_v4(), + vector: VersionVector { + bindings: BTreeMap::from([("opaque-binding-fingerprint".into(), 2)]), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 3, + authority_epoch: 4, + status_revision: 5, + }, + state: CheckpointState::Committed, + }; + let wire = serde_json::to_string(&checkpoint).expect("checkpoint JSON"); + assert!(wire.contains("opaque-binding-fingerprint")); + for prohibited in ["issuer", "subject", "display_name", "email", "pubkey"] { + assert!(!wire.contains(prohibited)); + } + } + + #[test] + fn pending_recovery_requires_the_post_receipt_vector() { + let previous = VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 4, + authority_epoch: 5, + status_revision: 6, + }; + let mut stale_pre_receipt = previous.clone(); + stale_pre_receipt.authority_epoch = 4; + let mut committed_post_receipt = previous.clone(); + committed_post_receipt.authority_epoch = 6; + + assert!(matches!( + validate_recovered_vector(&previous, &stale_pre_receipt), + Err(RestoreProtectionError::StaleRestore) + )); + assert!(validate_recovered_vector(&previous, &committed_post_receipt).is_ok()); + } + + #[test] + fn invalidation_convergence_accepts_only_monotonic_committed_or_later_pending_floors() { + let domain = Uuid::new_v4(); + let bootstrap_id = Uuid::new_v4(); + let previous = VersionVector { + invalidation_generation: 4, + authority_epoch: 7, + status_revision: 3, + ..VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 0, + authority_epoch: 0, + status_revision: 0, + } + }; + let mut covered = previous.clone(); + covered.invalidation_generation = 5; + let committed = Checkpoint { + format_version: FORMAT_VERSION, + community_id: domain, + bootstrap_id, + vector: covered.clone(), + state: CheckpointState::Committed, + }; + assert!(checkpoint_covers(&committed, &covered)); + + let mut later_floor = covered.clone(); + later_floor.invalidation_generation = 6; + assert!(!checkpoint_covers(&committed, &later_floor)); + + let later_pending = Checkpoint { + format_version: FORMAT_VERSION, + community_id: domain, + bootstrap_id, + vector: covered.clone(), + state: CheckpointState::Pending { + operation_id: Uuid::new_v4(), + request_fingerprint: [8; 32], + previous: covered.clone(), + }, + }; + assert!(checkpoint_covers(&later_pending, &covered)); + + let mut malformed = later_pending; + malformed.vector.invalidation_generation = 6; + assert!(!checkpoint_covers(&malformed, &covered)); + + let mut regressed = committed; + regressed.vector.authority_epoch = previous.authority_epoch - 1; + assert!(!checkpoint_covers(®ressed, &covered)); + } } diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs index 903f5673cc..6feebf3627 100644 --- a/crates/buzz-relay/src/authorization_runtime/transport.rs +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -33,8 +33,8 @@ pub enum LegacyIdentityLane { /// immutable. This lane can never enroll, reactivate, strengthen, update, /// or retire a binding. ObserveOnly, - /// Enforce: the protected resolver owns admission/finalization and legacy - /// projection must not run. + /// Enforce or DenyProtected: the protected runtime owns the surface and + /// legacy projection must not run. ProtectedEnforce, } @@ -57,7 +57,9 @@ pub const fn legacy_identity_lane_for_mode(mode: Option) -> L Some(AuthorizationMode::Shadow) | Some(AuthorizationMode::VerifyOnly) => { LegacyIdentityLane::ObserveOnly } - Some(AuthorizationMode::Enforce) => LegacyIdentityLane::ProtectedEnforce, + Some(AuthorizationMode::Enforce | AuthorizationMode::DenyProtected) => { + LegacyIdentityLane::ProtectedEnforce + } } } @@ -534,7 +536,7 @@ impl ProtectedTransportRuntime { pub fn enforcing_domains(&self) -> Vec { self.domains .iter() - .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .filter_map(|(domain, mode)| mode.protects_surfaces().then_some(*domain)) .collect() } @@ -557,6 +559,7 @@ impl ProtectedTransportRuntime { let _ = self.resolver.observe(request).await; Ok(ProtectedAuthorization::Legacy) } + AuthorizationMode::DenyProtected => deny_protected_request(request), AuthorizationMode::Enforce => { let resolution = self .resolver @@ -599,6 +602,7 @@ impl ProtectedTransportRuntime { .await .map(Some) .map_err(ProtectedTransportError::Resolution), + Some(AuthorizationMode::DenyProtected) => Err(ProtectedTransportError::DenyProtected), None | Some(AuthorizationMode::Off | AuthorizationMode::Shadow) => Ok(None), } } @@ -615,6 +619,7 @@ impl ProtectedTransportRuntime { AuthorizationMode::Off | AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { Ok(ProtectedEnrollmentAuthorization::Legacy) } + AuthorizationMode::DenyProtected => Err(ProtectedTransportError::DenyProtected), AuthorizationMode::Enforce => { if request.capability() != AuthorizationCapability::InviteClaim || request.enrollment_assertion().is_none() @@ -763,7 +768,17 @@ fn authorize_unwired_for_mode( | Some(AuthorizationMode::Shadow) | Some(AuthorizationMode::VerifyOnly) => Ok(ProtectedAuthorization::Legacy), Some(AuthorizationMode::Enforce) => Err(ProtectedTransportError::MissingVerifiedProof), + Some(AuthorizationMode::DenyProtected) => Err(ProtectedTransportError::DenyProtected), + } +} + +fn deny_protected_request( + request: &ProtectedOperationRequest, +) -> Result { + if let Some(cancellation) = request.cancellation() { + cancellation.cancel(); } + Err(ProtectedTransportError::DenyProtected) } impl fmt::Debug for ProtectedTransportRuntime { @@ -820,12 +835,10 @@ pub struct ProtectedEnrollmentAuthority { } impl ProtectedEnrollmentAuthority { - #[allow(dead_code)] pub(super) const fn disposition(&self) -> &EnrollmentDisposition { &self.disposition } - #[allow(dead_code)] pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { self.observer.as_ref() } @@ -923,7 +936,6 @@ impl ProtectedAuthorization { /// Seal opaque sender authority for one ephemeral event delivered across /// the trusted relay Redis plane. The resulting claim still requires a /// relay signature and writer-database revalidation on the receiving pod. - #[allow(dead_code)] pub(crate) fn seal_ephemeral_delivery( &self, event_id: [u8; 32], @@ -986,12 +998,10 @@ pub struct ProtectedOperationAuthority { } impl ProtectedOperationAuthority { - #[allow(dead_code)] pub(super) fn context(&self) -> &AuthContext { &self.context } - #[allow(dead_code)] pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { self.observer.as_ref() } @@ -1100,6 +1110,9 @@ pub enum ProtectedTransportError { /// An enforcing surface did not retain sealed verifier evidence. #[error("protected authorization requires verified transport evidence")] MissingVerifiedProof, + /// The exact domain is in the explicit fail-safe protected-denial mode. + #[error("protected authorization is unavailable in deny-protected mode")] + DenyProtected, /// Resolver denied or could not evaluate current policy. #[error(transparent)] Resolution(#[from] ProtectedResolutionError), diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index a04d6cb98c..f9e3081605 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; +use async_trait::async_trait; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures_util::{Sink, SinkExt, StreamExt}; use tokio::sync::{mpsc, Mutex, RwLock}; @@ -23,20 +24,123 @@ use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::{run_registered_community_connection, AppState}; use buzz_pubsub::EventTopic; -/// Fail-closed release check evaluated at the response boundary. +/// Fail-closed release check evaluated at the socket acceptance boundary. pub(crate) trait OutboundReleaseFence: Send + Sync { - /// Return true only while the retained authority is still current. fn release(&self) -> bool; } -impl OutboundReleaseFence for crate::authorization_runtime::transport::ProtectedAuthorization { - fn release(&self) -> bool { - self.release_fetched(()).is_ok() +/// Potentially asynchronous release fence retained until socket drain. +#[async_trait] +pub(crate) trait QueuedOutboundReleaseFence: Send + Sync { + async fn release(&self) -> bool; +} + +struct SyncQueuedReleaseFence { + authority: Arc, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for SyncQueuedReleaseFence { + async fn release(&self) -> bool { + self.authority.release() + } +} + +pub(crate) fn queued_local_authority( + authority: Arc, +) -> Arc { + Arc::new(SyncQueuedReleaseFence { authority }) +} + +#[async_trait] +trait ChannelReadAuthoritySource: Send + Sync { + async fn channel_set_read_authorized( + &self, + community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + actor: &[u8], + ) -> bool; +} + +#[async_trait] +impl ChannelReadAuthoritySource for buzz_db::Db { + async fn channel_set_read_authorized( + &self, + community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + actor: &[u8], + ) -> bool { + buzz_db::Db::channel_set_read_authorized(self, community_id, channel_ids, actor) + .await + .unwrap_or(false) + } +} + +struct ChannelReadReleaseFence { + source: Arc, + community_id: buzz_core::tenant::CommunityId, + channel_ids: Vec, + actor: Vec, + protected: Option>, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for ChannelReadReleaseFence { + async fn release(&self) -> bool { + if self + .protected + .as_ref() + .is_some_and(|authority| authority.revalidate().is_err()) + { + return false; + } + if !self + .source + .channel_set_read_authorized(self.community_id, &self.channel_ids, &self.actor) + .await + { + return false; + } + self.protected + .as_ref() + .is_none_or(|authority| authority.revalidate().is_ok()) } } -/// Revalidate aggregate channel and protected authority immediately before an -/// HTTP response is released. +/// Retain uncached channel access, plus optional protected identity authority, +/// until the socket writer accepts the queued frame. +pub(crate) fn queued_channel_read_authority( + db: buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + channel_id: Uuid, + actor: Vec, + protected: Option>, +) -> Arc { + queued_channel_set_read_authority(db, community_id, vec![channel_id], actor, protected) +} + +/// Retain uncached access to every channel that can contribute to one +/// aggregate response until the response is released. +pub(crate) fn queued_channel_set_read_authority( + db: buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + mut channel_ids: Vec, + actor: Vec, + protected: Option>, +) -> Arc { + channel_ids.sort_unstable(); + channel_ids.dedup(); + Arc::new(ChannelReadReleaseFence { + source: Arc::new(db), + community_id, + channel_ids, + actor, + protected, + }) +} + +/// Evaluate the aggregate read fence synchronously with an HTTP response +/// release. WebSocket callers retain the same fence in their outbound queue. pub(crate) async fn release_channel_set_read_authority( db: buzz_db::Db, community_id: buzz_core::tenant::CommunityId, @@ -44,22 +148,103 @@ pub(crate) async fn release_channel_set_read_authority( actor: Vec, protected: Option>, ) -> bool { - if protected - .as_ref() - .is_some_and(|authority| authority.revalidate().is_err()) - { - return false; - } - if !db - .channel_set_read_authorized(community_id, &channel_ids, &actor) + queued_channel_set_read_authority(db, community_id, channel_ids, actor, protected) + .release() .await - .unwrap_or(false) - { - return false; +} + +impl OutboundReleaseFence for crate::authorization_runtime::transport::ProtectedAuthorization { + fn release(&self) -> bool { + self.release_fetched(()).is_ok() + } +} + +struct CombinedReleaseFence { + sender: Arc, + recipient: Arc, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for CombinedReleaseFence { + async fn release(&self) -> bool { + self.sender.release().await + && self.recipient.release().await + && self.sender.release().await + && self.recipient.release().await + } +} + +/// One queued data frame with optional authority retained until socket drain. +pub struct OutboundData { + pub(crate) message: WsMessage, + authority: Option>, +} + +impl OutboundData { + pub(crate) fn plain(message: WsMessage) -> Self { + Self { + message, + authority: None, + } + } + + pub(crate) fn protected( + message: WsMessage, + authority: Arc, + ) -> Self { + Self { + message, + authority: Some(queued_local_authority(authority)), + } + } + + pub(crate) fn protected_pair( + message: WsMessage, + sender: Arc, + recipient: Arc, + ) -> Self { + Self { + message, + authority: Some(Arc::new(CombinedReleaseFence { + sender: queued_local_authority(sender), + recipient: queued_local_authority(recipient), + })), + } + } + + pub(crate) fn guarded( + message: WsMessage, + authority: Arc, + ) -> Self { + Self { + message, + authority: Some(authority), + } + } + + pub(crate) fn guarded_pair( + message: WsMessage, + sender: Arc, + recipient: Arc, + ) -> Self { + Self { + message, + authority: Some(Arc::new(CombinedReleaseFence { sender, recipient })), + } + } + + #[cfg(test)] + fn guarded_for_test(message: WsMessage, authority: Arc) -> Self { + Self::guarded(message, Arc::new(SyncQueuedReleaseFence { authority })) + } + + async fn release(self) -> Option { + match self.authority { + Some(authority) if authority.release().await => Some(self.message), + Some(_) => None, + None => Some(self.message), + } } - protected - .as_ref() - .is_none_or(|authority| authority.revalidate().is_ok()) } /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. @@ -98,14 +283,14 @@ pub struct ConnectionState { pub tenant: TenantContext, /// Remote socket address of the client. pub remote_addr: SocketAddr, - /// Optional corporate identity JWT captured from the WebSocket upgrade request. - pub corporate_identity_jwt: Option, + /// Optional direct identity assertion captured with verified provenance. + pub corporate_identity_assertion: Option, /// Current NIP-42 authentication state. pub auth_state: RwLock, /// Active subscriptions keyed by subscription ID. pub subscriptions: ConnectionSubscriptions, /// Sender for outbound data messages (EVENT, NOTICE, OK, etc.). - pub send_tx: mpsc::Sender, + pub send_tx: mpsc::Sender, /// Sender for outbound control frames (Pong, Close). /// Separate channel with priority drain — if this channel fills too, /// the connection is closed (writer is completely stalled). @@ -127,7 +312,43 @@ impl ConnectionState { /// `grace_limit` occurrences log a warning; sustained backpressure /// cancels the connection to prevent unbounded memory growth. pub fn send(&self, msg: String) -> bool { - match self.send_tx.try_send(WsMessage::Text(msg.into())) { + self.send_data(OutboundData::plain(WsMessage::Text(msg.into()))) + } + + /// Queue a terminal text frame on the priority control channel. + /// + /// Callers may cancel immediately after this returns: the send loop drains + /// control frames before emitting the WebSocket close frame. + pub(crate) fn send_terminal(&self, msg: String) -> bool { + self.ctrl_tx.try_send(WsMessage::Text(msg.into())).is_ok() + } + + /// Queue protected output while retaining its guard through socket drain. + pub fn send_protected( + &self, + msg: String, + authority: Arc, + ) -> bool { + self.send_data(OutboundData::protected( + WsMessage::Text(msg.into()), + authority, + )) + } + + /// Queue output behind an arbitrary asynchronous release fence. + pub(crate) fn send_guarded( + &self, + msg: String, + authority: Arc, + ) -> bool { + self.send_data(OutboundData::guarded( + WsMessage::Text(msg.into()), + authority, + )) + } + + fn send_data(&self, msg: OutboundData) -> bool { + match self.send_tx.try_send(msg) { Ok(_) => { // Successful send resets the grace counter. self.backpressure_count.store(0, Ordering::Relaxed); @@ -161,7 +382,7 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -183,7 +404,7 @@ pub async fn handle_connection( tenant, conn_id, cancel, - corporate_identity_jwt, + corporate_identity_assertion, ) }, ) @@ -197,7 +418,7 @@ async fn handle_active_connection( tenant: TenantContext, conn_id: Uuid, cancel: CancellationToken, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -209,7 +430,7 @@ async fn handle_active_connection( let challenge = generate_challenge(); - let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); + let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); // Control channel for Pong/Close — small capacity, guaranteed delivery // even when the data buffer is full. let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); @@ -221,7 +442,7 @@ async fn handle_active_connection( conn_id, tenant, remote_addr: addr, - corporate_identity_jwt, + corporate_identity_assertion, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), @@ -236,13 +457,13 @@ async fn handle_active_connection( info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); metrics::counter!( "buzz_ws_connections_total", - "community" => conn.tenant.host().to_owned() + "community" => crate::metrics::community_label(conn.tenant.community()) ) .increment(1); let challenge_msg = RelayMessage::auth_challenge(&challenge); if tx - .send(WsMessage::Text(challenge_msg.into())) + .send(OutboundData::plain(WsMessage::Text(challenge_msg.into()))) .await .is_err() { @@ -349,7 +570,7 @@ async fn handle_active_connection( /// treat a full control channel as terminal (Bug 7 fix). async fn send_loop( ws_send: futures_util::stream::SplitSink, - data_rx: mpsc::Receiver, + data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, cancel: CancellationToken, ) { @@ -358,7 +579,7 @@ async fn send_loop( async fn send_loop_inner( mut ws_send: S, - mut data_rx: mpsc::Receiver, + mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, cancel: CancellationToken, ) where @@ -396,16 +617,30 @@ async fn send_loop_inner( break; } } - Some(msg) = data_rx.recv() => { + Some(queued) = data_rx.recv() => { let mut batched = 1usize; - if ws_send.feed(msg).await.is_err() { + if !sink_ready_before_cancellation(&mut ws_send, &cancel).await { + break; + } + let Some(msg) = queued.release().await else { + cancel.cancel(); + break; + }; + if std::pin::Pin::new(&mut ws_send).start_send(msg).is_err() { break; } while batched < MAX_WS_SEND_BATCH { match data_rx.try_recv() { Ok(next) => { - if ws_send.feed(next).await.is_err() { + if !sink_ready_before_cancellation(&mut ws_send, &cancel).await { + return; + } + let Some(next) = next.release().await else { + cancel.cancel(); + return; + }; + if std::pin::Pin::new(&mut ws_send).start_send(next).is_err() { return; } batched += 1; @@ -424,6 +659,19 @@ async fn send_loop_inner( } } +async fn sink_ready_before_cancellation(ws_send: &mut S, cancel: &CancellationToken) -> bool +where + S: Sink + Unpin, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => false, + result = std::future::poll_fn(|cx| std::pin::Pin::new(&mut *ws_send).poll_ready(cx)) => { + result.is_ok() + } + } +} + /// 3 missed pongs → disconnect. /// /// Sends Ping through the control channel so it isn't blocked by a full @@ -742,6 +990,7 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::{Arc, Mutex}; #[derive(Debug, Default)] @@ -816,6 +1065,106 @@ mod tests { } } + struct ScriptedFence(AtomicBool); + + impl OutboundReleaseFence for ScriptedFence { + fn release(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + struct CountingQueuedFence(AtomicUsize); + + #[async_trait] + impl QueuedOutboundReleaseFence for CountingQueuedFence { + async fn release(&self) -> bool { + self.0.fetch_add(1, Ordering::SeqCst); + true + } + } + + #[tokio::test] + async fn combined_release_rechecks_both_sides_after_async_boundaries() { + let sender = Arc::new(CountingQueuedFence(AtomicUsize::new(0))); + let recipient = Arc::new(CountingQueuedFence(AtomicUsize::new(0))); + let fence = CombinedReleaseFence { + sender: sender.clone(), + recipient: recipient.clone(), + }; + + assert!(fence.release().await); + assert_eq!(sender.0.load(Ordering::SeqCst), 2); + assert_eq!(recipient.0.load(Ordering::SeqCst), 2); + } + + struct ScriptedChannelAuthority { + allowed: AtomicBool, + checked: Mutex>, + } + + #[async_trait] + impl ChannelReadAuthoritySource for ScriptedChannelAuthority { + async fn channel_set_read_authorized( + &self, + _community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + _actor: &[u8], + ) -> bool { + self.checked + .lock() + .expect("scripted channel checks poisoned") + .extend_from_slice(channel_ids); + self.allowed.load(Ordering::SeqCst) + } + } + + struct ReadinessBarrierSink { + ready: Arc, + polled: Arc, + waker: Arc>>, + state: Arc>, + } + + impl Sink for ReadinessBarrierSink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.ready.load(Ordering::SeqCst) { + std::task::Poll::Ready(Ok(())) + } else { + *self.waker.lock().expect("barrier waker poisoned") = Some(cx.waker().clone()); + self.polled.notify_one(); + std::task::Poll::Pending + } + } + + fn start_send(self: std::pin::Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.state + .lock() + .expect("barrier sink poisoned") + .messages + .push(item); + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.poll_flush(cx) + } + } + fn text_payloads(messages: &[WsMessage]) -> Vec { messages .iter() @@ -845,7 +1194,9 @@ mod tests { let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); for i in 0..5 { data_tx - .send(WsMessage::Text(format!("data-{i}").into())) + .send(OutboundData::plain(WsMessage::Text( + format!("data-{i}").into(), + ))) .await .expect("queue data frame"); } @@ -861,12 +1212,208 @@ mod tests { ); } + #[tokio::test] + async fn protected_frame_revalidates_after_sink_readiness() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let fence = Arc::new(ScriptedFence(AtomicBool::new(true))); + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::guarded_for_test( + WsMessage::Text("protected".into()), + fence.clone(), + )) + .await + .expect("queue protected frame"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + fence.0.store(false, Ordering::SeqCst); + ready.store(true, Ordering::SeqCst); + waker + .lock() + .expect("barrier waker poisoned") + .take() + .expect("poll_ready registered a waker") + .wake(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + assert!( + state + .lock() + .expect("barrier sink poisoned") + .messages + .is_empty(), + "authority loss while readiness is pending must prevent start_send" + ); + } + + #[tokio::test] + async fn count_release_rechecks_every_contributing_channel() { + let first = Uuid::new_v4(); + let second = Uuid::new_v4(); + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let fence = ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![first, second], + actor: vec![7; 32], + protected: None, + }; + + assert!(fence.release().await); + assert_eq!( + *source + .checked + .lock() + .expect("scripted channel checks poisoned"), + vec![first, second] + ); + } + + #[tokio::test] + async fn net_http_004_count_release_denies_authority_loss_after_fetch() { + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let fence = ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![Uuid::new_v4()], + actor: vec![9; 32], + protected: None, + }; + + // The query has completed. A membership removal or an open-to-private + // transition now makes the authoritative DB check return false. + source.allowed.store(false, Ordering::SeqCst); + assert!(!fence.release().await); + } + + /// NET-WS-009: a COUNT queued while access is valid must not become + /// visible if membership or channel visibility changes while the socket + /// is waiting for sink readiness. + #[tokio::test] + async fn net_ws_009_count_release_denies_authority_loss_before_socket_acceptance() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let release = Arc::new(ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![Uuid::new_v4()], + actor: vec![10; 32], + protected: None, + }); + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::guarded( + WsMessage::Text(RelayMessage::count("count-race", 1).into()), + release, + )) + .await + .expect("queue protected COUNT"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + source.allowed.store(false, Ordering::SeqCst); + ready.store(true, Ordering::SeqCst); + waker + .lock() + .expect("barrier waker poisoned") + .take() + .expect("poll_ready registered a waker") + .wake(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + assert!( + state + .lock() + .expect("barrier sink poisoned") + .messages + .is_empty(), + "COUNT must not reach start_send after its channel authority is lost" + ); + } + + /// O4-EXP-SESSION-001: session expiry wins over a COUNT that has been + /// computed and queued but has not yet crossed the socket boundary. + #[tokio::test(start_paused = true)] + async fn o4_exp_session_001_count_is_suppressed_when_deadline_precedes_emission() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::plain(WsMessage::Text( + RelayMessage::count("expiry-race", 1).into(), + ))) + .await + .expect("queue COUNT before expiry"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + tokio::time::advance(Duration::from_millis(500)).await; + cancel.cancel(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + let messages = &state.lock().expect("barrier sink poisoned").messages; + assert!( + messages + .iter() + .all(|message| !matches!(message, WsMessage::Text(_))), + "expired session must not emit its queued COUNT" + ); + } + #[tokio::test] async fn send_loop_batch_one_preserves_single_frame_flush_behavior() { let (data_tx, data_rx) = mpsc::channel(1); let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); data_tx - .send(WsMessage::Text("single".into())) + .send(OutboundData::plain(WsMessage::Text("single".into()))) .await .expect("queue data frame"); @@ -883,11 +1430,11 @@ mod tests { let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH); let (ctrl_tx, ctrl_rx) = mpsc::channel(1); data_tx - .send(WsMessage::Text("data-0".into())) + .send(OutboundData::plain(WsMessage::Text("data-0".into()))) .await .expect("queue data frame"); data_tx - .send(WsMessage::Text("data-1".into())) + .send(OutboundData::plain(WsMessage::Text("data-1".into()))) .await .expect("queue data frame"); ctrl_tx @@ -944,4 +1491,35 @@ mod tests { "Close is sent only after the reason frame is flushed" ); } + + #[tokio::test] + async fn protected_count_denial_is_visible_before_terminal_close() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (ctrl_tx, ctrl_rx) = mpsc::channel(1); + ctrl_tx + .send(WsMessage::Text( + RelayMessage::closed( + "deny-count", + "auth-required: protected authorization denied", + ) + .into(), + )) + .await + .expect("queue protected COUNT denial"); + + let cancel = CancellationToken::new(); + cancel.cancel(); + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.len(), 2); + assert!(matches!( + &state.messages[0], + WsMessage::Text(text) + if text.as_str().contains("deny-count") + && text.as_str().contains("protected authorization denied") + )); + assert!(matches!(state.messages[1], WsMessage::Close(_))); + } } diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 3584467fc9..6fe47888e6 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -44,9 +44,7 @@ const JWKS_MAX_RESPONSE_BYTES: usize = 1024 * 1024; const JWT_CLOCK_SKEW_LEEWAY_SECS: u64 = 60; const IDENTITY_ASSERTION_MAX_TTL_SECS: u64 = 60 * 60; const IDENTITY_SESSION_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); -#[allow(dead_code)] const PUBLIC_PROJECTION_RECONCILIATION_INTERVAL: Duration = Duration::from_secs(1); -#[allow(dead_code)] const PUBLIC_PROJECTION_STARTUP_LIMIT: usize = 4096; #[derive(Debug, Clone)] @@ -873,15 +871,6 @@ impl IdentityAssertionInput { pub fn jwt(&self) -> &str { &self.jwt } - - /// Preserve the inherited JWT-only WebSocket lane before the production - /// route slice installs deployment-verified provenance. - pub(crate) fn legacy_jwt(jwt: String) -> Self { - Self { - jwt, - assertion_transport: None, - } - } } impl fmt::Debug for IdentityAssertionInput { @@ -1300,7 +1289,6 @@ fn identity_assertion_matches( } } -#[allow(dead_code)] fn identity_assertion_has_base_shape( event: &Event, relay_author: PublicKey, @@ -1409,7 +1397,6 @@ fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64 } /// Internal O4 reconciliation failure. This carries no identity or token data. -#[allow(dead_code)] #[derive(Debug, Error)] pub(crate) enum PublicProjectionReconciliationError { /// Durable projection state was unavailable or inconsistent. @@ -1435,7 +1422,6 @@ pub(crate) enum PublicProjectionReconciliationError { Incomplete, } -#[allow(dead_code)] async fn reconcile_one_public_projection( state: &AppState, domains: &[CommunityId], @@ -1580,7 +1566,6 @@ async fn reconcile_one_public_projection( } /// Drain every materialized retirement before protected routes become reachable. -#[allow(dead_code)] pub(crate) async fn reconcile_public_projection_retirements_startup( state: &AppState, domains: &[CommunityId], @@ -1603,7 +1588,6 @@ pub(crate) async fn reconcile_public_projection_retirements_startup( } /// Continuously discover committed lifecycle rows and retry withdrawal/delivery. -#[allow(dead_code)] pub(crate) async fn run_public_projection_retirement_reconciliation( state: Arc, domains: Vec, diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index af9b85b762..dd9815d298 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -91,6 +91,28 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + if state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(conn.tenant.community())) + == Some( + crate::authorization_runtime::finalization::AuthorizationMode::DenyProtected, + ) + { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "deny_protected" + ) + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected authorization unavailable", + )); + conn.cancel.cancel(); + return; + } + // Community ban gate (NIP-42 seam). Runs immediately after auth // verification succeeds and before the allowlist and relay-membership // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the @@ -183,14 +205,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } - let identity_assertion = conn.corporate_identity_jwt.as_ref().map(|jwt| { - crate::corporate_identity::IdentityAssertionInput::legacy_jwt(jwt.clone()) - }); let identity_proof = match crate::corporate_identity::verify_corporate_identity( &state, conn.tenant.community(), pubkey, - identity_assertion.as_ref(), + conn.corporate_identity_assertion.as_ref(), auth_tag_json.as_deref(), ) .await diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807..0fcbca473a 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -38,30 +38,42 @@ pub async fn handle_command( state: &Arc, event: Event, auth: IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, ) -> Result { // Ensure the authenticated user exists in the users table (foreign key requirement). // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); - match state - .db - .ensure_user(tenant.community(), &pubkey_bytes) - .await - { - Ok(true) => { - metrics::counter!( - "buzz_users_created_total", - "community" => tenant.host().to_owned() - ) - .increment(1); - } - Ok(false) => {} - Err(e) => { - tracing::warn!("command_executor: ensure_user failed: {e}"); + if !protected.is_enforcing() { + match state + .db + .ensure_user(tenant.community(), &pubkey_bytes) + .await + { + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => crate::metrics::community_label(tenant.community()) + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("command_executor: ensure_user failed: {e}"); + } } } let kind = event.kind.as_u16() as u32; match kind { + KIND_DM_OPEN if protected.is_enforcing() => { + handle_dm_open_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_ADD_MEMBER if protected.is_enforcing() => { + handle_dm_add_member_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_HIDE if protected.is_enforcing() => { + handle_dm_hide_enforced(tenant, state, &event, &auth, protected).await + } KIND_DM_OPEN => handle_dm_open(tenant, state, &event, &auth).await, KIND_DM_ADD_MEMBER => handle_dm_add_member(tenant, state, &event, &auth).await, KIND_DM_HIDE => handle_dm_hide(tenant, state, &event, &auth).await, @@ -307,6 +319,237 @@ fn compute_definition_hash(json_str: &str) -> Vec { Sha256::digest(json_str.as_bytes()).to_vec() } +async fn begin_protected_command( + state: &AppState, + tenant: &TenantContext, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.command.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "event.command.v1", request.finalize().into()) + .map_err(|_| IngestError::AuthFailed("restricted: protected authorization denied".into()))? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}"))) +} + +fn replayed_command_result(event: &Event, payload: Vec) -> Result { + let message = String::from_utf8(payload) + .map_err(|_| IngestError::Internal("error: protected command receipt is invalid".into()))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) +} + +async fn persist_command_event_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + event: &Event, +) -> Result<(), IngestError> { + buzz_db::event::insert_event_with_thread_metadata_tx( + transaction, + tenant.community(), + event, + extract_channel_id(event), + None, + ) + .await + .map(|_| ()) + .map_err(|error| IngestError::Internal(format!("error: persist command: {error}"))) +} + +async fn handle_dm_open_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let tags = extract_p_tags(event); + if tags.is_empty() || tags.len() > 8 { + return Err(IngestError::Rejected( + "invalid: DM requires 1-8 other participants".into(), + )); + } + let mut participants = vec![actor.clone()]; + for tag in tags { + let pubkey = decode_pubkey(&tag)?; + if !participants.contains(&pubkey) { + participants.push(pubkey); + } + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in &participants { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let refs = participants.iter().map(Vec::as_slice).collect::>(); + let (channel, created) = + buzz_db::dm::open_dm_tx(operation.transaction(), tenant.community(), &refs, &actor) + .await + .map_err(|error| IngestError::Internal(format!("error: open DM: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({ + "channel_id": channel.id.to_string(), + "created": created, + }) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in &participants { + state.invalidate_membership(tenant, channel.id, participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_add_member_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + let additions = extract_p_tags(event) + .into_iter() + .map(|value| decode_pubkey(&value)) + .collect::, _>>()?; + if additions.is_empty() { + return Err(IngestError::Rejected( + "invalid: at least one participant is required".into(), + )); + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in additions.iter().chain(std::iter::once(&actor)) { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let (channel, _created, participants) = buzz_db::dm::expand_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &additions, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({"channel_id": channel.id.to_string()}) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in participants { + state.invalidate_membership(tenant, channel.id, &participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_hide_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + persist_command_event_tx(operation.transaction(), tenant, event).await?; + buzz_db::dm::hide_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = "{}".to_string(); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + async fn handle_dm_open( tenant: &TenantContext, state: &Arc, @@ -374,7 +617,7 @@ async fn handle_dm_open( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); @@ -535,7 +778,7 @@ async fn handle_dm_add_member( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 3eeab5e807..7b4cce662e 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -50,6 +50,40 @@ pub async fn handle_count( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn.conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state + .conn_manager + .federated_assertion_for_conn(conn.conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_count", + conn.conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(error = %error, "protected COUNT authorization denied"); + conn.send_terminal(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + // P-gated kinds (gift wraps, member notifications, observer frames) require // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. let authed_pubkey_hex = hex::encode(&pubkey_bytes); @@ -83,7 +117,10 @@ pub async fn handle_count( Ok(ids) => ids, Err(e) => { warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } }; @@ -98,6 +135,7 @@ pub async fn handle_count( // For each filter, count matching events with channel access enforcement. let mut total: u64 = 0; + let mut release_channels = std::collections::BTreeSet::new(); for filter in &filters { // Determine if this filter can match author-only kinds — if so, the // fast-path count_events() cannot be used because it doesn't do @@ -134,7 +172,10 @@ pub async fn handle_count( Ok(member) => Some(member), Err(e) => { warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -149,6 +190,7 @@ pub async fn handle_count( ) { continue; // Skip filters targeting inaccessible channels. } + release_channels.insert(ch_id); // Channel is accessible — count with pushability check. let mut query = super::req::build_event_query_from_filter( filter, @@ -176,7 +218,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -192,10 +237,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -210,7 +258,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -222,6 +273,7 @@ pub async fn handle_count( // If the filter has generic tags beyond what SQL can push down // (#h, #p single, #d single, #e), we must fall back to // query + post-filter to avoid overcounting. + release_channels.extend(accessible_channels.iter().copied()); let mut query = super::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -250,7 +302,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -265,10 +320,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -283,12 +341,24 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } } } } - conn.send(RelayMessage::count(&sub_id, total)); + let release = crate::connection::queued_channel_set_read_authority( + state.db.clone(), + conn.tenant.community(), + release_channels.into_iter().collect(), + pubkey_bytes, + Some(protected), + ); + if !conn.send_guarded(RelayMessage::count(&sub_id, total), release) { + conn.cancel.cancel(); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index e014b8fc37..873f0b21ad 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -31,6 +31,35 @@ fn reject(reason: &'static str) { reject_with_transport("ws", reason); } +fn seal_ephemeral_authority( + state: &AppState, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + event: &Event, +) -> Result, crate::authorization_runtime::ephemeral::EphemeralAuthorityError> { + authority + .is_enforcing() + .then(|| crate::authorization_runtime::ephemeral::seal(state, authority, event)) + .transpose() +} + +async fn publish_ephemeral_event( + state: &AppState, + tenant: &TenantContext, + topic: EventTopic, + event: &Event, + authority: Option<&str>, +) -> Result { + match authority { + Some(authority) => { + state + .pubsub + .publish_event_with_authority(tenant, topic, event, authority) + .await + } + None => state.pubsub.publish_event(tenant, topic, event).await, + } +} + /// Bound the `kind` label to prevent cardinality explosion from arbitrary Nostr kinds. pub(crate) fn bounded_kind_label(kind: u32) -> String { match kind { @@ -73,23 +102,70 @@ where frames } +/// A live fan-out target that retains exact authority until socket drain. +pub struct ProtectedFanoutRecipient { + conn_id: crate::subscription::ConnId, + sub_id: crate::subscription::SubId, + authority: Option>, +} + +impl std::fmt::Debug for ProtectedFanoutRecipient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProtectedFanoutRecipient") + .field("conn_id", &self.conn_id) + .field("sub_id", &self.sub_id) + .field("guarded", &self.authority.is_some()) + .finish() + } +} + +impl PartialEq<(crate::subscription::ConnId, crate::subscription::SubId)> + for ProtectedFanoutRecipient +{ + fn eq(&self, other: &(crate::subscription::ConnId, crate::subscription::SubId)) -> bool { + self.conn_id == other.0 && self.sub_id == other.1 + } +} + fn send_fanout_frames<'a, I>( state: &AppState, recipients: I, frames: &HashMap<&'a str, Arc>, + sender_authority: Option<&Arc>, ) -> u32 where - I: IntoIterator, + I: IntoIterator, { let mut drop_count = 0u32; - for (conn_id, sub_id) in recipients { + for recipient in recipients { let frame = frames - .get(sub_id) + .get(recipient.sub_id.as_str()) .expect("fan-out frame cache covers every recipient subscription id"); - if !state - .conn_manager - .send_to_text_bytes(conn_id, Arc::clone(frame)) - { + let sent = match (&recipient.authority, sender_authority) { + (Some(recipient_authority), Some(sender_authority)) => { + state.conn_manager.send_to_text_bytes_guarded_pair( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + Arc::clone(recipient_authority), + ) + } + (Some(authority), None) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(authority), + ), + (None, Some(sender_authority)) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + ), + (None, None) => state + .conn_manager + .send_to_text_bytes(recipient.conn_id, Arc::clone(frame)), + }; + if !sent { drop_count += 1; } } @@ -118,7 +194,7 @@ pub async fn filter_fanout_by_access( stored_event: &StoredEvent, matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, threaded: Option<&crate::state::ThreadedChannelVisibility>, -) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { +) -> Vec { // First enforce the receiver-side tenant label. Subscription indexes are // community-scoped, but stale/injected matches and future fan-out helpers // must still fail closed at the send chokepoint: a connection bound to @@ -175,7 +251,7 @@ pub async fn filter_fanout_by_access( }; let Some(channel_id) = stored_event.channel_id else { - return matches; + return filter_fanout_by_protected_authorization(state, community_id, None, matches).await; }; // Fence 3 (§4.8 phase-2): the threaded value is used only when it was // resolved under exactly this (community_id, channel_id); anything else @@ -192,7 +268,15 @@ pub async fn filter_fanout_by_access( } }; match visibility { - Ok(v) if v != "private" => return matches, + Ok(v) if v != "private" => { + return filter_fanout_by_protected_authorization( + state, + community_id, + Some(channel_id), + matches, + ) + .await; + } Ok(_) => {} Err(e) => { // Fail closed: if we cannot determine visibility, do not leak a @@ -218,6 +302,89 @@ pub async fn filter_fanout_by_access( } } } + filter_fanout_by_protected_authorization(state, community_id, Some(channel_id), allowed).await +} + +async fn filter_fanout_by_protected_authorization( + state: &AppState, + community_id: CommunityId, + channel_id: Option, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, +) -> Vec { + if state.protected_transport().is_none() { + return matches + .into_iter() + .map(|(conn_id, sub_id)| { + let authority = match channel_id { + Some(channel_id) => state.conn_manager.pubkey_for_conn(conn_id).map(|actor| { + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + None, + ) + }), + None => None, + }; + ProtectedFanoutRecipient { + conn_id, + sub_id, + authority, + } + }) + .collect(); + } + let mut allowed = Vec::with_capacity(matches.len()); + for (conn_id, sub_id) in matches { + let Some(proof) = state.conn_manager.authority_for_conn(conn_id) else { + continue; + }; + if proof.authorization_domain() != community_id { + state.conn_manager.cancel_connection(conn_id); + continue; + } + let Some(cancellation) = state.conn_manager.cancellation_for_conn(conn_id) else { + continue; + }; + match crate::authorization_runtime::transport::authorize_session_if_configured( + state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_fanout", + conn_id, + cancellation, + ) + .await + { + Ok(authority) if authority.revalidate().is_ok() => { + let authority = Arc::new(authority); + let release = match channel_id { + Some(channel_id) => { + let Some(actor) = state.conn_manager.pubkey_for_conn(conn_id) else { + continue; + }; + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + Some(authority), + ) + } + None => crate::connection::queued_local_authority(authority), + }; + allowed.push(ProtectedFanoutRecipient { + conn_id, + sub_id, + authority: Some(release), + }); + } + Ok(_) | Err(_) => state.conn_manager.cancel_connection(conn_id), + } + } allowed } @@ -243,6 +410,17 @@ pub(crate) async fn fan_out_event_to_local_subscribers( community_id: CommunityId, stored: &StoredEvent, ) { + fan_out_event_to_local_subscribers_with_authority(state, community_id, stored, None).await; +} + +async fn fan_out_event_to_local_subscribers_with_authority( + state: &AppState, + community_id: CommunityId, + stored: &StoredEvent, + sender_authority: Option<&Arc>, +) { + let sender_authority = sender_authority + .map(|authority| crate::connection::queued_local_authority(Arc::clone(authority))); let matches = state.sub_registry.fan_out_scoped(community_id, stored); let matches = filter_fanout_by_access(state, community_id, stored, matches, None).await; metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); @@ -258,16 +436,10 @@ pub(crate) async fn fan_out_event_to_local_subscribers( } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -280,16 +452,50 @@ pub(crate) async fn fan_out_event_to_local_subscribers( /// Fan out one event received from Redis pub/sub to this relay's local subscribers. #[tracing::instrument(skip_all)] pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pubsub::ChannelEvent) { + let buzz_pubsub::ChannelEvent { + community_id, + topic, + event, + authority, + } = channel_event; // The Redis topic carries the tenant-local routing scope explicitly: // `Channel(id)` for a per-channel event, `Global` for a channel-less one. // Convert back to the `Option` channel id `fan_out()` indexes on — // `Global` selects the global subscriber index. - let channel_id = match channel_event.topic { + let channel_id = match topic { buzz_pubsub::EventTopic::Channel(id) => Some(id), buzz_pubsub::EventTopic::Global => None, }; - let community_id = channel_event.community_id; - let stored = StoredEvent::new(channel_event.event, channel_id); + let protected_ephemeral = + is_ephemeral(event_kind_u32(&event)) || event_kind_u32(&event) == KIND_AGENT_OBSERVER_FRAME; + let sender_authority = match authority { + Some(authority) if protected_ephemeral => { + match crate::authorization_runtime::ephemeral::verify( + state, + community_id, + &event, + &authority, + ) + .await + { + Ok(authority) => Some(authority), + Err(error) => { + warn!(%error, "multi-node ephemeral sender authority denied"); + return; + } + } + } + Some(_) => { + warn!("multi-node persistent event carried unexpected sender authority"); + return; + } + None if protected_ephemeral && state.is_protected_enforcing(community_id) => { + warn!("multi-node Enforce ephemeral event omitted sender authority"); + return; + } + None => None, + }; + let stored = StoredEvent::new(event, channel_id); // Skip events that were already fanned out in-process (local echo). The // dedup key is `(community_id, event_id)` — a same-id event arriving for a @@ -318,16 +524,10 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -355,15 +555,17 @@ pub(crate) async fn dispatch_persistent_event( threaded_visibility: Option, ) -> usize { let event_id_hex = stored_event.event.id.to_hex(); - enqueue_event_created_audit( - tenant, - state, - stored_event, - kind_u32, - actor_pubkey_hex, - &event_id_hex, - ) - .await; + if legacy_audit_delivery_allowed(state, tenant.community()) { + enqueue_event_created_audit( + tenant, + state, + stored_event, + kind_u32, + actor_pubkey_hex, + &event_id_hex, + ) + .await; + } let tenant = tenant.clone(); let state = Arc::clone(state); @@ -476,21 +678,20 @@ async fn dispatch_persistent_event_inner( // frames only after applying it to the already access-filtered recipient set. let recipients: Vec<_> = matches .iter() - .filter_map(|(target_conn_id, sub_id)| { - if let Some(ref owner_hex) = private_event_owner { - let is_owner = state + .filter(|recipient| { + private_event_owner.as_ref().is_none_or(|owner_hex| { + state .conn_manager - .pubkey_for(*target_conn_id) - .is_some_and(|pk| hex::encode(pk) == *owner_hex); - if !is_owner { - return None; - } - } - Some((*target_conn_id, sub_id.as_str())) + .pubkey_for(recipient.conn_id) + .is_some_and(|pk| hex::encode(pk) == *owner_hex) + }) }) .collect(); - let frames = fanout_frame_cache(recipients.iter().map(|(_, sub_id)| *sub_id), &event_json); - let drop_count = send_fanout_frames(state, recipients, &frames); + let frames = fanout_frame_cache( + recipients.iter().map(|recipient| recipient.sub_id.as_str()), + &event_json, + ); + let drop_count = send_fanout_frames(state, recipients, &frames, None); if drop_count > 0 { tracing::warn!( event_id = %event_id_hex, @@ -505,7 +706,7 @@ async fn dispatch_persistent_event_inner( // out-of-band index to feed. The old Typesense `index_event` worker and its // `search_index_tx` mpsc are gone with the Typesense backend. - if enqueue_audit { + if enqueue_audit && legacy_audit_delivery_allowed(state, tenant.community()) { enqueue_event_created_audit( tenant, state, @@ -525,7 +726,15 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !buzz_core::kind::is_workflow_execution_kind(kind_u32) + let workflow_effect_allowed = crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())), + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .is_ok(); + if workflow_effect_allowed + && !buzz_core::kind::is_workflow_execution_kind(kind_u32) && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP @@ -533,24 +742,24 @@ async fn dispatch_persistent_event_inner( let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); let trigger_kind = kind_u32.to_string(); - let workflow_community_host = tenant.host().to_owned(); // The event was stored under `tenant.community()`; `StoredEvent` does // not carry the community, so pass it explicitly. The same channel UUID // can exist in another community — scoping the workflow lookup to this // community keeps a colliding channel id in B from triggering A's // workflows. let workflow_community = tenant.community(); + let workflow_community_label = crate::metrics::community_label(workflow_community); tokio::spawn(async move { if let Err(e) = workflow_engine .on_event(workflow_community, &workflow_event) .await { - tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); + tracing::error!("Workflow trigger failed: {e}"); } else { metrics::counter!( "buzz_workflow_runs_total", "trigger" => trigger_kind, - "community" => workflow_community_host + "community" => workflow_community_label ) .increment(1); } @@ -560,6 +769,16 @@ async fn dispatch_persistent_event_inner( matches.len() } +fn legacy_audit_delivery_allowed(state: &AppState, community_id: CommunityId) -> bool { + crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_ok() +} + async fn enqueue_event_created_audit( tenant: &TenantContext, state: &Arc, @@ -622,12 +841,9 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc kind_str).increment(1); - // Per-community volume counter: community-only, no kind tag. - // Use this for per-community throughput graphs; the fleet counter above - // for per-kind breakdowns. metrics::counter!( "buzz_community_events_received_total", - "community" => conn.tenant.host().to_owned() + "community" => crate::metrics::community_label(conn.tenant.community()) ) .increment(1); @@ -678,6 +894,39 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + Arc::clone(proof), + state.conn_manager.federated_assertion_for_conn(conn_id), + crate::protected_surface::event_ingest_capability(kind_u32), + uuid::Uuid::new_v4(), + "ws_event", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = match protected_result { + Ok(authority) => Arc::new(authority), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected EVENT authorization denied"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }; if kind_u32 == KIND_AGENT_OBSERVER_FRAME { if !scopes.is_empty() && !scopes.contains(&buzz_auth::Scope::MessagesWrite) { reject("scope"); @@ -688,7 +937,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, state: Arc, state: Arc, auth_pubkey: nostr::PublicKey, conn: Arc, state: Arc, + authority: Arc, ) { + let conn_id = conn.conn_id; let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -794,6 +1060,14 @@ async fn handle_ephemeral_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "ephemeral sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Special handling for presence events (kind:20001). if event_kind_u32(&event) == KIND_PRESENCE_UPDATE { @@ -814,16 +1088,49 @@ async fn handle_ephemeral_event( raw }; - if status == "offline" { - let _ = state + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let stored_status = match redis_authority.as_ref() { + Some(token) => match crate::authorization_runtime::ephemeral::encode_presence( + status.clone(), + event.id.to_bytes(), + token.clone(), + ) { + Ok(value) => value, + Err(_) => { + conn.cancel.cancel(); + return; + } + }, + None => status.clone(), + }; + let presence_result = if status == "offline" { + state .pubsub .clear_presence(&conn.tenant, &auth_pubkey) - .await; + .await } else { + state + .pubsub + .set_presence(&conn.tenant, &auth_pubkey, &stored_status) + .await + }; + if authority.revalidate().is_err() { + // Cleanup is opportunistic. Protected values retain their sealed + // authority and are revalidated before every read or emission, so + // a failed DEL cannot make stale presence visible. let _ = state .pubsub - .set_presence(&conn.tenant, &auth_pubkey, &status) + .clear_presence(&conn.tenant, &auth_pubkey) .await; + conn.cancel.cancel(); + return; + } + if presence_result.is_err() && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Presence is a channel-less ephemeral event. After updating Redis @@ -845,20 +1152,78 @@ async fn handle_ephemeral_event( conn.send(RelayMessage::ok(event_id_hex, false, &msg)); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + + // In Enforce, retain database locks on the channel and the actor's + // active membership through the authoritative Redis publication. A + // concurrent removal or open-to-private transition therefore orders + // entirely before this publication (which then denies) or after it. + // Off/Shadow/VerifyOnly keep the legacy preflight behavior. + let channel_authority_guard = if authority.is_enforcing() { + let mut transaction = match state.db.begin_transaction().await { + Ok(transaction) => transaction, + Err(error) => { + warn!(%error, %ch_id, "ephemeral channel authority transaction failed"); + conn.cancel.cancel(); + return; + } + }; + if let Err(error) = buzz_db::channel::require_channel_write_authority_tx( + &mut transaction, + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: channel authority changed before publication", + )); + warn!(%error, %ch_id, "ephemeral channel authority denied"); + return; + } + Some(transaction) + } else { + None + }; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Channel(ch_id), &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Channel(ch_id), + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + drop(channel_authority_guard); + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers, through the guarded send path @@ -866,7 +1231,21 @@ async fn handle_ephemeral_event( // receive this private-channel ephemeral event. // Pass the channel_id so fan_out() uses the channel-kind index. let stored_event = StoredEvent::new(event.clone(), Some(ch_id)); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } else { // Channel-less ephemeral events (e.g., NIP-AB pairing kind:24134). // @@ -876,17 +1255,39 @@ async fn handle_ephemeral_event( // The nil UUID is ONLY a Redis routing key — it never reaches the DB. // On the receiving end (main.rs subscriber loop), `is_nil()` is checked // and converted back to `None` so `fan_out()` uses the global index. + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers through the guarded send path. @@ -894,9 +1295,27 @@ async fn handle_ephemeral_event( // filter_fanout_by_access no-ops for channel-less events except the // author-only-kind gate. let stored_event = StoredEvent::new(event.clone(), None); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -950,6 +1369,7 @@ async fn handle_agent_observer_event( event_id_hex: &str, conn: Arc, state: Arc, + authority: Arc, ) { let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -972,6 +1392,14 @@ async fn handle_agent_observer_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "observer sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Freshness check: reject observer frames with stale/future timestamps let now = chrono::Utc::now().timestamp(); @@ -1054,6 +1482,10 @@ async fn handle_agent_observer_event( )); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } // Rate limit telemetry frames only (100/sec per agent). // Control frames (owner → agent) bypass the limiter — they are rare and must not @@ -1070,27 +1502,60 @@ async fn handle_agent_observer_event( } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Agent observer publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } let stored_event = StoredEvent::new(event.clone(), None); debug!( - event_id = %event_id_hex, - agent = %route.agent.to_hex(), - owner = %route.owner.to_hex(), direction = ?route.direction, "Agent observer fan-out" ); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -1390,7 +1855,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - corporate_identity_jwt: None, + corporate_identity_assertion: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), @@ -1414,11 +1879,12 @@ mod tests { &event.id.to_hex(), conn, state, + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy), ) .await; let axum::extract::ws::Message::Text(text) = - send_rx.try_recv().expect("observer rejection sent") + send_rx.try_recv().expect("observer rejection sent").message else { panic!("expected text relay message"); }; @@ -1456,7 +1922,7 @@ mod tests { sub_id: &str, filter: Filter, pubkey: Option>, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); @@ -1482,7 +1948,7 @@ mod tests { fn register_presence_sub( state: &AppState, sub_id: &str, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1495,7 +1961,7 @@ mod tests { state: &AppState, sub_id: &str, target: &Keys, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1544,11 +2010,13 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; - let delivered = event_from_ws_message(rx.try_recv().expect("presence delivered")); + let delivered = + event_from_ws_message(rx.try_recv().expect("presence delivered").message); assert_eq!(delivered.id, event_id); assert!(rx.try_recv().is_err(), "presence is delivered once"); } @@ -1567,6 +2035,7 @@ mod tests { community_id: community, topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1609,13 +2078,15 @@ mod tests { community_id: community_b, topic: EventTopic::Global, event, + authority: None, }, ) .await; let delivered = event_from_ws_message( rx.try_recv() - .expect("B's same-id event must be delivered — A's local mark is B-irrelevant"), + .expect("B's same-id event must be delivered — A's local mark is B-irrelevant") + .message, ); assert_eq!(delivered.id, event_id); } @@ -1638,6 +2109,7 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1645,7 +2117,8 @@ mod tests { let delivered = event_from_ws_message( target_rx .try_recv() - .expect("target receives membership notification"), + .expect("target receives membership notification") + .message, ); assert_eq!(delivered.id, event_id); assert!( @@ -1733,7 +2206,7 @@ mod tests { .await .expect("presence reached second relay") .expect("receiver connection still open"); - let delivered = event_from_ws_message(delivered); + let delivered = event_from_ws_message(delivered.message); assert_eq!(delivered.id, event_id); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), receiver_rx.recv()) diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483f..113e9fe02c 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -138,6 +138,61 @@ pub async fn handle_identity_archive_event( Ok(()) } +/// Validate and apply an identity archive request inside the caller's protected +/// authorization transaction. The request event is persisted by the caller in +/// that same transaction; relay-signed deltas remain unavailable background +/// effects in Enforce. +pub async fn handle_identity_archive_event_tx( + tenant: &TenantContext, + state: &Arc, + event: &Event, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + let kind = event.kind.as_u16() as u32; + let actor_hex = event.pubkey.to_hex(); + if kind != KIND_IA_ARCHIVE_REQUEST && kind != KIND_IA_UNARCHIVE_REQUEST { + return Err(format!("unexpected identity archive kind: {kind}")); + } + enforce_freshness(event)?; + require_single_protected_tag(event)?; + let target_hex = extract_single_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + let replaced_by = extract_optional_replaced_by(event, &target_hex)?; + if kind == KIND_IA_UNARCHIVE_REQUEST && replaced_by.is_some() { + return Err("replaced-by is not valid on unarchive requests".into()); + } + let reason = extract_tag_value(event, "reason"); + let consent_path = determine_consent_path_tx( + tenant.community(), + state, + event, + &target_hex, + &actor_hex, + transaction, + ) + .await?; + let request_event_id = event.id.to_hex(); + if kind == KIND_IA_ARCHIVE_REQUEST { + buzz_db::archived_identities::archive_tx( + transaction, + tenant.community(), + &target_hex, + consent_path.as_str(), + &actor_hex, + reason.as_deref(), + replaced_by.as_deref(), + &request_event_id, + ) + .await + .map_err(|error| format!("database error: {error}")) + } else { + buzz_db::archived_identities::unarchive_tx(transaction, tenant.community(), &target_hex) + .await + .map_err(|error| format!("database error: {error}")) + } +} + fn enforce_freshness(event: &Event) -> Result<(), String> { let event_ts = event.created_at.as_secs() as i64; let now = std::time::SystemTime::now() @@ -250,6 +305,40 @@ async fn determine_consent_path( Ok(ConsentPath::Owner) } +async fn determine_consent_path_tx( + community_id: CommunityId, + state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + if actor_hex == target_hex { + return Ok(ConsentPath::SelfSigned); + } + let actor_member = + buzz_db::relay_members::get_relay_member_tx(transaction, community_id, actor_hex) + .await + .map_err(|error| format!("database error: {error}"))?; + let actor_role = actor_member + .as_ref() + .map(|member| member.role.as_str()) + .unwrap_or(""); + if actor_role == "owner" || actor_role == "admin" { + return Ok(ConsentPath::Admin); + } + verify_owner_consent_tx( + community_id, + state, + event, + target_hex, + actor_hex, + transaction, + ) + .await?; + Ok(ConsentPath::Owner) +} + async fn verify_owner_consent( community_id: CommunityId, state: &Arc, @@ -297,6 +386,76 @@ async fn verify_owner_consent( Ok(()) } +async fn verify_owner_consent_tx( + community_id: CommunityId, + _state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), String> { + let request_auth = extract_single_auth_tag_json(event)?; + let request_owner = verify_auth_tag_owner(&request_auth, target_hex) + .map_err(|error| format!("invalid request auth tag: {error}"))?; + if request_owner != actor_hex { + return Err("request auth owner must equal request signer".into()); + } + enforce_request_auth_time_bounds(&request_auth, event.created_at.as_secs())?; + + let target_pubkey = PublicKey::from_hex(target_hex) + .map_err(|error| format!("invalid target pubkey: {error}"))?; + let target_author = target_pubkey.to_bytes().to_vec(); + + // The user-row share lock is also taken by the Enforce profile projection. + // It serializes archive consent against a concurrent kind:0 replacement so + // the profile read below remains the consent state through commit. + let target_exists = sqlx::query_scalar::<_, i32>( + "SELECT 1 FROM users \ + WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&target_author) + .fetch_optional(&mut **transaction) + .await + .map_err(|error| format!("database error: {error}"))? + .is_some(); + if !target_exists { + return Err("target has no live user profile".into()); + } + + let profile = buzz_db::event::query_events_tx( + transaction, + &EventQuery { + kinds: Some(vec![KIND_PROFILE as i32]), + authors: Some(vec![target_author]), + limit: Some(1), + global_only: true, + ..EventQuery::for_community(community_id) + }, + ) + .await + .map_err(|error| format!("database error: {error}"))? + .into_iter() + .next() + .ok_or_else(|| "target has no live kind:0 profile".to_string())?; + if !buzz_db::event::lock_live_event_tx(transaction, community_id, profile.event.id.as_bytes()) + .await + .map_err(|error| format!("database error: {error}"))? + { + return Err("live kind:0 changed during authorization".into()); + } + if profile.event.pubkey.to_hex() != target_hex { + return Err("live kind:0 author did not match target".into()); + } + let live_auth = extract_single_auth_tag_json(&profile.event)?; + let live_owner = verify_auth_tag_owner(&live_auth, target_hex) + .map_err(|error| format!("invalid live kind:0 auth tag: {error}"))?; + if live_owner != actor_hex { + return Err("live kind:0 no longer attests to request signer".into()); + } + Ok(()) +} + fn extract_single_auth_tag_json(event: &Event) -> Result { let mut found: Option> = None; for tag in event.tags.iter() { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 297bdfac90..a8fa49ddd5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -24,21 +24,23 @@ use buzz_core::kind::{ KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_CREATE_INVITE, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -67,9 +69,9 @@ pub enum IngestAuth { pubkey: nostr::PublicKey, /// Verified delegated owner, when present. owner_pubkey: Option, - /// Sealed NIP-42 proof, when retained by a later route installer. + /// Sealed NIP-42 proof retained from connection authentication. verified_proof: Option>, - /// Current direct federated evidence, when retained by a later route. + /// Current direct federated evidence retained from authentication. verified_assertion: Option>, /// Permission scopes granted to this connection. scopes: Vec, @@ -84,7 +86,7 @@ pub enum IngestAuth { pubkey: nostr::PublicKey, /// Verified delegated owner, when present. owner_pubkey: Option, - /// Sealed NIP-98 proof retained from this request. + /// Sealed NIP-98 proof retained from this exact HTTP request. verified_proof: Option>, /// Current direct federated evidence retained from this request. verified_assertion: Option>, @@ -296,7 +298,10 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), - KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => { + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE => { Ok(Scope::AdminChannels) } // NIP-43: relay membership admin commands (9030–9032) + Buzz @@ -535,6 +540,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_EVENT | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE | KIND_NIP29_LEAVE_REQUEST // Huddle lifecycle events + guidelines | KIND_HUDDLE_STARTED @@ -584,6 +590,18 @@ pub(crate) async fn check_channel_membership( } } +fn uses_generic_channel_write_authority(kind: u32) -> bool { + !matches!( + kind, + KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_CREATE_GROUP + | KIND_STREAM_MESSAGE_EDIT + | KIND_NIP29_EDIT_METADATA + | KIND_NIP29_DELETE_EVENT + | KIND_NIP29_DELETE_GROUP + ) +} + fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(), String> { if let Some(allowed) = auth.channel_ids() { if !allowed.contains(&channel_id) { @@ -902,6 +920,64 @@ async fn validate_edit_ownership( Ok(()) } +/// Repeat stream-edit target, membership, and agent-owner validation while the +/// common protected operation transaction owns all relevant database locks. +async fn validate_edit_ownership_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &Event, + state: &AppState, +) -> Result<(), String> { + let target_hex = event + .tags + .iter() + .find_map(|tag| { + (tag.kind().to_string() == "e") + .then(|| tag.content()) + .flatten() + }) + .filter(|value| { + value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .ok_or_else(|| "missing e tag for edit target".to_string())?; + let target_bytes = + hex::decode(target_hex).map_err(|_| "invalid target event ID".to_string())?; + let target_event = buzz_db::event::get_event_by_id_tx(transaction, community_id, &target_bytes) + .await + .map_err(|error| format!("db error: {error}"))? + .ok_or_else(|| "edit target event not found".to_string())?; + + let edit_channel_id = extract_channel_id(event); + match (edit_channel_id, target_event.channel_id) { + (Some(edit_channel), Some(target_channel)) if edit_channel != target_channel => { + return Err("target event belongs to a different channel".to_string()); + } + (Some(_), None) => return Err("target event has no channel".to_string()), + _ => {} + } + + let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + let actor = event.pubkey.to_bytes().to_vec(); + if author == actor { + if let Some(channel_id) = target_event.channel_id { + buzz_db::channel::require_channel_write_authority_tx( + transaction, + community_id, + channel_id, + &actor, + ) + .await + .map_err(|error| format!("restricted: channel authority changed: {error}"))?; + } + } else if !buzz_db::user::is_agent_owner_tx(transaction, community_id, &author, &actor) + .await + .map_err(|error| format!("db error checking agent ownership: {error}"))? + { + return Err("must be event author to edit".to_string()); + } + Ok(()) +} + /// Validate kind:45002 vote targets a forum post (45001) or comment (45003). async fn validate_forum_vote_target( community_id: CommunityId, @@ -1948,18 +2024,135 @@ async fn ingest_event_inner( ))); } + let protected_result = match auth.verified_proof() { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + Arc::clone(proof), + auth.verified_assertion().cloned(), + crate::protected_surface::event_ingest_capability(kind_u32), + stable_event_correlation(&event), + "event_ingest", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result.map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization denied: {error}" + )) + })?; + if protected.is_enforcing() + && crate::protected_surface::event_mutation_disposition(kind_u32) + != crate::protected_surface::EventMutationDisposition::TransactionalPersistence + { + return Err(IngestError::AuthFailed( + "restricted: protected event mutation unavailable".into(), + )); + } + let mut non_enforcing_postgresql_git = false; + let legacy_git_policy_guard = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT + && !protected.is_enforcing() + { + let object_authority = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let repo_id = protected_git_repo_id(&event)?; + let owner = hex::encode(event.pubkey.to_bytes()); + match object_authority.state { + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Legacy => { + if state + .db + .repo_publication_origin(tenant.community(), &repo_id, &owner) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))? + .as_deref() + == Some("protected_unpublished") + { + return Err(IngestError::AuthFailed( + "restricted: protected Git reservation cannot enter the legacy lane".into(), + )); + } + let guard = state + .db + .begin_legacy_visibility_write( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is fenced: {error}" + )) + })?; + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is permanently fenced: {error}" + )) + })?; + Some(guard) + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql => { + crate::api::git::migration::require_reconciled_authority(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: PostgreSQL Git policy is unavailable: {error}" + )) + })?; + non_enforcing_postgresql_git = true; + None + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Importing => { + return Err(IngestError::AuthFailed( + "restricted: Git policy migration is incomplete".into(), + )); + } + } + } else { + None + }; + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { - return super::command_executor::handle_command(tenant, state, event, auth).await; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + return super::command_executor::handle_command(tenant, state, event, auth, &protected) + .await; } // Product feedback is sidecarred directly into its private deployment table. // It never enters ordinary event storage or subscription fan-out. if kind_u32 == KIND_PRODUCT_FEEDBACK { - super::product_feedback::handle(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::product_feedback::handle_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::product_feedback::handle(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } // Feedback is a host-resolved, channel-less write. Although its row is // private to operator tooling rather than ordinary event reads, this is // the matching modeled success action at the ingest isolation seam. @@ -1978,9 +2171,20 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::report::handle_report_event_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::report::handle_report_event(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -1996,9 +2200,22 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::moderation_commands::handle_moderation_command_enforced( + tenant, state, &event, &protected, + ) .await .map_err(IngestError::Rejected)?; + } else { + super::moderation_commands::handle_moderation_command(tenant, state, &event) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2169,7 +2386,7 @@ async fn ingest_event_inner( // row is missing (global event, kind:9007 pre-create) this is `None` and // fan-out performs its own fresh fail-closed lookup — `None` is never // "assume open" (fence 1). - let threaded_visibility = match (channel_id, &channel_row) { + let mut threaded_visibility = match (channel_id, &channel_row) { (Some(ch_id), Some(row)) => state .channel_visibility_cached(tenant.community(), ch_id, Some(row)) .await @@ -2189,13 +2406,7 @@ async fn ingest_event_inner( // member/open gate here lets the owning human act on private agent channels // without being a member (OQ1 decision; see validate_edit_ownership / // validate_admin_event for per-kind enforcement). - let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST - || kind_u32 == KIND_NIP29_CREATE_GROUP - || kind_u32 == KIND_STREAM_MESSAGE_EDIT - || kind_u32 == KIND_NIP29_EDIT_METADATA - || kind_u32 == KIND_NIP29_DELETE_EVENT - || kind_u32 == KIND_NIP29_DELETE_GROUP; - if !skip_membership { + if uses_generic_channel_write_authority(kind_u32) { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag // (recorded separately to bite M2 / M8 — claim or A-host @@ -2231,9 +2442,22 @@ async fn ingest_event_inner( // gate above exempts relay-admin kinds so timed-out admins keep their // administrative capability, which leaves bans to the handler. if is_relay_admin_kind(event.kind.as_u16() as u32) { - crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + crate::handlers::relay_admin::handle_relay_admin_event_enforced( + tenant, state, &event, &protected, + ) .await .map_err(map_relay_admin_error)?; + } else { + crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + .await + .map_err(map_relay_admin_error)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2278,11 +2502,69 @@ async fn ingest_event_inner( let sender_hex = event.pubkey.to_hex(); // remove_relay_member handles both the NotFound and IsOwner cases atomically. - let remove_result = state - .db - .remove_relay_member(tenant.community(), &sender_hex) - .await - .map_err(|e| IngestError::Internal(format!("database error: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let remove_result = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.leave.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-leave-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.leave.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + if payload.as_slice() != b"left" { + return Err(IngestError::Internal( + "error: protected relay-leave receipt is invalid".into(), + )); + } + buzz_db::relay_members::RemoveResult::Removed + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let result = buzz_db::relay_members::remove_relay_member_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ) + .await + .map_err(|error| IngestError::Internal(format!("database error: {error}")))?; + if result == buzz_db::relay_members::RemoveResult::Removed { + operation.commit(b"left").await.map_err(|error| { + IngestError::AuthFailed(format!("restricted: {error}")) + })?; + } + result + } + } + } else { + state + .db + .remove_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| IngestError::Internal(format!("database error: {e}")))? + }; match remove_result { buzz_db::relay_members::RemoveResult::Removed => {} @@ -2305,20 +2587,27 @@ async fn ingest_event_inner( } } - // Publish NIP-43 announcements — fire-and-forget. - if let Err(e) = - crate::handlers::side_effects::publish_nip43_member_removed(tenant, state, &sender_hex) - .await - { - warn!(error = %e, "failed to publish NIP-43 member removed event"); - } - if let Err(e) = - crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await - { - warn!(error = %e, "failed to publish NIP-43 membership list"); + // Relay-signed announcements are derived background effects. Preserve + // them in legacy modes, but keep them unavailable before execution in + // Enforce until they have an authoritative delivery model. + if !protected.is_enforcing() { + if let Err(e) = crate::handlers::side_effects::publish_nip43_member_removed( + tenant, + state, + &sender_hex, + ) + .await + { + warn!(error = %e, "failed to publish NIP-43 member removed event"); + } + if let Err(e) = + crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await + { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } } - info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request"); + info!("relay member left via NIP-43 leave request"); return Ok(IngestResult { event_id: event_id_hex, @@ -2338,9 +2627,16 @@ async fn ingest_event_inner( // NIP-43 admin commands above — the request itself falls through to normal // storage so the delta's `["e", request_id]` audit reference resolves. if is_identity_archive_request_kind(kind_u32) { - crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) - .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if !protected.is_enforcing() { + crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } } if kind_u32 == KIND_DELETION { @@ -2519,50 +2815,57 @@ async fn ingest_event_inner( IngestError::Rejected(format!("invalid channel_type: {channel_type_str}")) })?; - if let Some(client_uuid) = channel_id { - let name = create_name.unwrap_or_default(); - let name = buzz_core::channel::canonical_channel_name(&name); + if !protected.is_enforcing() { + if let Some(client_uuid) = channel_id { + let name = create_name.unwrap_or_default(); + let name = buzz_core::channel::canonical_channel_name(&name); - let description = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "about" { - t.content().map(|s| s.to_string()) - } else { - None - } - }); + let description = event.tags.iter().find_map(|t| { + if t.kind().to_string() == "about" { + t.content().map(|s| s.to_string()) + } else { + None + } + }); - let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); + let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); - let actor_bytes = event.pubkey.to_bytes().to_vec(); - let (_, was_created) = state - .db - .create_channel_with_id( - tenant.community(), - client_uuid, - name, - channel_type, - visibility, - description.as_deref(), - &actor_bytes, - ttl_seconds, + let actor_bytes = event.pubkey.to_bytes().to_vec(); + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let (_, was_created) = state + .db + .create_channel_with_id( + tenant.community(), + client_uuid, + name, + channel_type, + visibility, + description.as_deref(), + &actor_bytes, + ttl_seconds, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + + if !was_created { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message: "duplicate: channel already exists".into(), + }); + } + pre_created_channel = Some(client_uuid); + metrics::counter!( + "buzz_channels_created_total", + "community" => crate::metrics::community_label(tenant.community()), + "type" => channel_type.to_string() ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; - - if !was_created { - return Ok(IngestResult { - event_id: event_id_hex, - accepted: false, - message: "duplicate: channel already exists".into(), - }); + .increment(1); } - pre_created_channel = Some(client_uuid); - metrics::counter!( - "buzz_channels_created_total", - "community" => tenant.host().to_owned(), - "type" => channel_type.to_string() - ) - .increment(1); } } @@ -2589,6 +2892,11 @@ async fn ingest_event_inner( } if kind_u32 == super::push_lease::KIND_PUSH_LEASE { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; let outcome = super::push_lease::accept(tenant, state, &event, now) .await .map_err(map_push_accept_error)?; @@ -2728,20 +3036,116 @@ async fn ingest_event_inner( // the event in the same transaction. Ordering is load-bearing: active // duplicate reactions must return before storing a duplicate kind:7 event. let thread_params = thread_meta.as_ref().map(|m| m.as_params()); - let (stored_event, was_inserted) = match state - .db - .insert_reaction_event_with_thread_metadata( - tenant.community(), - &event, - channel_id, - thread_params, - &target_id, - &actor_bytes, - emoji, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? - { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let outcome = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.reaction.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-reaction-request-v1"); + request.update(event.id.as_bytes()); + request.update(&target_id); + request.update(&actor_bytes); + request.update(emoji.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "event.reaction.v1", + request.finalize().into(), + ) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + let message = match payload.as_slice() { + b"inserted" => String::new(), + b"duplicate" => "duplicate: reaction already exists".to_owned(), + _ => { + return Err(IngestError::Internal( + "error: protected reaction receipt is invalid".into(), + )); + } + }; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: payload.as_slice() == b"inserted", + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if let Some(channel_id) = channel_id { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let outcome = buzz_db::event::insert_reaction_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + let receipt: &[u8] = match &outcome { + buzz_db::ReactionEventInsertOutcome::Inserted { .. } => b"inserted", + buzz_db::ReactionEventInsertOutcome::Duplicate => b"duplicate", + buzz_db::ReactionEventInsertOutcome::TargetMissing => { + return Err(IngestError::Rejected( + "invalid: reaction target event not found".into(), + )); + } + }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + outcome + } + } + } else { + state + .db + .insert_reaction_event_with_thread_metadata( + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + }; + let (stored_event, was_inserted) = match outcome { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected( "invalid: reaction target event not found".into(), @@ -2799,7 +3203,253 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let mut enforced_nip29_outcome = None; + let (stored_event, was_inserted) = if protected.is_enforcing() { + let thread_params = thread_meta.as_ref().map(|metadata| metadata.as_params()); + let mut stable = Sha256::new(); + stable.update(b"buzz-event-ingest-operation-v1"); + stable.update(tenant.community().as_uuid().as_bytes()); + stable.update(event.id.as_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.ingest.v1", + &stable, + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-ingest-request-v1"); + request.update(event.id.as_bytes()); + request.update(kind_u32.to_be_bytes()); + if let Some(channel_id) = channel_id { + request.update(channel_id.as_bytes()); + } + let permit = protected + .seal_postgres_mutation(operation_id, "event.ingest.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + let was_inserted = match payload.as_slice() { + b"inserted" => true, + b"duplicate" => false, + _ => { + return Err(IngestError::Internal( + "error: protected event receipt is invalid".into(), + )); + } + }; + let message = if was_inserted { + String::new() + } else { + "duplicate:".to_owned() + }; + let action = match (channel_id, was_inserted) { + (Some(channel), true) => TraceAction::WriteInsert { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (Some(channel), false) => TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed_community_from_event(&event), + }, + }; + emit(tracer, action, state_for_request(tenant, auth.pubkey())); + if let Some(outcome) = replay_nip29_outcome(kind_u32, channel_id, &event) { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + } + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if is_identity_archive_request_kind(kind_u32) { + crate::handlers::identity_archive::handle_identity_archive_event_tx( + tenant, + state, + &event, + operation.transaction(), + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if kind_u32 == KIND_STREAM_MESSAGE_EDIT { + validate_edit_ownership_tx( + operation.transaction(), + tenant.community(), + &event, + state, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if let Some(channel_id) = + channel_id.filter(|_| uses_generic_channel_write_authority(kind_u32)) + { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let result = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT { + let repo_id = protected_git_repo_id(&event)?; + buzz_db::git_repo::replace_protected_announcement_tx( + operation.transaction(), + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + } else if buzz_core::kind::is_replaceable(kind_u32) { + buzz_db::event::replace_addressable_event_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + } else if is_parameterized_replaceable(kind_u32) { + let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); + if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(IngestError::Rejected(format!( + "invalid: d tag too long ({} bytes, max {})", + d_tag.len(), + buzz_db::event::D_TAG_MAX_LEN, + ))); + } + buzz_db::event::replace_parameterized_event_tx( + operation.transaction(), + tenant.community(), + &event, + &d_tag, + channel_id, + ) + .await + } else { + buzz_db::event::insert_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + ) + .await + } + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if result.1 { + buzz_db::insert_mentions_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + } + if result.1 && matches!(kind_u32, KIND_PROFILE | KIND_AGENT_PROFILE) { + apply_profile_projection_tx(operation.transaction(), tenant, kind_u32, &event) + .await?; + } + if result.1 && kind_u32 == KIND_DELETION { + let actor = effective_message_author(&event, &state.relay_keypair.public_key()); + buzz_db::event::apply_standard_deletion_tx( + operation.transaction(), + tenant.community(), + &event, + &actor, + state.relay_keypair.public_key().as_bytes(), + ) + .await + .map_err(map_nip29_projection_error)?; + } + if result.1 && kind_u32 == KIND_NIP29_DELETE_EVENT { + buzz_db::event::apply_nip29_delete_event_tx( + operation.transaction(), + tenant.community(), + &event, + event.pubkey.to_bytes().as_slice(), + state.relay_keypair.public_key().as_bytes(), + channel_id.ok_or_else(|| { + IngestError::Rejected( + "invalid: channel deletion requires an h tag".into(), + ) + })?, + ) + .await + .map_err(map_nip29_projection_error)?; + } else if result.1 { + if let Some(mutation) = + protected_nip29_mutation(kind_u32, channel_id, &event, state)? + { + enforced_nip29_outcome = Some( + buzz_db::channel::apply_nip29_mutation_tx( + operation.transaction(), + tenant.community(), + event.pubkey.to_bytes().as_slice(), + mutation, + ) + .await + .map_err(map_nip29_projection_error)?, + ); + } + } + let receipt: &[u8] = if result.1 { b"inserted" } else { b"duplicate" }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + result + } + } + } else if non_enforcing_postgresql_git { + let repo_id = protected_git_repo_id(&event)?; + let mut transaction = state + .db + .begin_transaction() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let result = buzz_db::git_repo::replace_protected_announcement_tx( + &mut transaction, + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + .map_err(map_nip29_projection_error)?; + transaction + .commit() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + result + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -2866,7 +3516,10 @@ async fn ingest_event_inner( }); } - if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + if !protected.is_enforcing() + && !non_enforcing_postgresql_git + && crate::handlers::side_effects::is_side_effect_kind(kind_u32) + { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await @@ -2879,19 +3532,34 @@ async fn ingest_event_inner( error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}"); } } + if let Some(guard) = legacy_git_policy_guard { + guard + .commit() + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + } + + if let Some(outcome) = enforced_nip29_outcome { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + if outcome.channel_changed { + threaded_visibility = None; + } + } // A freshly inserted reply changed its thread's counters (updated in the // same transaction as the insert) — push a fresh relay-signed 39005 so // subscribed clients can update badge counts without refetching the head // window. Page responses recompute summaries independently, so this is // fan-out-only and best-effort. - if let Some(meta) = &thread_meta { - crate::handlers::side_effects::emit_live_thread_summary( - tenant, - state, - meta.channel_id, - meta.root_event_id.clone(), - ); + if !protected.is_enforcing() { + if let Some(meta) = &thread_meta { + crate::handlers::side_effects::emit_live_thread_summary( + tenant, + state, + meta.channel_id, + meta.root_event_id.clone(), + ); + } } let pubkey_hex = auth.pubkey().to_hex(); @@ -2943,6 +3611,318 @@ async fn ingest_event_inner( }) } +fn stable_event_correlation(event: &Event) -> Uuid { + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&event.id.as_bytes()[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +fn replay_nip29_outcome( + kind: u32, + channel_id: Option, + event: &Event, +) -> Option { + let (channel_id, membership_changed, channel_changed) = match kind { + KIND_NIP29_CREATE_GROUP => ( + channel_id.unwrap_or_else(|| stable_event_correlation(event)), + true, + true, + ), + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_LEAVE_REQUEST => (channel_id?, true, true), + KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_GROUP => (channel_id?, false, true), + _ => return None, + }; + Some(buzz_db::channel::Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed, + channel_changed, + }) +} + +async fn apply_enforced_nip29_postcommit( + tenant: &TenantContext, + state: &Arc, + kind: u32, + event: &Event, + outcome: buzz_db::channel::Nip29MutationOutcome, +) { + if outcome.membership_changed { + let member = match kind { + KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER => extract_p_tag_bytes(event).ok(), + KIND_NIP29_CREATE_GROUP | KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => { + Some(event.pubkey.to_bytes().to_vec()) + } + _ => None, + }; + if let Some(member) = member { + state.invalidate_membership(tenant, outcome.channel_id, &member); + if matches!(kind, KIND_NIP29_REMOVE_USER | KIND_NIP29_LEAVE_REQUEST) { + crate::handlers::side_effects::evict_live_channel_subscriptions( + tenant, + state, + outcome.channel_id, + &member, + ) + .await; + } + } + state.invalidate_all_accessible_channels(tenant); + } + if outcome.channel_changed { + state.invalidate_channel_visibility(tenant, outcome.channel_id); + if kind == KIND_NIP29_DELETE_GROUP { + state.invalidate_channel_deleted(tenant); + crate::handlers::side_effects::evict_all_channel_subscriptions( + tenant, + state, + outcome.channel_id, + ) + .await; + } + } +} + +async fn apply_profile_projection_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + kind: u32, + event: &Event, +) -> Result<(), IngestError> { + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|error| IngestError::Rejected(format!("invalid: profile content: {error}")))?; + let pubkey = event.pubkey.to_bytes(); + buzz_db::user::ensure_user_tx(transaction, tenant.community(), pubkey.as_slice()) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if kind == KIND_AGENT_PROFILE { + let policy = content + .get("channel_add_policy") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + IngestError::Rejected("invalid: agent profile missing channel_add_policy".into()) + })?; + buzz_db::user::set_channel_add_policy_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + policy, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + return Ok(()); + } + let display_name = content + .get("display_name") + .or_else(|| content.get("name")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let avatar_url = content + .get("picture") + .or_else(|| content.get("image")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let about = content + .get("about") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let nip05 = content + .get("nip05") + .and_then(serde_json::Value::as_str) + .and_then(|value| crate::api::nip05::canonicalize_nip05(value, tenant.host()).ok()) + .unwrap_or_default(); + buzz_db::user::replace_user_profile_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + display_name, + avatar_url, + about, + &nip05, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}"))) +} + +fn protected_nip29_mutation( + kind: u32, + channel_id: Option, + event: &Event, + state: &Arc, +) -> Result, IngestError> { + use buzz_db::channel::{ + ChannelType, ChannelUpdate, ChannelVisibility, MemberRole, Nip29Mutation, + }; + + let tag = |name: &str| { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some(name)) + .then(|| parts.get(1).cloned()) + .flatten() + }) + }; + let required_channel = + || channel_id.ok_or_else(|| IngestError::Rejected("invalid: missing h tag".into())); + let mutation = match kind { + KIND_NIP29_CREATE_GROUP => { + let name = tag("name") + .ok_or_else(|| IngestError::Rejected("invalid: channel name is required".into()))?; + let channel_type = tag("channel_type") + .unwrap_or_else(|| "stream".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel type".into()))?; + let visibility = tag("visibility") + .unwrap_or_else(|| "open".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel visibility".into()))?; + Nip29Mutation::Create { + channel_id: channel_id.unwrap_or_else(|| stable_event_correlation(event)), + name, + channel_type, + visibility, + description: tag("about"), + ttl_seconds: super::resolve_ttl(event, state.config.ephemeral_ttl_override), + } + } + KIND_NIP29_PUT_USER => { + let target = extract_p_tag_bytes(event)?; + let role = tag("role") + .map(|role| { + role.parse::() + .map_err(|_| IngestError::Rejected("invalid: member role".into())) + }) + .transpose()?; + Nip29Mutation::PutUser { + channel_id: required_channel()?, + target, + role, + } + } + KIND_NIP29_REMOVE_USER => Nip29Mutation::RemoveUser { + channel_id: required_channel()?, + target: extract_p_tag_bytes(event)?, + }, + KIND_NIP29_EDIT_METADATA => { + let ttl_value = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("ttl")).then(|| parts.get(1).cloned()) + }); + let ttl_seconds = match ttl_value { + None => None, + Some(None) => { + return Err(IngestError::Rejected( + "invalid: channel ttl must have a value".into(), + )); + } + Some(Some(value)) if value.is_empty() => Some(None), + Some(Some(value)) => { + Some(Some(value.parse::().map_err(|_| { + IngestError::Rejected("invalid: channel ttl".into()) + })?)) + } + }; + let archived = tag("archived") + .map(|value| match value.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(IngestError::Rejected("invalid: archive state".into())), + }) + .transpose()?; + Nip29Mutation::EditMetadata { + channel_id: required_channel()?, + updates: ChannelUpdate { + name: tag("name"), + description: tag("about"), + visibility: tag("visibility"), + ttl_seconds, + }, + topic: tag("topic"), + purpose: tag("purpose"), + archived, + } + } + KIND_NIP29_DELETE_GROUP => Nip29Mutation::DeleteGroup { + channel_id: required_channel()?, + relay_pubkey: state.relay_keypair.public_key().to_bytes().to_vec(), + }, + KIND_NIP29_JOIN_REQUEST => Nip29Mutation::Join { + channel_id: required_channel()?, + }, + KIND_NIP29_LEAVE_REQUEST => Nip29Mutation::Leave { + channel_id: required_channel()?, + }, + _ => return Ok(None), + }; + Ok(Some(mutation)) +} + +fn extract_p_tag_bytes(event: &Event) -> Result, IngestError> { + let value = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| IngestError::Rejected("invalid: missing p tag".into()))?; + let bytes = + hex::decode(value).map_err(|_| IngestError::Rejected("invalid: malformed p tag".into()))?; + if bytes.len() != 32 { + return Err(IngestError::Rejected("invalid: malformed p tag".into())); + } + Ok(bytes) +} + +fn map_nip29_projection_error(error: buzz_db::DbError) -> IngestError { + match error { + buzz_db::DbError::AccessDenied(message) + | buzz_db::DbError::InvalidData(message) + | buzz_db::DbError::NotFound(message) => { + IngestError::Rejected(format!("invalid: {message}")) + } + buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::MemberNotFound(_) => { + IngestError::Rejected("invalid: channel state changed".into()) + } + other => IngestError::Internal(format!("error: {other}")), + } +} + +fn protected_git_repo_id(event: &Event) -> Result { + let repo_id = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| { + IngestError::Rejected("invalid: repository announcement missing d tag".into()) + })?; + if repo_id.is_empty() + || repo_id.len() > 64 + || repo_id.starts_with('.') + || repo_id.contains("..") + || !repo_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + { + return Err(IngestError::Rejected( + "invalid: repository identifier is not portable".into(), + )); + } + Ok(repo_id.to_owned()) +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -3116,6 +4096,25 @@ mod tests { assert!(!requires_h_channel_scope(KIND_NIP29_CREATE_GROUP)); } + #[test] + fn protected_nip29_receipt_replay_restores_the_required_cache_fences() { + let event = make_dummy_event(); + let created = replay_nip29_outcome(KIND_NIP29_CREATE_GROUP, None, &event) + .expect("create-group receipts need replay fences"); + assert_eq!(created.channel_id, stable_event_correlation(&event)); + assert!(created.membership_changed); + assert!(created.channel_changed); + + let channel_id = Uuid::new_v4(); + let removed = replay_nip29_outcome(KIND_NIP29_REMOVE_USER, Some(channel_id), &event) + .expect("remove-user receipts need replay fences"); + assert_eq!(removed.channel_id, channel_id); + assert!(removed.membership_changed); + assert!(removed.channel_changed); + + assert!(replay_nip29_outcome(KIND_TEXT_NOTE, Some(channel_id), &event).is_none()); + } + #[test] fn join_request_does_not_require_h_tag_via_requires_h() { // kind:9021 uses h-tag for channel reference but doesn't go through diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 4a38441220..8956b2ba72 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -103,7 +103,7 @@ pub async fn authorize_moderation_action( // The target's community role is read only for the admin guard rail — i.e. // an admin actioning a pubkey with ban/timeout — so the owner and // channel-role paths stay at a single query. - let target_role = match (actor_role.as_deref(), action, target) { + let target_role: Option = match (actor_role.as_deref(), action, target) { (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { match target { ModerationTarget::Pubkey(pk) => state @@ -119,7 +119,7 @@ pub async fn authorize_moderation_action( // The channel role is read only when community authority does not apply and // the action is channel-local (DeleteMessage/Kick within `channel_id`). - let channel_role = match (actor_role.as_deref(), action, channel_id) { + let channel_role: Option = match (actor_role.as_deref(), action, channel_id) { (Some("owner") | Some("admin"), _, _) => None, (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { state @@ -148,6 +148,9 @@ pub async fn authorize_moderation_action_tx( target: ModerationTarget<'_>, action: ModerationAction, ) -> anyhow::Result { + // Moderation is rare. Table SHARE locks close the absent-row race as well + // as update/delete races: every role insert/update/delete takes the + // conflicting ROW EXCLUSIVE lock before it can commit. sqlx::query("LOCK TABLE relay_members IN SHARE MODE") .execute(&mut **transaction) .await?; @@ -159,7 +162,6 @@ pub async fn authorize_moderation_action_tx( .execute(&mut **transaction) .await?; } - let community = tenant.community(); let actor_role: Option = sqlx::query_scalar( "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index c769ac3cb9..1dd66920f2 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -65,6 +65,7 @@ use buzz_core::kind::{ use buzz_core::tenant::TenantContext; use chrono::{DateTime, TimeZone, Utc}; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::info; use uuid::Uuid; @@ -132,6 +133,495 @@ pub async fn handle_moderation_command( } } +/// Execute a moderation command in the protected PostgreSQL authorization +/// transaction. Durable moderation state, audit state, and the idempotency +/// receipt commit together; notices and disconnects are derived delivery after +/// that authoritative commit. +pub async fn handle_moderation_command_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let actor = event.pubkey.to_bytes().to_vec(); + validate_command_admission(tenant, state, event, &actor).await?; + let command = ProtectedModerationCommand::parse(event)?; + command.authorize(tenant, state, &actor).await?; + + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.command.v1", + event.id.as_bytes(), + ) + .map_err(|execution_error| error(execution_error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.command.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + let post_commit = + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"moderated" { + return Err(error("protected moderation receipt is invalid")); + } + None + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + command + .authorize_tx(operation.transaction(), tenant, &actor) + .await?; + let post_commit = command + .execute(operation.transaction(), tenant, &actor) + .await?; + operation + .commit(b"moderated") + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))?; + Some(post_commit) + } + }; + + if let Some(post_commit) = post_commit { + post_commit.deliver_enforced(tenant, state, event).await; + } + Ok(()) +} + +async fn validate_command_admission( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let restriction = state + .db + .moderation_restriction_state(tenant.community(), actor) + .await + .map_err(|e| error(format!("database error checking restriction state: {e}")))?; + ensure_actor_not_banned(&restriction)?; + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + return Err(invalid(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + event_ts - now + ))); + } + Ok(()) +} + +enum ProtectedModerationCommand { + Ban { + target: Vec, + expires_at: Option>, + reason: Option, + }, + Unban { + target: Vec, + }, + Timeout { + target: Vec, + muted_until: DateTime, + reason: Option, + }, + Untimeout { + target: Vec, + }, + Resolve { + report_event_id: Vec, + status: String, + action: String, + reason: Option, + }, +} + +impl ProtectedModerationCommand { + fn parse(event: &Event) -> Result { + match event.kind.as_u16() as u32 { + KIND_MODERATION_BAN => Ok(Self::Ban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + expires_at: extract_expiration(event)?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNBAN => Ok(Self::Unban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_TIMEOUT => Ok(Self::Timeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + muted_until: extract_expiration(event)? + .ok_or_else(|| invalid("timeout requires an expiration tag"))?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNTIMEOUT => Ok(Self::Untimeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_RESOLVE_REPORT => { + let report_event_id = extract_report_tag(event).ok_or_else(|| { + invalid("missing or invalid report tag (expect 64-hex event id)") + })?; + let status = extract_tag_value(event, "status") + .ok_or_else(|| invalid("missing status tag"))?; + let action = extract_tag_value(event, "action") + .ok_or_else(|| invalid("missing action tag"))?; + validate_resolution(&status, &action)?; + Ok(Self::Resolve { + report_event_id, + status, + action, + reason: extract_tag_value(event, "reason"), + }) + } + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), + } + } + + async fn authorize( + &self, + tenant: &TenantContext, + state: &Arc, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + authorize_moderation_action(tenant, state, actor, None, target, action) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn authorize_tx( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + super::moderation_authz::authorize_moderation_action_tx( + transaction, + tenant, + actor, + None, + target, + action, + ) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn execute( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result { + let community = tenant.community(); + match self { + Self::Ban { + target, + expires_at, + reason, + } => { + buzz_db::moderation::ban_member_tx( + transaction, + community, + target, + actor, + reason.as_deref(), + *expires_at, + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "ban", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::Ban { + target: target.clone(), + }) + } + Self::Unban { target } => { + if !buzz_db::moderation::unban_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not banned")); + } + insert_audit_tx( + transaction, + community, + actor, + "unban", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Timeout { + target, + muted_until, + reason, + } => { + buzz_db::moderation::timeout_member_tx( + transaction, + community, + target, + actor, + *muted_until, + reason.as_deref(), + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "timeout", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Untimeout { target } => { + if !buzz_db::moderation::untimeout_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not timed out")); + } + insert_audit_tx( + transaction, + community, + actor, + "untimeout", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Resolve { + report_event_id, + status, + action, + reason, + } => { + let report = buzz_db::moderation::get_report_by_event_tx( + transaction, + community, + report_event_id, + ) + .await + .map_err(moderation_db_error)? + .ok_or_else(|| invalid("report not found in this community"))?; + if report.status != "open" { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + let (target_pubkey, target_event_id) = match &report.target { + buzz_db::moderation::ReportTarget::Pubkey(pubkey) => { + (Some(pubkey.as_slice()), None) + } + buzz_db::moderation::ReportTarget::Event(event_id) => { + (None, Some(event_id.as_slice())) + } + buzz_db::moderation::ReportTarget::Blob(_) => (None, None), + }; + let action_id = insert_audit_tx( + transaction, + community, + actor, + resolution_audit_action(action), + target_pubkey, + target_event_id, + reason.as_deref(), + ) + .await?; + if !buzz_db::moderation::resolve_report_tx( + transaction, + community, + report.id, + status, + actor, + Some(action_id), + ) + .await + .map_err(moderation_db_error)? + { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + Ok(ModerationPostCommit::Resolve { + report_id: report.id, + status: status.clone(), + action: action.clone(), + }) + } + } + } +} + +enum ModerationPostCommit { + None, + Ban { + target: Vec, + }, + Resolve { + report_id: Uuid, + status: String, + action: String, + }, +} + +impl ModerationPostCommit { + /// Apply only effects that are safe after the transaction-owned Enforce + /// commit. Relay-signed notice delivery is intentionally unavailable here: + /// it can create or unhide a DM and persist helper events, so treating it as + /// derived delivery would reopen an unfenced protected mutation path. + async fn deliver_enforced(self, tenant: &TenantContext, state: &Arc, event: &Event) { + match self { + Self::None => {} + Self::Ban { target } => { + state.disconnect_pubkey_clusterwide( + tenant, + &target, + &event.id.to_hex(), + "blocked: you are banned from this community", + ); + } + Self::Resolve { + report_id, + status, + action, + } => { + info!(%report_id, %status, %action, "report resolved"); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn insert_audit_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: buzz_core::CommunityId, + actor: &[u8], + action: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + public_reason: Option<&str>, +) -> Result { + buzz_db::moderation::insert_action_tx( + transaction, + community, + NewAction { + actor_pubkey: actor, + action, + target_pubkey, + target_event_id, + channel_id: None, + reason_code: None, + public_reason, + private_reason: None, + matched_principal: None, + }, + ) + .await + .map_err(|database_error| error(format!("failed to write audit row: {database_error}"))) +} + +fn moderation_db_error(database_error: buzz_db::DbError) -> String { + error(format!("database error: {database_error}")) +} + +fn validate_resolution(status: &str, action: &str) -> Result<(), String> { + if status != "resolved" && status != "dismissed" { + return Err(invalid(format!( + "invalid status: {status} (expect resolved|dismissed)" + ))); + } + if !matches!( + action, + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(invalid(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + ))); + } + if (action == "dismiss") != (status == "dismissed") { + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); + } + Ok(()) +} + fn ensure_actor_not_banned( restriction: &buzz_db::moderation::RestrictionState, ) -> Result<(), String> { diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index 8f57eea71f..0cbf0ae394 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -112,7 +112,7 @@ pub async fn send_moderation_notice( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e194..3b77b5c8f0 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::product_feedback::NewProductFeedback; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -18,6 +19,101 @@ pub async fn handle( event: &Event, state: &Arc, ) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + state + .db + .insert_product_feedback( + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; + + Ok(()) +} + +/// Validate and persist feedback at the transaction-owned protected commit +/// boundary. A retry observes the original receipt and never writes twice. +pub async fn handle_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "product.feedback.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-product-feedback-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "product.feedback.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"accepted" { + return Err("error: protected feedback receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::product_feedback::insert_tx( + operation.transaction(), + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|error| { + format!("error: database error inserting product feedback: {error}") + })?; + operation + .commit(b"accepted") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +async fn validate<'a>( + tenant: &TenantContext, + event: &'a Event, + state: &Arc, +) -> Result< + ( + Option<&'a str>, + serde_json::Value, + chrono::DateTime, + ), + String, +> { let category = parse_category(event)?; validate_body(&event.content)?; let imeta_tags = event @@ -38,23 +134,7 @@ pub async fn handle( chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) .ok_or_else(|| "invalid: feedback timestamp is out of range".to_string())?; - state - .db - .insert_product_feedback( - tenant.community(), - NewProductFeedback { - event_id: event.id.as_bytes(), - submitter_pubkey: &event.pubkey.to_bytes(), - category, - body: &event.content, - tags: &tags, - event_created_at, - }, - ) - .await - .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; - - Ok(()) + Ok((category, tags, event_created_at)) } fn serialize_tags(event: &Event) -> Result { diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516..297540292c 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::{info, warn}; use buzz_core::kind::{ @@ -207,6 +208,218 @@ pub(super) async fn handle_relay_admin_event( .map_err(RelayAdminError::Rejected) } +/// Execute a relay-admin command inside the common protected authorization +/// transaction. Relay-signed roster announcements are deliberately not +/// emitted here: they are derived background effects and Enforce denies those +/// until they have their own authoritative model. +pub(super) async fn handle_relay_admin_event_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), RelayAdminError> { + enforce_freshness(event).map_err(RelayAdminError::Rejected)?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.admin.v1", + event.id.as_bytes(), + ) + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-admin-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.admin.v1", request.finalize().into()) + .map_err(|_| RelayAdminError::Rejected("protected authorization denied".into()))? + .ok_or_else(|| RelayAdminError::Rejected("protected authorization denied".into()))?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"applied" { + return Err(RelayAdminError::Internal( + "protected relay-admin receipt is invalid".into(), + )); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let restriction = buzz_db::moderation::restriction_state_tx( + operation.transaction(), + tenant.community(), + &event.pubkey.to_bytes(), + ) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + admits_relay_admin_command(&restriction)?; + execute_relay_admin_command_tx(tenant, event, operation.transaction()) + .await + .map_err(RelayAdminError::Rejected)?; + operation + .commit(b"applied") + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + } + } + Ok(()) +} + +fn enforce_freshness(event: &Event) -> Result<(), String> { + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", + event_ts - now + )); + } + Ok(()) +} + +async fn execute_relay_admin_command_tx( + tenant: &TenantContext, + event: &Event, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let sender_hex = event.pubkey.to_hex(); + let sender_member = + buzz_db::relay_members::get_relay_member_tx(transaction, tenant.community(), &sender_hex) + .await + .map_err(|error| format!("database error: {error}"))?; + let sender_role = sender_member + .as_ref() + .map(|member| member.role.as_str()) + .unwrap_or(""); + + if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let icon = extract_tag_value(event, "icon").unwrap_or_default(); + validate_workspace_icon(&icon)?; + let updated = sqlx::query("UPDATE communities SET icon = $2 WHERE id = $1") + .bind(tenant.community().as_uuid()) + .bind((!icon.is_empty()).then_some(icon.as_str())) + .execute(&mut **transaction) + .await + .map_err(|error| format!("failed to store workspace icon: {error}"))?; + if updated.rows_affected() != 1 { + return Err("community not found".into()); + } + return Ok(()); + } + + let target_hex = extract_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + match kind { + RELAY_ADMIN_ADD_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".into()); + if role == "owner" { + return Err("invalid role: use kind:9032 to promote to owner".into()); + } + if role == "admin" && sender_role != "owner" { + return Err("actor not authorized: only owner can grant admin role".into()); + } + if role != "admin" && role != "member" { + return Err(format!("invalid role: {role}")); + } + buzz_db::relay_members::add_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + &role, + Some(&sender_hex), + ) + .await + .map_err(|error| format!("database error: {error}"))?; + } + RELAY_ADMIN_REMOVE_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + if target_hex == sender_hex { + return Err("cannot remove yourself".into()); + } + let result = if sender_role == "admin" { + buzz_db::relay_members::remove_relay_member_if_role_tx( + transaction, + tenant.community(), + &target_hex, + "member", + ) + .await + } else { + buzz_db::relay_members::remove_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + } + .map_err(|error| format!("database error: {error}"))?; + match result { + RemoveResult::Removed => {} + RemoveResult::IsOwner => return Err("cannot remove the relay owner".into()), + RemoveResult::NotFound => return Err(format!("member not found: {target_hex}")), + RemoveResult::RoleMismatch => { + return Err("actor not authorized: admins can only remove members".into()) + } + } + } + RELAY_ADMIN_CHANGE_ROLE => { + if sender_role != "owner" { + return Err("actor not authorized: must be owner".into()); + } + if target_hex == sender_hex { + return Err("cannot change your own role".into()); + } + let new_role = + extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?; + if new_role == "owner" { + return Err("cannot set role to owner".into()); + } + if new_role != "admin" && new_role != "member" { + return Err(format!("invalid role: {new_role}")); + } + if !buzz_db::relay_members::update_relay_member_role_tx( + transaction, + tenant.community(), + &target_hex, + &new_role, + ) + .await + .map_err(|error| format!("database error: {error}"))? + { + let exists = buzz_db::relay_members::get_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + .map_err(|error| format!("database error: {error}"))?; + return Err(if exists.is_some() { + "cannot change the relay owner's role".into() + } else { + format!("member not found: {target_hex}") + }); + } + } + other => return Err(format!("unexpected relay admin kind: {other}")), + } + Ok(()) +} + /// Execute an already-admitted relay admin command. /// /// The handler: @@ -231,19 +444,7 @@ async fn execute_relay_admin_command( // This mirrors the NIP-42 auth event freshness check and prevents replay // of captured admin commands. The window is intentionally tight — admin // events should be freshly signed. - { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { - return Err(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", - event_ts - now - )); - } - } + enforce_freshness(event)?; let sender_member = state .db diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b..4261b228e8 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -46,6 +47,100 @@ pub async fn handle_report_event( event: &Event, state: &Arc, ) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + + state + .db + .insert_moderation_report( + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|e| format!("error: database error inserting report: {e}"))?; + + Ok(()) +} + +/// Persist a protected report and its authorization receipt atomically. +pub async fn handle_report_event_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.report.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-report-request-v1"); + request.update(event.id.as_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.report.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"reported" { + return Err("error: protected report receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::moderation::insert_report_tx( + operation.transaction(), + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|error| format!("error: database error inserting report: {error}"))?; + operation + .commit(b"reported") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +struct PreparedReport { + reporter_pubkey: Vec, + target: ReportTarget, + channel_id: Option, + report_type: String, + note: Option, +} + +impl PreparedReport { + fn as_new_report<'a>(&'a self, report_event_id: &'a [u8]) -> NewReport<'a> { + NewReport { + report_event_id, + reporter_pubkey: &self.reporter_pubkey, + target: self.target.clone(), + channel_id: self.channel_id, + report_type: &self.report_type, + note: self.note.as_deref(), + } + } +} + +async fn prepare_report( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -61,36 +156,35 @@ pub async fn handle_report_event( } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); - // Known Phase-1 limitation: the media sidecar API does not expose a - // cheap typed not-found vs transient-storage distinction here, so - // all lookup failures surface as a missing blob to the reporter. - state - .media_storage - .get_sidecar(tenant, &sha_hex) - .await - .map_err(|_| "invalid: report target blob not found".to_string())?; + if state.is_protected_enforcing(tenant.community()) { + state + .db + .media_publication(tenant.community(), &sha_hex) + .await + .map_err(|error| { + format!("error: database error resolving report target: {error}") + })? + .ok_or_else(|| "invalid: report target blob not found".to_string())?; + } else { + // Legacy modes preserve sidecar-authoritative resolution. + state + .media_storage + .get_sidecar(tenant, &sha_hex) + .await + .map_err(|_| "invalid: report target blob not found".to_string())?; + } (ReportTarget::Blob(sha256), None) } ParsedReportTarget::Pubkey { pubkey } => (ReportTarget::Pubkey(pubkey), None), }; - state - .db - .insert_moderation_report( - tenant.community(), - NewReport { - report_event_id: event.id.as_bytes(), - reporter_pubkey: &reporter_pubkey, - target, - channel_id, - report_type: parsed.report_type, - note: report_note(event), - }, - ) - .await - .map_err(|e| format!("error: database error inserting report: {e}"))?; - - Ok(()) + Ok(PreparedReport { + reporter_pubkey: reporter_pubkey.to_vec(), + target, + channel_id, + report_type: parsed.report_type.to_owned(), + note: report_note(event).map(ToOwned::to_owned), + }) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51..63db5a99e7 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -24,6 +24,25 @@ use crate::state::AppState; const MAX_SUBSCRIPTIONS: usize = 1024; +fn historical_event_release_fence( + state: &AppState, + conn: &ConnectionState, + channel_id: Option, + actor: &[u8], + protected: Arc, +) -> Arc { + match channel_id { + Some(channel_id) => crate::connection::queued_channel_read_authority( + state.db.clone(), + conn.tenant.community(), + channel_id, + actor.to_vec(), + Some(protected), + ), + None => crate::connection::queued_local_authority(protected), + } +} + /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. /// /// NIP-01 gives each filter its own DB query (OR semantics — see the comment at @@ -85,6 +104,38 @@ pub async fn handle_req( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_req", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected REQ authorization denied"); + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + let mut accessible_channels = if filters_are_nip43_membership_only(&filters) { metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534") .increment(1); @@ -97,7 +148,10 @@ pub async fn handle_req( Ok(ids) => ids, Err(e) => { warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -151,7 +205,10 @@ pub async fn handle_req( } Err(e) => { warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -162,10 +219,10 @@ pub async fn handle_req( token_allows, db_is_member, ) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: not a channel member", - )); + conn.send_protected( + RelayMessage::closed(&sub_id, "restricted: not a channel member"), + Arc::clone(&protected), + ); return; } } @@ -226,11 +283,21 @@ pub async fn handle_req( &conn, &state, trace_state.as_ref(), + &protected, ) .await; return; } + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + { let mut subs = conn.subscriptions.lock().await; subs.insert(sub_id.clone(), filters.clone()); @@ -323,7 +390,7 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); return; } }; @@ -400,7 +467,22 @@ pub async fn handle_req( } let msg = RelayMessage::event(&sub_id, &stored.event); - if !conn.send(msg) { + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + &state, + &conn, + stored.channel_id, + &pubkey_bytes, + Arc::clone(&protected), + ); + if !conn.send_guarded(msg, release) { return; } total_sent += 1; @@ -410,7 +492,11 @@ pub async fn handle_req( } } - conn.send(RelayMessage::eose(&sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); + } else { + conn.cancel.cancel(); + } debug!( conn_id = %conn_id, @@ -532,6 +618,7 @@ async fn handle_search_req( conn: &ConnectionState, state: &AppState, trace_state: Option<&crate::conformance::AbstractState>, + protected: &Arc, ) { // The community-wide channel scope (no #h tag on the filter). `None` means // "no accessible channels and no global access" → EOSE, exactly as the @@ -540,7 +627,7 @@ async fn handle_search_req( match build_search_channel_scope_filter(accessible_channels, include_global) { Some(scope) => scope, None => { - conn.send(RelayMessage::eose(sub_id)); + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); return; } }; @@ -730,7 +817,18 @@ async fn handle_search_req( if !seen_ids.insert(stored.event.id) { continue; } - if !conn.send(RelayMessage::event(sub_id, &stored.event)) { + if protected.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + state, + conn, + stored.channel_id, + reader_pubkey_bytes, + Arc::clone(protected), + ); + if !conn.send_guarded(RelayMessage::event(sub_id, &stored.event), release) { return; } emitted += 1; @@ -743,7 +841,11 @@ async fn handle_search_req( } } - conn.send(RelayMessage::eose(sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); + } else { + conn.cancel.cancel(); + } } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..e20de37d9a 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -36,7 +36,7 @@ pub fn is_side_effect_kind(kind: u32) -> bool { matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) } -async fn evict_live_channel_subscriptions( +pub(crate) async fn evict_live_channel_subscriptions( tenant: &TenantContext, state: &Arc, channel_id: Uuid, @@ -78,7 +78,6 @@ async fn disable_departed_member_workflows( Ok(n) => { tracing::info!( channel = %channel_id, - owner = %hex::encode(target_pubkey), disabled = n, "Disabled departed member's workflows" ); @@ -89,7 +88,6 @@ async fn disable_departed_member_workflows( Err(e) => { warn!( channel = %channel_id, - owner = %hex::encode(target_pubkey), error = %e, "Failed to disable departed member's workflows — per-fire authority gate still denies" ); @@ -1179,7 +1177,7 @@ async fn handle_agent_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1188,7 +1186,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(policy, "kind:10100 channel_add_policy updated"); Ok(()) } @@ -1237,7 +1235,7 @@ async fn handle_kind0_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1261,8 +1259,7 @@ async fn handle_kind0_profile( if let Err(ref e) = result { let msg = format!("{e}"); if msg.contains("duplicate key value") || msg.contains("23505") { - warn!(pubkey = %hex::encode(&pubkey_bytes), - "kind:0 NIP-05 handle contested, syncing profile without it"); + warn!("kind:0 NIP-05 handle contested, syncing profile without it"); state .db .update_user_profile( @@ -1279,7 +1276,7 @@ async fn handle_kind0_profile( } } - info!(pubkey = %hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); + info!("kind:0 profile synced to users table"); Ok(()) } @@ -1807,7 +1804,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -1829,7 +1826,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -2520,6 +2517,17 @@ async fn handle_git_repo_announcement( event: &Event, state: &Arc, ) -> anyhow::Result<()> { + // Enforce announcements reserve their name and replace the NIP-33 event in + // the authorization-owned PostgreSQL transaction. PostgreSQL starts them + // unpublished; the first authorized push publishes the immutable manifest. + // Running the legacy pointer path here would be an unfenced dual write. + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + // The ingest caller holds the legacy visibility transaction across event + // persistence and this pointer write. Acquiring a second guard here can + // exhaust the pool when announcements are processed concurrently. + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant).await?; // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; @@ -2664,10 +2672,8 @@ async fn handle_git_repo_announcement( "failed to ensure manifest pointer: {pointer_err}" )); } - info!( repo_id = %repo_id, - owner = %owner_hex, reserved = reserved_by_this_attempt, "kind:30617 repo announced (name reserved, manifest pointer ensured)" ); @@ -2691,7 +2697,6 @@ async fn handle_git_repo_announcement( // "repo now exists" event, but clone/push still works. warn!( repo_id = %repo_id, - owner = %owner_hex, error = %e, "failed to emit initial kind:30618 ref state (non-fatal)" ); @@ -2885,6 +2890,9 @@ pub async fn reconcile_nip43_membership_snapshots(state: &Arc) -> anyh for community in communities { let community_id = buzz_core::CommunityId::from_uuid(community.id); + if state.is_protected_enforcing(community_id) { + continue; + } let host = community.host; let result = async { if !state @@ -3057,6 +3065,10 @@ pub async fn reconcile_channel_events( ) -> anyhow::Result<()> { use buzz_db::event::EventQuery; + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + let channels = state.db.list_channels(tenant.community(), None).await?; if channels.is_empty() { return Ok(()); diff --git a/crates/buzz-relay/src/protected_surface.rs b/crates/buzz-relay/src/protected_surface.rs index 137186a8b5..4223d934e5 100644 --- a/crates/buzz-relay/src/protected_surface.rs +++ b/crates/buzz-relay/src/protected_surface.rs @@ -1,54 +1,219 @@ -//! Fail-closed compatibility seam for protected transport coupling. +//! Provider-neutral inventory of relay authorization surfaces. //! -//! The route-inventory slice replaces this bounded seam with the complete -//! machine-readable inventory. Unknown surfaces never match, and effects that -//! need the later inventory deny while enforcement is active. +//! This is the single reviewable registry for HTTP routes and long-lived +//! protocol operations. It records both protected operations and deliberate +//! exemptions. Runtime code derives the requested portable capability from +//! this module; request data never selects a provider profile or policy. +use axum::http::Method; use buzz_auth::{AuthTransport, AuthorizationCapability}; use crate::authorization_runtime::finalization::AuthorizationMode; -/// Closed effect identifiers referenced by the protected-transport slice. +/// Closed identifier for every backend-visible effect family. +/// +/// Adding an effect requires adding a registry row and choosing an explicit +/// Enforce disposition. Dynamic helper names cannot manufacture a permit. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum EffectSurfaceId { + /// Durable Nostr event storage. + EventPersistence, + /// Channel, member, profile, reaction, moderation, or deletion projection. + EventDomainProjection, + /// Invitation creation. + InviteMint, + /// Invitation consumption and final membership creation. + InviteClaim, + /// PostgreSQL-authoritative media visibility. + MediaPublication, + /// PostgreSQL-authoritative Git ref visibility. + GitPublication, + /// Existing-member audio session admission. + AudioAdmission, + /// Legacy automatic audio membership creation. + AudioAutomaticMembership, + /// Durable audio lifecycle event persistence. + AudioLifecyclePersistence, + /// Automatic last-participant channel archival. + AudioAutomaticArchive, + /// Interactive workflow definition or state mutation. + WorkflowStateMutation, /// Autonomous or delayed workflow execution. WorkflowBackgroundExecution, - /// Legacy best-effort audit delivery. + /// Arbitrary outbound HTTP webhook. + OutboundWebhook, + /// Any helper that has not been assigned a closed effect identifier. + UnclassifiedHelper, + /// Recipient-fenced local WebSocket delivery. + LocalFanout, + /// Recipient-fenced Redis delivery hint. + RedisFanout, + /// Redis-backed presence visible only through retained authority. + ProtectedPresence, + /// Persistent relay-signed helper event. + RelaySignedHelperEvent, + /// External push delivery. + PushDelivery, + /// Legacy best-effort audit-channel delivery; O5 owns durable audit. LegacyAuditDelivery, + /// Cache eviction or connection cancellation derived from a commit. + CacheAndConnectionInvalidation, + /// Repairable legacy media sidecar written after authoritative publication. + MediaLegacySidecar, + /// Legacy moderation upload record emitted by object-store creation. + MediaUploadRecord, + /// Repairable legacy Git pointer written after authoritative publication. + GitLegacyPointer, + /// Durable invalidation polling and reconciliation. + AuthorizationReconciliation, + /// Durable public-assertion retirement derived from committed identity lifecycle state. + PublicProjectionRetirement, + /// Retryable local and cross-replica delivery of a committed public retirement. + PublicProjectionRetirementDelivery, + /// Dedicated exact-connection delivery of current binding status or withdrawal. + ClientStatusDelivery, + /// Storage garbage collection. + StorageGarbageCollection, + /// Reminder claim or publication. + ReminderWorker, + /// Push matching or delivery worker. + PushWorker, + /// Partition, expiry, or retention maintenance. + DatabaseMaintenance, + /// Audio mesh ownership maintenance. + AudioMeshMaintenance, + /// Metrics-only observation. + MetricsObservation, } -/// Proof that one effect is permitted in a non-enforcing compatibility mode. -pub struct EffectPermit { - id: EffectSurfaceId, +/// Effect relationship to protected business state. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectClass { + /// The effect is the durable source of protected state or visibility. + AuthoritativeMutation, + /// The effect is derived from an already-committed authoritative result. + DerivedDelivery, + /// The effect is server-owned maintenance rather than user authority. + SystemMaintenance, } -impl EffectPermit { - /// Registered effect represented by this permit. - pub const fn id(&self) -> EffectSurfaceId { - self.id - } +/// Code location category used by inventory coverage checks. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectOrigin { + /// HTTP route. + Http, + /// WebSocket operation. + WebSocket, + /// Shared helper invoked from more than one route. + Helper, + /// Autonomous or delayed background task. + Background, } -/// Fail-closed compatibility error before the complete inventory is installed. -#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] -pub enum EffectPermitError { - /// The effect requires the complete route inventory in enforcing mode. - #[error("protected effect inventory is not installed")] - InventoryRequired, +/// Why an effect is deliberately unavailable in Enforce. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum UnavailableReason { + /// No transaction-owned authorization permit is retained. + MissingTransactionAuthority, + /// No reviewed autonomous/system authority model exists. + MissingBackgroundAuthority, + /// The target cannot provide an authoritative idempotent commit boundary. + MissingExternalCommitPrimitive, + /// The legacy behavior would create membership implicitly. + AutomaticMembershipForbidden, + /// The effect has not been classified and registered. + UnclassifiedEffect, } -/// Deny registered effects in enforcing mode until the complete inventory is installed. -pub fn require_effect_permit( - mode: Option, - id: EffectSurfaceId, -) -> Result { - if mode == Some(AuthorizationMode::Enforce) { - return Err(EffectPermitError::InventoryRequired); +/// Enforce behavior selected for a registered effect. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EnforceDisposition { + /// The effect has the required authoritative or release primitive. + Supported, + /// Deny synchronously before the effect begins. + DenyBeforeEffect(UnavailableReason), +} + +/// One machine-readable effect registry row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EffectSurface { + /// Closed effect identifier. + pub id: EffectSurfaceId, + /// Stable provider-neutral inventory label. + pub name: &'static str, + /// Relationship to protected state. + pub class: EffectClass, + /// Code location category. + pub origin: EffectOrigin, + /// Portable capability, when the effect acts for a user operation. + pub capability: Option, + /// Enforce behavior. + pub enforce: EnforceDisposition, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +/// Enforce implementation selected for one authenticated EVENT kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventMutationDisposition { + /// The event and thread metadata use the common PostgreSQL executor. + TransactionalPersistence, + /// The kind requires a projection that has no transaction-aware adapter. + UnavailableProjection, + /// The kind enters a command/workflow executor without a shared commit. + UnavailableCommandOrWorkflow, +} + +/// Classify every EVENT kind before any Enforce mutation begins. +/// +/// Unknown kinds still fail in the ingest allowlist. This function only +/// chooses the commit primitive for kinds that pass normal protocol checks. +pub fn event_mutation_disposition(kind: u32) -> EventMutationDisposition { + use buzz_core::kind::*; + + if buzz_core::kind::is_moderation_command_kind(kind) + || matches!(kind, KIND_REPORT | KIND_GIT_REPO_ANNOUNCEMENT) + { + return EventMutationDisposition::TransactionalPersistence; } - Ok(EffectPermit { id }) + if matches!( + kind, + KIND_WORKFLOW_DEF + | KIND_WORKFLOW_TRIGGER + | KIND_APPROVAL_GRANT + | KIND_APPROVAL_DENY + | KIND_PUSH_LEASE + ) { + return EventMutationDisposition::UnavailableCommandOrWorkflow; + } + if matches!( + kind, + KIND_DM_OPEN + | KIND_DM_ADD_MEMBER + | KIND_DM_HIDE + | KIND_PRODUCT_FEEDBACK + | KIND_NIP43_LEAVE_REQUEST + | KIND_NIP29_CREATE_INVITE + ) || buzz_core::kind::is_relay_admin_kind(kind) + || buzz_core::kind::is_identity_archive_request_kind(kind) + { + return EventMutationDisposition::TransactionalPersistence; + } + if matches!( + kind, + 9003..=9004 + | 9006 + | 9010..=9020 + | 41001..=41003 + | 40099 + ) { + return EventMutationDisposition::UnavailableProjection; + } + EventMutationDisposition::TransactionalPersistence } -/// Recheck the stable handler surface, proof transport, and portable capability. +/// Recheck the stable handler surface, proof transport, and selected portable +/// capability before a resolver can observe the request. pub fn protected_operation_matches( surface: &str, transport: AuthTransport, @@ -111,6 +276,952 @@ pub fn protected_operation_matches( } } +/// Why a registered surface is deliberately outside tenant authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum SurfaceExemption { + /// Public relay metadata (NIP-05 and NIP-11-adjacent information). + PublicMetadata, + /// Kubernetes or service health endpoint. + HealthProbe, + /// Public pre-membership policy bootstrap. + JoinBootstrap, + /// Deployment-global operator authentication. + OperatorAuth, + /// Deployment-admin host/session authentication. + AdminAuth, + /// Loopback-only, HMAC-authenticated Git hook callback. + LocalHookCallback, + /// Disabled-by-default mesh testbed endpoint. + TestbedOnly, + /// Static UI fallback that cannot reach an API handler. + StaticUiFallback, +} + +/// How a registered surface participates in protected authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SurfaceProtection { + /// One fixed portable capability is required for the request. + Capability(AuthorizationCapability), + /// The request body or protocol operation determines the capability. + DynamicCapability, + /// A fixed capability committed through an authoritative transaction/CAS. + AtomicMutation(AuthorizationCapability), + /// A dynamically selected mutation committed through an authoritative executor. + DynamicAtomicMutation, + /// A fixed capability whose Enforce path remains unavailable until a + /// backend-specific transaction/CAS executor owns the complete commit. + AtomicMutationUnavailable(AuthorizationCapability), + /// A dynamically selected mutation capability with the same fail-closed + /// backend-executor requirement. + DynamicAtomicMutationUnavailable, + /// Authentication is completed after the HTTP upgrade. + Session { + /// Fixed capability for the session, or `None` for per-operation WS + /// authorization after NIP-42 AUTH. + capability: Option, + }, + /// A long-lived session whose admission mutates protected state and is + /// therefore unavailable in Enforce without an atomic backend executor. + AtomicMutationSessionUnavailable { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// A non-persistent session admitted under a bounded lease and cancellation fence. + LeasedSession { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// Plain GET/HEAD is public metadata; a WebSocket upgrade enters protected + /// per-operation session authorization. + ConditionalWebSocketUpgrade, + /// Protection depends on the server-owned media-read setting. + ConditionalMediaRead(AuthorizationCapability), + /// Deliberately outside tenant protected authorization. + Exempt(SurfaceExemption), +} + +impl SurfaceProtection { + /// Stable low-cardinality label for tracing and inventory exports. + pub const fn trace_label(self) -> &'static str { + match self { + Self::Capability(_) => "required", + Self::DynamicCapability => "required_dynamic_capability", + Self::AtomicMutation(_) => "required_atomic_commit", + Self::DynamicAtomicMutation => "required_dynamic_atomic_commit", + Self::AtomicMutationUnavailable(_) => "enforce_unavailable_without_atomic_executor", + Self::DynamicAtomicMutationUnavailable => { + "enforce_unavailable_without_dynamic_atomic_executor" + } + Self::Session { .. } => "required_at_session_auth", + Self::AtomicMutationSessionUnavailable { .. } => { + "enforce_session_unavailable_without_atomic_executor" + } + Self::LeasedSession { .. } => "required_leased_session", + Self::ConditionalWebSocketUpgrade => "required_on_websocket_upgrade", + Self::ConditionalMediaRead(_) => "required_when_media_reads_protected", + Self::Exempt(SurfaceExemption::PublicMetadata) => "exempt_public_metadata", + Self::Exempt(SurfaceExemption::HealthProbe) => "exempt_health_probe", + Self::Exempt(SurfaceExemption::JoinBootstrap) => "exempt_join_bootstrap", + Self::Exempt(SurfaceExemption::OperatorAuth) => "exempt_operator_auth", + Self::Exempt(SurfaceExemption::AdminAuth) => "exempt_admin_auth", + Self::Exempt(SurfaceExemption::LocalHookCallback) => "exempt_local_hook_callback", + Self::Exempt(SurfaceExemption::TestbedOnly) => "exempt_testbed_only", + Self::Exempt(SurfaceExemption::StaticUiFallback) => "exempt_static_ui_fallback", + } + } +} + +/// Required lifetime checkpoints for a protected operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum GuardPoint { + /// Validate before handler work begins. + Request, + /// Revalidate before a durable or externally visible mutation commits. + PreCommit, + /// Revalidate after asynchronous fetches and before buffered output is released. + PreEmission, + /// Revalidate before each streamed chunk or live event emission. + StreamEmission, + /// Renew or close a long-lived session before its lease expires. + SessionRenewal, +} + +const REQUEST: &[GuardPoint] = &[GuardPoint::Request]; +const REQUEST_COMMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreCommit]; +const REQUEST_EMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreEmission]; +const REQUEST_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreEmission, + GuardPoint::StreamEmission, +]; +const SESSION_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; +const SESSION_COMMIT_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreCommit, + GuardPoint::PreEmission, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; + +const fn effect( + id: EffectSurfaceId, + name: &'static str, + class: EffectClass, + origin: EffectOrigin, + capability: Option, + enforce: EnforceDisposition, + guard_points: &'static [GuardPoint], +) -> EffectSurface { + EffectSurface { + id, + name, + class, + origin, + capability, + enforce, + guard_points, + } +} + +/// Exhaustive provider-neutral inventory of backend-visible effect families. +pub const EFFECT_SURFACES: &[EffectSurface] = &[ + effect( + EffectSurfaceId::EventPersistence, + "event.persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::EventDomainProjection, + "event.domain_projection", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteMint, + "invite.mint_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteMint), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteClaim, + "invite.claim_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteClaim), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::MediaPublication, + "media.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::MediaWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::GitPublication, + "git.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::GitWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAdmission, + "audio.existing_member_admission", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::Supported, + SESSION_COMMIT_STREAM, + ), + effect( + EffectSurfaceId::AudioAutomaticMembership, + "audio.automatic_membership", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::AutomaticMembershipForbidden), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioLifecyclePersistence, + "audio.lifecycle_persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAutomaticArchive, + "audio.automatic_archive", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowStateMutation, + "workflow.interactive_state", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowBackgroundExecution, + "workflow.background_execution", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::OutboundWebhook, + "workflow.outbound_webhook", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::UnclassifiedHelper, + "background.unclassified_helper", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::UnclassifiedEffect), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LocalFanout, + "delivery.local_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RedisFanout, + "delivery.redis_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ProtectedPresence, + "delivery.protected_presence", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RelaySignedHelperEvent, + "delivery.relay_signed_helper_event", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::PushDelivery, + "delivery.external_push", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LegacyAuditDelivery, + "delivery.legacy_audit_channel", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::CacheAndConnectionInvalidation, + "delivery.cache_connection_invalidation", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaLegacySidecar, + "delivery.media_legacy_sidecar", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaUploadRecord, + "delivery.media_upload_record", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::GitLegacyPointer, + "delivery.git_legacy_pointer", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::AuthorizationReconciliation, + "maintenance.authorization_reconciliation", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::PublicProjectionRetirement, + "maintenance.public_projection_retirement", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PublicProjectionRetirementDelivery, + "delivery.public_projection_retirement", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ClientStatusDelivery, + "client.status.dedicated_delivery", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::StorageGarbageCollection, + "maintenance.storage_gc", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::ReminderWorker, + "maintenance.reminder_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PushWorker, + "maintenance.push_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::DatabaseMaintenance, + "maintenance.database", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioMeshMaintenance, + "maintenance.audio_mesh", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::MetricsObservation, + "maintenance.metrics_observation", + EffectClass::SystemMaintenance, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST, + ), +]; + +/// Unforgeable proof that one registered effect is permitted in the selected mode. +pub struct EffectPermit { + id: EffectSurfaceId, +} + +impl EffectPermit { + /// Registered effect represented by this permit. + pub const fn id(&self) -> EffectSurfaceId { + self.id + } +} + +/// Fail-closed effect classification error. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum EffectPermitError { + /// The closed identifier has no registry row. + #[error("protected effect is not classified")] + Unclassified, + /// The registered effect is deliberately unavailable in protected mode. + #[error("protected effect is unavailable in protected mode")] + Unavailable(UnavailableReason), +} + +/// Require a registered pre-effect permit for one exact activation mode. +pub fn require_effect_permit( + mode: Option, + id: EffectSurfaceId, +) -> Result { + let surface = EFFECT_SURFACES + .iter() + .find(|surface| surface.id == id) + .ok_or(EffectPermitError::Unclassified)?; + if mode.is_some_and(AuthorizationMode::protects_surfaces) { + if let EnforceDisposition::DenyBeforeEffect(reason) = surface.enforce { + return Err(EffectPermitError::Unavailable(reason)); + } + } + Ok(EffectPermit { id }) +} + +/// Resolve only a registered stable helper name; unknown names never fall back. +pub fn effect_surface_by_name(name: &str) -> Option<&'static EffectSurface> { + EFFECT_SURFACES.iter().find(|surface| surface.name == name) +} + +/// Require a registered stable effect name. Unknown helper names preserve +/// legacy modes but map to the explicit unclassified-denial row in Enforce. +pub fn require_effect_name( + mode: Option, + name: &str, +) -> Result { + let id = effect_surface_by_name(name) + .map_or(EffectSurfaceId::UnclassifiedHelper, |surface| surface.id); + require_effect_permit(mode, id) +} + +/// One registered HTTP route and its protected-authorization contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpSurface { + /// HTTP method as an uppercase token. + pub method: &'static str, + /// Axum matched-path template, never a literal untrusted path. + pub matched_path: &'static str, + /// Authorization classification. + pub protection: SurfaceProtection, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +const fn http( + method: &'static str, + matched_path: &'static str, + protection: SurfaceProtection, + guard_points: &'static [GuardPoint], +) -> HttpSurface { + HttpSurface { + method, + matched_path, + protection, + guard_points, + } +} + +const fn exempt(reason: SurfaceExemption) -> SurfaceProtection { + SurfaceProtection::Exempt(reason) +} + +/// Exhaustive inventory of API routes registered by the relay router. +/// +/// Static UI fallbacks are recorded separately in [`NON_ROUTER_SURFACES`] +/// because they have no Axum `MatchedPath` and cannot reach an API handler. +pub const HTTP_SURFACES: &[HttpSurface] = &[ + http( + "GET", + "/", + SurfaceProtection::ConditionalWebSocketUpgrade, + SESSION_STREAM, + ), + http( + "HEAD", + "/", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/info", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/.well-known/nostr.json", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/health", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_liveness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_readiness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_status", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_mesh", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "POST", + "/events", + SurfaceProtection::DynamicAtomicMutation, + REQUEST_COMMIT, + ), + http( + "POST", + "/query", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "POST", + "/count", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "GET", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/archive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/unarchive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "GET", + "/operator/communities/availability", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/transfer", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/api/invites", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteMint), + REQUEST_COMMIT, + ), + http( + "POST", + "/api/invites/claim", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteClaim), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/join-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/terms", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/privacy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "POST", + "/api/invites/accept-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/moderation/reports", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/audit", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/restricted", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "POST", + "/hooks/{id}", + SurfaceProtection::AtomicMutationUnavailable(AuthorizationCapability::CommunityWrite), + REQUEST_COMMIT, + ), + http( + "POST", + "/_mesh/demo/echo", + exempt(SurfaceExemption::TestbedOnly), + REQUEST, + ), + http( + "POST", + "/internal/git/policy", + exempt(SurfaceExemption::LocalHookCallback), + REQUEST, + ), + http( + "GET", + "/huddle/{channel_id}/audio", + SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin, + }, + SESSION_COMMIT_STREAM, + ), + http( + "PUT", + "/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "PUT", + "/media/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_STREAM, + ), + http( + "HEAD", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_EMIT, + ), + http( + "GET", + "/git/{owner}/{repo}/info/refs", + SurfaceProtection::DynamicCapability, + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-upload-pack", + SurfaceProtection::Capability(AuthorizationCapability::GitRead), + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-receive-pack", + SurfaceProtection::AtomicMutation(AuthorizationCapability::GitWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/admin/v1/reports", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/reports/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}/attachments/{sha256}", + exempt(SurfaceExemption::AdminAuth), + REQUEST_STREAM, + ), +]; + +/// Surface outside Axum's matched-route inventory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NonRouterSurface { + /// Stable surface name. + pub name: &'static str, + /// Explicit protection or exemption. + pub protection: SurfaceProtection, +} + +/// Explicit inventory for request fallbacks and other non-router surfaces. +pub const NON_ROUTER_SURFACES: &[NonRouterSurface] = &[NonRouterSurface { + name: "static_ui_fallback", + protection: SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback), +}]; + +/// Classify a registered method and trusted Axum matched-path template. +pub fn classify_http(method: &Method, matched_path: &str) -> Option<&'static HttpSurface> { + let exact = HTTP_SURFACES + .iter() + .find(|surface| surface.method == method.as_str() && surface.matched_path == matched_path); + if exact.is_some() || method != Method::HEAD { + return exact; + } + HTTP_SURFACES + .iter() + .find(|surface| surface.method == "GET" && surface.matched_path == matched_path) +} + +/// Whether any registered method names the matched template. +pub fn is_known_http_path(matched_path: &str) -> bool { + HTTP_SURFACES + .iter() + .any(|surface| surface.matched_path == matched_path) +} + +/// RFC 9110 `Allow` value for a known matched template. +pub fn allowed_http_methods(matched_path: &str) -> Option { + let mut methods = Vec::new(); + for surface in HTTP_SURFACES + .iter() + .filter(|surface| surface.matched_path == matched_path) + { + if !methods.contains(&surface.method) { + methods.push(surface.method); + } + if surface.method == "GET" && !methods.contains(&"HEAD") { + methods.push("HEAD"); + } + } + (!methods.is_empty()).then(|| methods.join(", ")) +} + +/// Protected WebSocket operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum WebSocketOperation { + /// NIP-42 session bootstrap. It establishes identity but grants no data-plane capability. + Auth, + /// Historical or live subscription creation. + Req, + /// Aggregate query. + Count, + /// Persistent or ephemeral event ingest. + Event { + /// Nostr event kind used to select write or moderation authority. + kind: u32, + }, + /// Live event delivery to a subscription. + Fanout, +} + +/// One protocol-level WebSocket operation and its release/commit contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebSocketSurface { + /// Stable low-cardinality operation label. + pub operation: &'static str, + /// Operation-level authorization classification. + pub protection: SurfaceProtection, + /// Required checks from dispatch through commit or socket emission. + pub guard_points: &'static [GuardPoint], +} + +/// Operation-level inventory for the WebSocket route. +/// +/// The HTTP `/` row describes only upgrade/session lifetime. This table keeps +/// EVENT commit fencing distinct from REQ/COUNT/fanout release fencing. +pub const WEBSOCKET_SURFACES: &[WebSocketSurface] = &[ + WebSocketSurface { + operation: "AUTH", + protection: SurfaceProtection::Session { capability: None }, + guard_points: REQUEST, + }, + WebSocketSurface { + operation: "REQ", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, + WebSocketSurface { + operation: "COUNT", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_EMIT, + }, + WebSocketSurface { + operation: "EVENT", + protection: SurfaceProtection::DynamicAtomicMutation, + guard_points: REQUEST_COMMIT, + }, + WebSocketSurface { + operation: "fanout", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, +]; + +/// Return the exact capability for a WebSocket operation. +/// +/// AUTH deliberately returns `None`: verification or authentication alone must +/// never become data-plane authority. Every subsequent operation requests its +/// own capability through the same direct/delegated runtime path. +pub const fn websocket_capability( + operation: WebSocketOperation, +) -> Option { + match operation { + WebSocketOperation::Auth => None, + WebSocketOperation::Req | WebSocketOperation::Count | WebSocketOperation::Fanout => { + Some(AuthorizationCapability::CommunityRead) + } + WebSocketOperation::Event { kind: 9040..=9044 } => Some(AuthorizationCapability::Moderate), + WebSocketOperation::Event { .. } => Some(AuthorizationCapability::CommunityWrite), + } +} + /// Return the exact HTTP bridge event-ingest capability. pub const fn event_ingest_capability(kind: u32) -> AuthorizationCapability { match kind { @@ -119,7 +1230,7 @@ pub const fn event_ingest_capability(kind: u32) -> AuthorizationCapability { } } -/// Resolve Git's `info/refs` capability from the validated service name. +/// Resolve Git's `info/refs` capability from the server-validated service. pub fn git_info_refs_capability(service: &str) -> Option { match service { "git-upload-pack" => Some(AuthorizationCapability::GitRead), @@ -127,3 +1238,543 @@ pub fn git_info_refs_capability(service: &str) -> Option None, } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn effect_inventory_is_closed_unique_and_guarded() { + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + for surface in EFFECT_SURFACES { + assert!( + ids.insert(surface.id), + "duplicate effect id: {:?}", + surface.id + ); + assert!( + names.insert(surface.name), + "duplicate effect name: {}", + surface.name + ); + assert!(!surface.name.is_empty()); + assert!(!surface.guard_points.is_empty()); + if surface.class == EffectClass::AuthoritativeMutation + && surface.enforce == EnforceDisposition::Supported + && surface.id != EffectSurfaceId::AudioAdmission + { + assert!(surface.guard_points.contains(&GuardPoint::PreCommit)); + } + if surface.class == EffectClass::DerivedDelivery + && surface.enforce == EnforceDisposition::Supported + { + assert!(surface.guard_points.contains(&GuardPoint::PreEmission)); + } + assert_eq!(effect_surface_by_name(surface.name), Some(surface)); + } + } + + #[test] + fn event_effect_classification_separates_transactional_and_unavailable_paths() { + for kind in [ + buzz_core::kind::KIND_TEXT_NOTE, + buzz_core::kind::KIND_REACTION, + buzz_core::kind::KIND_DELETION, + buzz_core::kind::KIND_REPORT, + buzz_core::kind::KIND_LONG_FORM, + buzz_core::kind::KIND_MODERATION_BAN, + buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT, + buzz_core::kind::KIND_PROFILE, + buzz_core::kind::KIND_AGENT_PROFILE, + buzz_core::kind::KIND_PRODUCT_FEEDBACK, + buzz_core::kind::KIND_NIP43_LEAVE_REQUEST, + buzz_core::kind::KIND_IA_ARCHIVE_REQUEST, + buzz_core::kind::KIND_IA_UNARCHIVE_REQUEST, + buzz_core::kind::RELAY_ADMIN_ADD_MEMBER, + buzz_core::kind::RELAY_ADMIN_REMOVE_MEMBER, + buzz_core::kind::RELAY_ADMIN_CHANGE_ROLE, + buzz_core::kind::RELAY_ADMIN_SET_WORKSPACE_PROFILE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence, + "interactive kind {kind} must retain transaction-owned persistence" + ); + } + for kind in [ + buzz_core::kind::KIND_NIP29_PUT_USER, + buzz_core::kind::KIND_NIP29_REMOVE_USER, + buzz_core::kind::KIND_NIP29_EDIT_METADATA, + buzz_core::kind::KIND_NIP29_DELETE_EVENT, + buzz_core::kind::KIND_NIP29_CREATE_GROUP, + buzz_core::kind::KIND_NIP29_DELETE_GROUP, + buzz_core::kind::KIND_NIP29_JOIN_REQUEST, + buzz_core::kind::KIND_NIP29_LEAVE_REQUEST, + buzz_core::kind::KIND_NIP29_CREATE_INVITE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence + ); + } + assert_eq!( + event_mutation_disposition(buzz_core::kind::KIND_WORKFLOW_TRIGGER), + EventMutationDisposition::UnavailableCommandOrWorkflow + ); + } + + #[test] + fn enforce_denies_background_webhooks_and_automatic_membership_before_effect() { + for id in [ + EffectSurfaceId::WorkflowBackgroundExecution, + EffectSurfaceId::OutboundWebhook, + EffectSurfaceId::UnclassifiedHelper, + EffectSurfaceId::AudioAutomaticMembership, + EffectSurfaceId::ReminderWorker, + EffectSurfaceId::PushWorker, + EffectSurfaceId::LegacyAuditDelivery, + ] { + assert!(require_effect_permit(Some(AuthorizationMode::Enforce), id).is_err()); + assert!(require_effect_permit(Some(AuthorizationMode::Off), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::Shadow), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::VerifyOnly), id).is_ok()); + assert!(require_effect_permit(None, id).is_ok()); + } + } + + #[test] + fn unknown_helper_name_has_no_enforce_fallback() { + assert!(effect_surface_by_name("helper.added_without_classification").is_none()); + assert!(require_effect_name( + Some(AuthorizationMode::Enforce), + "helper.added_without_classification" + ) + .is_err()); + assert!(require_effect_name( + Some(AuthorizationMode::Off), + "helper.added_without_classification" + ) + .is_ok()); + } + + fn assert_ordered(source: &str, first: &str, second: &str) { + let first_index = source.find(first).expect("first boundary exists"); + let second_index = source.find(second).expect("second boundary exists"); + assert!(first_index < second_index, "{first} must precede {second}"); + } + + #[test] + fn inventory_keys_are_unique_and_every_row_has_guards() { + let mut seen = HashSet::new(); + for surface in HTTP_SURFACES { + assert!( + seen.insert((surface.method, surface.matched_path)), + "duplicate protected-surface row for {} {}", + surface.method, + surface.matched_path + ); + assert!(!surface.guard_points.is_empty()); + } + for surface in WEBSOCKET_SURFACES { + assert!(seen.insert(("WS", surface.operation))); + assert!(!surface.guard_points.is_empty()); + } + } + + #[test] + fn dynamic_operations_request_exact_capabilities() { + assert_eq!(websocket_capability(WebSocketOperation::Auth), None); + assert_eq!( + websocket_capability(WebSocketOperation::Req), + Some(AuthorizationCapability::CommunityRead) + ); + assert_eq!( + event_ingest_capability(9042), + AuthorizationCapability::Moderate + ); + assert_eq!( + event_ingest_capability(1), + AuthorizationCapability::CommunityWrite + ); + assert!(protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteClaim, + )); + assert!(!protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteMint, + )); + } + + #[test] + fn conditional_and_session_roots_are_explicit() { + assert!(matches!( + classify_http(&Method::GET, "/").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalWebSocketUpgrade) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::PublicMetadata)) + )); + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .map(|surface| surface.protection), + Some(SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin + }) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/media/{sha256_ext}").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalMediaRead( + AuthorizationCapability::MediaRead + )) + )); + } + + #[test] + fn exemptions_are_named_and_unknown_routes_fail_classification() { + assert!(matches!( + classify_http(&Method::GET, "/_readiness").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::HealthProbe)) + )); + assert!(classify_http(&Method::GET, "/unclassified").is_none()); + assert!(classify_http(&Method::GET, "/media/literal-sha").is_none()); + assert_eq!(NON_ROUTER_SURFACES.len(), 1); + assert!(matches!( + NON_ROUTER_SURFACES[0].protection, + SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback) + )); + } + + #[test] + fn git_advertisement_service_selects_read_or_write() { + assert_eq!( + git_info_refs_capability("git-upload-pack"), + Some(AuthorizationCapability::GitRead) + ); + assert_eq!( + git_info_refs_capability("git-receive-pack"), + Some(AuthorizationCapability::GitWrite) + ); + assert_eq!(git_info_refs_capability("unknown"), None); + } + + #[test] + fn every_mutating_route_declares_its_authoritative_or_unavailable_boundary() { + for (method, path) in [ + (Method::POST, "/events"), + (Method::POST, "/api/invites"), + (Method::POST, "/api/invites/claim"), + ] { + let protection = classify_http(&method, path) + .expect("mutating route is inventoried") + .protection; + assert!(matches!( + protection, + SurfaceProtection::AtomicMutation(_) | SurfaceProtection::DynamicAtomicMutation + )); + } + assert!(matches!( + classify_http(&Method::POST, "/hooks/{id}") + .expect("webhook is inventoried") + .protection, + SurfaceProtection::AtomicMutationUnavailable(_) + )); + for (method, path) in [ + (Method::PUT, "/upload"), + (Method::PUT, "/media/upload"), + (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + ] { + assert!(matches!( + classify_http(&method, path) + .expect("functional mutation is inventoried") + .protection, + SurfaceProtection::AtomicMutation(_) + )); + } + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .expect("audio is inventoried") + .protection, + SurfaceProtection::LeasedSession { .. } + )); + assert!(WEBSOCKET_SURFACES.iter().any(|surface| { + surface.operation == "EVENT" + && surface.protection == SurfaceProtection::DynamicAtomicMutation + && surface.guard_points.contains(&GuardPoint::PreCommit) + })); + } + + #[test] + fn enforce_mutation_gates_precede_every_shared_business_effect_boundary() { + let ingest = include_str!("handlers/ingest.rs"); + assert_ordered( + ingest, + "event_mutation_disposition", + "handle_moderation_command", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "insert_event_with_thread_metadata_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "apply_nip29_mutation_tx", + ); + let report_path = ingest + .split_once("if kind_u32 == KIND_REPORT") + .expect("report path exists") + .1; + assert_ordered( + report_path, + "handle_report_event_enforced", + "return Ok(IngestResult", + ); + let moderation_path = ingest + .split_once("if buzz_core::kind::is_moderation_command_kind(kind_u32)") + .expect("moderation path exists") + .1; + assert_ordered( + moderation_path, + "handle_moderation_command_enforced", + "return Ok(IngestResult", + ); + let feedback_path = ingest + .split_once("if kind_u32 == KIND_PRODUCT_FEEDBACK") + .expect("feedback path exists") + .1; + assert_ordered( + feedback_path, + "handle_enforced(tenant, &event, state, &protected)", + "emit_product_feedback_success", + ); + let relay_admin = include_str!("handlers/relay_admin.rs"); + let enforced_relay_admin = relay_admin + .split_once("handle_relay_admin_event_enforced") + .expect("protected relay-admin executor exists") + .1; + assert_ordered( + enforced_relay_admin, + "begin_authorized_operation", + "execute_relay_admin_command_tx", + ); + let identity_archive = include_str!("handlers/identity_archive.rs"); + let enforced_archive = identity_archive + .split_once("handle_identity_archive_event_tx") + .expect("protected archive transaction exists") + .1; + assert_ordered(enforced_archive, "determine_consent_path_tx", "archive_tx"); + let relay_leave = ingest + .split_once("if kind_u32 == KIND_NIP43_LEAVE_REQUEST") + .expect("relay leave path exists") + .1; + assert_ordered( + relay_leave, + "begin_authorized_operation", + "remove_relay_member_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "replace_protected_announcement_tx", + ); + let media = include_str!("api/media.rs"); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "if upload_rate_limited(", + ); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "acquire_upload_permit(state, community_id, pubkey)", + ); + let media_upload = media + .split_once("pub async fn upload_blob") + .expect("media upload handler exists") + .1; + assert_ordered( + media_upload, + "commit_media_publication(", + "Ok(Json(descriptor))", + ); + + let git = include_str!("api/git/transport.rs"); + let finalize_push = git + .split_once("async fn finalize_push") + .expect("Git push finalizer exists") + .1; + assert_ordered( + finalize_push, + "begin_authorized_operation(", + "compare_and_publish_git(", + ); + let after_commit = finalize_push + .split_once("operation.commit(&payload)") + .expect("PostgreSQL Git publication commits a receipt") + .1; + assert!(after_commit.contains("build_git_response(\"receive-pack\"")); + + let bridge = include_str!("api/bridge.rs"); + let webhook = bridge + .split_once("pub async fn workflow_webhook") + .expect("workflow webhook handler exists") + .1; + assert_ordered(webhook, "require_effect_permit(", ".get_workflow("); + assert_ordered(webhook, "require_effect_permit(", ".create_workflow_run("); + assert_ordered( + include_str!("workflow_sink.rs"), + "require_effect_permit(", + ".insert_event_with_thread_metadata(", + ); + + let workflow_engine = include_str!("../../buzz-workflow/src/lib.rs"); + let on_event = workflow_engine + .split_once("pub async fn on_event") + .expect("event workflow trigger exists") + .1; + assert_ordered(on_event, "self.require_mutation(", ".create_workflow_run("); + let scheduler = workflow_engine + .split_once("pub async fn run") + .expect("workflow scheduler exists") + .1; + assert_ordered( + scheduler, + "self.require_mutation(", + ".claim_scheduled_workflow_fire(", + ); + assert_ordered(scheduler, "self.require_mutation(", ".create_workflow_run("); + + let workflow_executor = include_str!("../../buzz-workflow/src/executor.rs"); + let action_dispatch = workflow_executor + .split_once("pub async fn dispatch_action") + .expect("workflow action dispatcher exists") + .1; + assert_ordered( + action_dispatch, + "engine.require_mutation(", + "add_reaction_impl(", + ); + assert_ordered( + action_dispatch, + "engine.require_outbound_webhook(", + "call_webhook_impl(", + ); + + let relay_main = include_str!("main.rs"); + assert_ordered(relay_main, "set_mutation_gate(", "wf_cron.run("); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "reap_expired_ephemeral_channels_excluding", + ); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "query_due_reminders_excluding", + ); + + let push = include_str!("push_runtime.rs"); + assert_ordered( + push, + "enforcing_protected_domain_ids()", + "claim_due_push_match_batch_excluding", + ); + assert_ordered(push, "is_protected_enforcing", "claim_due_push_wakes"); + + let websocket = include_str!("handlers/event.rs"); + let ephemeral = websocket + .split_once("async fn handle_ephemeral_event") + .expect("ephemeral event handler exists") + .1; + assert_ordered(ephemeral, "authority.revalidate()", ".publish_event("); + assert_ordered( + ephemeral, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(ephemeral.contains("fan_out_event_to_local_subscribers_with_authority")); + let observer = websocket + .split_once("async fn handle_agent_observer_event") + .expect("observer event handler exists") + .1; + assert_ordered(observer, "authority.revalidate()", ".publish_event("); + assert_ordered( + observer, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(observer.contains("fan_out_event_to_local_subscribers_with_authority")); + let presence = websocket + .split_once("if event_kind_u32(&event) == KIND_PRESENCE_UPDATE") + .expect("presence effect boundary exists") + .1; + assert_ordered(presence, "encode_presence(", ".set_presence("); + assert!(websocket.contains("send_to_text_bytes_guarded_pair")); + assert_ordered( + websocket, + "if legacy_audit_delivery_allowed", + "enqueue_event_created_audit(", + ); + let connection = include_str!("connection.rs"); + assert!(connection.contains("CombinedReleaseFence")); + assert!(connection.contains("self.sender.release().await")); + assert!(connection.contains("self.recipient.release().await")); + + let presence_read = bridge + .split_once("async fn synthesize_presence") + .expect("presence read boundary exists") + .1; + assert_ordered(presence_read, "decode_presence(", "verify_actor_context("); + assert_ordered( + presence_read, + "verify_actor_context(", + "presence_map.insert(", + ); + + let invites = include_str!("api/invites.rs"); + let mint = invites + .split_once("pub async fn mint_invite") + .expect("invite mint handler exists") + .1; + assert_ordered(mint, "begin_authorized_operation", "mint_relay_invite_tx"); + let claim = invites + .split_once("pub async fn claim_invite") + .expect("invite claim handler exists") + .1; + assert_ordered( + claim, + "begin_authorized_enrollment", + "claim_relay_invite_with_identity_tx", + ); + + assert_ordered( + include_str!("audio/handler.rs"), + "debug_assert!(!protected_authority.is_enforcing())", + ".add_member_with_identity(", + ); + + let corporate_identity = include_str!("corporate_identity.rs") + .split_once("async fn record_identity_binding_audit") + .expect("identity audit helper exists") + .1; + assert_ordered( + corporate_identity, + "require_effect_permit(", + "let Some(audit_tx)", + ); + + let media_audit = media + .split_once("// Audit via bounded channel") + .expect("media audit boundary exists") + .1; + assert_ordered(media_audit, "require_effect_permit(", "audit_tx"); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 7fe4a5f667..5c922b2e52 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -206,16 +206,20 @@ async fn enforce_corporate_identity_route_inventory( request: Request, next: Next, ) -> axum::response::Response { - enforce_route_inventory_for_requirement(state.config.corporate_identity.require, request, next) - .await + enforce_route_inventory_for_requirement( + state.config.corporate_identity.require || state.protected_transport().is_some(), + request, + next, + ) + .await } async fn enforce_route_inventory_for_requirement( - corporate_identity_required: bool, + protected_inventory_required: bool, request: Request, next: Next, ) -> axum::response::Response { - if !corporate_identity_required { + if !protected_inventory_required { return next.run(request).await; } let matched_path = request.extensions().get::(); @@ -240,11 +244,11 @@ async fn enforce_route_inventory_for_requirement( tracing::error!( method = %request.method(), matched_path = matched_path.map(|path| path.as_str()).unwrap_or(""), - "rejecting route missing corporate identity policy classification" + "rejecting route missing protected-surface policy classification" ); ( StatusCode::SERVICE_UNAVAILABLE, - "route unavailable: identity policy is not configured", + "route unavailable: protected-surface policy is not configured", ) .into_response() } @@ -374,15 +378,21 @@ async fn nip11_or_ws_handler( .into_response(); } }; - let corporate_identity_jwt = match crate::corporate_identity::identity_jwt_from_headers( - &headers, - &state.config.corporate_identity, - ) { - Ok(jwt) => jwt, - Err(_) => { - return (StatusCode::UNAUTHORIZED, "invalid identity assertion").into_response(); - } - }; + let corporate_identity_assertion = + match crate::corporate_identity::identity_assertion_from_headers( + &state, + tenant.community(), + &headers, + ) { + Ok(assertion) => assertion, + Err(error) => { + return ( + error.status_code(), + format!("restricted: {}", error.public_message()), + ) + .into_response() + } + }; let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { @@ -398,7 +408,7 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection(socket, state, addr, tenant, corporate_identity_jwt) + handle_connection(socket, state, addr, tenant, corporate_identity_assertion) }) .into_response() } diff --git a/crates/buzz-relay/src/router/route_policy.rs b/crates/buzz-relay/src/router/route_policy.rs index 38859e10a9..37653953da 100644 --- a/crates/buzz-relay/src/router/route_policy.rs +++ b/crates/buzz-relay/src/router/route_policy.rs @@ -1,369 +1,33 @@ -//! Central inventory of corporate-identity policy at the HTTP routing boundary. -//! -//! This module classifies axum's *matched route template* (for example, -//! `/media/{sha256_ext}`), not an untrusted literal request path. Keeping the -//! complete inventory here makes every authenticated surface and every -//! deliberate exemption reviewable in one place. The handlers remain the -//! enforcement point because they have the authenticated principal, resolved -//! tenant, and admission result needed to finalize an identity safely. +//! Router adapter for the provider-neutral protected-surface inventory. use axum::http::Method; -/// Why a route deliberately does not use tenant corporate-identity auth. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityExemption { - /// Public relay metadata (NIP-05, NIP-11-adjacent information). - PublicMetadata, - /// Kubernetes/service health endpoint. - HealthProbe, - /// Public pre-membership policy and policy-acceptance bootstrap. - JoinBootstrap, - /// Deployment-global operator NIP-98 allowlist, outside tenant auth. - OperatorAuth, - /// Deployment-admin host/session authentication, outside tenant auth. - AdminAuth, - /// Per-workflow secret authentication. - WebhookSecret, - /// Loopback-only, HMAC-authenticated Git hook callback. - LocalHookCallback, - /// Disabled-by-default mesh testbed endpoint. - TestbedOnly, -} - -/// Corporate-identity policy for a registered route. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityRoutePolicy { - /// Authenticate and enforce corporate identity during this HTTP request. - Required, - /// Enforce when the upgraded WebSocket performs its protocol auth flow. - RequiredAtSessionAuth, - /// Public only when protected media reads are disabled; otherwise required. - RequiredWhenMediaReadsProtected, - /// Deliberately outside tenant corporate-identity authentication. - Exempt(CorporateIdentityExemption), -} - -impl CorporateIdentityRoutePolicy { - /// Stable, low-cardinality label used on HTTP trace spans. - pub(super) const fn trace_label(self) -> &'static str { - match self { - Self::Required => "required", - Self::RequiredAtSessionAuth => "required_at_session_auth", - Self::RequiredWhenMediaReadsProtected => "required_when_media_reads_protected", - Self::Exempt(CorporateIdentityExemption::PublicMetadata) => "exempt_public_metadata", - Self::Exempt(CorporateIdentityExemption::HealthProbe) => "exempt_health_probe", - Self::Exempt(CorporateIdentityExemption::JoinBootstrap) => "exempt_join_bootstrap", - Self::Exempt(CorporateIdentityExemption::OperatorAuth) => "exempt_operator_auth", - Self::Exempt(CorporateIdentityExemption::AdminAuth) => "exempt_admin_auth", - Self::Exempt(CorporateIdentityExemption::WebhookSecret) => "exempt_webhook_secret", - Self::Exempt(CorporateIdentityExemption::LocalHookCallback) => { - "exempt_local_hook_callback" - } - Self::Exempt(CorporateIdentityExemption::TestbedOnly) => "exempt_testbed_only", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct RoutePolicyRule { - method: &'static str, - matched_path: &'static str, - policy: CorporateIdentityRoutePolicy, -} - -const REQUIRED: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::Required; -const SESSION: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::RequiredAtSessionAuth; -const PROTECTED_MEDIA: CorporateIdentityRoutePolicy = - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected; - -const fn exempt(exemption: CorporateIdentityExemption) -> CorporateIdentityRoutePolicy { - CorporateIdentityRoutePolicy::Exempt(exemption) -} - -/// Exhaustive inventory of registered relay routes. -/// -/// Static UI fallback paths are intentionally absent: they do not have an -/// axum `MatchedPath` and cannot reach an API handler. A missing API entry is -/// visible as `unclassified` in the HTTP trace span and must be added here as -/// part of registering the route. -const ROUTE_POLICY_RULES: &[RoutePolicyRule] = &[ - // Protocol and public metadata. - RoutePolicyRule { - method: "GET", - matched_path: "/", - policy: SESSION, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/info", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/.well-known/nostr.json", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - // Health routes on the primary and health-only listeners. - RoutePolicyRule { - method: "GET", - matched_path: "/health", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_liveness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_readiness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_status", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_mesh", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - // NIP-98 HTTP bridge. - RoutePolicyRule { - method: "POST", - matched_path: "/events", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/query", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/count", - policy: REQUIRED, - }, - // Deployment-global operator control plane. - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/archive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/unarchive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities/availability", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/transfer", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - // Invite admission and its deliberately public pre-join policy surface. - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/claim", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/terms", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/privacy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/accept-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - // Moderation data is tenant-authenticated even though it is not event data. - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/reports", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/audit", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/restricted", - policy: REQUIRED, - }, - // Alternate-auth and test-only callbacks. - RoutePolicyRule { - method: "POST", - matched_path: "/hooks/{id}", - policy: exempt(CorporateIdentityExemption::WebhookSecret), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/_mesh/demo/echo", - policy: exempt(CorporateIdentityExemption::TestbedOnly), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/internal/git/policy", - policy: exempt(CorporateIdentityExemption::LocalHookCallback), - }, - // Huddle authentication is performed inside the upgraded socket. - RoutePolicyRule { - method: "GET", - matched_path: "/huddle/{channel_id}/audio", - policy: SESSION, - }, - // Blossom media: writes are always authenticated; reads are configurable. - RoutePolicyRule { - method: "PUT", - matched_path: "/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "PUT", - matched_path: "/media/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - RoutePolicyRule { - method: "HEAD", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - // Git smart HTTP is tenant-authenticated on every request. - RoutePolicyRule { - method: "GET", - matched_path: "/git/{owner}/{repo}/info/refs", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-upload-pack", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-receive-pack", - policy: REQUIRED, - }, - // Deployment-admin APIs use the dedicated admin-host auth middleware. - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}/attachments/{sha256}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, -]; +#[cfg(test)] +use crate::protected_surface::SurfaceExemption as CorporateIdentityExemption; +pub(super) use crate::protected_surface::SurfaceProtection as CorporateIdentityRoutePolicy; -/// Classify a registered method and axum matched-path template. +/// Classify a registered method and Axum matched-path template. pub(super) fn classify_matched_route( method: &Method, matched_path: &str, ) -> Option { - let exact = ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == method.as_str() && rule.matched_path == matched_path) - .map(|rule| rule.policy); - if exact.is_some() || method != Method::HEAD { - return exact; - } - - // axum automatically serves HEAD through GET routes when no explicit HEAD - // handler is registered. Mirror that routing fallback so those requests - // cannot appear unclassified. The explicit protected-media HEAD rule above - // wins before this branch. - ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == "GET" && rule.matched_path == matched_path) - .map(|rule| rule.policy) + crate::protected_surface::classify_http(method, matched_path).map(|entry| entry.protection) } -/// Whether this matched template is registered in the inventory for any -/// method. An unknown method on a known template is a 405, not a new route. +/// Whether this matched template is registered for any method. pub(super) fn is_known_matched_path(matched_path: &str) -> bool { - ROUTE_POLICY_RULES - .iter() - .any(|rule| rule.matched_path == matched_path) + crate::protected_surface::is_known_http_path(matched_path) } -/// RFC 9110 `Allow` value for a known matched template. GET routes include -/// Axum's implicit HEAD support. +/// RFC 9110 `Allow` value for a known matched template. pub(super) fn allowed_methods(matched_path: &str) -> Option { - let mut methods = Vec::new(); - for rule in ROUTE_POLICY_RULES - .iter() - .filter(|rule| rule.matched_path == matched_path) - { - if !methods.contains(&rule.method) { - methods.push(rule.method); - } - if rule.method == "GET" && !methods.contains(&"HEAD") { - methods.push("HEAD"); - } - } - (!methods.is_empty()).then(|| methods.join(", ")) + crate::protected_surface::allowed_http_methods(matched_path) } #[cfg(test)] mod tests { - use std::collections::HashSet; - use super::*; + use buzz_auth::AuthorizationCapability; fn policy(method: Method, path: &str) -> CorporateIdentityRoutePolicy { classify_matched_route(&method, path) @@ -371,129 +35,111 @@ mod tests { } #[test] - fn every_policy_rule_has_a_unique_method_and_path() { - let mut seen = HashSet::new(); - for rule in ROUTE_POLICY_RULES { - assert!( - seen.insert((rule.method, rule.matched_path)), - "duplicate route policy for {} {}", - rule.method, - rule.matched_path - ); - } - } - - #[test] - fn every_tenant_authenticated_http_route_requires_corporate_identity() { - let routes = [ - (Method::POST, "/events"), - (Method::POST, "/query"), - (Method::POST, "/count"), - (Method::POST, "/api/invites"), - (Method::POST, "/api/invites/claim"), - (Method::GET, "/moderation/reports"), - (Method::GET, "/moderation/audit"), - (Method::GET, "/moderation/restricted"), - (Method::PUT, "/upload"), - (Method::PUT, "/media/upload"), - (Method::GET, "/git/{owner}/{repo}/info/refs"), - (Method::POST, "/git/{owner}/{repo}/git-upload-pack"), - (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + fn tenant_routes_map_to_exact_portable_capabilities() { + let read_routes = [ + ( + Method::POST, + "/query", + AuthorizationCapability::CommunityRead, + ), + ( + Method::GET, + "/moderation/reports", + AuthorizationCapability::Moderate, + ), ]; - for (method, path) in routes { - assert_eq!(policy(method, path), CorporateIdentityRoutePolicy::Required); + for (method, path, capability) in read_routes { + assert_eq!( + policy(method, path), + CorporateIdentityRoutePolicy::Capability(capability) + ); } - } - - #[test] - fn websocket_and_media_policies_capture_deferred_and_conditional_auth() { - assert_eq!( - policy(Method::GET, "/"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - assert_eq!( - policy(Method::GET, "/huddle/{channel_id}/audio"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - for method in [Method::GET, Method::HEAD] { + let unavailable_mutation_routes = [( + Method::POST, + "/hooks/{id}", + AuthorizationCapability::CommunityWrite, + )]; + for (method, path, capability) in unavailable_mutation_routes { assert_eq!( - policy(method, "/media/{sha256_ext}"), - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected + policy(method, path), + CorporateIdentityRoutePolicy::AtomicMutationUnavailable(capability) ); } - } - - #[test] - fn privileged_non_tenant_surfaces_have_narrow_named_exemptions() { - let routes = [ + for (method, path, capability) in [ ( Method::POST, - "/operator/communities/archive", - CorporateIdentityExemption::OperatorAuth, - ), - ( - Method::GET, - "/api/admin/v1/reports", - CorporateIdentityExemption::AdminAuth, + "/api/invites", + AuthorizationCapability::InviteMint, ), ( Method::POST, - "/hooks/{id}", - CorporateIdentityExemption::WebhookSecret, + "/api/invites/claim", + AuthorizationCapability::InviteClaim, ), + (Method::PUT, "/upload", AuthorizationCapability::MediaWrite), ( Method::POST, - "/internal/git/policy", - CorporateIdentityExemption::LocalHookCallback, + "/git/{owner}/{repo}/git-receive-pack", + AuthorizationCapability::GitWrite, ), - ]; - for (method, path, exemption) in routes { + ] { assert_eq!( policy(method, path), - CorporateIdentityRoutePolicy::Exempt(exemption) + CorporateIdentityRoutePolicy::AtomicMutation(capability) ); } + assert_eq!( + policy(Method::POST, "/events"), + CorporateIdentityRoutePolicy::DynamicAtomicMutation + ); + assert_eq!( + policy(Method::GET, "/git/{owner}/{repo}/info/refs"), + CorporateIdentityRoutePolicy::DynamicCapability + ); } #[test] - fn public_routes_are_explicit_and_unknown_routes_are_unclassified() { + fn websocket_and_media_policies_are_deferred_or_conditional() { + assert_eq!( + policy(Method::GET, "/"), + CorporateIdentityRoutePolicy::ConditionalWebSocketUpgrade + ); assert_eq!( - policy(Method::GET, "/.well-known/nostr.json"), + policy(Method::HEAD, "/"), CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); assert_eq!( - policy(Method::GET, "/_readiness"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) + policy(Method::GET, "/huddle/{channel_id}/audio"), + CorporateIdentityRoutePolicy::LeasedSession { + capability: AuthorizationCapability::AudioJoin + } ); + for method in [Method::GET, Method::HEAD] { + assert_eq!( + policy(method, "/media/{sha256_ext}"), + CorporateIdentityRoutePolicy::ConditionalMediaRead( + AuthorizationCapability::MediaRead + ) + ); + } + } + + #[test] + fn exemptions_are_narrow_and_unknown_routes_are_unclassified() { assert_eq!( - policy(Method::GET, "/api/join-policy"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::JoinBootstrap) + policy(Method::GET, "/_readiness"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) ); assert_eq!( - policy(Method::POST, "/_mesh/demo/echo"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::TestbedOnly) + policy(Method::POST, "/internal/git/policy"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::LocalHookCallback) ); assert_eq!( policy(Method::HEAD, "/info"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata), - "axum's automatic GET-to-HEAD fallback inherits the GET policy" + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); - assert_eq!(classify_matched_route(&Method::GET, "/events"), None); assert_eq!(classify_matched_route(&Method::GET, "/unknown"), None); - assert_eq!( - classify_matched_route(&Method::GET, "/media/literal-sha"), - None, - "the classifier accepts trusted matched templates, not literal paths" - ); - } - - #[test] - fn known_path_detection_distinguishes_method_fallbacks_from_new_routes() { assert!(is_known_matched_path("/events")); - assert!(is_known_matched_path("/health")); - assert!(!is_known_matched_path("/new-unclassified-route")); assert_eq!(allowed_methods("/events").as_deref(), Some("POST")); - assert_eq!(allowed_methods("/info").as_deref(), Some("GET, HEAD")); - assert_eq!(allowed_methods("/new-unclassified-route"), None); } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 4cc0da398b..5e7b2bebe7 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use buzz_audit::AuditService; -use buzz_auth::{AuthService, Nip98ReplayGuard}; +use buzz_auth::{AuthService, Nip98ReplayGuard, VerifiedFederatedAssertion, VerifiedNostrProof}; use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; use buzz_db::Db; @@ -32,6 +32,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; use crate::connection::ConnectionSubscriptions; +use crate::connection::OutboundData; use crate::corporate_identity::CorporateIdentityService; use crate::subscription::SubscriptionRegistry; @@ -41,7 +42,7 @@ type ScopedRateLimiter = DashMap; /// Per-connection entry in the connection manager. struct ConnEntry { - tx: mpsc::Sender, + tx: mpsc::Sender, /// Control-frame sender, drained ahead of data and before cancel wins in /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). @@ -55,9 +56,57 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + authenticated_owner_pubkey: Arc>>>, + /// Sealed NIP-42 proof retained for every protected operation on this + /// connection. Legacy test registrations may intentionally leave it empty. + verified_nostr_proof: Arc>>>, + /// Current direct federated evidence sealed during authentication. + verified_federated_assertion: Arc>>>, + /// The enforcing authority and its hard-expiry task share one lock so + /// concurrent operations can only tighten, never extend, the session. + protected_session: Arc>, grace_limit: u8, } +struct ProtectedSessionExpiryTask { + deadline: u64, + cancel: CancellationToken, +} + +#[derive(Default)] +struct ProtectedSessionState { + /// Retaining this value keeps its invalidation observer registered until + /// tighter authority replaces it or the connection is removed. + authority: Option>, + expiry: Option, +} + +fn should_replace_protected_session_deadline(current: Option, candidate: u64) -> bool { + current.is_none_or(|current| candidate < current) +} + +fn protected_session_wake_at_from_samples( + deadline: u64, + monotonic_anchor: tokio::time::Instant, + wall_now: std::time::Duration, + coarse_delay: std::time::Duration, +) -> Option { + let wall_remaining = std::time::Duration::from_secs(deadline).checked_sub(wall_now)?; + let conservative_coarse = coarse_delay.saturating_sub(std::time::Duration::from_secs(1)); + monotonic_anchor.checked_add(wall_remaining.min(conservative_coarse)) +} + +fn protected_session_wake_at( + deadline: u64, + monotonic_anchor: tokio::time::Instant, + coarse_delay: std::time::Duration, +) -> Option { + let wall_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + protected_session_wake_at_from_samples(deadline, monotonic_anchor, wall_now, coarse_delay) +} + /// Community-scoped lifecycle registry shared by every long-lived socket type. /// /// A handler registers before durable active-state revalidation. Archival after @@ -206,7 +255,7 @@ impl ConnectionManager { pub fn register( &self, conn_id: Uuid, - tx: mpsc::Sender, + tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, community_id: CommunityId, @@ -226,6 +275,12 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + authenticated_owner_pubkey: Arc::new(std::sync::RwLock::new(None)), + verified_nostr_proof: Arc::new(std::sync::RwLock::new(None)), + verified_federated_assertion: Arc::new(std::sync::RwLock::new(None)), + protected_session: Arc::new( + std::sync::Mutex::new(ProtectedSessionState::default()), + ), grace_limit, }, ); @@ -241,7 +296,64 @@ impl ConnectionManager { /// Removes a connection from the registry. pub fn deregister(&self, conn_id: Uuid) { - self.connections.remove(&conn_id); + if let Some((_, entry)) = self.connections.remove(&conn_id) { + Self::clear_protected_session(&entry); + } + } + + fn clear_protected_session(entry: &ConnEntry) { + if let Ok(mut session) = entry.protected_session.lock() { + if let Some(task) = session.expiry.take() { + task.cancel.cancel(); + } + session.authority = None; + } else { + entry.cancel.cancel(); + } + } + + /// Atomically retain only authority with an earlier hard deadline. + /// + /// The comparison, task replacement, and authority replacement must stay + /// in this critical section: WebSocket handlers for one connection run + /// concurrently and a split read/install would permit lease extension. + fn retain_earlier_protected_session( + entry: &ConnEntry, + deadline: u64, + wake_at: tokio::time::Instant, + authority: Option>, + after_read_hook: Option<&dyn Fn()>, + ) -> bool { + if let Ok(mut session) = entry.protected_session.lock() { + let current = session.expiry.as_ref().map(|task| task.deadline); + if let Some(hook) = after_read_hook { + hook(); + } + if !should_replace_protected_session_deadline(current, deadline) { + return false; + } + if let Some(previous) = session.expiry.take() { + previous.cancel.cancel(); + } + let expiry_task = CancellationToken::new(); + let expiry_cancel = expiry_task.clone(); + let connection_cancel = entry.cancel.clone(); + tokio::spawn(async move { + tokio::select! { + _ = expiry_cancel.cancelled() => {} + _ = tokio::time::sleep_until(wake_at) => connection_cancel.cancel(), + } + }); + session.expiry = Some(ProtectedSessionExpiryTask { + deadline, + cancel: expiry_task, + }); + session.authority = authority; + true + } else { + entry.cancel.cancel(); + false + } } /// Record the authenticated pubkey for a connection after NIP-42 succeeds. @@ -250,6 +362,81 @@ impl ConnectionManager { if let Ok(mut slot) = entry.authenticated_pubkey.write() { *slot = Some(pubkey_bytes); } + if let Ok(mut slot) = entry.authenticated_owner_pubkey.write() { + *slot = None; + } + if let Ok(mut slot) = entry.verified_nostr_proof.write() { + *slot = None; + } + if let Ok(mut slot) = entry.verified_federated_assertion.write() { + *slot = None; + } + Self::clear_protected_session(&entry); + } + } + + /// Record sealed connection evidence and derive actor/owner indexes from it. + pub fn set_authenticated_authority( + &self, + conn_id: Uuid, + proof: Arc, + assertion: Option>, + ) { + if let Some(entry) = self.connections.get(&conn_id) { + if let Ok(mut slot) = entry.authenticated_pubkey.write() { + *slot = Some(proof.actor_pubkey().to_bytes().to_vec()); + } + if let Ok(mut slot) = entry.authenticated_owner_pubkey.write() { + *slot = proof + .verified_delegation() + .map(|delegation| delegation.owner_pubkey().to_bytes().to_vec()); + } + if let Ok(mut slot) = entry.verified_nostr_proof.write() { + *slot = Some(proof); + } + if let Ok(mut slot) = entry.verified_federated_assertion.write() { + *slot = assertion; + } + Self::clear_protected_session(&entry); + } + } + + /// Retain the latest enforcing authority for an established connection. + /// Legacy and observational results clear any older authority without + /// creating a new invalidation registration. + pub fn retain_protected_session_authority( + &self, + conn_id: Uuid, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + ) { + if let Some(entry) = self.connections.get(&conn_id) { + if authority.is_enforcing() { + let candidate = authority.expires_at().unwrap_or_default(); + // Anchor monotonic time before consulting the injected + // whole-second authorization clock. Time spent sampling or + // installing the task must consume authority, never extend it. + let monotonic_anchor = tokio::time::Instant::now(); + match authority.expiry_delay() { + Ok(Some(delay)) => { + if let Some(wake_at) = + protected_session_wake_at(candidate, monotonic_anchor, delay) + { + Self::retain_earlier_protected_session( + &entry, + candidate, + wake_at, + Some(Arc::new(authority.clone())), + None, + ); + } else { + entry.cancel.cancel(); + } + } + Ok(None) | Err(_) => entry.cancel.cancel(), + } + } else { + Self::clear_protected_session(&entry); + } } } @@ -290,6 +477,37 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Return the sealed verifier evidence recorded for a connection. + pub fn authority_for_conn(&self, conn_id: Uuid) -> Option> { + self.connections + .get(&conn_id) + .and_then(|entry| entry.verified_nostr_proof.read().ok()?.clone()) + } + + /// Return current direct federated evidence recorded for a connection. + pub fn federated_assertion_for_conn( + &self, + conn_id: Uuid, + ) -> Option> { + self.connections + .get(&conn_id) + .and_then(|entry| entry.verified_federated_assertion.read().ok()?.clone()) + } + + /// Return the server-owned cancellation token for a live connection. + pub fn cancellation_for_conn(&self, conn_id: Uuid) -> Option { + self.connections + .get(&conn_id) + .map(|entry| entry.cancel.clone()) + } + + /// Cancel one connection after protected session authority expires. + pub fn cancel_connection(&self, conn_id: Uuid) { + if let Some(entry) = self.connections.get(&conn_id) { + entry.cancel.cancel(); + } + } + /// Disconnect every live connection authenticated as `pubkey` **in /// `community`**, delivering a final `OK false` frame carrying `reason` /// before closing. @@ -429,25 +647,13 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } - /// Compatibility seam for retaining protected session authority. - /// - /// No protected runtime is installable in this lower review unit, so the - /// session-conformance slice replaces this no-op with expiry and - /// invalidation retention before production installation becomes possible. - pub fn retain_protected_session_authority( - &self, - _conn_id: Uuid, - _authority: &crate::authorization_runtime::transport::ProtectedAuthorization, - ) { - } - /// Sends a text message to the given connection. /// /// Returns `false` if the connection is gone or the buffer is full. /// On sustained backpressure (>grace_limit consecutive full buffers), /// cancels the connection. Transient stalls get a warning only. pub fn send_to(&self, conn_id: Uuid, msg: String) -> bool { - self.try_send_ws_message(conn_id, WsMessage::Text(msg.into())) + self.try_send_outbound(conn_id, OutboundData::plain(WsMessage::Text(msg.into()))) } /// Sends an already-serialized UTF-8 text payload to the given connection. @@ -457,10 +663,73 @@ impl ConnectionManager { pub fn send_to_text_bytes(&self, conn_id: Uuid, msg: Arc) -> bool { let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) .expect("relay fan-out frames are serialized UTF-8 JSON"); - self.try_send_ws_message(conn_id, WsMessage::Text(text)) + self.try_send_outbound(conn_id, OutboundData::plain(WsMessage::Text(text))) + } + + /// Queue protected text and retain its exact authority until socket drain. + pub fn send_to_text_bytes_protected( + &self, + conn_id: Uuid, + msg: Arc, + authority: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::protected(WsMessage::Text(text), authority), + ) + } + + /// Queue output guarded by both the emitting operation and the recipient's + /// current read authority until socket drain. + pub fn send_to_text_bytes_protected_pair( + &self, + conn_id: Uuid, + msg: Arc, + sender: Arc, + recipient: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::protected_pair(WsMessage::Text(text), sender, recipient), + ) } - fn try_send_ws_message(&self, conn_id: Uuid, msg: WsMessage) -> bool { + /// Queue output behind an arbitrary asynchronous sender fence. + pub(crate) fn send_to_text_bytes_guarded( + &self, + conn_id: Uuid, + msg: Arc, + authority: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::guarded(WsMessage::Text(text), authority), + ) + } + + /// Queue output behind a remote sender fence and local recipient fence. + pub(crate) fn send_to_text_bytes_guarded_pair( + &self, + conn_id: Uuid, + msg: Arc, + sender: Arc, + recipient: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::guarded_pair(WsMessage::Text(text), sender, recipient), + ) + } + + fn try_send_outbound(&self, conn_id: Uuid, msg: OutboundData) -> bool { if let Some(entry) = self.connections.get(&conn_id) { let conn = entry.value(); match conn.tx.try_send(msg) { @@ -640,19 +909,24 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, - /// Optional exact-domain protected transport, unset by this review unit. + /// Optional exact-domain protected-transport runtime. + /// + /// Unset preserves legacy behavior. Once installed it is immutable for the + /// process lifetime so request data cannot switch provider policy. pub protected_transport: Arc< std::sync::OnceLock< Arc, >, >, - /// Deployment-owned assertion provenance, unset by the stock binary. + /// Deployment-verified ingress provenance for direct identity assertions. + /// + /// Unset by the stock binary. Header presence alone is never trusted. pub identity_assertion_provenance: Arc< std::sync::OnceLock< Arc, >, >, - /// Independent restore witness, unavailable until its owning slice. + /// Independent version witness installed with protected authorization. pub restore_protection: Arc< std::sync::OnceLock>, >, @@ -851,14 +1125,22 @@ impl AppState { self.mesh.get() } - /// Current protected transport, if a later composition root installed it. + /// Install immutable protected-transport policy exactly once. + pub fn install_protected_transport( + &self, + runtime: Arc, + ) -> Result<(), Arc> { + self.protected_transport.set(runtime) + } + + /// Current protected-transport runtime, if configured. pub fn protected_transport( &self, ) -> Option<&Arc> { self.protected_transport.get() } - /// Install immutable deployment-owned assertion provenance. + /// Install the immutable deployment-owned ingress provenance adapter. pub fn install_identity_assertion_provenance( &self, verifier: Arc, @@ -866,21 +1148,42 @@ impl AppState { self.identity_assertion_provenance.set(verifier) } - /// Return deployment-owned assertion provenance, if installed. + /// Return the installed ingress provenance adapter, if any. pub fn identity_assertion_provenance( &self, ) -> Option<&Arc> { self.identity_assertion_provenance.get() } - /// Current independent restore witness, if installed by its owning slice. + /// Install the restore-independent version witness exactly once. + pub fn install_restore_protection( + &self, + runtime: Arc, + ) -> Result<(), Arc> { + self.restore_protection.set(runtime) + } + + /// Current restore-independent version witness, if protected mode exists. pub fn restore_protection( &self, ) -> Option<&Arc> { self.restore_protection.get() } - /// Whether an exact domain is in authoritative protected enforcement. + /// Database UUIDs for exact domains where autonomous effects must not run. + pub fn enforcing_protected_domain_ids(&self) -> Vec { + self.protected_transport() + .map(|runtime| { + runtime + .enforcing_domains() + .into_iter() + .map(|domain| *domain.as_uuid()) + .collect() + }) + .unwrap_or_default() + } + + /// Whether one exact server-resolved domain is in authoritative Enforce. pub fn is_protected_enforcing(&self, domain: CommunityId) -> bool { self.protected_transport() .and_then(|runtime| runtime.mode_for_domain(domain)) @@ -1304,7 +1607,7 @@ mod tests { ) -> ( ConnectionManager, Uuid, - mpsc::Receiver, + mpsc::Receiver, mpsc::Receiver, CancellationToken, Arc, @@ -1328,6 +1631,228 @@ mod tests { (mgr, conn_id, rx, ctrl_rx, cancel, bp) } + #[tokio::test(start_paused = true)] + async fn protected_session_deadline_is_conservative_and_anchor_bound() { + let anchor = tokio::time::Instant::now(); + let wake_at = protected_session_wake_at_from_samples( + 102, + anchor, + std::time::Duration::from_millis(100_900), + std::time::Duration::from_secs(2), + ) + .expect("future deadline"); + assert_eq!( + wake_at.duration_since(anchor), + std::time::Duration::from_secs(1), + "whole-second authority is shortened conservatively instead of rounded late" + ); + + // Simulate work between authority-clock sampling and task install. The + // absolute monotonic wake stays tied to the earlier anchor. + tokio::time::advance(std::time::Duration::from_millis(750)).await; + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session(&entry, 102, wake_at, None, None); + drop(entry); + + tokio::time::advance(std::time::Duration::from_millis(249)).await; + tokio::task::yield_now().await; + assert!(!cancel.is_cancelled()); + tokio::time::advance(std::time::Duration::from_millis(1)).await; + tokio::task::yield_now().await; + assert!(cancel.is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn protected_session_expiry_never_extends_and_disconnect_cleans_task() { + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(10), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(9)).await; + assert!(!cancel.is_cancelled()); + + assert!(!should_replace_protected_session_deadline(Some(10), 20)); + assert!(!should_replace_protected_session_deadline(Some(10), 10)); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!( + cancel.is_cancelled(), + "ordinary traffic cannot extend the first hard session deadline" + ); + + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 20, + tokio::time::Instant::now() + std::time::Duration::from_secs(20), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(5)).await; + assert!(should_replace_protected_session_deadline(Some(20), 10)); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(5)).await; + tokio::task::yield_now().await; + assert!( + cancel.is_cancelled(), + "a shorter authority tightens the hard deadline" + ); + + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 5, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + None, + None, + ); + } + tokio::task::yield_now().await; + mgr.deregister(conn_id); + tokio::time::advance(std::time::Duration::from_secs(5)).await; + assert!( + !cancel.is_cancelled(), + "disconnect removes the obsolete expiry task" + ); + } + + #[tokio::test] + async fn concurrent_protected_operations_cannot_replace_an_earlier_deadline() { + let (mgr, conn_id, _rx, _ctrl_rx, _cancel, _bp) = setup_conn(8); + let mgr = Arc::new(mgr); + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + let protected_session = Arc::clone(&entry.protected_session); + drop(entry); + + let runtime = tokio::runtime::Handle::current(); + let short_authority = + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy); + let long_authority = + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy); + let (short_locked, wait_for_short_lock) = std::sync::mpsc::channel(); + let (release_short, wait_for_release) = std::sync::mpsc::channel(); + let short_manager = Arc::clone(&mgr); + let retained_short_authority = Arc::clone(&short_authority); + let short_runtime = runtime.clone(); + let short = std::thread::spawn(move || { + let _runtime = short_runtime.enter(); + let entry = short_manager + .connections + .get(&conn_id) + .expect("registered connection"); + let hook = || { + short_locked.send(()).expect("test still waiting"); + wait_for_release.recv().expect("test releases first lock"); + }; + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(10), + Some(retained_short_authority), + Some(&hook), + ); + }); + wait_for_short_lock + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("short contender holds the comparison/install lock"); + + let (long_attempting, wait_for_long_attempt) = std::sync::mpsc::channel(); + let (long_read, wait_for_long_read) = std::sync::mpsc::channel(); + let long_manager = Arc::clone(&mgr); + let long_runtime = runtime.clone(); + let long = std::thread::spawn(move || { + let _runtime = long_runtime.enter(); + long_attempting.send(()).expect("test still waiting"); + let entry = long_manager + .connections + .get(&conn_id) + .expect("registered connection"); + let hook = || { + long_read.send(()).expect("test still waiting"); + }; + ConnectionManager::retain_earlier_protected_session( + &entry, + 20, + tokio::time::Instant::now() + std::time::Duration::from_secs(20), + Some(long_authority), + Some(&hook), + ); + }); + wait_for_long_attempt + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("long contender reached the atomic helper"); + assert!( + matches!( + wait_for_long_read.recv_timeout(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "a second contender cannot read the deadline before the first install completes" + ); + release_short.send(()).expect("short contender is waiting"); + short.join().expect("short retention thread"); + wait_for_long_read + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("long contender reads only after the short install"); + long.join().expect("long retention thread"); + + let session = protected_session.lock().expect("protected session lock"); + assert_eq!( + session.expiry.as_ref().map(|task| task.deadline), + Some(10), + "the minimum concurrent deadline is retained regardless of completion order" + ); + assert!( + session + .authority + .as_ref() + .is_some_and(|authority| Arc::ptr_eq(authority, &short_authority)), + "the retained authority belongs to the minimum-deadline contender" + ); + } + async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; @@ -1430,7 +1955,7 @@ mod tests { "test.local".to_string(), ), remote_addr: "127.0.0.1:1234".parse().unwrap(), - corporate_identity_jwt: None, + corporate_identity_assertion: None, auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), diff --git a/crates/buzz-relay/src/storage_sweep.rs b/crates/buzz-relay/src/storage_sweep.rs index eccadcd835..241748d970 100644 --- a/crates/buzz-relay/src/storage_sweep.rs +++ b/crates/buzz-relay/src/storage_sweep.rs @@ -25,6 +25,7 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use uuid::Uuid; +use buzz_core::CommunityId; use buzz_media::{BucketSnapshot, SweepError}; /// Sweep knobs, read once at boot. See `PLANS/S3_STORAGE_METRICS_PLAN.md` F7. @@ -107,9 +108,9 @@ struct SweepAttempt { /// renamed, or scope-excluded) are zeroed rather than left at their last /// nonzero value until the recorder's idle-eviction kicks in. /// -/// Carries the resolved host label (not the UUID) so a rename can still zero -/// the old series, and distinguishes bytes vs. objects because they are -/// separate Prometheus series. +/// Carries a stable runtime pseudonym rather than a host or UUID, and +/// distinguishes bytes vs. objects because they are separate Prometheus +/// series. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum StorageEmittedKey { Bytes(String), @@ -119,12 +120,12 @@ pub(crate) enum StorageEmittedKey { impl StorageEmittedKey { fn set(&self, value: f64) { match self { - Self::Bytes(host) => { - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + Self::Bytes(label) => { + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(value); } - Self::Objects(host) => { - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + Self::Objects(label) => { + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(value); } } @@ -263,15 +264,15 @@ pub async fn maybe_spawn_sweep( /// never from the spawned sweep task itself, so a sweep that completes after /// this pod loses leadership parks its snapshot without ever publishing it. /// -/// `host_map` resolves a community UUID to its label string for per- -/// community series; `allows` gates those series the same way +/// `host_map` proves that a community UUID still resolves to a live tenant; +/// the emitted label is an opaque runtime pseudonym. `allows` gates those series the same way /// `EmissionScope` gates the DB-derived ones. A bound community UUID absent /// from `host_map` is "unmapped" (sidecar references a community with no DB /// row) and rolls into `buzz_storage_unmapped_community_bytes` instead of a /// per-community series. /// /// Per-community series whose community disappears from the current snapshot -/// (unmapped, host rename, or scope exclusion) are explicitly zeroed — the +/// (unmapped or scope exclusion) are explicitly zeroed — the /// same pattern as `emit_in_memory_usage_metrics`. Without this, a series /// would linger at its last nonzero value until the recorder's idle eviction /// fires (≥3 ticks), producing a transient double-count against the @@ -326,19 +327,20 @@ pub async fn emit_storage_metrics( let mut current = HashSet::new(); let mut unmapped_bytes = 0u64; for (community_id, storage) in &snapshot.per_community { - let Some(host) = host_map.get(community_id) else { + if !host_map.contains_key(community_id) { unmapped_bytes += storage.bytes; continue; - }; + } if !allows(community_id) { continue; } - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + let label = crate::metrics::community_label(CommunityId::from_uuid(*community_id)); + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(storage.bytes as f64); - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(storage.objects as f64); - current.insert(StorageEmittedKey::Bytes(host.clone())); - current.insert(StorageEmittedKey::Objects(host.clone())); + current.insert(StorageEmittedKey::Bytes(label.clone())); + current.insert(StorageEmittedKey::Objects(label)); } metrics::gauge!("buzz_storage_unmapped_community_bytes").set(unmapped_bytes as f64); @@ -982,6 +984,9 @@ mod tests { }); let recorder = DebuggingRecorder::new(); + let label_a = crate::metrics::community_label(CommunityId::from_uuid(community_a)); + let label_b = crate::metrics::community_label(CommunityId::from_uuid(community_b)); + let label_c = crate::metrics::community_label(CommunityId::from_uuid(community_c)); // --- Emission 1: all three communities visible --- let mut host_map_1 = HashMap::new(); @@ -994,18 +999,12 @@ mod tests { { let labeled = labeled_community_gauges(&recorder); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&10.0), "emission 1: host.a bytes should be 10" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), "emission 1: host.old bytes should be 20" ); @@ -1033,56 +1032,36 @@ mod tests { let labeled = labeled_community_gauges(&recorder); - // (a) community_a disappeared — old host.a series must be zeroed + // (a) community_a disappeared — its pseudonymous series is zeroed. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&0.0), "(a) disappeared community: host.a bytes must be zeroed" ); assert_eq!( labeled.get(&( "buzz_community_storage_objects".to_string(), - "host.a".to_string() + label_a.clone() )), Some(&0.0), "(a) disappeared community: host.a objects must be zeroed" ); - // (b) community_b renamed host.old → host.new — old series must be zeroed - assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), - Some(&0.0), - "(b) host rename: host.old bytes must be zeroed" - ); + // (b) a host rename retains the same non-host label and value. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.new".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), - "(b) host rename: host.new bytes must be 20" + "(b) host rename must not expose or churn a tenant-host label" ); // (c) community_c scope-excluded — host.c series must be zeroed assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_c.clone())), Some(&0.0), "(c) scope removal: host.c bytes must be zeroed" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_objects".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_objects".to_string(), label_c)), Some(&0.0), "(c) scope removal: host.c objects must be zeroed" ); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..9bec1be734 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -19,6 +19,73 @@ use uuid::Uuid; use crate::handlers::event::dispatch_persistent_event; use crate::state::AppState; +/// Relay-owned provider-neutral workflow mutation gate. +/// +/// The weak application-state reference avoids a cycle through +/// `AppState -> WorkflowEngine -> MutationGate -> AppState`. +pub struct RelayWorkflowMutationGate { + state: Weak, +} + +impl RelayWorkflowMutationGate { + /// Create a gate backed by the relay's immutable protected-domain policy. + pub fn new(state: &Arc) -> Self { + Self { + state: Arc::downgrade(state), + } + } +} + +impl buzz_workflow::MutationGate for RelayWorkflowMutationGate { + fn require_mutation( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + })?; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .map(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + }) + } + + fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + })?; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::OutboundWebhook, + ) + .map(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + }) + } +} + /// Resolves `@Name` mentions in workflow message text to the pubkeys of the /// channel members they name, so the emitted kind:9 carries the `p` tags that /// ACP agent-wake (`event_mentions_agent`) is gated on. @@ -188,6 +255,17 @@ impl ActionSink for RelayActionSink { .upgrade() .ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?; + // A delayed action may outlive the authority that started its run. + // With no transaction-owning workflow executor, Enforce must stop + // before tenant lookup, event construction, persistence, or fanout. + crate::authorization_runtime::transport::require_unwired_atomic_mutation_if_configured( + &state, + community_id, + ) + .map_err(|_| { + ActionSinkError::Database("protected workflow mutation unavailable".into()) + })?; + // The run carries its owning community (`community_id`); the // relay-signed kind:9 message belongs to *that* community, never the // deployment default. Re-deriving the tenant from `config.relay_url` @@ -567,10 +645,41 @@ mod integration_tests { //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` use super::*; + use async_trait::async_trait; + use buzz_auth::{AuthorizationClock, AuthorizationClockError, AuthorizationTime}; use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; use buzz_db::CreateCommunityWithOwnerResult; use std::sync::Arc; + struct UnavailableResolver; + + #[async_trait] + impl crate::authorization_runtime::transport::ProtectedAuthorizationResolver + for UnavailableResolver + { + async fn resolve( + &self, + _request: &crate::authorization_runtime::transport::ProtectedOperationRequest, + ) -> Result< + crate::authorization_runtime::transport::ProtectedResolution, + crate::authorization_runtime::transport::ProtectedResolutionError, + > { + Err( + crate::authorization_runtime::transport::ProtectedResolutionError::new( + "synthetic_unavailable", + ), + ) + } + } + + struct FixedClock; + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(100)) + } + } + /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); @@ -609,6 +718,75 @@ mod integration_tests { Arc::new(state) } + #[tokio::test] + async fn workflow_action_enforce_without_executor_persists_no_event() { + let state = test_state().await; + let community = CommunityId::from_uuid(Uuid::from_u128(0xF10)); + let runtime = crate::authorization_runtime::transport::ProtectedTransportRuntime::new( + [ + crate::authorization_runtime::transport::DomainTransportPolicy::from_server_configuration( + community, + crate::authorization_runtime::finalization::AuthorizationMode::Enforce, + ), + ], + Arc::new(UnavailableResolver), + Arc::new(FixedClock), + ) + .expect("synthetic protected runtime"); + state + .install_protected_transport(Arc::new(runtime)) + .expect("install protected runtime once"); + state + .workflow_engine + .set_mutation_gate(Arc::new(RelayWorkflowMutationGate::new(&state))); + + let trigger = buzz_workflow::executor::TriggerContext { + message_id: "synthetic-event".into(), + ..Default::default() + }; + for action in [ + buzz_workflow::ActionDef::AddReaction { + emoji: "check".into(), + }, + buzz_workflow::ActionDef::CallWebhook { + url: "https://example.invalid/hook".into(), + method: None, + headers: None, + body: None, + }, + ] { + let error = buzz_workflow::executor::dispatch_action( + "blocked", + &action, + &state.workflow_engine, + community, + Uuid::from_u128(2), + &trigger, + ) + .await + .expect_err("direct workflow effects must stop at the central gate"); + assert!(matches!( + error, + buzz_workflow::WorkflowError::Unauthorized(_) + )); + } + + let error = RelayActionSink::new(&state) + .send_message( + community, + &Uuid::from_u128(1).to_string(), + "must not persist", + &nostr::Keys::generate().public_key().to_hex(), + ) + .await + .expect_err("Enforce without an executor must fail before persistence"); + + assert_eq!( + error.to_string(), + "database error: protected workflow mutation unavailable" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn workflow_send_message_p_tags_mentioned_member() { diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e..6fe2c44763 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -526,6 +526,16 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + // This is the final common boundary for every action, including effects + // that do not use the relay ActionSink. A delayed run must therefore pass + // the embedding relay's current mutation gate again immediately before + // SendMessage, AddReaction, CallWebhook, or any future action dispatch. + if matches!(action, CallWebhook { .. }) { + engine.require_outbound_webhook(community_id)?; + } else { + engine.require_mutation(community_id)?; + } + match action { SendMessage { text, channel } => { // Look up workflow metadata for destination validation and @@ -982,6 +992,10 @@ pub async fn execute_run( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + engine .db .update_workflow_run( @@ -1032,6 +1046,10 @@ pub async fn execute_from_step( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + // Mark run as Running now that we have a permit (resume from approval). // Preserve the existing execution trace from pre-approval steps. let existing_trace = match engine.db.get_workflow_run(community_id, run_id).await { diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..d17e7c3f05 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -33,11 +33,13 @@ pub mod action_sink; pub mod error; pub mod executor; +pub mod mutation_gate; pub mod schema; pub use action_sink::{ActionSink, ActionSinkError}; pub use error::{PartialProgress, WorkflowError}; pub use executor::ExecutionResult; +pub use mutation_gate::MutationGate; pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef}; use std::collections::HashMap; @@ -87,6 +89,9 @@ pub struct WorkflowEngine { /// Action sink for executing side-effects (SendMessage, etc.). /// Late-initialized via [`set_action_sink`] after `AppState` construction. pub(crate) action_sink: OnceLock>, + /// Provider-neutral gate evaluated before every mutation or external effect. + /// Late-initialized by the embedding relay after `AppState` construction. + pub(crate) mutation_gate: OnceLock>, /// Short-TTL cache for the per-event enabled-workflow lookup, keyed /// `(community_id, channel_id)`. Most channels have no workflows, so this /// removes one SELECT from nearly every ingested event. @@ -115,6 +120,7 @@ impl WorkflowEngine { run_semaphore, last_fired: DashMap::new(), action_sink: OnceLock::new(), + mutation_gate: OnceLock::new(), workflow_cache: moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) @@ -180,6 +186,38 @@ impl WorkflowEngine { } } + /// Set the workflow mutation gate. Called once by an embedding relay. + /// + /// # Panics + /// Panics if called more than once. + pub fn set_mutation_gate(&self, gate: Arc) { + if self.mutation_gate.set(gate).is_err() { + panic!("mutation_gate already initialized"); + } + } + + /// Require current authority before a workflow mutation or external effect. + /// + /// A standalone engine with no installed gate preserves legacy behavior. + /// Once an embedding relay installs a gate, every engine-owned mutation + /// door calls this method before touching durable or external state. + pub(crate) fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + mutation_gate::require_configured_mutation( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + + pub(crate) fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), WorkflowError> { + mutation_gate::require_configured_outbound_webhook( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + /// Get the action sink reference. /// /// Returns `Err(WorkflowError)` if the sink has not been initialized via @@ -217,6 +255,13 @@ impl WorkflowEngine { result: Result, existing_trace: Option>, ) { + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + run_id = %run_id, + "Skipping workflow finalization because mutation authority is unavailable: {error}" + ); + return; + } let prefix = existing_trace.unwrap_or_default(); match result { @@ -395,6 +440,14 @@ impl WorkflowEngine { continue; } + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + workflow_id = %workflow.id, + "Skipping workflow because mutation authority is unavailable: {error}" + ); + continue; + } + let trigger_event_id_bytes = event.event.id.as_bytes().to_vec(); let run_id = match self .db @@ -608,6 +661,14 @@ impl WorkflowEngine { continue; } + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + workflow_id = %workflow.id, + "Cron tick: skipping workflow because mutation authority is unavailable: {error}" + ); + continue; + } + // Durable at-most-once claim — the cross-pod fire boundary. // The loser receives `None` and skips BEFORE any run creation or // side effect. `community_id` is the workflow row's own diff --git a/crates/buzz-workflow/src/mutation_gate.rs b/crates/buzz-workflow/src/mutation_gate.rs new file mode 100644 index 0000000000..bed8666143 --- /dev/null +++ b/crates/buzz-workflow/src/mutation_gate.rs @@ -0,0 +1,77 @@ +//! Provider-neutral admission gate for workflow mutations and external effects. + +use buzz_core::tenant::CommunityId; + +use crate::WorkflowError; + +/// Server-owned gate evaluated before every workflow mutation or external effect. +/// +/// The workflow engine deliberately knows nothing about identity providers, +/// leases, or deployment configuration. A relay can install a gate that denies +/// an authorization domain until it has a transaction-owning executor. When no +/// gate is installed, the standalone engine preserves its legacy behavior. +pub trait MutationGate: Send + Sync { + /// Require current authority for one server-resolved authorization domain. + fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError>; + + /// Require authority for an outbound network effect. + fn require_outbound_webhook(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + self.require_mutation(community_id) + } +} + +pub(crate) fn require_configured_mutation( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_mutation(community_id), + None => Ok(()), + } +} + +pub(crate) fn require_configured_outbound_webhook( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_outbound_webhook(community_id), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + struct DenyGate(AtomicUsize); + + impl MutationGate for DenyGate { + fn require_mutation(&self, _community_id: CommunityId) -> Result<(), WorkflowError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(WorkflowError::Unauthorized( + "synthetic mutation denial".into(), + )) + } + } + + #[test] + fn absent_gate_preserves_legacy_and_configured_denial_is_authoritative() { + let community_id = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + assert!(require_configured_mutation(None, community_id).is_ok()); + + let gate = DenyGate(AtomicUsize::new(0)); + assert!(matches!( + require_configured_mutation(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 1); + assert!(matches!( + require_configured_outbound_webhook(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 2); + } +} diff --git a/migrations/0040_authorization_invalidation_floors.sql b/migrations/0040_authorization_invalidation_floors.sql new file mode 100644 index 0000000000..372246711a --- /dev/null +++ b/migrations/0040_authorization_invalidation_floors.sql @@ -0,0 +1,41 @@ +-- Extend the durable, provider-neutral protected-domain marker with +-- authorization invalidation authority. +-- +-- Generations are allocated under a per-community row lock. Receipts make +-- retries idempotent, while selector floors retain the strongest committed +-- fail-closed effect. Redis fan-out is only a hint to read these tables. + +CREATE TABLE authorization_invalidation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, generation) +); + +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind TEXT NOT NULL CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner' + )), + selector_fingerprint BYTEA NOT NULL CHECK (length(selector_fingerprint) = 32), + generation BIGINT NOT NULL CHECK (generation > 0), + sticky_deny BOOLEAN NOT NULL DEFAULT FALSE, + binding_version_floor BIGINT CHECK (binding_version_floor > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, generation) + REFERENCES authorization_invalidation_receipts (community_id, generation), + CHECK ((selector_kind = 'binding') = (binding_version_floor IS NOT NULL)) +); + +CREATE INDEX idx_authorization_invalidation_floors_generation + ON authorization_invalidation_floors (community_id, generation); diff --git a/migrations/0041_authorization_operation_receipts.sql b/migrations/0041_authorization_operation_receipts.sql new file mode 100644 index 0000000000..ddb4b40c12 --- /dev/null +++ b/migrations/0041_authorization_operation_receipts.sql @@ -0,0 +1,42 @@ +-- Transaction-owned protected-operation idempotency. +-- +-- These receipts are part of the mutation commit protocol. They are not an +-- authorization decision log or operator audit trail: a receipt exists only +-- when the protected mutation committed in the same transaction. + +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL CHECK ( + length(operation_kind) > 0 AND length(operation_kind) <= 128 + ), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + result_version SMALLINT NOT NULL DEFAULT 1 CHECK (result_version > 0), + result_payload BYTEA NOT NULL CHECK (octet_length(result_payload) <= 65536), + lease_expires_at TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, operation_id) +); + +CREATE INDEX idx_authorization_operation_receipts_committed_at + ON authorization_operation_receipts (community_id, committed_at); + +-- The transaction rechecks expiry before inserting its receipt, and this +-- deferred trigger closes the final interval between that check and COMMIT. +CREATE FUNCTION authorization_operation_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.lease_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'protected operation authorization expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operation_expiry + AFTER INSERT OR UPDATE OF lease_expires_at + ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION authorization_operation_expiry_guard(); diff --git a/migrations/0042_authorization_authority_epochs.sql b/migrations/0042_authorization_authority_epochs.sql new file mode 100644 index 0000000000..291589618e --- /dev/null +++ b/migrations/0042_authorization_authority_epochs.sql @@ -0,0 +1,226 @@ +-- Monotonic authority epoch covering every PostgreSQL-backed V1 authority +-- reduction. The epoch is transactionally advanced by table triggers, so a +-- stale restore cannot hide a principal/key/pair tombstone, membership loss, +-- invalidation, publication transition, or audio-admission transition from +-- the independent restore witness. + +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + authority_epoch BIGINT NOT NULL DEFAULT 1 CHECK (authority_epoch > 0), + status_revision BIGINT NOT NULL DEFAULT 1 CHECK (status_revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id) +); + +INSERT INTO authorization_authority_epochs (community_id) +SELECT id FROM communities +ON CONFLICT (community_id) DO NOTHING; + +-- Dedicated current-only client projection revision. The event-author key is +-- the presentation scope; issuer/subject and lifecycle history remain solely +-- in the identity authority tables. +CREATE TABLE client_status_revisions ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + event_author_pubkey BYTEA NOT NULL CHECK (length(event_author_pubkey) = 32), + revision BIGINT NOT NULL CHECK (revision > 0), + disposition TEXT NOT NULL CHECK (disposition IN ('current', 'withdrawn')), + binding_id UUID, + binding_version BIGINT CHECK (binding_version > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, event_author_pubkey), + CHECK ( + (disposition = 'current' AND binding_id IS NOT NULL AND binding_version IS NOT NULL) + OR + (disposition = 'withdrawn' AND binding_id IS NULL AND binding_version IS NULL) + ) +); + +-- Authorization state has no meaning after its owning community is removed. +-- Earlier additive migrations intentionally used restrictive foreign keys; +-- make teardown atomic now that the complete protected state set is known. +ALTER TABLE authorization_invalidation_domains + DROP CONSTRAINT authorization_invalidation_domains_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_domains_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_invalidation_receipts + DROP CONSTRAINT authorization_invalidation_receipts_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_receipts_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_invalidation_floors + DROP CONSTRAINT authorization_invalidation_floors_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_floors_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_operation_receipts + DROP CONSTRAINT authorization_operation_receipts_community_id_fkey, + ADD CONSTRAINT authorization_operation_receipts_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; + +CREATE FUNCTION advance_authorization_authority_epoch() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + domain_id UUID; + invalidation_event UUID; + invalidation_generation BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + domain_id := OLD.community_id; + ELSE + domain_id := NEW.community_id; + END IF; + + -- The nested generation update below has its own table trigger. The outer + -- protected mutation owns the epoch advancement, so the nested trigger is + -- deliberately inert rather than double-counting one authority change. + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + -- Invalidation-domain presence is the durable marker installed during + -- protected-domain initialization. Legacy/Off communities must retain + -- byte-for-byte behavior and must not acquire authorization side effects. + IF TG_TABLE_NAME <> 'authorization_invalidation_domains' + AND NOT EXISTS ( + SELECT 1 FROM authorization_invalidation_domains + WHERE community_id = domain_id + ) + THEN + RETURN NULL; + END IF; + + -- Community teardown removes all protected state through cascading + -- foreign keys; it cannot publish a new floor for a domain that no longer + -- exists. + IF TG_TABLE_NAME = 'authorization_invalidation_domains' AND TG_OP = 'DELETE' THEN + RETURN NULL; + END IF; + + INSERT INTO authorization_authority_epochs + (community_id, authority_epoch, status_revision, updated_at) + VALUES (domain_id, 2, 2, clock_timestamp()) + ON CONFLICT (community_id) DO UPDATE + SET authority_epoch = authorization_authority_epochs.authority_epoch + 1, + status_revision = authorization_authority_epochs.status_revision + 1, + updated_at = clock_timestamp(); + + IF TG_TABLE_NAME IN ( + 'identity_bindings', + 'identity_principals', + 'identity_revoked_keys', + 'identity_retired_pairs', + 'relay_members', + 'channel_members', + 'community_bans', + 'channels', + 'users' + ) THEN + invalidation_event := gen_random_uuid(); + UPDATE authorization_invalidation_domains + SET generation = generation + 1, + updated_at = clock_timestamp() + WHERE community_id = domain_id + RETURNING generation INTO invalidation_generation; + + IF invalidation_generation IS NULL THEN + RETURN NULL; + END IF; + + INSERT INTO authorization_invalidation_receipts + (community_id, event_id, generation, request_fingerprint) + VALUES ( + domain_id, + invalidation_event, + invalidation_generation, + digest(invalidation_event::text, 'sha256') + ); + + INSERT INTO authorization_invalidation_floors + (community_id, selector_kind, selector_fingerprint, generation, + sticky_deny, binding_version_floor) + VALUES ( + domain_id, + 'domain', + decode('a3c641c058d6498e4cc4177eb5f9cf6ba32e01c05a21f69aa85661a8044a5c78', 'hex'), + invalidation_generation, + FALSE, + NULL + ) + ON CONFLICT (community_id, selector_kind, selector_fingerprint) DO UPDATE + SET generation = EXCLUDED.generation, + updated_at = clock_timestamp(); + END IF; + RETURN NULL; +END +$$; + +-- Kind 30617 is live Git authorization policy. Install these triggers only +-- after the epoch function exists so fresh databases migrate in one pass. +CREATE TRIGGER git_policy_insert_authority_epoch + AFTER INSERT ON events + FOR EACH ROW WHEN (NEW.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_policy_update_authority_epoch + AFTER UPDATE ON events + FOR EACH ROW WHEN (OLD.kind = 30617 OR NEW.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_policy_delete_authority_epoch + AFTER DELETE ON events + FOR EACH ROW WHEN (OLD.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_bindings_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_principals_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_principals + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_revoked_keys_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_revoked_keys + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_retired_pairs_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_retired_pairs + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER relay_members_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON relay_members + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER channel_members_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON channel_members + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER community_bans_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON community_bans + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER channels_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON channels + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER users_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER authorization_invalidation_domains_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_repo_publications_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON git_repo_publications + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER media_publications_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON media_publications + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER protected_object_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER audio_session_admissions_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON audio_session_admissions + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); diff --git a/schema/schema.sql b/schema/schema.sql index 2436644919..25cf84d8e1 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -604,6 +604,89 @@ CREATE INDEX idx_identity_lifecycle_operations_principal CREATE INDEX idx_identity_lifecycle_operations_key ON identity_lifecycle_operations (community_id, pubkey, created_at); +-- ── Authorization invalidation authority ────────────────────────────────────── +-- Generations and selector floors are durable authority. Cross-node pub/sub +-- carries only a hint that consumers should reconcile from these tables. + +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL REFERENCES communities(id), + generation BIGINT NOT NULL DEFAULT 0 CHECK (generation >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id) +); + +CREATE TABLE authorization_invalidation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, generation) +); + +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind TEXT NOT NULL CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner' + )), + selector_fingerprint BYTEA NOT NULL CHECK (length(selector_fingerprint) = 32), + generation BIGINT NOT NULL CHECK (generation > 0), + sticky_deny BOOLEAN NOT NULL DEFAULT FALSE, + binding_version_floor BIGINT CHECK (binding_version_floor > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, generation) + REFERENCES authorization_invalidation_receipts (community_id, generation), + CHECK ((selector_kind = 'binding') = (binding_version_floor IS NOT NULL)) +); + +CREATE INDEX idx_authorization_invalidation_floors_generation + ON authorization_invalidation_floors (community_id, generation); + +-- Transaction-owned protected-operation idempotency. This is commit protocol +-- state, not an authorization decision or operator audit log. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL CHECK ( + length(operation_kind) > 0 AND length(operation_kind) <= 128 + ), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + result_version SMALLINT NOT NULL DEFAULT 1 CHECK (result_version > 0), + result_payload BYTEA NOT NULL CHECK (octet_length(result_payload) <= 65536), + lease_expires_at TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, operation_id) +); + +CREATE INDEX idx_authorization_operation_receipts_committed_at + ON authorization_operation_receipts (community_id, committed_at); + +CREATE FUNCTION authorization_operation_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.lease_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'protected operation authorization expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operation_expiry + AFTER INSERT OR UPDATE OF lease_expires_at + ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION authorization_operation_expiry_guard(); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the