mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(auth): bind capability snapshots to enrollment policy
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -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<u64> {
|
||||
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<BindingVersion>,
|
||||
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;
|
||||
|
||||
@@ -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<u64>,
|
||||
) -> 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
|
||||
|
||||
Reference in New Issue
Block a user