mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(auth): seal capability finalization runtime
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -55,12 +55,13 @@ pub use nip98_replay::{
|
||||
MAX_REPLAY_TTL_SECS,
|
||||
};
|
||||
pub use provider::{
|
||||
resolve_authorization, AuthorizationAuthority, AuthorizationCapability, AuthorizationDenial,
|
||||
AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial,
|
||||
AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider,
|
||||
AuthorizationProviderFuture, AuthorizationRequest, CapabilitySet, CapabilitySnapshot,
|
||||
DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, ProviderContractError,
|
||||
ProviderDecision, ProviderTimeout, ProviderUnavailable, ProviderUnavailableReason, RetryAfter,
|
||||
MAX_PROVIDER_FRESHNESS_SECONDS, MAX_PROVIDER_TIMEOUT,
|
||||
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,
|
||||
|
||||
@@ -12,8 +12,13 @@ use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{
|
||||
AuthMethod, AuthTransport, AuthoritativeBindingEvidence, BindingVersion, FederatedPolicyStamp,
|
||||
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;
|
||||
@@ -99,15 +104,17 @@ impl fmt::Debug for CapabilitySet {
|
||||
|
||||
/// Opaque identifier for the server-resolved authorization profile.
|
||||
///
|
||||
/// Production construction is intentionally unavailable until a sealed policy
|
||||
/// adapter can prove that the profile came from server-owned configuration.
|
||||
/// 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.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(value: impl Into<String>) -> Result<Self, ProviderContractError> {
|
||||
pub fn from_server_configuration(
|
||||
value: impl Into<String>,
|
||||
) -> Result<Self, ProviderContractError> {
|
||||
let value = value.into();
|
||||
if value.is_empty() {
|
||||
return Err(ProviderContractError::EmptyProfileId);
|
||||
@@ -117,7 +124,6 @@ impl AuthorizationProfileId {
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Exact profile identifier for provider routing.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
@@ -224,26 +230,30 @@ pub struct AuthorizationRequest {
|
||||
proof_method: AuthMethod,
|
||||
authority: AuthorizationAuthority,
|
||||
principal: FederatedPrincipal,
|
||||
key_attested: bool,
|
||||
assertion_transport: Option<AssertionTransport>,
|
||||
assertion_not_before: Option<u64>,
|
||||
assertion_expires_at: Option<u64>,
|
||||
federated_policy: FederatedPolicyStamp,
|
||||
profile_id: AuthorizationProfileId,
|
||||
requested_capabilities: CapabilitySet,
|
||||
correlation_id: Uuid,
|
||||
decision_source: DecisionSource,
|
||||
evidence_valid_until: Option<u64>,
|
||||
evidence_valid_from: u64,
|
||||
evidence_valid_until: u64,
|
||||
}
|
||||
|
||||
impl AuthorizationRequest {
|
||||
/// Build a direct request from a current key-attested assertion and Nostr proof.
|
||||
/// Build a direct request from a current assertion and Nostr proof.
|
||||
///
|
||||
/// An unattested assertion is intentionally insufficient in this phase. A
|
||||
/// future trust-on-first-use path must also consume authoritative active or
|
||||
/// atomic-enrollment binding evidence before it can produce direct authority.
|
||||
/// 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,
|
||||
profile_id: AuthorizationProfileId,
|
||||
federated_policy: ResolvedFederatedPolicy,
|
||||
requested_capabilities: CapabilitySet,
|
||||
correlation_id: Uuid,
|
||||
now_unix_seconds: u64,
|
||||
@@ -252,7 +262,7 @@ impl AuthorizationRequest {
|
||||
return Err(ProviderContractError::InvalidCorrelationId);
|
||||
}
|
||||
validate_federated_policy(
|
||||
federated_policy,
|
||||
&federated_policy,
|
||||
proof.authorization_domain(),
|
||||
correlation_id,
|
||||
now_unix_seconds,
|
||||
@@ -266,10 +276,10 @@ impl AuthorizationRequest {
|
||||
if proof.authorized_transport() != assertion.authorized_transport() {
|
||||
return Err(ProviderContractError::TransportMismatch);
|
||||
}
|
||||
let Some(key_attestation) = assertion.key_attestation() else {
|
||||
return Err(ProviderContractError::MissingKeyAttestation);
|
||||
};
|
||||
if key_attestation.pubkey() != proof.actor_pubkey() {
|
||||
if assertion
|
||||
.key_attestation()
|
||||
.is_some_and(|attestation| attestation.pubkey() != proof.actor_pubkey())
|
||||
{
|
||||
return Err(ProviderContractError::KeyAttestationMismatch);
|
||||
}
|
||||
if assertion
|
||||
@@ -281,6 +291,18 @@ impl AuthorizationRequest {
|
||||
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(),
|
||||
@@ -288,17 +310,16 @@ impl AuthorizationRequest {
|
||||
proof_method: proof.proof_method(),
|
||||
authority: AuthorizationAuthority::Direct,
|
||||
principal: assertion.principal().clone(),
|
||||
federated_policy: federated_policy.stamp().clone(),
|
||||
profile_id,
|
||||
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_until: Some(
|
||||
assertion
|
||||
.expires_at()
|
||||
.unix_seconds()
|
||||
.min(federated_policy.stamp().effective_until()),
|
||||
),
|
||||
evidence_valid_from,
|
||||
evidence_valid_until,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -307,11 +328,10 @@ impl AuthorizationRequest {
|
||||
/// 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 fn delegated(
|
||||
pub(crate) fn delegated(
|
||||
proof: &VerifiedNostrProof,
|
||||
owner: &AuthoritativeBindingEvidence,
|
||||
federated_policy: &ResolvedFederatedPolicy,
|
||||
profile_id: AuthorizationProfileId,
|
||||
owner: &AuthoritativeBindingResolution,
|
||||
federated_policy: ResolvedFederatedPolicy,
|
||||
requested_capabilities: CapabilitySet,
|
||||
correlation_id: Uuid,
|
||||
now_unix_seconds: u64,
|
||||
@@ -320,7 +340,7 @@ impl AuthorizationRequest {
|
||||
return Err(ProviderContractError::InvalidCorrelationId);
|
||||
}
|
||||
validate_federated_policy(
|
||||
federated_policy,
|
||||
&federated_policy,
|
||||
proof.authorization_domain(),
|
||||
correlation_id,
|
||||
now_unix_seconds,
|
||||
@@ -328,6 +348,9 @@ impl AuthorizationRequest {
|
||||
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);
|
||||
};
|
||||
@@ -346,6 +369,7 @@ impl AuthorizationRequest {
|
||||
{
|
||||
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());
|
||||
@@ -364,12 +388,16 @@ impl AuthorizationRequest {
|
||||
binding_version: owner.binding_version(),
|
||||
},
|
||||
principal: owner.principal().clone(),
|
||||
federated_policy: federated_policy.stamp().clone(),
|
||||
profile_id,
|
||||
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_until: Some(evidence_valid_until),
|
||||
evidence_valid_from,
|
||||
evidence_valid_until,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -408,11 +436,6 @@ impl AuthorizationRequest {
|
||||
&self.federated_policy
|
||||
}
|
||||
|
||||
/// Server-resolved provider profile.
|
||||
pub const fn profile_id(&self) -> &AuthorizationProfileId {
|
||||
&self.profile_id
|
||||
}
|
||||
|
||||
/// Portable capabilities requested for this decision.
|
||||
pub const fn requested_capabilities(&self) -> &CapabilitySet {
|
||||
&self.requested_capabilities
|
||||
@@ -428,9 +451,13 @@ impl AuthorizationRequest {
|
||||
self.decision_source
|
||||
}
|
||||
|
||||
/// Earliest validity bound supplied by verified assertion, owner-binding,
|
||||
/// delegation, or authoritative enrollment-policy evidence.
|
||||
pub const fn evidence_valid_until(&self) -> Option<u64> {
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -445,16 +472,86 @@ impl fmt::Debug for AuthorizationRequest {
|
||||
.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("profile_id", &"[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<A: FederatedAuthorityAdapter + ?Sized>(
|
||||
adapter: &A,
|
||||
proof: &VerifiedNostrProof,
|
||||
principal: FederatedPrincipal,
|
||||
federated_policy: ResolvedFederatedPolicy,
|
||||
requested_capabilities: CapabilitySet,
|
||||
correlation_id: Uuid,
|
||||
clock: &dyn AuthorizationClock,
|
||||
) -> Result<AuthorizationRequest, ProviderAuthorizationError<A::Error>> {
|
||||
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 {
|
||||
@@ -534,6 +631,8 @@ pub enum AuthorizationDenialReason {
|
||||
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,
|
||||
}
|
||||
@@ -551,6 +650,7 @@ impl AuthorizationDenialReason {
|
||||
Self::IdentityEvidenceExpired => "authorization_provider_deny_007",
|
||||
Self::AuthorizationProfileMismatch => "authorization_provider_deny_008",
|
||||
Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009",
|
||||
Self::IdentityEvidenceNotYetValid => "authorization_provider_deny_010",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -754,6 +854,13 @@ pub type AuthorizationProviderFuture<'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
|
||||
@@ -795,14 +902,99 @@ impl fmt::Debug for ProviderAllowReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed error while joining provider and authoritative state.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum ProviderAuthorizationError<E> {
|
||||
/// Trusted server time was unavailable.
|
||||
ClockUnavailable,
|
||||
/// Provider evidence or snapshot shape violated the contract.
|
||||
Contract(ProviderContractError),
|
||||
/// Current policy or binding resolution failed.
|
||||
Authority(AuthorityAdapterError<E>),
|
||||
/// 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<A, C, P> {
|
||||
authority: A,
|
||||
clock: C,
|
||||
provider: P,
|
||||
binding: Uuid,
|
||||
}
|
||||
|
||||
impl<A, C, P> AuthorizationRuntime<A, C, P> {
|
||||
/// 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<A, C, P> fmt::Debug for AuthorizationRuntime<A, C, P> {
|
||||
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<E> From<ProviderContractError> for ProviderAuthorizationError<E> {
|
||||
fn from(error: ProviderContractError) -> Self {
|
||||
Self::Contract(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<AuthorityAdapterError<E>> for ProviderAuthorizationError<E> {
|
||||
fn from(error: AuthorityAdapterError<E>) -> Self {
|
||||
Self::Authority(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<AuthContextError> for ProviderAuthorizationError<E> {
|
||||
fn from(error: AuthContextError) -> Self {
|
||||
Self::Context(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> fmt::Debug for ProviderAuthorizationError<E> {
|
||||
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
|
||||
/// [`resolve_authorization`] can create it after checking the provider response.
|
||||
/// The move-only snapshot is the private finalizer evidence for a later phase;
|
||||
/// [`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,
|
||||
@@ -811,12 +1003,17 @@ pub struct CapabilitySnapshot {
|
||||
binding_version: Option<BindingVersion>,
|
||||
proof_method: AuthMethod,
|
||||
principal: FederatedPrincipal,
|
||||
key_attested: bool,
|
||||
assertion_transport: Option<AssertionTransport>,
|
||||
assertion_not_before: Option<u64>,
|
||||
assertion_expires_at: Option<u64>,
|
||||
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,
|
||||
@@ -824,6 +1021,13 @@ pub struct CapabilitySnapshot {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -872,10 +1076,10 @@ impl CapabilitySnapshot {
|
||||
&self.federated_policy
|
||||
}
|
||||
|
||||
/// Whether a freshly resolved O3 policy is exactly the policy used here.
|
||||
/// Whether a freshly resolved authoritative 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.
|
||||
/// 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()
|
||||
}
|
||||
@@ -905,7 +1109,12 @@ impl CapabilitySnapshot {
|
||||
self.fresh_until
|
||||
}
|
||||
|
||||
/// Earliest effective bound across provider and identity evidence.
|
||||
/// 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
|
||||
}
|
||||
@@ -924,12 +1133,300 @@ impl CapabilitySnapshot {
|
||||
pub const fn reason(&self) -> ProviderAllowReason {
|
||||
self.reason
|
||||
}
|
||||
|
||||
/// Consume a direct capability decision and finalize authoritative context.
|
||||
///
|
||||
/// The current enrollment policy is reread after provider I/O, then the
|
||||
/// 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<A: FederatedAuthorityAdapter + ?Sized>(
|
||||
self,
|
||||
adapter: &A,
|
||||
input: AuthContextInput,
|
||||
assertion: VerifiedFederatedAssertion,
|
||||
clock: &dyn AuthorizationClock,
|
||||
) -> Result<AuthContext, ProviderAuthorizationError<A::Error>> {
|
||||
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<A: FederatedAuthorityAdapter + ?Sized>(
|
||||
self,
|
||||
adapter: &A,
|
||||
input: AuthContextInput,
|
||||
clock: &dyn AuthorizationClock,
|
||||
) -> Result<AuthContext, ProviderAuthorizationError<A::Error>> {
|
||||
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_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<PublicKey, ProviderContractError> {
|
||||
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<E>(
|
||||
clock: &dyn AuthorizationClock,
|
||||
) -> Result<u64, ProviderAuthorizationError<E>> {
|
||||
clock
|
||||
.now_unix_seconds()
|
||||
.ok_or(ProviderAuthorizationError::ClockUnavailable)
|
||||
}
|
||||
|
||||
impl<A, C, P> AuthorizationRuntime<A, C, P>
|
||||
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<AuthorizationRequest, ProviderAuthorizationError<A::Error>> {
|
||||
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<AuthContext, ProviderAuthorizationError<A::Error>> {
|
||||
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<AuthContext, ProviderAuthorizationError<A::Error>> {
|
||||
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]")
|
||||
@@ -938,12 +1435,17 @@ impl fmt::Debug for CapabilitySnapshot {
|
||||
.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]")
|
||||
@@ -981,12 +1483,14 @@ impl fmt::Debug for AuthorizationOutcome {
|
||||
/// completes, an allowed decision is checked against exactly one fresh sample.
|
||||
/// Provider freshness and all effective evidence bounds use that same value;
|
||||
/// callers must not precompute and pass a decision-start timestamp.
|
||||
pub async fn resolve_authorization(
|
||||
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,
|
||||
@@ -1025,7 +1529,7 @@ pub async fn resolve_authorization(
|
||||
if allow.principal != request.principal {
|
||||
return deny(AuthorizationDenialReason::PrincipalMismatch);
|
||||
}
|
||||
if allow.profile_id != request.profile_id {
|
||||
if allow.profile_id != configured_profile {
|
||||
return deny(AuthorizationDenialReason::AuthorizationProfileMismatch);
|
||||
}
|
||||
if allow.issued_at > now_unix_seconds {
|
||||
@@ -1041,14 +1545,17 @@ pub async fn resolve_authorization(
|
||||
return deny(AuthorizationDenialReason::MissingCapability);
|
||||
}
|
||||
|
||||
let effective_until = request
|
||||
.evidence_valid_until
|
||||
.map_or(allow.fresh_until, |bound| bound.min(allow.fresh_until));
|
||||
if effective_until <= now_unix_seconds {
|
||||
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,
|
||||
@@ -1068,12 +1575,17 @@ pub async fn resolve_authorization(
|
||||
},
|
||||
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,
|
||||
@@ -1140,9 +1652,9 @@ pub enum ProviderContractError {
|
||||
/// Assertion key attestation named another actor.
|
||||
#[error("provider request key attestation does not match the Nostr actor")]
|
||||
KeyAttestationMismatch,
|
||||
/// Direct assertion omitted a key attestation.
|
||||
#[error("direct provider request requires key attestation")]
|
||||
MissingKeyAttestation,
|
||||
/// Delegated 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,
|
||||
@@ -1167,6 +1679,30 @@ pub enum ProviderContractError {
|
||||
/// 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 {
|
||||
@@ -1192,13 +1728,21 @@ impl ProviderContractError {
|
||||
Self::DelegationExpired => "authorization_provider_contract_017",
|
||||
Self::InvalidProviderTimeout => "authorization_provider_contract_018",
|
||||
Self::InvalidCorrelationId => "authorization_provider_contract_019",
|
||||
Self::MissingKeyAttestation => "authorization_provider_contract_020",
|
||||
Self::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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@ use nostr::Keys;
|
||||
use super::*;
|
||||
use crate::context::{
|
||||
AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport,
|
||||
AuthoritativeBindingEvidence, BindingExpiry, BindingSource, BindingVersion, DelegationExpiry,
|
||||
EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy,
|
||||
VerifiedKeyAttestation, VerifiedTransportDelegation,
|
||||
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;
|
||||
@@ -29,7 +35,8 @@ fn principal() -> FederatedPrincipal {
|
||||
}
|
||||
|
||||
fn profile() -> AuthorizationProfileId {
|
||||
AuthorizationProfileId::new("profile-1").expect("synthetic profile is valid")
|
||||
AuthorizationProfileId::from_server_configuration("profile-1")
|
||||
.expect("synthetic profile is valid")
|
||||
}
|
||||
|
||||
fn policy_version(value: &str) -> PolicyVersion {
|
||||
@@ -111,13 +118,185 @@ impl AuthorizationClock for TestClock {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestAuthorityAdapter {
|
||||
policy_epoch: u64,
|
||||
enrollment_mode: EnrollmentMode,
|
||||
enroll_direct: bool,
|
||||
policy_reads: Arc<AtomicUsize>,
|
||||
direct_calls: Arc<AtomicUsize>,
|
||||
existing_calls: Arc<AtomicUsize>,
|
||||
committed_enrollments: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<ResolvedFederatedPolicy, AuthorityAdapterError<Self::Error>>,
|
||||
> {
|
||||
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<AuthoritativeBindingResolution, AuthorityAdapterError<Self::Error>>,
|
||||
> {
|
||||
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<AuthoritativeBindingResolution, AuthorityAdapterError<Self::Error>>,
|
||||
> {
|
||||
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).await
|
||||
resolve_authorization(
|
||||
provider,
|
||||
request,
|
||||
&TestClock::at(now),
|
||||
timeout,
|
||||
Uuid::from_u128(99),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet {
|
||||
@@ -165,7 +344,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod {
|
||||
}
|
||||
}
|
||||
|
||||
fn all_contract_errors() -> [ProviderContractError; 26] {
|
||||
fn all_contract_errors() -> [ProviderContractError; 34] {
|
||||
[
|
||||
ProviderContractError::EmptyCapabilitySet,
|
||||
ProviderContractError::EmptyProfileId,
|
||||
@@ -184,7 +363,7 @@ fn all_contract_errors() -> [ProviderContractError; 26] {
|
||||
ProviderContractError::AssertionNotYetValid,
|
||||
ProviderContractError::AssertionExpired,
|
||||
ProviderContractError::KeyAttestationMismatch,
|
||||
ProviderContractError::MissingKeyAttestation,
|
||||
ProviderContractError::DelegatedBindingNotExistingActive,
|
||||
ProviderContractError::DelegationRequired,
|
||||
ProviderContractError::DelegatedOwnerMismatch,
|
||||
ProviderContractError::DelegationExpired,
|
||||
@@ -193,6 +372,14 @@ fn all_contract_errors() -> [ProviderContractError; 26] {
|
||||
ProviderContractError::FederatedPolicyCorrelationMismatch,
|
||||
ProviderContractError::FederatedPolicyNotYetEffective,
|
||||
ProviderContractError::FederatedPolicyExpired,
|
||||
ProviderContractError::CapabilityNotYetEffective,
|
||||
ProviderContractError::CapabilityExpired,
|
||||
ProviderContractError::CapabilityContextMismatch,
|
||||
ProviderContractError::CapabilityAuthorityMismatch,
|
||||
ProviderContractError::CapabilityPrincipalMismatch,
|
||||
ProviderContractError::CapabilityBindingChanged,
|
||||
ProviderContractError::FederatedPolicyChanged,
|
||||
ProviderContractError::AuthorizationRuntimeMismatch,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -219,8 +406,7 @@ fn direct_request_for_transport(
|
||||
AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&assertion,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
requested,
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -251,11 +437,11 @@ fn direct_request(actor: &Keys) -> AuthorizationRequest {
|
||||
)
|
||||
}
|
||||
|
||||
fn existing_binding(owner: &Keys) -> AuthoritativeBindingEvidence {
|
||||
fn existing_binding(owner: &Keys) -> AuthoritativeBindingResolution {
|
||||
existing_binding_in(1, owner)
|
||||
}
|
||||
|
||||
fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingEvidence {
|
||||
fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingResolution {
|
||||
existing_binding_with_expiry_in(domain_value, owner, None)
|
||||
}
|
||||
|
||||
@@ -263,8 +449,8 @@ fn existing_binding_with_expiry_in(
|
||||
domain_value: u128,
|
||||
owner: &Keys,
|
||||
expires_at: Option<u64>,
|
||||
) -> AuthoritativeBindingEvidence {
|
||||
AuthoritativeBindingEvidence::new(
|
||||
) -> AuthoritativeBindingResolution {
|
||||
let evidence = AuthoritativeBindingEvidence::new(
|
||||
domain(domain_value),
|
||||
Uuid::from_u128(10),
|
||||
principal(),
|
||||
@@ -274,7 +460,8 @@ fn existing_binding_with_expiry_in(
|
||||
.map(|expiry| BindingExpiry::new(expiry).expect("synthetic binding expiry is valid")),
|
||||
BindingSource::Provisioned,
|
||||
)
|
||||
.expect("synthetic binding is valid")
|
||||
.expect("synthetic binding is valid");
|
||||
AuthoritativeBindingResolution::existing_active(evidence)
|
||||
}
|
||||
|
||||
fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProof {
|
||||
@@ -299,8 +486,7 @@ fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRe
|
||||
AuthorizationRequest::delegated(
|
||||
&proof,
|
||||
&existing_binding(owner),
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -319,7 +505,7 @@ fn allow_for(
|
||||
ProviderAllow::new(
|
||||
request.authorization_domain(),
|
||||
request.principal().clone(),
|
||||
request.profile_id().clone(),
|
||||
profile(),
|
||||
granted,
|
||||
policy_version(version),
|
||||
issued_at,
|
||||
@@ -342,6 +528,10 @@ impl FakeProvider {
|
||||
}
|
||||
|
||||
impl AuthorizationProvider for FakeProvider {
|
||||
fn profile_id(&self) -> AuthorizationProfileId {
|
||||
profile()
|
||||
}
|
||||
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
_request: &'a AuthorizationRequest,
|
||||
@@ -356,6 +546,29 @@ impl AuthorizationProvider for FakeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
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<Option<ProviderDecision>>,
|
||||
clock: TestClock,
|
||||
@@ -384,6 +597,10 @@ impl AdvancingProvider {
|
||||
}
|
||||
|
||||
impl AuthorizationProvider for AdvancingProvider {
|
||||
fn profile_id(&self) -> AuthorizationProfileId {
|
||||
profile()
|
||||
}
|
||||
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
_request: &'a AuthorizationRequest,
|
||||
@@ -420,6 +637,10 @@ impl Drop for CancellationMarker {
|
||||
}
|
||||
|
||||
impl AuthorizationProvider for PendingProvider {
|
||||
fn profile_id(&self) -> AuthorizationProfileId {
|
||||
profile()
|
||||
}
|
||||
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
_request: &'a AuthorizationRequest,
|
||||
@@ -462,7 +683,7 @@ async fn current_allow_returns_request_scoped_snapshot() {
|
||||
assert_eq!(snapshot.binding_version(), None);
|
||||
assert_eq!(snapshot.proof_method(), AuthMethod::Nip42);
|
||||
assert_eq!(snapshot.principal(), request.principal());
|
||||
assert_eq!(snapshot.profile_id(), request.profile_id());
|
||||
assert_eq!(snapshot.profile_id(), &profile());
|
||||
assert_eq!(
|
||||
snapshot.capabilities().as_slice(),
|
||||
&[AuthorizationCapability::CommunityRead]
|
||||
@@ -476,6 +697,212 @@ async fn current_allow_returns_request_scoped_snapshot() {
|
||||
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 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 = [
|
||||
@@ -598,8 +1025,14 @@ async fn provider_freshness_is_evaluated_after_async_io() {
|
||||
105,
|
||||
);
|
||||
|
||||
let AuthorizationOutcome::Deny(denial) =
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await
|
||||
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");
|
||||
};
|
||||
@@ -638,8 +1071,7 @@ async fn federated_policy_expiry_is_evaluated_after_async_io() {
|
||||
let request = AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&assertion,
|
||||
&policy,
|
||||
profile(),
|
||||
policy,
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -658,8 +1090,14 @@ async fn federated_policy_expiry_is_evaluated_after_async_io() {
|
||||
105,
|
||||
);
|
||||
|
||||
let AuthorizationOutcome::Deny(denial) =
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await
|
||||
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");
|
||||
};
|
||||
@@ -701,8 +1139,7 @@ async fn snapshot_requires_exact_enrollment_policy_lineage() {
|
||||
let request = AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&assertion,
|
||||
¤t_policy,
|
||||
profile(),
|
||||
current_policy,
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -723,7 +1160,15 @@ async fn snapshot_requires_exact_enrollment_policy_lineage() {
|
||||
|
||||
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));
|
||||
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!(
|
||||
@@ -763,8 +1208,7 @@ async fn enrollment_policy_bounds_snapshot_effective_interval() {
|
||||
let request = AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&assertion,
|
||||
&policy,
|
||||
profile(),
|
||||
policy,
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -783,7 +1227,7 @@ async fn enrollment_policy_bounds_snapshot_effective_interval() {
|
||||
panic!("current bounded policy must allow");
|
||||
};
|
||||
|
||||
assert_eq!(request.evidence_valid_until(), Some(150));
|
||||
assert_eq!(request.evidence_valid_until(), 150);
|
||||
assert_eq!(snapshot.effective_until(), 150);
|
||||
}
|
||||
|
||||
@@ -808,8 +1252,14 @@ async fn identity_evidence_is_evaluated_after_async_io() {
|
||||
105,
|
||||
);
|
||||
|
||||
let AuthorizationOutcome::Deny(denial) =
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await
|
||||
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");
|
||||
};
|
||||
@@ -828,14 +1278,13 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() {
|
||||
let request = AuthorizationRequest::delegated(
|
||||
&proof,
|
||||
&binding,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
)
|
||||
.expect("owner binding is current at request construction");
|
||||
assert_eq!(request.evidence_valid_until(), Some(105));
|
||||
assert_eq!(request.evidence_valid_until(), 105);
|
||||
|
||||
let clock = TestClock::at(NOW);
|
||||
let provider = AdvancingProvider::returning_at(
|
||||
@@ -849,8 +1298,14 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() {
|
||||
clock.clone(),
|
||||
105,
|
||||
);
|
||||
let AuthorizationOutcome::Deny(denial) =
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await
|
||||
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");
|
||||
};
|
||||
@@ -870,8 +1325,7 @@ fn delegated_request_rejects_owner_binding_at_exact_expiry() {
|
||||
let error = AuthorizationRequest::delegated(
|
||||
&proof,
|
||||
&binding,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -898,7 +1352,14 @@ async fn decision_issued_during_async_io_is_not_false_future() {
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await,
|
||||
resolve_authorization(
|
||||
&provider,
|
||||
&request,
|
||||
&clock,
|
||||
provider_timeout(),
|
||||
Uuid::from_u128(99)
|
||||
)
|
||||
.await,
|
||||
AuthorizationOutcome::Allow(_)
|
||||
));
|
||||
}
|
||||
@@ -919,8 +1380,14 @@ async fn clock_failure_after_provider_io_is_unavailable() {
|
||||
clock.clone(),
|
||||
);
|
||||
|
||||
let AuthorizationOutcome::Unavailable(unavailable) =
|
||||
resolve_authorization(&provider, &request, &clock, provider_timeout()).await
|
||||
let AuthorizationOutcome::Unavailable(unavailable) = resolve_authorization(
|
||||
&provider,
|
||||
&request,
|
||||
&clock,
|
||||
provider_timeout(),
|
||||
Uuid::from_u128(99),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("unavailable decision time must fail closed");
|
||||
};
|
||||
@@ -1018,7 +1485,7 @@ async fn domain_principal_and_capability_mismatches_deny() {
|
||||
ProviderAllow::new(
|
||||
domain(2),
|
||||
request.principal().clone(),
|
||||
request.profile_id().clone(),
|
||||
profile(),
|
||||
request.requested_capabilities().clone(),
|
||||
policy_version("version-a"),
|
||||
90,
|
||||
@@ -1041,7 +1508,7 @@ async fn domain_principal_and_capability_mismatches_deny() {
|
||||
domain(1),
|
||||
FederatedPrincipal::new("https://idp.example", "other-subject")
|
||||
.expect("synthetic principal is valid"),
|
||||
request.profile_id().clone(),
|
||||
profile(),
|
||||
request.requested_capabilities().clone(),
|
||||
policy_version("version-a"),
|
||||
90,
|
||||
@@ -1063,7 +1530,8 @@ async fn domain_principal_and_capability_mismatches_deny() {
|
||||
ProviderAllow::new(
|
||||
domain(1),
|
||||
request.principal().clone(),
|
||||
AuthorizationProfileId::new("other-profile").expect("synthetic profile is valid"),
|
||||
AuthorizationProfileId::from_server_configuration("other-profile")
|
||||
.expect("synthetic profile is valid"),
|
||||
request.requested_capabilities().clone(),
|
||||
policy_version("version-a"),
|
||||
90,
|
||||
@@ -1323,14 +1791,16 @@ fn provider_contract_rejects_malformed_values() {
|
||||
Err(ProviderContractError::EmptyCapabilitySet)
|
||||
);
|
||||
assert_eq!(
|
||||
AuthorizationProfileId::new(""),
|
||||
AuthorizationProfileId::from_server_configuration(""),
|
||||
Err(ProviderContractError::EmptyProfileId)
|
||||
);
|
||||
assert_eq!(
|
||||
AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)),
|
||||
AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES + 1)),
|
||||
Err(ProviderContractError::ProfileIdTooLong)
|
||||
);
|
||||
assert!(AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok());
|
||||
assert!(
|
||||
AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
PolicyVersion::new(""),
|
||||
Err(ProviderContractError::EmptyPolicyVersion)
|
||||
@@ -1452,8 +1922,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() {
|
||||
AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&expired,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::nil(),
|
||||
NOW,
|
||||
@@ -1464,8 +1933,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() {
|
||||
AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&expired,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -1486,8 +1954,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() {
|
||||
AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&future,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -1516,12 +1983,11 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() {
|
||||
None,
|
||||
AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"),
|
||||
);
|
||||
let request_with = |policy: &ResolvedFederatedPolicy| {
|
||||
let request_with = |policy: ResolvedFederatedPolicy| {
|
||||
AuthorizationRequest::direct(
|
||||
&proof,
|
||||
&assertion,
|
||||
policy,
|
||||
profile(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -1537,7 +2003,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() {
|
||||
180,
|
||||
);
|
||||
assert_eq!(
|
||||
request_with(&wrong_domain),
|
||||
request_with(wrong_domain),
|
||||
Err(ProviderContractError::FederatedPolicyDomainMismatch)
|
||||
);
|
||||
let wrong_correlation = federated_policy_with(
|
||||
@@ -1549,7 +2015,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() {
|
||||
180,
|
||||
);
|
||||
assert_eq!(
|
||||
request_with(&wrong_correlation),
|
||||
request_with(wrong_correlation),
|
||||
Err(ProviderContractError::FederatedPolicyCorrelationMismatch)
|
||||
);
|
||||
let future = federated_policy_with(
|
||||
@@ -1561,7 +2027,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() {
|
||||
180,
|
||||
);
|
||||
assert_eq!(
|
||||
request_with(&future),
|
||||
request_with(future),
|
||||
Err(ProviderContractError::FederatedPolicyNotYetEffective)
|
||||
);
|
||||
let expired = federated_policy_with(
|
||||
@@ -1573,7 +2039,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() {
|
||||
NOW,
|
||||
);
|
||||
assert_eq!(
|
||||
request_with(&expired),
|
||||
request_with(expired),
|
||||
Err(ProviderContractError::FederatedPolicyExpired)
|
||||
);
|
||||
}
|
||||
@@ -1608,8 +2074,7 @@ fn request_construction_rejects_mismatched_verified_evidence() {
|
||||
AuthorizationRequest::direct(
|
||||
proof,
|
||||
assertion,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -1637,13 +2102,11 @@ fn request_construction_rejects_mismatched_verified_evidence() {
|
||||
),
|
||||
Err(ProviderContractError::KeyAttestationMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&proof,
|
||||
&assertion_in_domain(1, AuthTransport::RelayWebSocket, None),
|
||||
),
|
||||
Err(ProviderContractError::MissingKeyAttestation)
|
||||
);
|
||||
assert!(request(
|
||||
&proof,
|
||||
&assertion_in_domain(1, AuthTransport::RelayWebSocket, None),
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let delegation = VerifiedTransportDelegation::new_unrestricted(
|
||||
owner.public_key(),
|
||||
@@ -1668,12 +2131,11 @@ fn request_construction_rejects_mismatched_verified_evidence() {
|
||||
);
|
||||
|
||||
let delegated_request_from =
|
||||
|proof: &VerifiedNostrProof, binding: &AuthoritativeBindingEvidence| {
|
||||
|proof: &VerifiedNostrProof, binding: &AuthoritativeBindingResolution| {
|
||||
AuthorizationRequest::delegated(
|
||||
proof,
|
||||
binding,
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::from_u128(20),
|
||||
NOW,
|
||||
@@ -1683,8 +2145,7 @@ fn request_construction_rejects_mismatched_verified_evidence() {
|
||||
AuthorizationRequest::delegated(
|
||||
&delegated_proof,
|
||||
&existing_binding(&owner),
|
||||
&federated_policy(),
|
||||
profile(),
|
||||
federated_policy(),
|
||||
capabilities(&[AuthorizationCapability::CommunityRead]),
|
||||
Uuid::nil(),
|
||||
NOW,
|
||||
@@ -1733,9 +2194,13 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
|
||||
"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]\", ",
|
||||
"profile_id: \"[redacted]\", requested_capabilities: \"[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
|
||||
@@ -1766,7 +2231,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
|
||||
let allow = ProviderAllow::new(
|
||||
request.authorization_domain(),
|
||||
request.principal().clone(),
|
||||
request.profile_id().clone(),
|
||||
profile(),
|
||||
request.requested_capabilities().clone(),
|
||||
policy_version("private-policy-version"),
|
||||
90,
|
||||
@@ -1796,15 +2261,20 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
|
||||
assert_eq!(
|
||||
format!("{snapshot:?}"),
|
||||
concat!(
|
||||
"CapabilitySnapshot { authorization_domain: \"[redacted]\", ",
|
||||
"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_until: \"[redacted]\", ",
|
||||
"fresh_until: \"[redacted]\", effective_from: \"[redacted]\", ",
|
||||
"effective_until: \"[redacted]\", ",
|
||||
"decision_source: \"[redacted]\", correlation_id: \"[redacted]\", ",
|
||||
"reason: \"[redacted]\" }"
|
||||
)
|
||||
@@ -1869,7 +2339,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
|
||||
"ProviderTimeout(\"[redacted]\")"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", request.profile_id()),
|
||||
format!("{:?}", &profile()),
|
||||
"AuthorizationProfileId(\"[redacted]\")"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1897,6 +2367,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
|
||||
AuthorizationDenialReason::StaleDecision,
|
||||
AuthorizationDenialReason::FutureDecision,
|
||||
AuthorizationDenialReason::IdentityEvidenceExpired,
|
||||
AuthorizationDenialReason::IdentityEvidenceNotYetValid,
|
||||
AuthorizationDenialReason::FederatedPolicyNotCurrent,
|
||||
] {
|
||||
assert_eq!(
|
||||
@@ -1955,6 +2426,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() {
|
||||
AuthorizationDenialReason::StaleDecision.code(),
|
||||
AuthorizationDenialReason::FutureDecision.code(),
|
||||
AuthorizationDenialReason::IdentityEvidenceExpired.code(),
|
||||
AuthorizationDenialReason::IdentityEvidenceNotYetValid.code(),
|
||||
AuthorizationDenialReason::FederatedPolicyNotCurrent.code(),
|
||||
ProviderUnavailableReason::TemporarilyUnavailable.code(),
|
||||
ProviderUnavailableReason::Timeout.code(),
|
||||
@@ -1962,7 +2434,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() {
|
||||
];
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), 13);
|
||||
assert_eq!(codes.len(), 14);
|
||||
|
||||
let contract_errors = all_contract_errors();
|
||||
let mut contract_codes = contract_errors
|
||||
|
||||
Reference in New Issue
Block a user