mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(auth): bind authoritative policy and binding evidence
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -44,55 +44,196 @@ impl fmt::Debug for FederatedIdentityRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact authoritative enrollment-policy lineage for one decision.
|
||||
///
|
||||
/// This stamp is not provider capability-policy evidence. It names the
|
||||
/// server-owned federated enrollment policy that supplied the requirement and
|
||||
/// its half-open effective interval. The constructor validates shape, while the
|
||||
/// O3 authority adapter remains responsible for sourcing current policy state.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FederatedPolicyStamp {
|
||||
authorization_domain: CommunityId,
|
||||
policy_id: Uuid,
|
||||
epoch: u64,
|
||||
correlation_id: Uuid,
|
||||
requirement: FederatedIdentityRequirement,
|
||||
effective_from: u64,
|
||||
effective_until: u64,
|
||||
}
|
||||
|
||||
impl FederatedPolicyStamp {
|
||||
/// Validate lineage read from current authoritative O3 policy state.
|
||||
///
|
||||
/// This constructor enforces structural invariants only. Callers must not
|
||||
/// source any field from transport input, and O3 must compare the epoch as
|
||||
/// an atomic precondition before enrollment.
|
||||
pub fn from_authoritative_state(
|
||||
authorization_domain: CommunityId,
|
||||
policy_id: Uuid,
|
||||
epoch: u64,
|
||||
correlation_id: Uuid,
|
||||
requirement: FederatedIdentityRequirement,
|
||||
effective_from: u64,
|
||||
effective_until: u64,
|
||||
) -> Result<Self, AuthContextError> {
|
||||
if policy_id.is_nil() {
|
||||
return Err(AuthContextError::InvalidFederatedPolicyId);
|
||||
}
|
||||
if epoch == 0 {
|
||||
return Err(AuthContextError::InvalidFederatedPolicyEpoch);
|
||||
}
|
||||
if correlation_id.is_nil() {
|
||||
return Err(AuthContextError::InvalidFederatedPolicyCorrelation);
|
||||
}
|
||||
if effective_from >= effective_until {
|
||||
return Err(AuthContextError::InvalidFederatedPolicyInterval);
|
||||
}
|
||||
Ok(Self {
|
||||
authorization_domain,
|
||||
policy_id,
|
||||
epoch,
|
||||
correlation_id,
|
||||
requirement,
|
||||
effective_from,
|
||||
effective_until,
|
||||
})
|
||||
}
|
||||
|
||||
/// Authorization domain whose enrollment policy was resolved.
|
||||
pub const fn authorization_domain(&self) -> CommunityId {
|
||||
self.authorization_domain
|
||||
}
|
||||
|
||||
/// Stable, non-nil identifier of the enrollment-policy namespace.
|
||||
pub const fn policy_id(&self) -> Uuid {
|
||||
self.policy_id
|
||||
}
|
||||
|
||||
/// Positive monotonic epoch within the enrollment-policy namespace.
|
||||
pub const fn epoch(&self) -> u64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// Correlation identifier of the decision that resolved this policy.
|
||||
pub const fn correlation_id(&self) -> Uuid {
|
||||
self.correlation_id
|
||||
}
|
||||
|
||||
/// Federated-identity requirement resolved at this epoch.
|
||||
pub const fn requirement(&self) -> FederatedIdentityRequirement {
|
||||
self.requirement
|
||||
}
|
||||
|
||||
/// Inclusive start of the policy's effective interval.
|
||||
pub const fn effective_from(&self) -> u64 {
|
||||
self.effective_from
|
||||
}
|
||||
|
||||
/// Exclusive end of the policy's effective interval.
|
||||
pub const fn effective_until(&self) -> u64 {
|
||||
self.effective_until
|
||||
}
|
||||
|
||||
/// Whether the policy is not yet effective at trusted server time.
|
||||
pub const fn is_not_yet_effective_at(&self, now_unix_seconds: u64) -> bool {
|
||||
now_unix_seconds < self.effective_from
|
||||
}
|
||||
|
||||
/// Whether the policy is expired at trusted server time.
|
||||
pub const fn is_expired_at(&self, now_unix_seconds: u64) -> bool {
|
||||
now_unix_seconds >= self.effective_until
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FederatedPolicyStamp {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("FederatedPolicyStamp")
|
||||
.field("authorization_domain", &"[redacted]")
|
||||
.field("policy_id", &"[redacted]")
|
||||
.field("epoch", &"[redacted]")
|
||||
.field("correlation_id", &"[redacted]")
|
||||
.field("requirement", &"[redacted]")
|
||||
.field("effective_from", &"[redacted]")
|
||||
.field("effective_until", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-resolved federated-identity policy for an authorization decision.
|
||||
///
|
||||
/// Raw request data cannot construct this value. A policy adapter must resolve
|
||||
/// the authorization domain's configuration before producing it. The evidence
|
||||
/// is intentionally move-only and has no default or deserialization path.
|
||||
/// A policy adapter must resolve the authorization domain's current
|
||||
/// configuration before producing it; transport values are never authoritative
|
||||
/// input. The evidence is intentionally move-only and has no default or
|
||||
/// deserialization path.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct ResolvedFederatedPolicy {
|
||||
authorization_domain: CommunityId,
|
||||
requirement: FederatedIdentityRequirement,
|
||||
stamp: FederatedPolicyStamp,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ResolvedFederatedPolicy {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ResolvedFederatedPolicy")
|
||||
.field("authorization_domain", &"[redacted]")
|
||||
.field("requirement", &"[redacted]")
|
||||
.field("stamp", &self.stamp)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedFederatedPolicy {
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn not_required(authorization_domain: CommunityId) -> Self {
|
||||
Self {
|
||||
authorization_domain,
|
||||
requirement: FederatedIdentityRequirement::NotRequired,
|
||||
}
|
||||
/// Seal structurally validated current O3 policy lineage for finalization.
|
||||
pub const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self {
|
||||
Self { stamp }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn required(
|
||||
pub(crate) fn not_required(authorization_domain: CommunityId) -> Self {
|
||||
Self::from_authoritative_resolution(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain,
|
||||
Uuid::from_u128(40),
|
||||
1,
|
||||
Uuid::from_u128(2),
|
||||
FederatedIdentityRequirement::NotRequired,
|
||||
1,
|
||||
u64::MAX,
|
||||
)
|
||||
.expect("synthetic federated policy lineage is valid"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn required(
|
||||
authorization_domain: CommunityId,
|
||||
enrollment_mode: EnrollmentMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
authorization_domain,
|
||||
requirement: FederatedIdentityRequirement::Required(enrollment_mode),
|
||||
}
|
||||
Self::from_authoritative_resolution(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain,
|
||||
Uuid::from_u128(40),
|
||||
1,
|
||||
Uuid::from_u128(2),
|
||||
FederatedIdentityRequirement::Required(enrollment_mode),
|
||||
1,
|
||||
u64::MAX,
|
||||
)
|
||||
.expect("synthetic federated policy lineage is valid"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Authorization domain whose configuration was resolved.
|
||||
pub const fn authorization_domain(&self) -> CommunityId {
|
||||
self.authorization_domain
|
||||
self.stamp.authorization_domain()
|
||||
}
|
||||
|
||||
/// Resolved federated-identity requirement.
|
||||
pub const fn requirement(&self) -> FederatedIdentityRequirement {
|
||||
self.requirement
|
||||
self.stamp.requirement()
|
||||
}
|
||||
|
||||
/// Exact authoritative enrollment-policy lineage for this decision.
|
||||
pub const fn stamp(&self) -> &FederatedPolicyStamp {
|
||||
&self.stamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,13 +332,9 @@ impl BindingExpiry {
|
||||
/// lease bound; expiry does not synthesize lifecycle state. An
|
||||
/// authoritative binding adapter constructs this move-only value after checking
|
||||
/// active lifecycle state; it has no default or deserialization path.
|
||||
/// Production construction is intentionally unavailable in this phase. A
|
||||
/// future crate-owned, sealed finalizer must require a durable non-nil ID, a
|
||||
/// positive version, authoritative active-state evidence, and a typed database
|
||||
/// result distinguishing a binding that already existed from one atomically
|
||||
/// enrolled during this decision. Transport callers must never select that
|
||||
/// lifecycle result. Pending, revoked, newly proposed, and synthetic records
|
||||
/// must not cross that gate.
|
||||
/// Production construction is available only through the crate-owned
|
||||
/// authoritative-resolution finalizer. Pending, revoked, newly proposed, and
|
||||
/// synthetic records must not cross that gate.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct VersionedBindingRef {
|
||||
authorization_domain: CommunityId,
|
||||
@@ -210,6 +347,144 @@ pub struct VersionedBindingRef {
|
||||
resolution_reason: AuthorizationReason,
|
||||
}
|
||||
|
||||
/// Structurally validated binding fields returned by authoritative O3 state.
|
||||
///
|
||||
/// This is not authorization by itself. The crate-owned finalizer additionally
|
||||
/// requires a typed lifecycle outcome proving that the binding was already
|
||||
/// active or was atomically enrolled during this decision. It has no default or
|
||||
/// deserialization path.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct AuthoritativeBindingEvidence {
|
||||
authorization_domain: CommunityId,
|
||||
binding_id: Uuid,
|
||||
principal: FederatedPrincipal,
|
||||
bound_pubkey: PublicKey,
|
||||
binding_version: BindingVersion,
|
||||
expires_at: Option<BindingExpiry>,
|
||||
source: BindingSource,
|
||||
}
|
||||
|
||||
impl AuthoritativeBindingEvidence {
|
||||
/// Validate typed fields read from authoritative binding state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
authorization_domain: CommunityId,
|
||||
binding_id: Uuid,
|
||||
principal: FederatedPrincipal,
|
||||
bound_pubkey: PublicKey,
|
||||
binding_version: BindingVersion,
|
||||
expires_at: Option<BindingExpiry>,
|
||||
source: BindingSource,
|
||||
) -> Result<Self, AuthContextError> {
|
||||
if binding_id.is_nil() {
|
||||
return Err(AuthContextError::InvalidBindingId);
|
||||
}
|
||||
Ok(Self {
|
||||
authorization_domain,
|
||||
binding_id,
|
||||
principal,
|
||||
bound_pubkey,
|
||||
binding_version,
|
||||
expires_at,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Server-resolved authorization domain that owns the binding.
|
||||
pub const fn authorization_domain(&self) -> CommunityId {
|
||||
self.authorization_domain
|
||||
}
|
||||
|
||||
/// Stable binding identifier.
|
||||
pub const fn binding_id(&self) -> Uuid {
|
||||
self.binding_id
|
||||
}
|
||||
|
||||
/// Issuer-qualified principal represented by the binding.
|
||||
pub const fn principal(&self) -> &FederatedPrincipal {
|
||||
&self.principal
|
||||
}
|
||||
|
||||
/// Nostr key owned by the binding.
|
||||
pub const fn bound_pubkey(&self) -> PublicKey {
|
||||
self.bound_pubkey
|
||||
}
|
||||
|
||||
/// Current local binding version.
|
||||
pub const fn binding_version(&self) -> BindingVersion {
|
||||
self.binding_version
|
||||
}
|
||||
|
||||
/// Optional authoritative temporal bound for authorization eligibility.
|
||||
pub const fn expires_at(&self) -> Option<BindingExpiry> {
|
||||
self.expires_at
|
||||
}
|
||||
|
||||
/// Persisted provenance of the active binding.
|
||||
pub const fn source(&self) -> BindingSource {
|
||||
self.source
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthoritativeBindingEvidence {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthoritativeBindingEvidence")
|
||||
.field("authorization_domain", &"[redacted]")
|
||||
.field("binding_id", &"[redacted]")
|
||||
.field("principal", &"[redacted]")
|
||||
.field("bound_pubkey", &"[redacted]")
|
||||
.field("binding_version", &"[redacted]")
|
||||
.field("expires_at", &"[redacted]")
|
||||
.field("source", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum BindingResolutionOutcome {
|
||||
ExistingActive,
|
||||
AtomicallyEnrolled,
|
||||
}
|
||||
|
||||
/// Typed authoritative lifecycle result consumed by the O1 finalizer.
|
||||
///
|
||||
/// It carries no caller-selected authorization reason; O1 derives that reason
|
||||
/// from the lifecycle outcome, persisted provenance, and current enrollment
|
||||
/// policy.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct AuthoritativeBindingResolution {
|
||||
evidence: AuthoritativeBindingEvidence,
|
||||
outcome: BindingResolutionOutcome,
|
||||
}
|
||||
|
||||
impl AuthoritativeBindingResolution {
|
||||
/// Record O3's authoritative result that the binding already existed.
|
||||
pub fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self {
|
||||
Self {
|
||||
evidence,
|
||||
outcome: BindingResolutionOutcome::ExistingActive,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record O3's authoritative result that enrollment committed atomically.
|
||||
pub fn atomically_enrolled(evidence: AuthoritativeBindingEvidence) -> Self {
|
||||
Self {
|
||||
evidence,
|
||||
outcome: BindingResolutionOutcome::AtomicallyEnrolled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthoritativeBindingResolution {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_tuple("AuthoritativeBindingResolution")
|
||||
.field(&"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for VersionedBindingRef {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
@@ -227,6 +502,54 @@ impl fmt::Debug for VersionedBindingRef {
|
||||
}
|
||||
|
||||
impl VersionedBindingRef {
|
||||
pub(super) fn from_authoritative_resolution(
|
||||
resolution: AuthoritativeBindingResolution,
|
||||
requirement: FederatedIdentityRequirement,
|
||||
) -> Result<Self, AuthContextError> {
|
||||
let reason = match resolution.outcome {
|
||||
BindingResolutionOutcome::ExistingActive => AuthorizationReason::ExistingBinding,
|
||||
BindingResolutionOutcome::AtomicallyEnrolled => {
|
||||
match (requirement, resolution.evidence.source) {
|
||||
(
|
||||
FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey),
|
||||
BindingSource::AttestedKey,
|
||||
) => AuthorizationReason::EnrolledAttestedKey,
|
||||
(
|
||||
FederatedIdentityRequirement::Required(EnrollmentMode::Tofu),
|
||||
BindingSource::Tofu | BindingSource::AttestedKey,
|
||||
) => AuthorizationReason::EnrolledTofu,
|
||||
_ => return Err(AuthContextError::InvalidAuthorizationReason),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Self::from_authoritative_evidence(
|
||||
resolution.evidence,
|
||||
reason,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn from_existing_authoritative_evidence(
|
||||
evidence: AuthoritativeBindingEvidence,
|
||||
) -> Self {
|
||||
Self::from_authoritative_evidence(evidence, AuthorizationReason::ExistingBinding)
|
||||
}
|
||||
|
||||
fn from_authoritative_evidence(
|
||||
evidence: AuthoritativeBindingEvidence,
|
||||
resolution_reason: AuthorizationReason,
|
||||
) -> Self {
|
||||
Self {
|
||||
authorization_domain: evidence.authorization_domain,
|
||||
binding_id: evidence.binding_id,
|
||||
principal: evidence.principal,
|
||||
bound_pubkey: evidence.bound_pubkey,
|
||||
binding_version: evidence.binding_version,
|
||||
expires_at: evidence.expires_at,
|
||||
source: evidence.source,
|
||||
resolution_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reference to a binding authoritatively resolved as already active.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_existing_active_for_test(
|
||||
|
||||
@@ -18,7 +18,8 @@ mod evidence;
|
||||
mod reason;
|
||||
|
||||
pub use binding::{
|
||||
BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement,
|
||||
AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource,
|
||||
BindingVersion, EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp,
|
||||
ResolvedFederatedPolicy, VersionedBindingRef,
|
||||
};
|
||||
pub use evidence::{
|
||||
@@ -72,6 +73,39 @@ impl fmt::Debug for FederatedAuthorization {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative O3 result consumed by the public O1 production finalizer.
|
||||
///
|
||||
/// Unlike [`FederatedAuthorization`], this input cannot contain a raw
|
||||
/// [`VersionedBindingRef`] or a caller-selected authorization reason.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum AuthoritativeFederatedResolution {
|
||||
/// This domain's current policy does not require federated identity.
|
||||
NotRequired,
|
||||
/// Direct authority backed by an existing or atomically enrolled binding.
|
||||
Direct {
|
||||
/// Typed authoritative O3 lifecycle result.
|
||||
binding: AuthoritativeBindingResolution,
|
||||
/// Current verified assertion for the authenticated actor.
|
||||
assertion: VerifiedFederatedAssertion,
|
||||
},
|
||||
/// Delegated authority backed by an already-active owner binding.
|
||||
Delegated {
|
||||
/// Typed authoritative evidence for the existing owner binding.
|
||||
owner: AuthoritativeBindingEvidence,
|
||||
/// Current admission resolved for the owner.
|
||||
admission: VerifiedOwnerAdmission,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthoritativeFederatedResolution {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_tuple("AuthoritativeFederatedResolution")
|
||||
.field(&"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial shared authorization-context contract.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct AuthContextV1 {
|
||||
@@ -148,6 +182,41 @@ impl fmt::Debug for AuthContext {
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
/// Finalize an immutable V1 context from authoritative O3 binding evidence.
|
||||
///
|
||||
/// This is the production O1↔O3 seam. O3 must resolve the enrollment-policy
|
||||
/// stamp and binding lifecycle state from current authoritative storage,
|
||||
/// use the policy epoch as a conditional precondition for any atomic
|
||||
/// enrollment, and pass the resulting typed lifecycle outcome here. O1
|
||||
/// derives the authorization reason; transport code cannot select it.
|
||||
pub fn finalize_authoritative_v1(
|
||||
input: AuthContextInput,
|
||||
federated_policy: ResolvedFederatedPolicy,
|
||||
resolution: AuthoritativeFederatedResolution,
|
||||
now_unix_seconds: u64,
|
||||
) -> Result<Self, AuthContextError> {
|
||||
validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?;
|
||||
let authorization = match resolution {
|
||||
AuthoritativeFederatedResolution::NotRequired => FederatedAuthorization::NotRequired,
|
||||
AuthoritativeFederatedResolution::Direct { binding, assertion } => {
|
||||
FederatedAuthorization::Direct {
|
||||
binding: VersionedBindingRef::from_authoritative_resolution(
|
||||
binding,
|
||||
federated_policy.requirement(),
|
||||
)?,
|
||||
assertion,
|
||||
}
|
||||
}
|
||||
AuthoritativeFederatedResolution::Delegated { owner, admission } => {
|
||||
FederatedAuthorization::Delegated {
|
||||
owner: VersionedBindingRef::from_existing_authoritative_evidence(owner),
|
||||
admission,
|
||||
}
|
||||
}
|
||||
};
|
||||
Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds)
|
||||
}
|
||||
|
||||
/// Validate all authorization evidence and finalize an immutable V1 context.
|
||||
///
|
||||
/// Adapters must preserve this phase order: cryptographic proof and
|
||||
@@ -158,7 +227,7 @@ impl AuthContext {
|
||||
///
|
||||
/// `now_unix_seconds` must come from the server clock for the authorization
|
||||
/// decision being finalized.
|
||||
pub fn finalize_v1(
|
||||
pub(crate) fn finalize_v1(
|
||||
input: AuthContextInput,
|
||||
federated_policy: ResolvedFederatedPolicy,
|
||||
authorization: FederatedAuthorization,
|
||||
@@ -169,9 +238,7 @@ impl AuthContext {
|
||||
if input.nostr_proof.authorization_domain() != authorization_domain {
|
||||
return Err(AuthContextError::NostrProofDomainMismatch);
|
||||
}
|
||||
if federated_policy.authorization_domain() != authorization_domain {
|
||||
return Err(AuthContextError::PolicyDomainMismatch);
|
||||
}
|
||||
validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?;
|
||||
if input.community_access.authorization_domain() != authorization_domain {
|
||||
return Err(AuthContextError::CommunityAccessDomainMismatch);
|
||||
}
|
||||
@@ -293,6 +360,29 @@ impl AuthContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_federated_policy_stamp(
|
||||
input: &AuthContextInput,
|
||||
federated_policy: &ResolvedFederatedPolicy,
|
||||
now_unix_seconds: u64,
|
||||
) -> Result<(), AuthContextError> {
|
||||
if federated_policy.authorization_domain() != input.tenant.community() {
|
||||
return Err(AuthContextError::PolicyDomainMismatch);
|
||||
}
|
||||
if federated_policy.stamp().correlation_id() != input.correlation_id {
|
||||
return Err(AuthContextError::FederatedPolicyCorrelationMismatch);
|
||||
}
|
||||
if federated_policy
|
||||
.stamp()
|
||||
.is_not_yet_effective_at(now_unix_seconds)
|
||||
{
|
||||
return Err(AuthContextError::FederatedPolicyNotYetEffective);
|
||||
}
|
||||
if federated_policy.stamp().is_expired_at(now_unix_seconds) {
|
||||
return Err(AuthContextError::FederatedPolicyExpired);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) const fn transport_accepts_proof(
|
||||
transport: AuthTransport,
|
||||
proof_method: AuthMethod,
|
||||
|
||||
@@ -61,6 +61,18 @@ pub enum AuthContextError {
|
||||
/// Binding expiry was not a valid Unix timestamp.
|
||||
#[error("identity binding expiry must be greater than zero")]
|
||||
InvalidBindingExpiry,
|
||||
/// Enrollment-policy identifier was nil.
|
||||
#[error("federated enrollment-policy identifier must not be nil")]
|
||||
InvalidFederatedPolicyId,
|
||||
/// Enrollment-policy epoch was zero.
|
||||
#[error("federated enrollment-policy epoch must be greater than zero")]
|
||||
InvalidFederatedPolicyEpoch,
|
||||
/// Enrollment-policy correlation identifier was nil.
|
||||
#[error("federated enrollment-policy correlation must not be nil")]
|
||||
InvalidFederatedPolicyCorrelation,
|
||||
/// Enrollment-policy effective interval was empty or reversed.
|
||||
#[error("federated enrollment-policy effective interval is invalid")]
|
||||
InvalidFederatedPolicyInterval,
|
||||
/// Assertion expiry was not a valid Unix timestamp.
|
||||
#[error("federated assertion expiry must be greater than zero")]
|
||||
InvalidAssertionExpiry,
|
||||
@@ -76,6 +88,15 @@ pub enum AuthContextError {
|
||||
/// Binding was no longer authorization-eligible when evaluated.
|
||||
#[error("identity binding has expired")]
|
||||
BindingExpired,
|
||||
/// Enrollment policy was resolved for another authorization decision.
|
||||
#[error("federated enrollment policy does not match the authorization decision")]
|
||||
FederatedPolicyCorrelationMismatch,
|
||||
/// Enrollment policy was used before its effective interval.
|
||||
#[error("federated enrollment policy is not yet effective")]
|
||||
FederatedPolicyNotYetEffective,
|
||||
/// Enrollment policy was used after its effective interval.
|
||||
#[error("federated enrollment policy has expired")]
|
||||
FederatedPolicyExpired,
|
||||
/// Assertion was used before its validated not-before bound.
|
||||
#[error("federated assertion is not yet valid")]
|
||||
AssertionNotYetValid,
|
||||
@@ -159,11 +180,18 @@ impl AuthContextError {
|
||||
Self::InvalidBindingVersion => "federated_binding_invalid_version",
|
||||
Self::InvalidBindingId => "federated_binding_invalid_id",
|
||||
Self::InvalidBindingExpiry => "federated_binding_invalid_expiry",
|
||||
Self::InvalidFederatedPolicyId => "federated_policy_invalid_id",
|
||||
Self::InvalidFederatedPolicyEpoch => "federated_policy_invalid_epoch",
|
||||
Self::InvalidFederatedPolicyCorrelation => "federated_policy_invalid_correlation",
|
||||
Self::InvalidFederatedPolicyInterval => "federated_policy_invalid_interval",
|
||||
Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry",
|
||||
Self::InvalidDelegationExpiry => "delegation_invalid_expiry",
|
||||
Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry",
|
||||
Self::AssertionExpired => "federated_assertion_expired",
|
||||
Self::BindingExpired => "federated_binding_expired",
|
||||
Self::FederatedPolicyCorrelationMismatch => "federated_policy_correlation_mismatch",
|
||||
Self::FederatedPolicyNotYetEffective => "federated_policy_not_yet_effective",
|
||||
Self::FederatedPolicyExpired => "federated_policy_expired",
|
||||
Self::AssertionNotYetValid => "federated_assertion_not_yet_valid",
|
||||
Self::KeyAttestationRequired => "federated_key_attestation_required",
|
||||
Self::KeyAttestationMismatch => "federated_key_attestation_mismatch",
|
||||
|
||||
@@ -92,10 +92,46 @@ fn policy_required(enrollment_mode: EnrollmentMode) -> ResolvedFederatedPolicy {
|
||||
ResolvedFederatedPolicy::required(authorization_domain(1), enrollment_mode)
|
||||
}
|
||||
|
||||
fn policy_with_lineage(
|
||||
enrollment_mode: EnrollmentMode,
|
||||
correlation_id: Uuid,
|
||||
effective_from: u64,
|
||||
effective_until: u64,
|
||||
) -> ResolvedFederatedPolicy {
|
||||
ResolvedFederatedPolicy::from_authoritative_resolution(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(40),
|
||||
7,
|
||||
correlation_id,
|
||||
FederatedIdentityRequirement::Required(enrollment_mode),
|
||||
effective_from,
|
||||
effective_until,
|
||||
)
|
||||
.expect("synthetic federated policy lineage is valid"),
|
||||
)
|
||||
}
|
||||
|
||||
fn binding(pubkey: PublicKey) -> VersionedBindingRef {
|
||||
binding_in(1, pubkey)
|
||||
}
|
||||
|
||||
fn authoritative_binding_evidence(
|
||||
pubkey: PublicKey,
|
||||
source: BindingSource,
|
||||
) -> AuthoritativeBindingEvidence {
|
||||
AuthoritativeBindingEvidence::new(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(10),
|
||||
principal(),
|
||||
pubkey,
|
||||
BindingVersion::INITIAL,
|
||||
None,
|
||||
source,
|
||||
)
|
||||
.expect("synthetic authoritative binding evidence is valid")
|
||||
}
|
||||
|
||||
fn binding_in(domain: u128, pubkey: PublicKey) -> VersionedBindingRef {
|
||||
binding_with_source_in(domain, pubkey, BindingSource::AttestedKey)
|
||||
}
|
||||
@@ -395,7 +431,10 @@ fn context_debug_output_omits_tenant_host() {
|
||||
"nostr: NostrAuthority { actor_pubkey: \"[redacted]\", ",
|
||||
"proof_method: Nip42, verified_delegation: \"[redacted]\" }, ",
|
||||
"federated_policy: ResolvedFederatedPolicy { ",
|
||||
"authorization_domain: \"[redacted]\", requirement: \"[redacted]\" }, ",
|
||||
"stamp: FederatedPolicyStamp { authorization_domain: \"[redacted]\", ",
|
||||
"policy_id: \"[redacted]\", epoch: \"[redacted]\", ",
|
||||
"correlation_id: \"[redacted]\", requirement: \"[redacted]\", ",
|
||||
"effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ",
|
||||
"federated: FederatedAuthorization(\"[redacted]\"), ",
|
||||
"scopes: \"[redacted]\", channel_ids: \"[redacted]\" })"
|
||||
)
|
||||
@@ -814,7 +853,12 @@ fn enrolled_reason_must_match_policy_and_binding_source() {
|
||||
BindingSource::AttestedKey,
|
||||
AuthorizationReason::EnrolledAttestedKey,
|
||||
),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200),
|
||||
assertion: assertion_with_attested_key(
|
||||
principal(),
|
||||
AssertionTransport::TrustedProxy,
|
||||
200,
|
||||
actor.public_key(),
|
||||
),
|
||||
},
|
||||
100,
|
||||
)
|
||||
@@ -1047,11 +1091,18 @@ fn authorization_error_codes_are_unique_and_provider_neutral() {
|
||||
AuthContextError::InvalidBindingVersion,
|
||||
AuthContextError::InvalidBindingId,
|
||||
AuthContextError::InvalidBindingExpiry,
|
||||
AuthContextError::InvalidFederatedPolicyId,
|
||||
AuthContextError::InvalidFederatedPolicyEpoch,
|
||||
AuthContextError::InvalidFederatedPolicyCorrelation,
|
||||
AuthContextError::InvalidFederatedPolicyInterval,
|
||||
AuthContextError::InvalidAssertionExpiry,
|
||||
AuthContextError::InvalidDelegationExpiry,
|
||||
AuthContextError::InvalidAdmissionExpiry,
|
||||
AuthContextError::AssertionExpired,
|
||||
AuthContextError::BindingExpired,
|
||||
AuthContextError::FederatedPolicyCorrelationMismatch,
|
||||
AuthContextError::FederatedPolicyNotYetEffective,
|
||||
AuthContextError::FederatedPolicyExpired,
|
||||
AuthContextError::AssertionNotYetValid,
|
||||
AuthContextError::KeyAttestationRequired,
|
||||
AuthContextError::KeyAttestationMismatch,
|
||||
@@ -1146,12 +1197,243 @@ fn security_posture_debug_output_is_fully_redacted() {
|
||||
assert_eq!(
|
||||
format!("{:?}", policy_required(EnrollmentMode::AttestedKey)),
|
||||
concat!(
|
||||
"ResolvedFederatedPolicy { authorization_domain: \"[redacted]\", ",
|
||||
"requirement: \"[redacted]\" }"
|
||||
"ResolvedFederatedPolicy { stamp: FederatedPolicyStamp { ",
|
||||
"authorization_domain: \"[redacted]\", policy_id: \"[redacted]\", ",
|
||||
"epoch: \"[redacted]\", correlation_id: \"[redacted]\", ",
|
||||
"requirement: \"[redacted]\", effective_from: \"[redacted]\", ",
|
||||
"effective_until: \"[redacted]\" } }"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_policy_must_match_the_authorization_correlation() {
|
||||
let actor = Keys::generate();
|
||||
let error = AuthContext::finalize_v1(
|
||||
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200),
|
||||
FederatedAuthorization::Direct {
|
||||
binding: binding(actor.public_key()),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect_err("policy evidence from another decision must not finalize");
|
||||
|
||||
assert_eq!(error, AuthContextError::FederatedPolicyCorrelationMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_policy_effective_interval_is_half_open() {
|
||||
let actor = Keys::generate();
|
||||
let not_yet_effective = AuthContext::finalize_v1(
|
||||
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200),
|
||||
FederatedAuthorization::Direct {
|
||||
binding: binding(actor.public_key()),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect_err("policy must not authorize before its effective interval");
|
||||
assert_eq!(
|
||||
not_yet_effective,
|
||||
AuthContextError::FederatedPolicyNotYetEffective
|
||||
);
|
||||
|
||||
let expired = AuthContext::finalize_v1(
|
||||
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100),
|
||||
FederatedAuthorization::Direct {
|
||||
binding: binding(actor.public_key()),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect_err("policy must deny at its exact exclusive bound");
|
||||
assert_eq!(expired, AuthContextError::FederatedPolicyExpired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_policy_stamp_rejects_invalid_lineage() {
|
||||
let requirement = FederatedIdentityRequirement::Required(EnrollmentMode::Provisioned);
|
||||
assert_eq!(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain(1),
|
||||
Uuid::nil(),
|
||||
1,
|
||||
Uuid::from_u128(2),
|
||||
requirement,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
Err(AuthContextError::InvalidFederatedPolicyId)
|
||||
);
|
||||
assert_eq!(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(40),
|
||||
0,
|
||||
Uuid::from_u128(2),
|
||||
requirement,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
Err(AuthContextError::InvalidFederatedPolicyEpoch)
|
||||
);
|
||||
assert_eq!(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(40),
|
||||
1,
|
||||
Uuid::nil(),
|
||||
requirement,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
Err(AuthContextError::InvalidFederatedPolicyCorrelation)
|
||||
);
|
||||
assert_eq!(
|
||||
FederatedPolicyStamp::from_authoritative_state(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(40),
|
||||
1,
|
||||
Uuid::from_u128(2),
|
||||
requirement,
|
||||
200,
|
||||
200,
|
||||
),
|
||||
Err(AuthContextError::InvalidFederatedPolicyInterval)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_finalizer_derives_binding_reason() {
|
||||
let existing_actor = Keys::generate();
|
||||
let existing = AuthContext::finalize_authoritative_v1(
|
||||
input(
|
||||
existing_actor.public_key(),
|
||||
AuthTransport::RelayWebSocket,
|
||||
None,
|
||||
),
|
||||
policy_required(EnrollmentMode::Tofu),
|
||||
AuthoritativeFederatedResolution::Direct {
|
||||
binding: AuthoritativeBindingResolution::existing_active(
|
||||
authoritative_binding_evidence(
|
||||
existing_actor.public_key(),
|
||||
BindingSource::Provisioned,
|
||||
),
|
||||
),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect("existing authoritative binding is eligible");
|
||||
assert_eq!(
|
||||
existing.authorization_reason(),
|
||||
AuthorizationReason::ExistingBinding
|
||||
);
|
||||
|
||||
let attested_actor = Keys::generate();
|
||||
let attested = AuthContext::finalize_authoritative_v1(
|
||||
input(
|
||||
attested_actor.public_key(),
|
||||
AuthTransport::RelayWebSocket,
|
||||
None,
|
||||
),
|
||||
policy_required(EnrollmentMode::AttestedKey),
|
||||
AuthoritativeFederatedResolution::Direct {
|
||||
binding: AuthoritativeBindingResolution::atomically_enrolled(
|
||||
authoritative_binding_evidence(
|
||||
attested_actor.public_key(),
|
||||
BindingSource::AttestedKey,
|
||||
),
|
||||
),
|
||||
assertion: assertion_with_attested_key(
|
||||
principal(),
|
||||
AssertionTransport::TrustedProxy,
|
||||
200,
|
||||
attested_actor.public_key(),
|
||||
),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect("attested enrollment result is eligible");
|
||||
assert_eq!(
|
||||
attested.authorization_reason(),
|
||||
AuthorizationReason::EnrolledAttestedKey
|
||||
);
|
||||
|
||||
let tofu_actor = Keys::generate();
|
||||
let tofu = AuthContext::finalize_authoritative_v1(
|
||||
input(tofu_actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_required(EnrollmentMode::Tofu),
|
||||
AuthoritativeFederatedResolution::Direct {
|
||||
binding: AuthoritativeBindingResolution::atomically_enrolled(
|
||||
authoritative_binding_evidence(tofu_actor.public_key(), BindingSource::AttestedKey),
|
||||
),
|
||||
assertion: assertion_with_attested_key(
|
||||
principal(),
|
||||
AssertionTransport::TrustedProxy,
|
||||
200,
|
||||
tofu_actor.public_key(),
|
||||
),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect("stronger attested provenance remains valid under TOFU enrollment");
|
||||
assert_eq!(
|
||||
tofu.authorization_reason(),
|
||||
AuthorizationReason::EnrolledTofu
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_finalizer_rejects_incompatible_enrollment_result() {
|
||||
let actor = Keys::generate();
|
||||
let error = AuthContext::finalize_authoritative_v1(
|
||||
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_required(EnrollmentMode::Provisioned),
|
||||
AuthoritativeFederatedResolution::Direct {
|
||||
binding: AuthoritativeBindingResolution::atomically_enrolled(
|
||||
authoritative_binding_evidence(actor.public_key(), BindingSource::Provisioned),
|
||||
),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect_err("ordinary finalization cannot relabel provisioned state as enrollment");
|
||||
|
||||
assert_eq!(error, AuthContextError::InvalidAuthorizationReason);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_finalizer_carries_binding_expiry() {
|
||||
let actor = Keys::generate();
|
||||
let evidence = AuthoritativeBindingEvidence::new(
|
||||
authorization_domain(1),
|
||||
Uuid::from_u128(10),
|
||||
principal(),
|
||||
actor.public_key(),
|
||||
BindingVersion::INITIAL,
|
||||
Some(BindingExpiry::new(100).expect("synthetic binding expiry is valid")),
|
||||
BindingSource::Provisioned,
|
||||
)
|
||||
.expect("synthetic authoritative binding evidence is valid");
|
||||
let error = AuthContext::finalize_authoritative_v1(
|
||||
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
|
||||
policy_required(EnrollmentMode::Provisioned),
|
||||
AuthoritativeFederatedResolution::Direct {
|
||||
binding: AuthoritativeBindingResolution::existing_active(evidence),
|
||||
assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200),
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect_err("production finalization must preserve the authoritative binding bound");
|
||||
|
||||
assert_eq!(error, AuthContextError::BindingExpired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tofu_enrollment_cannot_use_attested_key_policy_reason() {
|
||||
let actor = Keys::generate();
|
||||
|
||||
Reference in New Issue
Block a user