diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index df963bc4e0..1699555831 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,15 @@ pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, }; +pub use provider::{ + AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, 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, +}; 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 0000000000..7b3e032962 --- /dev/null +++ b/crates/buzz-auth/src/provider/mod.rs @@ -0,0 +1,1785 @@ +//! 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::{ + authority::{resolve_direct_binding, resolve_existing_binding}, + resolve_current_federated_policy, AdmissionExpiry, AssertionTransport, AuthContext, + AuthContextError, AuthContextInput, AuthMethod, AuthTransport, AuthoritativeBindingResolution, + AuthoritativeFederatedResolution, AuthorityAdapterError, BindingVersion, + CapabilityFinalizationSeal, FederatedAuthorityAdapter, FederatedPolicyStamp, + FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, + VerifiedOwnerAdmission, +}; + +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 invitations. + InviteMint, + /// Claim an invitation before membership exists. + InviteClaim, + /// 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. +/// +/// Transport input and provider responses must never select this identifier. +/// Production callers construct it only while loading trusted server +/// configuration, before request handling begins. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct AuthorizationProfileId(String); + +impl AuthorizationProfileId { + /// Preserve a non-empty, bounded profile identifier exactly as configured. + pub fn from_server_configuration( + 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 capability-policy version returned by a provider. +/// +/// This is the typed policy-change seam that later lease and invalidation code +/// can use without assuming a provider-specific numeric ordering. It is a +/// distinct namespace from [`FederatedPolicyStamp::epoch`] and must never be +/// used as enrollment-policy currency evidence. +#[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, + /// Stable identifier of the active owner binding. + binding_id: Uuid, + /// Exact active owner-binding version used for this decision. + binding_version: BindingVersion, + }, +} + +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, + transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + authority: AuthorizationAuthority, + principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, + federated_policy: FederatedPolicyStamp, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + decision_source: DecisionSource, + evidence_valid_from: u64, + evidence_valid_until: u64, +} + +impl AuthorizationRequest { + /// Build a direct request from a current assertion and Nostr proof. + /// + /// A matching key claim is preserved for later enrollment, but its absence + /// does not block provider evaluation: an existing active binding can still + /// authorize. Any atomic attested-key enrollment fails closed later unless + /// this assertion carried the exact authenticated key. + /// `now_unix_seconds` must come from the server clock. + pub fn direct( + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + 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.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); + } + if assertion + .key_attestation() + .is_some_and(|attestation| 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); + } + let evidence_valid_from = + assertion + .not_before() + .map_or(federated_policy.stamp().effective_from(), |bound| { + bound + .unix_seconds() + .max(federated_policy.stamp().effective_from()) + }); + let evidence_valid_until = assertion + .expires_at() + .unix_seconds() + .min(federated_policy.stamp().effective_until()); + Ok(Self { + authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Direct, + principal: assertion.principal().clone(), + key_attested: assertion.key_attestation().is_some(), + assertion_transport: Some(assertion.transport()), + assertion_not_before: assertion.not_before().map(|bound| bound.unix_seconds()), + assertion_expires_at: Some(assertion.expires_at().unix_seconds()), + federated_policy: federated_policy.into_stamp(), + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DirectAssertion, + evidence_valid_from, + evidence_valid_until, + }) + } + + /// 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. + /// `now_unix_seconds` must come from the server clock. + pub(crate) fn delegated( + proof: &VerifiedNostrProof, + owner: &AuthoritativeBindingResolution, + 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.is_existing_active() { + return Err(ProviderContractError::DelegatedBindingNotExistingActive); + } + 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); + } + 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, + }) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact protected transport authorized by the verified proof. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// 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 + } + + /// Exact authoritative enrollment-policy lineage bound to this request. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + + /// 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 + } + + /// Inclusive joined lower validity bound supplied by verified evidence. + pub const fn evidence_valid_from(&self) -> u64 { + self.evidence_valid_from + } + + /// Exclusive joined upper validity bound supplied by verified evidence. + pub const fn evidence_valid_until(&self) -> u64 { + self.evidence_valid_until + } +} + +impl fmt::Debug for AuthorizationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRequest") + .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("authority", &"[redacted]") + .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") + .field("federated_policy", &"[redacted]") + .field("requested_capabilities", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("evidence_valid_from", &"[redacted]") + .field("evidence_valid_until", &"[redacted]") + .finish() + } +} + +/// Resolve an existing delegated owner and build a provider request. +/// +/// The policy is consumed, the owner lifecycle outcome is produced only by the +/// configured authority adapter, and server time is sampled again after the +/// binding read. This path cannot enroll or relabel an owner binding. +#[allow(clippy::too_many_arguments)] +async fn resolve_delegated_authorization_request( + adapter: &A, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + clock: &dyn AuthorizationClock, +) -> Result> { + let Some(before_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + before_io, + )?; + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired.into()); + }; + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(before_io)) + { + return Err(ProviderContractError::DelegationExpired.into()); + } + let effective_from = federated_policy.stamp().effective_from(); + let effective_until = + delegation + .expires_at() + .map_or(federated_policy.stamp().effective_until(), |bound| { + bound + .unix_seconds() + .min(federated_policy.stamp().effective_until()) + }); + let owner = resolve_existing_binding( + adapter, + &federated_policy, + principal, + delegation.owner_pubkey(), + effective_from, + effective_until, + before_io, + ) + .await?; + let Some(after_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + AuthorizationRequest::delegated( + proof, + &owner, + federated_policy, + requested_capabilities, + correlation_id, + after_io, + ) + .map_err(ProviderAuthorizationError::from) +} + +/// 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, + /// Trusted time moved before the joined evidence interval. + IdentityEvidenceNotYetValid, + /// The bound federated enrollment policy was not current after provider I/O. + FederatedPolicyNotCurrent, +} + +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", + Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009", + Self::IdentityEvidenceNotYetValid => "authorization_provider_deny_010", + } + } +} + +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() + } +} + +/// Trusted server time used to validate a provider result. +/// +/// The resolver samples this source exactly once for an allowed decision. A +/// source must return current Unix time without reusing a value captured before +/// provider I/O, and must not block the async executor. Returning `None` fails +/// closed as dependency unavailability. +pub trait AuthorizationClock: Send + Sync { + /// Current trusted Unix time, or `None` when it cannot be obtained. + fn now_unix_seconds(&self) -> Option; +} + +/// 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 { + /// Profile fixed by trusted server configuration for this provider. + /// + /// Request and transport data must never influence this value. Returning it + /// from the configured provider keeps route selection out of + /// [`AuthorizationRequest`]. + fn profile_id(&self) -> AuthorizationProfileId; + + /// 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() + } +} + +/// Fail-closed error while joining provider and authoritative state. +#[derive(PartialEq, Eq)] +pub enum ProviderAuthorizationError { + /// Trusted server time was unavailable. + ClockUnavailable, + /// Provider evidence or snapshot shape violated the contract. + Contract(ProviderContractError), + /// Current policy or binding resolution failed. + Authority(AuthorityAdapterError), + /// Final immutable context validation failed. + Context(AuthContextError), +} + +/// Server-configured provider, authority adapter, and trusted clock. +/// +/// Construct exactly one runtime during server startup and inject it into +/// request handling. Every capability snapshot is privately bound to the +/// runtime that performed provider I/O, so a caller cannot substitute another +/// adapter or clock during finalization. +pub struct AuthorizationRuntime { + authority: A, + clock: C, + provider: P, + binding: Uuid, +} + +impl AuthorizationRuntime { + /// Bind trusted startup configuration into one authorization runtime. + pub fn from_server_configuration(authority: A, clock: C, provider: P) -> Self { + Self { + authority, + clock, + provider, + binding: Uuid::new_v4(), + } + } +} + +impl fmt::Debug for AuthorizationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRuntime") + .field("authority", &"[redacted]") + .field("clock", &"[redacted]") + .field("provider", &"[redacted]") + .field("binding", &"[redacted]") + .finish() + } +} + +impl From for ProviderAuthorizationError { + fn from(error: ProviderContractError) -> Self { + Self::Contract(error) + } +} + +impl From> for ProviderAuthorizationError { + fn from(error: AuthorityAdapterError) -> Self { + Self::Authority(error) + } +} + +impl From for ProviderAuthorizationError { + fn from(error: AuthContextError) -> Self { + Self::Context(error) + } +} + +impl fmt::Debug for ProviderAuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let variant = match self { + Self::ClockUnavailable => "ClockUnavailable", + Self::Contract(_) => "Contract", + Self::Authority(_) => "Authority", + Self::Context(_) => "Context", + }; + formatter + .debug_struct("ProviderAuthorizationError") + .field("variant", &variant) + .field("detail", &"[redacted]") + .finish() + } +} + +/// Validated, request-scoped capability snapshot. +/// +/// This type has no public constructor, default, or deserialization path. Only +/// [`AuthorizationRuntime::resolve_authorization`] can create it after checking +/// the provider response. The move-only snapshot is private finalizer evidence; +/// callers may inspect its bounded metadata but cannot recreate trusted state. +#[derive(PartialEq, Eq)] +pub struct CapabilitySnapshot { + runtime_binding: Uuid, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding_id: Option, + binding_version: Option, + proof_method: AuthMethod, + principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, + federated_policy: FederatedPolicyStamp, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + effective_from: u64, + effective_until: u64, + decision_source: DecisionSource, + correlation_id: Uuid, + reason: ProviderAllowReason, +} + +impl CapabilitySnapshot { + fn validate_runtime(&self, runtime_binding: Uuid) -> Result<(), ProviderContractError> { + if self.runtime_binding != runtime_binding { + return Err(ProviderContractError::AuthorizationRuntimeMismatch); + } + Ok(()) + } + + /// Authorization domain for this decision. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact protected transport for which this snapshot was resolved. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// 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 + } + + /// Stable active binding identifier for delegated authority. + pub const fn binding_id(&self) -> Option { + self.binding_id + } + + /// Exact active binding version for delegated authority. + /// + /// This is not a lease: later consumers must compare it with current + /// authoritative binding state before reusing a cached snapshot. + pub const fn binding_version(&self) -> Option { + self.binding_version + } + + /// 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 + } + + /// Exact authoritative enrollment-policy lineage bound to this decision. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + + /// Whether a freshly resolved authoritative policy is exactly the policy used here. + /// + /// The authority adapter must additionally compare this stamp with current + /// state and use its epoch as an atomic enrollment precondition. + pub fn is_bound_to_federated_policy(&self, policy: &ResolvedFederatedPolicy) -> bool { + self.federated_policy == *policy.stamp() + } + + /// 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 + } + + /// Inclusive joined lower bound across provider and identity evidence. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive joined upper 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 + } + + /// Consume a direct capability decision and finalize authoritative context. + /// + /// The current enrollment policy is reread after provider I/O, then the + /// exact assertion, policy, capability interval, and authenticated key are + /// supplied to the configured binding adapter. Server time is resampled + /// after each awaited authority operation. + async fn finalize_direct_v1( + self, + adapter: &A, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + clock: &dyn AuthorizationClock, + ) -> Result> { + self.validate_embedded_domains(&input)?; + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + self.validate_direct_shape(&input, &assertion, before_policy)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_direct_shape(&input, &assertion, after_policy)?; + self.validate_current_policy(&policy)?; + + let binding = resolve_direct_binding( + adapter, + &policy, + self.principal.clone(), + self.actor_pubkey, + self.key_attested, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_direct_shape(&input, &assertion, after_binding)?; + self.validate_current_policy(&policy)?; + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Direct { binding, assertion }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + /// Consume a delegated capability decision and finalize authoritative context. + /// + /// The bound owner is reread without enrollment after a fresh exact policy + /// read. Binding identifier and version must match the provider decision; + /// provider admission is derived from this snapshot's joined interval. + async fn finalize_delegated_v1( + self, + adapter: &A, + input: AuthContextInput, + clock: &dyn AuthorizationClock, + ) -> Result> { + self.validate_embedded_domains(&input)?; + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + let owner_pubkey = self.validate_delegated_shape(&input)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + + let owner = resolve_existing_binding( + adapter, + &policy, + self.principal.clone(), + owner_pubkey, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + if Some(owner.binding_id()) != self.binding_id + || Some(owner.binding_version()) != self.binding_version + || owner + .expires_at() + .is_some_and(|bound| bound.unix_seconds() < self.effective_until) + { + return Err(ProviderContractError::CapabilityBindingChanged.into()); + } + let admission = VerifiedOwnerAdmission::new( + self.authorization_domain, + self.principal, + AdmissionExpiry::new(self.effective_until)?, + ); + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Delegated { owner, admission }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + fn validate_common( + &self, + input: &AuthContextInput, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if input.authorization_domain() != self.authorization_domain + || input.correlation_id() != self.correlation_id + || input.transport() != self.transport + || input.proof_method() != self.proof_method + || input.actor_pubkey() != self.actor_pubkey + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if now_unix_seconds < self.effective_from { + return Err(ProviderContractError::CapabilityNotYetEffective); + } + if now_unix_seconds >= self.effective_until { + return Err(ProviderContractError::CapabilityExpired); + } + Ok(()) + } + + fn validate_embedded_domains(&self, input: &AuthContextInput) -> Result<(), AuthContextError> { + let authorization_domain = input.authorization_domain(); + if input.nostr_proof_authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + if input.community_access_authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + Ok(()) + } + + fn validate_direct_shape( + &self, + input: &AuthContextInput, + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if self.decision_source != DecisionSource::DirectAssertion + || self.owner_pubkey.is_some() + || self.binding_id.is_some() + || self.binding_version.is_some() + || input.verified_owner_pubkey().is_some() + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + if assertion.authorization_domain() != self.authorization_domain + || assertion.authorized_transport() != self.transport + || Some(assertion.transport()) != self.assertion_transport + || assertion.not_before().map(|bound| bound.unix_seconds()) != self.assertion_not_before + || Some(assertion.expires_at().unix_seconds()) != self.assertion_expires_at + || assertion.key_attestation().is_some() != self.key_attested + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if assertion.principal() != &self.principal { + return Err(ProviderContractError::CapabilityPrincipalMismatch); + } + if assertion + .key_attestation() + .is_some_and(|attestation| attestation.pubkey() != self.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(()) + } + + fn validate_delegated_shape( + &self, + input: &AuthContextInput, + ) -> Result { + let Some(owner_pubkey) = self.owner_pubkey else { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + }; + if self.decision_source != DecisionSource::DelegatedOwnerBinding + || self.binding_id.is_none() + || self.binding_version.is_none() + || self.key_attested + || self.assertion_transport.is_some() + || self.assertion_not_before.is_some() + || self.assertion_expires_at.is_some() + || input.verified_owner_pubkey() != Some(owner_pubkey) + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + Ok(owner_pubkey) + } + + fn validate_current_policy( + &self, + policy: &ResolvedFederatedPolicy, + ) -> Result<(), ProviderContractError> { + if !self.is_bound_to_federated_policy(policy) { + return Err(ProviderContractError::FederatedPolicyChanged); + } + Ok(()) + } +} + +fn finalization_time( + clock: &dyn AuthorizationClock, +) -> Result> { + clock + .now_unix_seconds() + .ok_or(ProviderAuthorizationError::ClockUnavailable) +} + +impl AuthorizationRuntime +where + A: FederatedAuthorityAdapter, + C: AuthorizationClock, + P: AuthorizationProvider, +{ + /// Resolve a provider decision using this runtime's fixed provider and clock. + pub async fn resolve_authorization( + &self, + request: &AuthorizationRequest, + timeout: ProviderTimeout, + ) -> AuthorizationOutcome { + resolve_authorization(&self.provider, request, &self.clock, timeout, self.binding).await + } + + /// Resolve an existing delegated owner and build a provider request. + pub async fn resolve_delegated_authorization_request( + &self, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result> { + resolve_delegated_authorization_request( + &self.authority, + proof, + principal, + federated_policy, + requested_capabilities, + correlation_id, + &self.clock, + ) + .await + } + + /// Consume a runtime-bound direct capability snapshot. + pub async fn finalize_direct_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_direct_v1(&self.authority, input, assertion, &self.clock) + .await + } + + /// Consume a runtime-bound delegated capability snapshot. + pub async fn finalize_delegated_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_delegated_v1(&self.authority, input, &self.clock) + .await + } +} + +impl fmt::Debug for CapabilitySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilitySnapshot") + .field("runtime_binding", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") + .field("federated_policy", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("effective_from", &"[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. +/// `clock` must be the server's trusted time source. After provider I/O +/// 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( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + clock: &dyn AuthorizationClock, + timeout: ProviderTimeout, + runtime_binding: Uuid, +) -> AuthorizationOutcome { + let configured_profile = provider.profile_id(); + 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); + } + }; + let Some(now_unix_seconds) = clock.now_unix_seconds() else { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )); + }; + + if request + .federated_policy + .is_not_yet_effective_at(now_unix_seconds) + || request.federated_policy.is_expired_at(now_unix_seconds) + { + return deny(AuthorizationDenialReason::FederatedPolicyNotCurrent); + } + + if allow.authorization_domain != request.authorization_domain { + return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); + } + if allow.principal != request.principal { + return deny(AuthorizationDenialReason::PrincipalMismatch); + } + if allow.profile_id != configured_profile { + 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_from = request.evidence_valid_from.max(allow.issued_at); + let effective_until = request.evidence_valid_until.min(allow.fresh_until); + if now_unix_seconds < effective_from { + return deny(AuthorizationDenialReason::IdentityEvidenceNotYetValid); + } + if effective_until <= now_unix_seconds || effective_from >= effective_until { + return deny(AuthorizationDenialReason::IdentityEvidenceExpired); + } + + AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { + runtime_binding, + authorization_domain: allow.authorization_domain, + transport: request.transport, + actor_pubkey: request.actor_pubkey, + owner_pubkey: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { owner_pubkey, .. } => Some(*owner_pubkey), + }, + binding_id: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { binding_id, .. } => Some(*binding_id), + }, + binding_version: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { + binding_version, .. + } => Some(*binding_version), + }, + proof_method: request.proof_method, + principal: allow.principal, + key_attested: request.key_attested, + assertion_transport: request.assertion_transport, + assertion_not_before: request.assertion_not_before, + assertion_expires_at: request.assertion_expires_at, + federated_policy: request.federated_policy.clone(), + 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_from, + 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, + /// Delegated owner resolution did not represent an already-active binding. + #[error("delegated provider request requires an existing active binding")] + DelegatedBindingNotExistingActive, + /// 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, + /// Owner binding was expired at server time. + #[error("delegated provider request owner binding has expired")] + BindingExpired, + /// Enrollment policy belonged to another authorization domain. + #[error("provider request enrollment policy does not match the authorization domain")] + FederatedPolicyDomainMismatch, + /// Enrollment policy belonged to another correlated decision. + #[error("provider request enrollment policy does not match the correlation identifier")] + FederatedPolicyCorrelationMismatch, + /// Enrollment policy was not yet effective at server time. + #[error("provider request enrollment policy is not yet effective")] + FederatedPolicyNotYetEffective, + /// Enrollment policy was expired at server time. + #[error("provider request enrollment policy has expired")] + FederatedPolicyExpired, + /// A capability snapshot was used before its joined effective interval. + #[error("provider capability snapshot is not yet effective")] + CapabilityNotYetEffective, + /// A capability snapshot reached its joined exclusive expiry. + #[error("provider capability snapshot has expired")] + CapabilityExpired, + /// A capability snapshot did not match immutable request context. + #[error("provider capability snapshot does not match authorization context")] + CapabilityContextMismatch, + /// A capability snapshot did not match direct or delegated authority shape. + #[error("provider capability snapshot authority shape is invalid")] + CapabilityAuthorityMismatch, + /// A capability snapshot did not match the sealed assertion principal. + #[error("provider capability snapshot principal is invalid")] + CapabilityPrincipalMismatch, + /// The delegated binding identifier, version, or expiry changed. + #[error("provider capability snapshot binding is no longer current")] + CapabilityBindingChanged, + /// Fresh authoritative policy lineage differed from the capability snapshot. + #[error("provider capability snapshot enrollment policy changed")] + FederatedPolicyChanged, + /// A capability snapshot was presented to a different configured runtime. + #[error("provider capability snapshot does not belong to this authorization runtime")] + AuthorizationRuntimeMismatch, +} + +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::DelegatedBindingNotExistingActive => "authorization_provider_contract_020", + Self::FreshnessWindowTooLong => "authorization_provider_contract_021", + Self::BindingExpired => "authorization_provider_contract_022", + Self::FederatedPolicyDomainMismatch => "authorization_provider_contract_023", + Self::FederatedPolicyCorrelationMismatch => "authorization_provider_contract_024", + Self::FederatedPolicyNotYetEffective => "authorization_provider_contract_025", + Self::FederatedPolicyExpired => "authorization_provider_contract_026", + Self::CapabilityNotYetEffective => "authorization_provider_contract_027", + Self::CapabilityExpired => "authorization_provider_contract_028", + Self::CapabilityContextMismatch => "authorization_provider_contract_029", + Self::CapabilityAuthorityMismatch => "authorization_provider_contract_030", + Self::CapabilityPrincipalMismatch => "authorization_provider_contract_031", + Self::CapabilityBindingChanged => "authorization_provider_contract_032", + Self::FederatedPolicyChanged => "authorization_provider_contract_033", + Self::AuthorizationRuntimeMismatch => "authorization_provider_contract_034", + } + } +} + +fn validate_federated_policy( + policy: &ResolvedFederatedPolicy, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result<(), ProviderContractError> { + if policy.authorization_domain() != authorization_domain { + return Err(ProviderContractError::FederatedPolicyDomainMismatch); + } + if policy.stamp().correlation_id() != correlation_id { + return Err(ProviderContractError::FederatedPolicyCorrelationMismatch); + } + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyNotYetEffective); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyExpired); + } + Ok(()) +} + +#[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 0000000000..190cdf7da6 --- /dev/null +++ b/crates/buzz-auth/src/provider/tests.rs @@ -0,0 +1,2538 @@ +use std::{ + future::pending, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use nostr::Keys; + +use super::*; +use crate::context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource, + BindingVersion, DelegationExpiry, EnrollmentMode, FederatedIdentityRequirement, + FederatedPolicyStamp, ResolvedFederatedPolicy, VerifiedKeyAttestation, + VerifiedTransportDelegation, +}; +use crate::{ + AuthorityAdapterFuture, AuthorizedCommunityAccess, BindingResolutionRequest, + CurrentPolicyRequest, CurrentPolicyResolutionSink, DirectBindingResolutionSink, + ExistingBindingResolutionSink, Scope, +}; + +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::from_server_configuration("profile-1") + .expect("synthetic profile is valid") +} + +fn policy_version(value: &str) -> PolicyVersion { + PolicyVersion::new(value).expect("synthetic policy version is valid") +} + +fn federated_policy_with( + domain_value: u128, + correlation_id: Uuid, + epoch: u64, + enrollment_mode: EnrollmentMode, + effective_from: u64, + effective_until: u64, +) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(domain_value), + Uuid::from_u128(40), + epoch, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + effective_from, + effective_until, + ) + .expect("synthetic federated policy lineage is valid"), + ) +} + +fn federated_policy() -> ResolvedFederatedPolicy { + federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 200, + ) +} + +fn provider_timeout() -> ProviderTimeout { + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") +} + +#[derive(Clone)] +struct TestClock { + now: Arc, + available: Arc, + reads: Arc, +} + +impl TestClock { + fn at(now: u64) -> Self { + Self { + now: Arc::new(AtomicU64::new(now)), + available: Arc::new(AtomicBool::new(true)), + reads: Arc::new(AtomicUsize::new(0)), + } + } + + fn set(&self, now: u64) { + self.now.store(now, Ordering::SeqCst); + } + + fn set_available(&self, available: bool) { + self.available.store(available, Ordering::SeqCst); + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } +} + +impl AuthorizationClock for TestClock { + fn now_unix_seconds(&self) -> Option { + self.reads.fetch_add(1, Ordering::SeqCst); + self.available + .load(Ordering::SeqCst) + .then(|| self.now.load(Ordering::SeqCst)) + } +} + +#[derive(Clone)] +struct TestAuthorityAdapter { + policy_epoch: u64, + enrollment_mode: EnrollmentMode, + enroll_direct: bool, + policy_reads: Arc, + direct_calls: Arc, + existing_calls: Arc, + committed_enrollments: Arc, +} + +impl TestAuthorityAdapter { + fn new(policy_epoch: u64, enrollment_mode: EnrollmentMode, enroll_direct: bool) -> Self { + Self { + policy_epoch, + enrollment_mode, + enroll_direct, + policy_reads: Arc::new(AtomicUsize::new(0)), + direct_calls: Arc::new(AtomicUsize::new(0)), + existing_calls: Arc::new(AtomicUsize::new(0)), + committed_enrollments: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl FederatedAuthorityAdapter for TestAuthorityAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.policy_reads.fetch_add(1, Ordering::SeqCst); + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + self.policy_epoch, + FederatedIdentityRequirement::Required(self.enrollment_mode), + 1, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.direct_calls.fetch_add(1, Ordering::SeqCst); + let result = if self.enroll_direct { + let source = match self.enrollment_mode { + EnrollmentMode::AttestedKey => BindingSource::AttestedKey, + EnrollmentMode::Tofu => BindingSource::Tofu, + EnrollmentMode::Provisioned => BindingSource::Provisioned, + }; + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + source, + ) + } else { + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + }; + let resolution = result.map_err(AuthorityAdapterError::from)?; + if self.enroll_direct { + self.committed_enrollments.fetch_add(1, Ordering::SeqCst); + } + Ok(resolution) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.existing_calls.fetch_add(1, Ordering::SeqCst); + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .map_err(AuthorityAdapterError::from) + }) + } +} + +fn direct_evidence( + actor: &Keys, + enrollment_mode: EnrollmentMode, + key_attested: bool, +) -> ( + VerifiedNostrProof, + VerifiedFederatedAssertion, + 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(), + key_attested.then(|| VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(90)), + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + federated_policy_with(1, Uuid::from_u128(20), 1, enrollment_mode, 1, 200), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic direct request is valid"); + (proof, assertion, request) +} + +fn finalization_input(proof: VerifiedNostrProof) -> AuthContextInput { + AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(1), Scope::all_known(), None), + ) +} + +async fn resolve_at( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + now: u64, + timeout: ProviderTimeout, +) -> AuthorizationOutcome { + resolve_authorization( + provider, + request, + &TestClock::at(now), + timeout, + Uuid::from_u128(99), + ) + .await +} + +fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { + CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") +} + +fn all_capabilities() -> [AuthorizationCapability; 10] { + [ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + AuthorizationCapability::Moderate, + AuthorizationCapability::InviteMint, + AuthorizationCapability::InviteClaim, + AuthorizationCapability::MediaRead, + AuthorizationCapability::MediaWrite, + AuthorizationCapability::GitRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::AudioJoin, + ] +} + +fn capability_coverage_is_exhaustive(capability: AuthorizationCapability) { + match capability { + AuthorizationCapability::CommunityRead + | AuthorizationCapability::CommunityWrite + | AuthorizationCapability::Moderate + | AuthorizationCapability::InviteMint + | AuthorizationCapability::InviteClaim + | AuthorizationCapability::MediaRead + | AuthorizationCapability::MediaWrite + | AuthorizationCapability::GitRead + | AuthorizationCapability::GitWrite + | AuthorizationCapability::AudioJoin => {} + } +} + +fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { + match transport { + AuthTransport::RelayWebSocket => AuthMethod::Nip42, + AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaDownload => { + AuthMethod::Nip98 + } + AuthTransport::MediaUpload => AuthMethod::Blossom, + AuthTransport::Audio => AuthMethod::Nip42, + } +} + +fn all_contract_errors() -> [ProviderContractError; 34] { + [ + 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::DelegatedBindingNotExistingActive, + ProviderContractError::DelegationRequired, + ProviderContractError::DelegatedOwnerMismatch, + ProviderContractError::DelegationExpired, + ProviderContractError::BindingExpired, + ProviderContractError::FederatedPolicyDomainMismatch, + ProviderContractError::FederatedPolicyCorrelationMismatch, + ProviderContractError::FederatedPolicyNotYetEffective, + ProviderContractError::FederatedPolicyExpired, + ProviderContractError::CapabilityNotYetEffective, + ProviderContractError::CapabilityExpired, + ProviderContractError::CapabilityContextMismatch, + ProviderContractError::CapabilityAuthorityMismatch, + ProviderContractError::CapabilityPrincipalMismatch, + ProviderContractError::CapabilityBindingChanged, + ProviderContractError::FederatedPolicyChanged, + ProviderContractError::AuthorizationRuntimeMismatch, + ] +} + +fn direct_request_for_transport( + actor: &Keys, + transport: AuthTransport, + proof_method: AuthMethod, + not_before: Option, + expiry: u64, + requested: CapabilitySet, +) -> Result { + let proof = + VerifiedNostrProof::new(domain(1), transport, actor.public_key(), proof_method, None) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + transport, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + not_before.map(AssertionNotBefore::new), + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ); + AuthorizationRequest::direct( + &proof, + &assertion, + federated_policy(), + requested, + Uuid::from_u128(20), + NOW, + ) +} + +fn direct_request_with_expiry( + actor: &Keys, + expiry: u64, + requested: CapabilitySet, +) -> AuthorizationRequest { + direct_request_for_transport( + actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + None, + expiry, + requested, + ) + .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) -> AuthoritativeBindingResolution { + existing_binding_in(1, owner) +} + +fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingResolution { + existing_binding_with_expiry_in(domain_value, owner, None) +} + +fn existing_binding_with_expiry_in( + domain_value: u128, + owner: &Keys, + expires_at: Option, +) -> AuthoritativeBindingResolution { + let evidence = AuthoritativeBindingEvidence::new( + domain(domain_value), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + expires_at + .map(|expiry| BindingExpiry::new(expiry).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .expect("synthetic binding is valid"); + AuthoritativeBindingResolution::existing_active(evidence) +} + +fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProof { + 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"); + VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic delegated proof is valid") +} + +fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { + let proof = delegated_proof(actor, owner, expiry); + AuthorizationRequest::delegated( + &proof, + &existing_binding(owner), + federated_policy(), + 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(), + profile(), + 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 profile_id(&self) -> AuthorizationProfileId { + profile() + } + + 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 EchoAllowProvider; + +impl AuthorizationProvider for EchoAllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + allow_for( + request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + ) + }) + } +} + +struct AdvancingProvider { + decision: Mutex>, + clock: TestClock, + decision_time: u64, + clock_available: bool, +} + +impl AdvancingProvider { + fn returning_at(decision: ProviderDecision, clock: TestClock, decision_time: u64) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time, + clock_available: true, + } + } + + fn returning_with_clock_failure(decision: ProviderDecision, clock: TestClock) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time: 0, + clock_available: false, + } + } +} + +impl AuthorizationProvider for AdvancingProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + tokio::task::yield_now().await; + assert_eq!( + self.clock.reads(), + 0, + "decision time must not be sampled before provider I/O completes" + ); + self.clock.set(self.decision_time); + self.clock.set_available(self.clock_available); + 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 profile_id(&self) -> AuthorizationProfileId { + profile() + } + + 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_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_eq!(snapshot.authorization_domain(), domain(1)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), None); + assert_eq!(snapshot.binding_id(), None); + assert_eq!(snapshot.binding_version(), None); + assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); + assert_eq!(snapshot.principal(), request.principal()); + assert_eq!(snapshot.profile_id(), &profile()); + 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 runtime_finalizer_allows_existing_binding_without_key_claim() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Provisioned, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + + let context = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect("an existing active binding does not require a later key claim"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::ExistingBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn mismatched_embedded_proof_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (_, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let mismatched_proof = VerifiedNostrProof::new( + domain(2), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic mismatched proof is structurally valid"); + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(mismatched_proof), assertion) + .await + .expect_err("embedded proof domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::NostrProofDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn mismatched_community_access_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let input = AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(2), Scope::all_known(), None), + ); + + let error = runtime + .finalize_direct_v1(*snapshot, input, assertion) + .await + .expect_err("embedded admission domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::CommunityAccessDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn attested_enrollment_without_sealed_key_claim_fails_before_commit() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::AttestedKey, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::AttestedKey, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("provider evaluation may allow before binding resolution"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("attested-key enrollment requires the sealed matching key claim"); + + assert_eq!( + error, + ProviderAuthorizationError::Authority(AuthorityAdapterError::Contract( + AuthContextError::KeyAttestationRequired + )) + ); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn fresh_policy_epoch_drift_blocks_binding_mutation() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(2, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("request-time policy is current during provider evaluation"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("fresh authoritative policy drift must fail before binding I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::FederatedPolicyChanged) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn runtime_binding_rejects_forged_adapter_and_clock_substitution() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let genuine_provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let genuine_runtime = AuthorizationRuntime::from_server_configuration( + TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true), + TestClock::at(NOW), + genuine_provider, + ); + let AuthorizationOutcome::Allow(snapshot) = genuine_runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("genuine runtime must issue the capability snapshot"); + }; + + let forged_authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let forged_runtime = AuthorizationRuntime::from_server_configuration( + forged_authority.clone(), + TestClock::at(NOW), + FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))), + ); + let error = forged_runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("a legitimate snapshot cannot be spliced to a caller-selected runtime"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::AuthorizationRuntimeMismatch) + ); + assert_eq!(forged_authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(forged_authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!( + forged_authority + .committed_enrollments + .load(Ordering::SeqCst), + 0 + ); +} + +#[tokio::test] +async fn runtime_resolves_and_refinalizes_existing_delegated_owner() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 180); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + EchoAllowProvider, + ); + let request = runtime + .resolve_delegated_authorization_request( + &proof, + principal(), + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + ) + .await + .expect("the configured adapter resolves an existing delegated owner"); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("the current owner admission must allow"); + }; + + let context = runtime + .finalize_delegated_v1(*snapshot, finalization_input(proof)) + .await + .expect("the owner is reread and finalized without enrollment"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::DelegatedOwnerBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.existing_calls.load(Ordering::SeqCst), 2); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn allowed_snapshot_preserves_every_requested_transport_scope() { + let transports = [ + AuthTransport::RelayWebSocket, + AuthTransport::HttpBridge, + AuthTransport::Git, + AuthTransport::MediaUpload, + AuthTransport::MediaDownload, + AuthTransport::Audio, + ]; + + for transport in transports { + let proof_method = proof_method_for_transport(transport); + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + transport, + proof_method, + None, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("synthetic direct request is valid"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow every transport profile"); + }; + assert_eq!(snapshot.transport(), transport); + assert_eq!(snapshot.proof_method(), proof_method); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + } +} + +#[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_at(&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_at(&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_at(&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 provider_freshness_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 105, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("a provider decision stale after I/O must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn federated_policy_expiry_is_evaluated_after_async_io() { + let actor = Keys::generate(); + 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(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 105, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("federated policy is current when provider I/O begins"); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("federated enrollment policy expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::FederatedPolicyNotCurrent + ); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn snapshot_requires_exact_enrollment_policy_lineage() { + let actor = Keys::generate(); + 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(180).expect("synthetic assertion expiry is valid"), + ); + let current_policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + current_policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "6", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider and enrollment policy must allow"); + }; + + let stale_tofu_policy = + federated_policy_with(1, Uuid::from_u128(20), 6, EnrollmentMode::Tofu, 1, 160); + let current_policy_for_comparison = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + assert!(snapshot.is_bound_to_federated_policy(¤t_policy_for_comparison)); + assert!(!snapshot.is_bound_to_federated_policy(&stale_tofu_policy)); + assert_eq!(snapshot.policy_version().as_str(), "6"); + assert_ne!( + snapshot.policy_version().as_str(), + snapshot.federated_policy().epoch().to_string() + ); +} + +#[tokio::test] +async fn enrollment_policy_bounds_snapshot_effective_interval() { + let actor = Keys::generate(); + 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(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 150, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + 90, + 170, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + + assert_eq!(request.evidence_valid_until(), 150); + assert_eq!(snapshot.effective_until(), 150); +} + +#[tokio::test] +async fn identity_evidence_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 105, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("identity evidence expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[tokio::test] +async fn owner_binding_expiry_is_evaluated_after_async_io() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(105)); + let request = AuthorizationRequest::delegated( + &proof, + &binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("owner binding is current at request construction"); + assert_eq!(request.evidence_valid_until(), 105); + + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("owner binding expired after provider I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[test] +fn delegated_request_rejects_owner_binding_at_exact_expiry() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(NOW)); + + let error = AuthorizationRequest::delegated( + &proof, + &binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect_err("expired owner binding must not enter provider evaluation"); + assert_eq!(error, ProviderContractError::BindingExpired); +} + +#[tokio::test] +async fn decision_issued_during_async_io_is_not_false_future() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 104, + 180, + ), + clock.clone(), + 105, + ); + + assert!(matches!( + resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99) + ) + .await, + AuthorizationOutcome::Allow(_) + )); +} + +#[tokio::test] +async fn clock_failure_after_provider_io_is_unavailable() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_with_clock_failure( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + ); + + let AuthorizationOutcome::Unavailable(unavailable) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("unavailable decision time must fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::DependencyUnavailable + ); +} + +#[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_at(&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_at(&future, &request, NOW, provider_timeout()).await + else { + panic!("future decision must deny"); + }; + assert_eq!( + future_denial.reason(), + AuthorizationDenialReason::FutureDecision + ); +} + +#[tokio::test] +async fn provider_time_boundaries_and_current_assertion_are_exact() { + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + Some(NOW), + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("assertion with not-before equal to server time is current"); + + let issued_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + )); + assert!(matches!( + resolve_at(&issued_now, &request, NOW, provider_timeout()).await, + AuthorizationOutcome::Allow(_) + )); + + let stale_at_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + NOW, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&stale_at_now, &request, NOW, provider_timeout()).await + else { + panic!("freshness ending at server time must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); +} + +#[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(), + profile(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&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"), + profile(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&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::from_server_configuration("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_at(&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_at(&missing_capability, &request, NOW, provider_timeout()).await + else { + panic!("missing capability must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[tokio::test] +async fn invite_mint_does_not_authorize_invite_claim() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 200, + capabilities(&[AuthorizationCapability::InviteClaim]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::InviteMint]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Deny(denial) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("invitation minting must not authorize a claim"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[test] +fn capability_sets_are_normalized_and_deduplicated() { + let normalized = CapabilitySet::new(vec![ + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + ]) + .expect("synthetic capabilities are non-empty"); + + assert_eq!( + normalized.as_slice(), + &[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + ] + ); +} + +#[tokio::test] +async fn no_distinct_capability_authorizes_another_capability() { + let actor = Keys::generate(); + for requested in all_capabilities() { + for granted in all_capabilities() { + if requested == granted { + continue; + } + + let request = direct_request_with_expiry(&actor, 200, capabilities(&[requested])); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[granted]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("a distinct capability must not widen provider authority"); + }; + 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_at(&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 identity_evidence_expiring_during_provider_resolution_denies() { + let actor = Keys::generate(); + let direct = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let direct_provider = FakeProvider::returning(allow_for( + &direct, + direct.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(direct_denial) = + resolve_at(&direct_provider, &direct, 120, provider_timeout()).await + else { + panic!("assertion expiring during provider resolution must deny"); + }; + assert_eq!( + direct_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated = delegated_request(&delegate, &owner, 140); + let delegated_provider = FakeProvider::returning(allow_for( + &delegated, + delegated.requested_capabilities().clone(), + "version-a", + 130, + 180, + )); + let AuthorizationOutcome::Deny(delegated_denial) = + resolve_at(&delegated_provider, &delegated, 140, provider_timeout()).await + else { + panic!("delegation expiring during provider resolution must deny"); + }; + assert_eq!( + delegated_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[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_at(&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())); + assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); + assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); +} + +#[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_at(&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_at(&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::from_server_configuration(""), + Err(ProviderContractError::EmptyProfileId) + ); + assert_eq!( + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::ProfileIdTooLong) + ); + assert!( + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok() + ); + assert_eq!( + PolicyVersion::new(""), + Err(ProviderContractError::EmptyPolicyVersion) + ); + assert_eq!( + PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::PolicyVersionTooLong) + ); + assert!(PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()); + assert_eq!( + RetryAfter::new(0), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS) + .expect("maximum retry hint is valid") + .seconds(), + MAX_RETRY_AFTER_SECONDS + ); + 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!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT) + .expect("maximum provider timeout is valid") + .duration(), + MAX_PROVIDER_TIMEOUT + ); + 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, + 99, + ), + 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) + ); + assert!(ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS, + ) + .is_ok()); +} + +#[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, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + federated_policy(), + 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, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionNotYetValid) + ); +} + +#[test] +fn request_construction_rejects_non_current_or_mismatched_federated_policy() { + let actor = Keys::generate(); + 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(180).expect("synthetic assertion expiry is valid"), + ); + let request_with = |policy: ResolvedFederatedPolicy| { + AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + let wrong_domain = federated_policy_with( + 2, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(wrong_domain), + Err(ProviderContractError::FederatedPolicyDomainMismatch) + ); + let wrong_correlation = federated_policy_with( + 1, + Uuid::from_u128(21), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(wrong_correlation), + Err(ProviderContractError::FederatedPolicyCorrelationMismatch) + ); + let future = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + NOW + 1, + 180, + ); + assert_eq!( + request_with(future), + Err(ProviderContractError::FederatedPolicyNotYetEffective) + ); + let expired = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + NOW, + ); + assert_eq!( + request_with(expired), + Err(ProviderContractError::FederatedPolicyExpired) + ); +} + +#[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, + federated_policy(), + 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!(request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ) + .is_ok()); + + 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: &AuthoritativeBindingResolution| { + AuthorizationRequest::delegated( + proof, + binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + assert_eq!( + AuthorizationRequest::delegated( + &delegated_proof, + &existing_binding(&owner), + federated_policy(), + 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); + let request_debug = concat!( + "AuthorizationRequest { authorization_domain: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", ", + "authority: \"[redacted]\", principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", + "requested_capabilities: \"[redacted]\", ", + "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_from: \"[redacted]\", ", + "evidence_valid_until: \"[redacted]\" }" + ); + // Keep this exact-shape assertion deliberately: adding a field must fail until + // the disclosure contract explicitly confirms that the new field is redacted. + assert_eq!(format!("{request:?}"), request_debug); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated_request = delegated_request(&delegate, &owner, 180); + assert_eq!(format!("{delegated_request:?}"), request_debug); + assert_eq!( + format!("{:?}", request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", request.decision_source()), + "DecisionSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.decision_source()), + "DecisionSource(\"[redacted]\")" + ); + + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + profile(), + 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_at(&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 { runtime_binding: \"[redacted]\", ", + "authorization_domain: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", + "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", + "principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", + "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", + "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", + "fresh_until: \"[redacted]\", effective_from: \"[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!( + "{:?}", + ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", provider_timeout()), + "ProviderTimeout(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", &profile()), + "AuthorizationProfileId(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.policy_version()), + "PolicyVersion(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.capabilities()), + "CapabilitySet(\"[redacted]\")" + ); + + for capability in all_capabilities() { + capability_coverage_is_exhaustive(capability); + assert_eq!( + format!("{capability:?}"), + "AuthorizationCapability(\"[redacted]\")" + ); + } + for reason in [ + AuthorizationDenialReason::ProviderDenied, + AuthorizationDenialReason::AuthorizationDomainMismatch, + AuthorizationDenialReason::PrincipalMismatch, + AuthorizationDenialReason::AuthorizationProfileMismatch, + AuthorizationDenialReason::MissingCapability, + AuthorizationDenialReason::StaleDecision, + AuthorizationDenialReason::FutureDecision, + AuthorizationDenialReason::IdentityEvidenceExpired, + AuthorizationDenialReason::IdentityEvidenceNotYetValid, + AuthorizationDenialReason::FederatedPolicyNotCurrent, + ] { + assert_eq!( + format!("{reason:?}"), + "AuthorizationDenialReason(\"[redacted]\")" + ); + } + for reason in [ + ProviderUnavailableReason::TemporarilyUnavailable, + ProviderUnavailableReason::Timeout, + ProviderUnavailableReason::DependencyUnavailable, + ] { + assert_eq!( + format!("{reason:?}"), + "ProviderUnavailableReason(\"[redacted]\")" + ); + } + assert_eq!( + format!("{:?}", ProviderAllowReason::CurrentPolicy), + "ProviderAllowReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", RetryAfter::new(30).expect("retry hint is valid")), + "RetryAfter(\"[redacted]\")" + ); + + for error in all_contract_errors() { + for rendered in [error.to_string(), format!("{error:?}")] { + for private_value in [ + "idp.example", + "subject-123", + "profile-1", + "private-policy-version", + ] { + assert!(!rendered.contains(private_value)); + } + } + } +} + +#[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(), + AuthorizationDenialReason::IdentityEvidenceNotYetValid.code(), + AuthorizationDenialReason::FederatedPolicyNotCurrent.code(), + ProviderUnavailableReason::TemporarilyUnavailable.code(), + ProviderUnavailableReason::Timeout.code(), + ProviderUnavailableReason::DependencyUnavailable.code(), + ]; + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), 14); + + let contract_errors = all_contract_errors(); + 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()); +}