diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index df963bc4e..e4d6e831e 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -27,6 +27,8 @@ pub mod nip42; pub mod nip98; /// NIP-98 replay protection — shared, community-scoped, atomic seen-set. pub mod nip98_replay; +/// Provider-neutral authorization policy and validated capability snapshots. +pub mod provider; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -52,6 +54,14 @@ pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, }; +pub use provider::{ + resolve_authorization, AuthorizationAuthority, AuthorizationCapability, AuthorizationDenial, + AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, + AuthorizationProviderFuture, AuthorizationRequest, CapabilitySet, CapabilitySnapshot, + DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, 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 new file mode 100644 index 000000000..970e2bac2 --- /dev/null +++ b/crates/buzz-auth/src/provider/mod.rs @@ -0,0 +1,1031 @@ +//! Provider-neutral authorization decisions. +//! +//! This module defines a runtime-neutral boundary between verified identity +//! evidence and deployment-specific policy. It does not select or configure a +//! provider, construct identity evidence, or change any relay handler. + +use std::{fmt, future::Future, pin::Pin, time::Duration}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::context::{ + AuthMethod, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedNostrProof, + VersionedBindingRef, +}; + +const MAX_OPAQUE_ID_BYTES: usize = 256; +const MAX_RETRY_AFTER_SECONDS: u32 = 3_600; +/// Maximum freshness window accepted from an authorization provider. +pub const MAX_PROVIDER_FRESHNESS_SECONDS: u64 = 86_400; +/// Maximum deadline accepted for one authorization-provider call. +pub const MAX_PROVIDER_TIMEOUT: Duration = Duration::from_secs(60); + +/// Portable capability evaluated by an [`AuthorizationProvider`]. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum AuthorizationCapability { + /// Read community content. + CommunityRead, + /// Publish community content. + CommunityWrite, + /// Perform moderation operations. + Moderate, + /// Mint or claim invitations. + Invite, + /// Read authenticated media. + MediaRead, + /// Upload media. + MediaWrite, + /// Read Git content. + GitRead, + /// Write Git content. + GitWrite, + /// Join an audio session. + AudioJoin, +} + +impl fmt::Debug for AuthorizationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Non-empty, normalized set of portable capabilities. +#[derive(Clone, PartialEq, Eq)] +pub struct CapabilitySet(Vec); + +impl CapabilitySet { + /// Build a non-empty set, sorting and removing duplicate capabilities. + pub fn new( + mut capabilities: Vec, + ) -> Result { + capabilities.sort_unstable(); + capabilities.dedup(); + if capabilities.is_empty() { + return Err(ProviderContractError::EmptyCapabilitySet); + } + Ok(Self(capabilities)) + } + + /// Normalized capabilities in stable order. + pub fn as_slice(&self) -> &[AuthorizationCapability] { + &self.0 + } + + fn contains_all(&self, requested: &Self) -> bool { + requested + .as_slice() + .iter() + .all(|capability| self.0.binary_search(capability).is_ok()) + } +} + +impl fmt::Debug for CapabilitySet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CapabilitySet") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque identifier for the server-resolved authorization profile. +/// +/// Production construction is intentionally unavailable until a sealed policy +/// adapter can prove that the profile came from server-owned configuration. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct AuthorizationProfileId(String); + +impl AuthorizationProfileId { + /// Preserve a non-empty, bounded profile identifier exactly as configured. + #[cfg(test)] + pub(crate) fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyProfileId); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::ProfileIdTooLong); + } + Ok(Self(value)) + } + + /// Exact profile identifier for provider routing. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AuthorizationProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationProfileId") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque, equality-comparable policy version returned by a provider. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct PolicyVersion(String); + +impl PolicyVersion { + /// Preserve a non-empty, bounded policy version without interpreting it. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyPolicyVersion); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::PolicyVersionTooLong); + } + Ok(Self(value)) + } + + /// Exact opaque version bytes. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PolicyVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PolicyVersion") + .field(&"[redacted]") + .finish() + } +} + +/// Authority whose provider admission is requested. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationAuthority { + /// The authenticated actor matches the admitted principal's key attestation. + Direct, + /// The authenticated actor derives authority from a bound owner. + Delegated { + /// Cryptographically verified and actively bound owner key. + owner_pubkey: PublicKey, + }, +} + +impl fmt::Debug for AuthorizationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationAuthority") + .field(&"[redacted]") + .finish() + } +} + +/// Redaction-safe description of how the provider request was derived. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DecisionSource { + /// Current verified assertion for the authenticated actor. + DirectAssertion, + /// Current active binding for a cryptographically verified owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for DecisionSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DecisionSource") + .field(&"[redacted]") + .finish() + } +} + +/// Provider request derived from server-verified identity evidence. +#[derive(PartialEq, Eq)] +pub struct AuthorizationRequest { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + authority: AuthorizationAuthority, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + decision_source: DecisionSource, + evidence_valid_until: Option, +} + +impl AuthorizationRequest { + /// Build a direct request from a current key-attested assertion and Nostr proof. + /// + /// An unattested assertion is intentionally insufficient in this phase. A + /// future trust-on-first-use path must also consume authoritative active or + /// atomic-enrollment binding evidence before it can produce direct authority. + pub fn direct( + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + if proof.verified_delegation().is_some() { + return Err(ProviderContractError::DirectRequestHasOwner); + } + if proof.authorization_domain() != assertion.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if proof.authorized_transport() != assertion.authorized_transport() { + return Err(ProviderContractError::TransportMismatch); + } + let Some(key_attestation) = assertion.key_attestation() else { + return Err(ProviderContractError::MissingKeyAttestation); + }; + if key_attestation.pubkey() != proof.actor_pubkey() { + return Err(ProviderContractError::KeyAttestationMismatch); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(ProviderContractError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::AssertionExpired); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Direct, + principal: assertion.principal().clone(), + profile_id, + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DirectAssertion, + evidence_valid_until: Some(assertion.expires_at().unix_seconds()), + }) + } + + /// Build a delegated request for a cryptographically verified bound owner. + /// + /// This path does not require an owner assertion. The provider resolves + /// current admission for the exact issuer-qualified bound owner. + pub fn delegated( + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + if proof.authorization_domain() != owner.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired); + }; + 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); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Delegated { + owner_pubkey: owner.bound_pubkey(), + }, + principal: owner.principal().clone(), + profile_id, + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DelegatedOwnerBinding, + evidence_valid_until: delegation.expires_at().map(|bound| bound.unix_seconds()), + }) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method used for the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Direct or delegated authority whose admission is requested. + pub const fn authority(&self) -> &AuthorizationAuthority { + &self.authority + } + + /// Exact issuer-qualified principal whose admission is requested. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Portable capabilities requested for this decision. + pub const fn requested_capabilities(&self) -> &CapabilitySet { + &self.requested_capabilities + } + + /// Correlation identifier for this request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Verified source from which this request was derived. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } +} + +impl fmt::Debug for AuthorizationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRequest") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("authority", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("requested_capabilities", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("evidence_valid_until", &"[redacted]") + .finish() + } +} + +/// Provider-produced allowed capability data before crate-owned validation. +#[derive(PartialEq, Eq)] +pub struct ProviderAllow { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, +} + +impl ProviderAllow { + /// Build a provider allow result with mandatory policy and freshness data. + pub fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + ) -> Result { + if issued_at == 0 { + return Err(ProviderContractError::InvalidIssuedAt); + } + if fresh_until <= issued_at { + return Err(ProviderContractError::InvalidFreshnessBound); + } + if fresh_until - issued_at > MAX_PROVIDER_FRESHNESS_SECONDS { + return Err(ProviderContractError::FreshnessWindowTooLong); + } + Ok(Self { + authorization_domain, + principal, + profile_id, + capabilities, + policy_version, + issued_at, + fresh_until, + }) + } +} + +impl fmt::Debug for ProviderAllow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderAllow") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Stable reason for a denied provider authorization. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationDenialReason { + /// The configured provider denied the request. + ProviderDenied, + /// The provider response named another authorization domain. + AuthorizationDomainMismatch, + /// The provider response named another principal. + PrincipalMismatch, + /// The provider response named another authorization profile. + AuthorizationProfileMismatch, + /// The provider response omitted a requested capability. + MissingCapability, + /// The provider response was already stale. + StaleDecision, + /// The provider response was issued in the future. + FutureDecision, + /// Verified identity evidence expired before the decision became effective. + IdentityEvidenceExpired, +} + +impl AuthorizationDenialReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::ProviderDenied => "authorization_provider_deny_001", + Self::AuthorizationDomainMismatch => "authorization_provider_deny_002", + Self::PrincipalMismatch => "authorization_provider_deny_003", + Self::MissingCapability => "authorization_provider_deny_004", + Self::StaleDecision => "authorization_provider_deny_005", + Self::FutureDecision => "authorization_provider_deny_006", + Self::IdentityEvidenceExpired => "authorization_provider_deny_007", + Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", + } + } +} + +impl fmt::Debug for AuthorizationDenialReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationDenialReason") + .field(&"[redacted]") + .finish() + } +} + +/// Provider-neutral denial returned to an authorization caller. +#[derive(PartialEq, Eq)] +pub struct AuthorizationDenial { + reason: AuthorizationDenialReason, +} + +impl AuthorizationDenial { + /// Build a denial with a stable provider-neutral reason. + pub const fn new(reason: AuthorizationDenialReason) -> Self { + Self { reason } + } + + /// Stable reason for the denial. + pub const fn reason(&self) -> AuthorizationDenialReason { + self.reason + } +} + +impl fmt::Debug for AuthorizationDenial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationDenial") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Stable provider-unavailability reason. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderUnavailableReason { + /// The provider is temporarily unavailable. + TemporarilyUnavailable, + /// The provider call exceeded its bounded deadline. + Timeout, + /// A provider dependency is unavailable. + DependencyUnavailable, +} + +impl ProviderUnavailableReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::TemporarilyUnavailable => "authorization_provider_unavailable_001", + Self::Timeout => "authorization_provider_unavailable_002", + Self::DependencyUnavailable => "authorization_provider_unavailable_003", + } + } +} + +impl fmt::Debug for ProviderUnavailableReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderUnavailableReason") + .field(&"[redacted]") + .finish() + } +} + +/// Bounded provider retry hint in seconds. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RetryAfter(u32); + +impl RetryAfter { + /// Build a non-zero retry hint no greater than one hour. + pub const fn new(seconds: u32) -> Result { + if seconds == 0 || seconds > MAX_RETRY_AFTER_SECONDS { + return Err(ProviderContractError::InvalidRetryAfter); + } + Ok(Self(seconds)) + } + + /// Retry hint in seconds. + pub const fn seconds(self) -> u32 { + self.0 + } +} + +impl fmt::Debug for RetryAfter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RetryAfter") + .field(&"[redacted]") + .finish() + } +} + +/// Explicit finite deadline for one provider call. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProviderTimeout(Duration); + +impl ProviderTimeout { + /// Build a provider-call deadline no greater than one minute. + pub fn new(duration: Duration) -> Result { + if duration.is_zero() || duration > MAX_PROVIDER_TIMEOUT { + return Err(ProviderContractError::InvalidProviderTimeout); + } + Ok(Self(duration)) + } + + /// Configured provider-call deadline. + pub const fn duration(self) -> Duration { + self.0 + } +} + +impl fmt::Debug for ProviderTimeout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderTimeout") + .field(&"[redacted]") + .finish() + } +} + +/// Fail-closed provider unavailability. +#[derive(PartialEq, Eq)] +pub struct ProviderUnavailable { + reason: ProviderUnavailableReason, + retry_after: Option, +} + +impl ProviderUnavailable { + /// Build an unavailable result with optional bounded retry metadata. + pub const fn new(reason: ProviderUnavailableReason, retry_after: Option) -> Self { + Self { + reason, + retry_after, + } + } + + /// Stable reason for unavailability. + pub const fn reason(&self) -> ProviderUnavailableReason { + self.reason + } + + /// Optional bounded retry hint. + pub const fn retry_after(&self) -> Option { + self.retry_after + } +} + +impl fmt::Debug for ProviderUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderUnavailable") + .field("reason", &"[redacted]") + .field("retry_after", &"[redacted]") + .finish() + } +} + +/// Raw decision returned by an [`AuthorizationProvider`]. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderDecision { + /// Provider policy allowed a capability set. + Allow(ProviderAllow), + /// Provider policy denied the request. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for ProviderDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderDecision") + .field(&"[redacted]") + .finish() + } +} + +/// Boxed provider future used to keep [`AuthorizationProvider`] object-safe. +pub type AuthorizationProviderFuture<'a> = + Pin + Send + 'a>>; + +/// Object-safe, asynchronous, provider-neutral authorization policy. +pub trait AuthorizationProvider: Send + Sync { + /// Evaluate one request without mutating identity or community state. + /// + /// Implementations must yield while waiting for I/O and must not block the + /// async executor. The returned future must be cancellation-safe: the + /// caller drops it on timeout, so dropping at any await point must release + /// resources through RAII and must not leave shared state partially + /// updated. Provider evaluation is read-only; cache updates, if any, must + /// become visible atomically. The deadline bounds future polling and cannot + /// preempt blocking synchronous work inside this method. + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a>; +} + +/// Stable reason for a validated allowed decision. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderAllowReason { + /// Current provider policy granted the exact requested capabilities. + CurrentPolicy, +} + +impl ProviderAllowReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::CurrentPolicy => "authorization_provider_allow_001", + } + } +} + +impl fmt::Debug for ProviderAllowReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderAllowReason") + .field(&"[redacted]") + .finish() + } +} + +/// Validated, request-scoped capability snapshot. +/// +/// This type has no public constructor, default, or deserialization path. Only +/// [`resolve_authorization`] can create it after checking the provider response. +#[derive(PartialEq, Eq)] +pub struct CapabilitySnapshot { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + owner_pubkey: Option, + proof_method: AuthMethod, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + effective_until: u64, + decision_source: DecisionSource, + correlation_id: Uuid, + reason: ProviderAllowReason, +} + +impl CapabilitySnapshot { + /// Authorization domain for this decision. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact authenticated Nostr actor for this decision. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Exact verified owner for delegated authority, when present. + pub const fn owner_pubkey(&self) -> Option { + self.owner_pubkey + } + + /// Cryptographic proof method for the authenticated actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Exact admitted issuer-qualified principal. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Server-resolved authorization profile for this decision. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Exact request-scoped portable capabilities. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Provider decision issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Provider freshness bound in Unix seconds. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Earliest effective bound across provider and identity evidence. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Verified request source for this snapshot. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } + + /// Correlation identifier binding the snapshot to its request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable reason for this allowed decision. + pub const fn reason(&self) -> ProviderAllowReason { + self.reason + } +} + +impl fmt::Debug for CapabilitySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilitySnapshot") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("effective_until", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Fail-closed result of validating a provider decision. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationOutcome { + /// Provider policy allowed the exact requested capabilities. + Allow(Box), + /// Provider policy or response validation denied authorization. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated; callers must not fall back. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for AuthorizationOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationOutcome") + .field(&"[redacted]") + .finish() + } +} + +/// Resolve and validate one provider authorization decision. +/// +/// Unavailability is preserved as a fail-closed outcome. This function never +/// falls back to Nostr-only authorization or applies an implicit grace period. +pub async fn resolve_authorization( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + now_unix_seconds: u64, + timeout: ProviderTimeout, +) -> AuthorizationOutcome { + let decision = match tokio::time::timeout(timeout.duration(), provider.authorize(request)).await + { + Ok(decision) => decision, + Err(_) => { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::Timeout, + None, + )); + } + }; + let allow = match decision { + ProviderDecision::Allow(allow) => allow, + ProviderDecision::Deny(denial) => return AuthorizationOutcome::Deny(denial), + ProviderDecision::Unavailable(unavailable) => { + return AuthorizationOutcome::Unavailable(unavailable); + } + }; + + if allow.authorization_domain != request.authorization_domain { + return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); + } + if allow.principal != request.principal { + return deny(AuthorizationDenialReason::PrincipalMismatch); + } + if allow.profile_id != request.profile_id { + return deny(AuthorizationDenialReason::AuthorizationProfileMismatch); + } + if allow.issued_at > now_unix_seconds { + return deny(AuthorizationDenialReason::FutureDecision); + } + if allow.fresh_until <= now_unix_seconds { + return deny(AuthorizationDenialReason::StaleDecision); + } + if !allow + .capabilities + .contains_all(&request.requested_capabilities) + { + return deny(AuthorizationDenialReason::MissingCapability); + } + + let effective_until = request + .evidence_valid_until + .map_or(allow.fresh_until, |bound| bound.min(allow.fresh_until)); + if effective_until <= now_unix_seconds { + return deny(AuthorizationDenialReason::IdentityEvidenceExpired); + } + + AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { + authorization_domain: allow.authorization_domain, + actor_pubkey: request.actor_pubkey, + owner_pubkey: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { owner_pubkey } => Some(*owner_pubkey), + }, + proof_method: request.proof_method, + principal: allow.principal, + profile_id: allow.profile_id, + capabilities: request.requested_capabilities.clone(), + policy_version: allow.policy_version, + issued_at: allow.issued_at, + fresh_until: allow.fresh_until, + effective_until, + decision_source: request.decision_source, + correlation_id: request.correlation_id, + reason: ProviderAllowReason::CurrentPolicy, + })) +} + +const fn deny(reason: AuthorizationDenialReason) -> AuthorizationOutcome { + AuthorizationOutcome::Deny(AuthorizationDenial::new(reason)) +} + +/// Invalid provider request or response construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ProviderContractError { + /// A capability set was empty. + #[error("authorization capability set must not be empty")] + EmptyCapabilitySet, + /// The authorization profile identifier was empty. + #[error("authorization profile identifier must not be empty")] + EmptyProfileId, + /// The authorization profile identifier exceeded its size bound. + #[error("authorization profile identifier exceeds the size bound")] + ProfileIdTooLong, + /// The policy version was empty. + #[error("authorization policy version must not be empty")] + EmptyPolicyVersion, + /// The policy version exceeded its size bound. + #[error("authorization policy version exceeds the size bound")] + PolicyVersionTooLong, + /// Provider decision issue time was zero. + #[error("provider decision issue time must be greater than zero")] + InvalidIssuedAt, + /// Provider freshness did not follow issue time. + #[error("provider freshness bound must follow its issue time")] + InvalidFreshnessBound, + /// Provider freshness exceeded the public maximum window. + #[error("provider freshness window exceeds its public bound")] + FreshnessWindowTooLong, + /// Retry metadata was zero or exceeded its public bound. + #[error("provider retry hint is outside its public bound")] + InvalidRetryAfter, + /// Provider call deadline was zero or exceeded its public bound. + #[error("provider call deadline is outside its public bound")] + InvalidProviderTimeout, + /// Correlation identifier was nil. + #[error("provider request correlation identifier must not be nil")] + InvalidCorrelationId, + /// Direct evidence contained delegated authority. + #[error("direct provider request cannot contain a delegated owner")] + DirectRequestHasOwner, + /// Verified evidence belonged to different authorization domains. + #[error("provider request evidence does not share an authorization domain")] + AuthorizationDomainMismatch, + /// Verified assertion and Nostr proof authorized different transports. + #[error("provider request evidence does not share an authorization transport")] + TransportMismatch, + /// Assertion was not yet valid at server time. + #[error("provider request assertion is not yet valid")] + AssertionNotYetValid, + /// Assertion was expired at server time. + #[error("provider request assertion has expired")] + AssertionExpired, + /// Assertion key attestation named another actor. + #[error("provider request key attestation does not match the Nostr actor")] + KeyAttestationMismatch, + /// Direct assertion omitted a key attestation. + #[error("direct provider request requires key attestation")] + MissingKeyAttestation, + /// Delegated request lacked verified delegation. + #[error("delegated provider request requires verified delegation")] + DelegationRequired, + /// Delegated request named another bound owner. + #[error("delegated provider request does not match the bound owner")] + DelegatedOwnerMismatch, + /// Delegation was expired at server time. + #[error("delegated provider request has expired")] + DelegationExpired, +} + +impl ProviderContractError { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyCapabilitySet => "authorization_provider_contract_001", + Self::EmptyProfileId => "authorization_provider_contract_002", + Self::ProfileIdTooLong => "authorization_provider_contract_003", + Self::EmptyPolicyVersion => "authorization_provider_contract_004", + Self::PolicyVersionTooLong => "authorization_provider_contract_005", + Self::InvalidIssuedAt => "authorization_provider_contract_006", + Self::InvalidFreshnessBound => "authorization_provider_contract_007", + Self::InvalidRetryAfter => "authorization_provider_contract_008", + Self::DirectRequestHasOwner => "authorization_provider_contract_009", + Self::AuthorizationDomainMismatch => "authorization_provider_contract_010", + Self::TransportMismatch => "authorization_provider_contract_011", + Self::AssertionNotYetValid => "authorization_provider_contract_012", + Self::AssertionExpired => "authorization_provider_contract_013", + Self::KeyAttestationMismatch => "authorization_provider_contract_014", + Self::DelegationRequired => "authorization_provider_contract_015", + Self::DelegatedOwnerMismatch => "authorization_provider_contract_016", + Self::DelegationExpired => "authorization_provider_contract_017", + Self::InvalidProviderTimeout => "authorization_provider_contract_018", + Self::InvalidCorrelationId => "authorization_provider_contract_019", + Self::MissingKeyAttestation => "authorization_provider_contract_020", + Self::FreshnessWindowTooLong => "authorization_provider_contract_021", + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs new file mode 100644 index 000000000..733ff520a --- /dev/null +++ b/crates/buzz-auth/src/provider/tests.rs @@ -0,0 +1,970 @@ +use std::{ + future::pending, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use nostr::Keys; + +use super::*; +use crate::context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingSource, + BindingVersion, DelegationExpiry, VerifiedKeyAttestation, VerifiedTransportDelegation, +}; + +const NOW: u64 = 100; + +fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn profile() -> AuthorizationProfileId { + AuthorizationProfileId::new("profile-1").expect("synthetic profile is valid") +} + +fn policy_version(value: &str) -> PolicyVersion { + PolicyVersion::new(value).expect("synthetic policy version is valid") +} + +fn provider_timeout() -> ProviderTimeout { + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") +} + +fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { + CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") +} + +fn direct_request_with_expiry( + actor: &Keys, + expiry: u64, + requested: CapabilitySet, +) -> AuthorizationRequest { + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ); + AuthorizationRequest::direct( + &proof, + &assertion, + profile(), + requested, + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic direct request is valid") +} + +fn direct_request(actor: &Keys) -> AuthorizationRequest { + direct_request_with_expiry( + actor, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) +} + +fn existing_binding(owner: &Keys) -> VersionedBindingRef { + existing_binding_in(1, owner) +} + +fn existing_binding_in(domain_value: u128, owner: &Keys) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + domain(domain_value), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + BindingSource::Provisioned, + ) + .expect("synthetic binding is valid") +} + +fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic delegated proof is valid"); + AuthorizationRequest::delegated( + &proof, + &existing_binding(owner), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic delegated request is valid") +} + +fn allow_for( + request: &AuthorizationRequest, + granted: CapabilitySet, + version: &str, + issued_at: u64, + fresh_until: u64, +) -> ProviderDecision { + ProviderDecision::Allow( + ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + request.profile_id().clone(), + granted, + policy_version(version), + issued_at, + fresh_until, + ) + .expect("synthetic provider allow is structurally valid"), + ) +} + +struct FakeProvider { + decision: Mutex>, +} + +impl FakeProvider { + fn returning(decision: ProviderDecision) -> Self { + Self { + decision: Mutex::new(Some(decision)), + } + } +} + +impl AuthorizationProvider for FakeProvider { + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + self.decision + .lock() + .expect("synthetic provider mutex is not poisoned") + .take() + .expect("synthetic provider is called exactly once") + }) + } +} + +struct PendingProvider { + calls: Arc, + dropped: Arc, +} + +struct CancellationMarker(Arc); + +impl Drop for CancellationMarker { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl AuthorizationProvider for PendingProvider { + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + self.calls.fetch_add(1, Ordering::SeqCst); + let marker = CancellationMarker(Arc::clone(&self.dropped)); + Box::pin(async move { + let _marker = marker; + pending().await + }) + } +} + +#[tokio::test] +async fn current_allow_returns_request_scoped_snapshot() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + ]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_eq!(snapshot.authorization_domain(), domain(1)); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), None); + assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); + assert_eq!(snapshot.principal(), request.principal()); + assert_eq!(snapshot.profile_id(), request.profile_id()); + assert_eq!( + snapshot.capabilities().as_slice(), + &[AuthorizationCapability::CommunityRead] + ); + assert_eq!(snapshot.policy_version().as_str(), "version-a"); + assert_eq!(snapshot.issued_at(), 90); + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 180); + assert_eq!(snapshot.decision_source(), DecisionSource::DirectAssertion); + assert_eq!(snapshot.correlation_id(), request.correlation_id()); + assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy); +} + +#[tokio::test] +async fn explicit_denial_is_preserved() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider denial must fail closed"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::ProviderDenied); +} + +#[tokio::test] +async fn provider_unavailability_never_falls_back_to_allow() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let retry_after = RetryAfter::new(30).expect("synthetic retry hint is bounded"); + let provider = + FakeProvider::returning(ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::TemporarilyUnavailable, + Some(retry_after), + ))); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider unavailability must remain fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::TemporarilyUnavailable + ); + assert_eq!(unavailable.retry_after(), Some(retry_after)); +} + +#[tokio::test] +async fn provider_call_deadline_returns_timeout_unavailability() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let provider = PendingProvider { + calls: Arc::clone(&calls), + dropped: Arc::clone(&dropped), + }; + let timeout = + ProviderTimeout::new(Duration::from_millis(1)).expect("synthetic timeout is finite"); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_authorization(&provider, &request, NOW, timeout).await + else { + panic!("provider timeout must remain fail closed"); + }; + assert_eq!(unavailable.reason(), ProviderUnavailableReason::Timeout); + assert_eq!(unavailable.retry_after(), None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn stale_and_future_provider_decisions_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let stale = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 80, + 90, + )); + let AuthorizationOutcome::Deny(stale_denial) = + resolve_authorization(&stale, &request, NOW, provider_timeout()).await + else { + panic!("stale decision must deny"); + }; + assert_eq!( + stale_denial.reason(), + AuthorizationDenialReason::StaleDecision + ); + + let future = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(future_denial) = + resolve_authorization(&future, &request, NOW, provider_timeout()).await + else { + panic!("future decision must deny"); + }; + assert_eq!( + future_denial.reason(), + AuthorizationDenialReason::FutureDecision + ); +} + +#[tokio::test] +async fn domain_principal_and_capability_mismatches_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + + let wrong_domain = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(2), + request.principal().clone(), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_domain, &request, NOW, provider_timeout()).await + else { + panic!("cross-domain decision must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationDomainMismatch + ); + + let wrong_principal = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_principal, &request, NOW, provider_timeout()).await + else { + panic!("principal mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::PrincipalMismatch + ); + + let wrong_profile = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + request.principal().clone(), + AuthorizationProfileId::new("other-profile").expect("synthetic profile is valid"), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_profile, &request, NOW, provider_timeout()).await + else { + panic!("profile mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationProfileMismatch + ); + + let missing_capability = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::CommunityWrite]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&missing_capability, &request, NOW, provider_timeout()).await + else { + panic!("missing capability must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[tokio::test] +async fn assertion_expiry_bounds_provider_freshness() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 120); +} + +#[tokio::test] +async fn delegated_owner_admission_does_not_require_owner_assertion() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let request = delegated_request(&actor, &owner, 140); + assert!(matches!( + request.authority(), + AuthorizationAuthority::Delegated { owner_pubkey } + if *owner_pubkey == owner.public_key() + )); + assert_eq!( + request.decision_source(), + DecisionSource::DelegatedOwnerBinding + ); + + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current owner admission must allow delegated authority"); + }; + assert_eq!(snapshot.effective_until(), 140); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), Some(owner.public_key())); +} + +#[tokio::test] +async fn policy_versions_detect_equality_and_change_without_ordering() { + let actor = Keys::generate(); + let request_a = direct_request(&actor); + let provider_a = FakeProvider::returning(allow_for( + &request_a, + request_a.requested_capabilities().clone(), + "opaque-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_a) = + resolve_authorization(&provider_a, &request_a, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + let request_b = direct_request(&actor); + let provider_b = FakeProvider::returning(allow_for( + &request_b, + request_b.requested_capabilities().clone(), + "opaque-b", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_b) = + resolve_authorization(&provider_b, &request_b, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_ne!(snapshot_a.policy_version(), snapshot_b.policy_version()); + assert_eq!(snapshot_a.policy_version(), &policy_version("opaque-a")); +} + +#[test] +fn provider_contract_rejects_malformed_values() { + assert_eq!( + CapabilitySet::new(Vec::new()), + Err(ProviderContractError::EmptyCapabilitySet) + ); + assert_eq!( + AuthorizationProfileId::new(""), + Err(ProviderContractError::EmptyProfileId) + ); + assert_eq!( + AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::ProfileIdTooLong) + ); + assert_eq!( + PolicyVersion::new(""), + Err(ProviderContractError::EmptyPolicyVersion) + ); + assert_eq!( + RetryAfter::new(0), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + ProviderTimeout::new(Duration::ZERO), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT + Duration::from_nanos(1)), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 0, + 180, + ), + Err(ProviderContractError::InvalidIssuedAt) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100, + ), + Err(ProviderContractError::InvalidFreshnessBound) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS + 1, + ), + Err(ProviderContractError::FreshnessWindowTooLong) + ); +} + +#[test] +fn request_construction_rechecks_verified_bounds_and_relationships() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let expired = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionExpired) + ); + + let future = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(NOW + 1)), + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &future, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionNotYetValid) + ); +} + +#[test] +fn request_construction_rejects_mismatched_verified_evidence() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let other = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + + let assertion_in_domain = + |domain_value, transport, attested_pubkey: Option| { + VerifiedFederatedAssertion::new( + domain(domain_value), + transport, + principal(), + attested_pubkey.map(VerifiedKeyAttestation::new), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ) + }; + let request = |proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion| { + AuthorizationRequest::direct( + proof, + assertion, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + assert_eq!( + request( + &proof, + &assertion_in_domain(2, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::HttpBridge, None), + ), + Err(ProviderContractError::TransportMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, Some(other.public_key()),), + ), + Err(ProviderContractError::KeyAttestationMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::MissingKeyAttestation) + ); + + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW + 20).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let delegated_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + request( + &delegated_proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::DirectRequestHasOwner) + ); + + let delegated_request_from = |proof: &VerifiedNostrProof, binding: &VersionedBindingRef| { + AuthorizationRequest::delegated( + proof, + binding, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + assert_eq!( + AuthorizationRequest::delegated( + &delegated_proof, + &existing_binding(&owner), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + delegated_request_from(&proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationRequired) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding(&other)), + Err(ProviderContractError::DelegatedOwnerMismatch) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding_in(2, &owner)), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + + let expired_delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let expired_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(expired_delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + delegated_request_from(&expired_proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationExpired) + ); +} + +#[tokio::test] +async fn request_decision_snapshot_and_errors_are_redaction_safe() { + let actor = Keys::generate(); + let request = direct_request(&actor); + assert_eq!( + format!("{request:?}"), + concat!( + "AuthorizationRequest { authorization_domain: \"[redacted]\", ", + "actor_pubkey: \"[redacted]\", proof_method: \"[redacted]\", ", + "authority: \"[redacted]\", principal: \"[redacted]\", ", + "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", + "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_until: \"[redacted]\" }" + ) + ); + + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("private-policy-version"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"); + assert_eq!( + format!("{allow:?}"), + concat!( + "ProviderAllow { authorization_domain: \"[redacted]\", ", + "principal: \"[redacted]\", profile_id: \"[redacted]\", ", + "capabilities: \"[redacted]\", policy_version: \"[redacted]\", ", + "issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }" + ) + ); + let decision = ProviderDecision::Allow(allow); + assert_eq!(format!("{decision:?}"), "ProviderDecision(\"[redacted]\")"); + let provider = FakeProvider::returning(decision); + let outcome = resolve_authorization(&provider, &request, NOW, provider_timeout()).await; + assert_eq!( + format!("{outcome:?}"), + "AuthorizationOutcome(\"[redacted]\")" + ); + let AuthorizationOutcome::Allow(snapshot) = outcome else { + panic!("current provider policy must allow"); + }; + assert_eq!( + format!("{snapshot:?}"), + concat!( + "CapabilitySnapshot { authorization_domain: \"[redacted]\", ", + "actor_pubkey: \"[redacted]\", owner_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", principal: \"[redacted]\", ", + "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", + "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", + "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", + "decision_source: \"[redacted]\", correlation_id: \"[redacted]\", ", + "reason: \"[redacted]\" }" + ) + ); + + let denial = AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied); + assert_eq!( + format!("{denial:?}"), + "AuthorizationDenial { reason: \"[redacted]\" }" + ); + let unavailable = ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + Some(RetryAfter::new(30).expect("synthetic retry hint is bounded")), + ); + assert_eq!( + format!("{unavailable:?}"), + concat!( + "ProviderUnavailable { reason: \"[redacted]\", ", + "retry_after: \"[redacted]\" }" + ) + ); + assert_eq!( + format!("{:?}", provider_timeout()), + "ProviderTimeout(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", request.profile_id()), + "AuthorizationProfileId(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.policy_version()), + "PolicyVersion(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.capabilities()), + "CapabilitySet(\"[redacted]\")" + ); + + for error in [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::DelegatedOwnerMismatch, + ] { + let rendered = error.to_string(); + assert!(!rendered.contains("idp.example")); + assert!(!rendered.contains("subject-123")); + assert!(!rendered.contains("private-policy-version")); + } +} + +#[test] +fn provider_trait_is_object_safe_and_codes_are_unique() { + let provider: Arc = + Arc::new(FakeProvider::returning(ProviderDecision::Deny( + AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied), + ))); + assert!(Arc::strong_count(&provider) == 1); + + let mut codes = vec![ + ProviderAllowReason::CurrentPolicy.code(), + AuthorizationDenialReason::ProviderDenied.code(), + AuthorizationDenialReason::AuthorizationDomainMismatch.code(), + AuthorizationDenialReason::PrincipalMismatch.code(), + AuthorizationDenialReason::AuthorizationProfileMismatch.code(), + AuthorizationDenialReason::MissingCapability.code(), + AuthorizationDenialReason::StaleDecision.code(), + AuthorizationDenialReason::FutureDecision.code(), + AuthorizationDenialReason::IdentityEvidenceExpired.code(), + ProviderUnavailableReason::TemporarilyUnavailable.code(), + ProviderUnavailableReason::Timeout.code(), + ProviderUnavailableReason::DependencyUnavailable.code(), + ]; + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), 12); + + let contract_errors = [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::ProfileIdTooLong, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::PolicyVersionTooLong, + ProviderContractError::InvalidIssuedAt, + ProviderContractError::InvalidFreshnessBound, + ProviderContractError::FreshnessWindowTooLong, + ProviderContractError::InvalidRetryAfter, + ProviderContractError::InvalidProviderTimeout, + ProviderContractError::InvalidCorrelationId, + ProviderContractError::DirectRequestHasOwner, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::TransportMismatch, + ProviderContractError::AssertionNotYetValid, + ProviderContractError::AssertionExpired, + ProviderContractError::KeyAttestationMismatch, + ProviderContractError::MissingKeyAttestation, + ProviderContractError::DelegationRequired, + ProviderContractError::DelegatedOwnerMismatch, + ProviderContractError::DelegationExpired, + ]; + let mut contract_codes = contract_errors + .iter() + .copied() + .map(ProviderContractError::code) + .collect::>(); + contract_codes.sort_unstable(); + contract_codes.dedup(); + assert_eq!(contract_codes.len(), contract_errors.len()); +}