From 4bd64092bcb083646791ed926ec5640bfc801764 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:48:30 -0500 Subject: [PATCH] fix(auth): bind capability snapshots to enrollment policy Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/mod.rs | 125 +++++++-- crates/buzz-auth/src/provider/tests.rs | 353 +++++++++++++++++++++++-- 2 files changed, 443 insertions(+), 35 deletions(-) diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 569da23de..fc4c1069c 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -12,8 +12,8 @@ use thiserror::Error; use uuid::Uuid; use crate::context::{ - AuthMethod, AuthTransport, BindingVersion, FederatedPrincipal, VerifiedFederatedAssertion, - VerifiedNostrProof, VersionedBindingRef, + AuthMethod, AuthTransport, AuthoritativeBindingEvidence, BindingVersion, FederatedPolicyStamp, + FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -133,10 +133,12 @@ impl fmt::Debug for AuthorizationProfileId { } } -/// Opaque, equality-comparable policy version returned by a provider. +/// 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. +/// 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); @@ -222,6 +224,7 @@ pub struct AuthorizationRequest { proof_method: AuthMethod, authority: AuthorizationAuthority, principal: FederatedPrincipal, + federated_policy: FederatedPolicyStamp, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -239,6 +242,7 @@ impl AuthorizationRequest { pub fn direct( proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion, + federated_policy: &ResolvedFederatedPolicy, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -247,6 +251,12 @@ impl AuthorizationRequest { 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); } @@ -278,11 +288,17 @@ impl AuthorizationRequest { proof_method: proof.proof_method(), authority: AuthorizationAuthority::Direct, principal: assertion.principal().clone(), + federated_policy: federated_policy.stamp().clone(), profile_id, requested_capabilities, correlation_id, decision_source: DecisionSource::DirectAssertion, - evidence_valid_until: Some(assertion.expires_at().unix_seconds()), + evidence_valid_until: Some( + assertion + .expires_at() + .unix_seconds() + .min(federated_policy.stamp().effective_until()), + ), }) } @@ -293,7 +309,8 @@ impl AuthorizationRequest { /// `now_unix_seconds` must come from the server clock. pub fn delegated( proof: &VerifiedNostrProof, - owner: &VersionedBindingRef, + owner: &AuthoritativeBindingEvidence, + federated_policy: &ResolvedFederatedPolicy, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -302,6 +319,12 @@ impl AuthorizationRequest { 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); } @@ -323,14 +346,13 @@ impl AuthorizationRequest { { return Err(ProviderContractError::BindingExpired); } - let evidence_valid_until = match (delegation.expires_at(), owner.expires_at()) { - (Some(delegation), Some(binding)) => { - Some(delegation.unix_seconds().min(binding.unix_seconds())) - } - (Some(delegation), None) => Some(delegation.unix_seconds()), - (None, Some(binding)) => Some(binding.unix_seconds()), - (None, None) => None, - }; + 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(), @@ -342,11 +364,12 @@ impl AuthorizationRequest { binding_version: owner.binding_version(), }, principal: owner.principal().clone(), + federated_policy: federated_policy.stamp().clone(), profile_id, requested_capabilities, correlation_id, decision_source: DecisionSource::DelegatedOwnerBinding, - evidence_valid_until, + evidence_valid_until: Some(evidence_valid_until), }) } @@ -380,6 +403,11 @@ impl AuthorizationRequest { &self.principal } + /// Exact authoritative enrollment-policy lineage bound to this request. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + /// Server-resolved provider profile. pub const fn profile_id(&self) -> &AuthorizationProfileId { &self.profile_id @@ -401,7 +429,7 @@ impl AuthorizationRequest { } /// Earliest validity bound supplied by verified assertion, owner-binding, - /// or delegation evidence. + /// delegation, or authoritative enrollment-policy evidence. pub const fn evidence_valid_until(&self) -> Option { self.evidence_valid_until } @@ -417,6 +445,7 @@ impl fmt::Debug for AuthorizationRequest { .field("proof_method", &"[redacted]") .field("authority", &"[redacted]") .field("principal", &"[redacted]") + .field("federated_policy", &"[redacted]") .field("profile_id", &"[redacted]") .field("requested_capabilities", &"[redacted]") .field("correlation_id", &"[redacted]") @@ -505,6 +534,8 @@ pub enum AuthorizationDenialReason { FutureDecision, /// Verified identity evidence expired before the decision became effective. IdentityEvidenceExpired, + /// The bound federated enrollment policy was not current after provider I/O. + FederatedPolicyNotCurrent, } impl AuthorizationDenialReason { @@ -519,6 +550,7 @@ impl AuthorizationDenialReason { Self::FutureDecision => "authorization_provider_deny_006", Self::IdentityEvidenceExpired => "authorization_provider_deny_007", Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", + Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009", } } } @@ -779,6 +811,7 @@ pub struct CapabilitySnapshot { binding_version: Option, proof_method: AuthMethod, principal: FederatedPrincipal, + federated_policy: FederatedPolicyStamp, profile_id: AuthorizationProfileId, capabilities: CapabilitySet, policy_version: PolicyVersion, @@ -834,6 +867,19 @@ impl CapabilitySnapshot { &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 O3 policy is exactly the policy used here. + /// + /// O3 must additionally compare this stamp with current authoritative 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 @@ -892,6 +938,7 @@ impl fmt::Debug for CapabilitySnapshot { .field("binding_version", &"[redacted]") .field("proof_method", &"[redacted]") .field("principal", &"[redacted]") + .field("federated_policy", &"[redacted]") .field("profile_id", &"[redacted]") .field("capabilities", &"[redacted]") .field("policy_version", &"[redacted]") @@ -964,6 +1011,14 @@ pub async fn resolve_authorization( )); }; + 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); } @@ -1013,6 +1068,7 @@ pub async fn resolve_authorization( }, proof_method: request.proof_method, principal: allow.principal, + federated_policy: request.federated_policy.clone(), profile_id: allow.profile_id, capabilities: request.requested_capabilities.clone(), policy_version: allow.policy_version, @@ -1099,6 +1155,18 @@ pub enum ProviderContractError { /// 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, } impl ProviderContractError { @@ -1127,9 +1195,34 @@ impl ProviderContractError { Self::MissingKeyAttestation => "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", } } } +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 index 56a8780bb..99418b354 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -11,9 +11,10 @@ use nostr::Keys; use super::*; use crate::context::{ - AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingExpiry, - BindingSource, BindingVersion, DelegationExpiry, VerifiedKeyAttestation, - VerifiedTransportDelegation, + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, + AuthoritativeBindingEvidence, BindingExpiry, BindingSource, BindingVersion, DelegationExpiry, + EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, + VerifiedKeyAttestation, VerifiedTransportDelegation, }; const NOW: u64 = 100; @@ -35,6 +36,39 @@ 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") } @@ -131,7 +165,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { } } -fn all_contract_errors() -> [ProviderContractError; 22] { +fn all_contract_errors() -> [ProviderContractError; 26] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -155,6 +189,10 @@ fn all_contract_errors() -> [ProviderContractError; 22] { ProviderContractError::DelegatedOwnerMismatch, ProviderContractError::DelegationExpired, ProviderContractError::BindingExpired, + ProviderContractError::FederatedPolicyDomainMismatch, + ProviderContractError::FederatedPolicyCorrelationMismatch, + ProviderContractError::FederatedPolicyNotYetEffective, + ProviderContractError::FederatedPolicyExpired, ] } @@ -181,6 +219,7 @@ fn direct_request_for_transport( AuthorizationRequest::direct( &proof, &assertion, + &federated_policy(), profile(), requested, Uuid::from_u128(20), @@ -212,11 +251,11 @@ fn direct_request(actor: &Keys) -> AuthorizationRequest { ) } -fn existing_binding(owner: &Keys) -> VersionedBindingRef { +fn existing_binding(owner: &Keys) -> AuthoritativeBindingEvidence { existing_binding_in(1, owner) } -fn existing_binding_in(domain_value: u128, owner: &Keys) -> VersionedBindingRef { +fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingEvidence { existing_binding_with_expiry_in(domain_value, owner, None) } @@ -224,8 +263,8 @@ fn existing_binding_with_expiry_in( domain_value: u128, owner: &Keys, expires_at: Option, -) -> VersionedBindingRef { - VersionedBindingRef::new_existing_active_for_test( +) -> AuthoritativeBindingEvidence { + AuthoritativeBindingEvidence::new( domain(domain_value), Uuid::from_u128(10), principal(), @@ -260,6 +299,7 @@ fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRe AuthorizationRequest::delegated( &proof, &existing_binding(owner), + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -567,6 +607,186 @@ async fn provider_freshness_is_evaluated_after_async_io() { 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, + profile(), + 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()).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, + ¤t_policy, + profile(), + 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); + assert!(snapshot.is_bound_to_federated_policy(¤t_policy)); + 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, + profile(), + 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(), Some(150)); + assert_eq!(snapshot.effective_until(), 150); +} + #[tokio::test] async fn identity_evidence_is_evaluated_after_async_io() { let actor = Keys::generate(); @@ -608,6 +828,7 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() { let request = AuthorizationRequest::delegated( &proof, &binding, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -649,6 +870,7 @@ fn delegated_request_rejects_owner_binding_at_exact_expiry() { let error = AuthorizationRequest::delegated( &proof, &binding, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1230,6 +1452,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), @@ -1241,6 +1464,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1262,6 +1486,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &future, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1271,6 +1496,88 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { ); } +#[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, + profile(), + 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(); @@ -1301,6 +1608,7 @@ fn request_construction_rejects_mismatched_verified_evidence() { AuthorizationRequest::direct( proof, assertion, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1359,20 +1667,23 @@ fn request_construction_rejects_mismatched_verified_evidence() { Err(ProviderContractError::DirectRequestHasOwner) ); - let delegated_request_from = |proof: &VerifiedNostrProof, binding: &VersionedBindingRef| { - AuthorizationRequest::delegated( - proof, - binding, - profile(), - capabilities(&[AuthorizationCapability::CommunityRead]), - Uuid::from_u128(20), - NOW, - ) - }; + let delegated_request_from = + |proof: &VerifiedNostrProof, binding: &AuthoritativeBindingEvidence| { + AuthorizationRequest::delegated( + proof, + binding, + &federated_policy(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; assert_eq!( AuthorizationRequest::delegated( &delegated_proof, &existing_binding(&owner), + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), @@ -1422,6 +1733,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", "proof_method: \"[redacted]\", ", "authority: \"[redacted]\", principal: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", "evidence_valid_until: \"[redacted]\" }" @@ -1489,6 +1801,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", "principal: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", @@ -1584,6 +1897,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { AuthorizationDenialReason::StaleDecision, AuthorizationDenialReason::FutureDecision, AuthorizationDenialReason::IdentityEvidenceExpired, + AuthorizationDenialReason::FederatedPolicyNotCurrent, ] { assert_eq!( format!("{reason:?}"), @@ -1641,13 +1955,14 @@ fn provider_trait_is_object_safe_and_codes_are_unique() { AuthorizationDenialReason::StaleDecision.code(), AuthorizationDenialReason::FutureDecision.code(), AuthorizationDenialReason::IdentityEvidenceExpired.code(), + AuthorizationDenialReason::FederatedPolicyNotCurrent.code(), ProviderUnavailableReason::TemporarilyUnavailable.code(), ProviderUnavailableReason::Timeout.code(), ProviderUnavailableReason::DependencyUnavailable.code(), ]; codes.sort_unstable(); codes.dedup(); - assert_eq!(codes.len(), 12); + assert_eq!(codes.len(), 13); let contract_errors = all_contract_errors(); let mut contract_codes = contract_errors