fix(auth): preserve provider decision scope

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
(cherry picked from commit 26f64a507c3fe1cd4db3352998b2804b0cb80644)
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-03 17:03:50 -05:00
parent edf28cca46
commit 33b11db767
2 changed files with 110 additions and 9 deletions
+70 -5
View File
@@ -12,8 +12,8 @@ use thiserror::Error;
use uuid::Uuid;
use crate::context::{
AuthMethod, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedNostrProof,
VersionedBindingRef,
AuthMethod, AuthTransport, BindingVersion, FederatedPrincipal, VerifiedFederatedAssertion,
VerifiedNostrProof, VersionedBindingRef,
};
const MAX_OPAQUE_ID_BYTES: usize = 256;
@@ -33,8 +33,10 @@ pub enum AuthorizationCapability {
CommunityWrite,
/// Perform moderation operations.
Moderate,
/// Mint or claim invitations.
Invite,
/// Mint invitations.
InviteMint,
/// Claim an invitation before membership exists.
InviteClaim,
/// Read authenticated media.
MediaRead,
/// Upload media.
@@ -132,6 +134,9 @@ impl fmt::Debug for AuthorizationProfileId {
}
/// Opaque, equality-comparable policy version returned by a provider.
///
/// This is the typed policy-change seam that later lease and invalidation code
/// can use without assuming a provider-specific numeric ordering.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct PolicyVersion(String);
@@ -173,6 +178,10 @@ pub enum AuthorizationAuthority {
Delegated {
/// Cryptographically verified and actively bound owner key.
owner_pubkey: PublicKey,
/// Stable identifier of the active owner binding.
binding_id: Uuid,
/// Exact active owner-binding version used for this decision.
binding_version: BindingVersion,
},
}
@@ -208,6 +217,7 @@ impl fmt::Debug for DecisionSource {
#[derive(PartialEq, Eq)]
pub struct AuthorizationRequest {
authorization_domain: CommunityId,
transport: AuthTransport,
actor_pubkey: PublicKey,
proof_method: AuthMethod,
authority: AuthorizationAuthority,
@@ -225,6 +235,7 @@ impl AuthorizationRequest {
/// 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.
/// `now_unix_seconds` must come from the server clock.
pub fn direct(
proof: &VerifiedNostrProof,
assertion: &VerifiedFederatedAssertion,
@@ -262,6 +273,7 @@ impl AuthorizationRequest {
}
Ok(Self {
authorization_domain: proof.authorization_domain(),
transport: proof.authorized_transport(),
actor_pubkey: proof.actor_pubkey(),
proof_method: proof.proof_method(),
authority: AuthorizationAuthority::Direct,
@@ -278,6 +290,7 @@ 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(
proof: &VerifiedNostrProof,
owner: &VersionedBindingRef,
@@ -306,10 +319,13 @@ impl AuthorizationRequest {
}
Ok(Self {
authorization_domain: proof.authorization_domain(),
transport: proof.authorized_transport(),
actor_pubkey: proof.actor_pubkey(),
proof_method: proof.proof_method(),
authority: AuthorizationAuthority::Delegated {
owner_pubkey: owner.bound_pubkey(),
binding_id: owner.binding_id(),
binding_version: owner.binding_version(),
},
principal: owner.principal().clone(),
profile_id,
@@ -325,6 +341,11 @@ impl AuthorizationRequest {
self.authorization_domain
}
/// Exact protected transport authorized by the verified proof.
pub const fn transport(&self) -> AuthTransport {
self.transport
}
/// Authenticated Nostr actor.
pub const fn actor_pubkey(&self) -> PublicKey {
self.actor_pubkey
@@ -364,6 +385,11 @@ impl AuthorizationRequest {
pub const fn decision_source(&self) -> DecisionSource {
self.decision_source
}
/// Earliest validity bound supplied by verified assertion or delegation evidence.
pub const fn evidence_valid_until(&self) -> Option<u64> {
self.evidence_valid_until
}
}
impl fmt::Debug for AuthorizationRequest {
@@ -371,6 +397,7 @@ impl fmt::Debug for AuthorizationRequest {
formatter
.debug_struct("AuthorizationRequest")
.field("authorization_domain", &"[redacted]")
.field("transport", &"[redacted]")
.field("actor_pubkey", &"[redacted]")
.field("proof_method", &"[redacted]")
.field("authority", &"[redacted]")
@@ -714,11 +741,16 @@ impl fmt::Debug for ProviderAllowReason {
///
/// 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;
/// callers may inspect its bounded metadata but cannot recreate trusted state.
#[derive(PartialEq, Eq)]
pub struct CapabilitySnapshot {
authorization_domain: CommunityId,
transport: AuthTransport,
actor_pubkey: PublicKey,
owner_pubkey: Option<PublicKey>,
binding_id: Option<Uuid>,
binding_version: Option<BindingVersion>,
proof_method: AuthMethod,
principal: FederatedPrincipal,
profile_id: AuthorizationProfileId,
@@ -738,6 +770,11 @@ impl CapabilitySnapshot {
self.authorization_domain
}
/// Exact protected transport for which this snapshot was resolved.
pub const fn transport(&self) -> AuthTransport {
self.transport
}
/// Exact authenticated Nostr actor for this decision.
pub const fn actor_pubkey(&self) -> PublicKey {
self.actor_pubkey
@@ -748,6 +785,19 @@ impl CapabilitySnapshot {
self.owner_pubkey
}
/// Stable active binding identifier for delegated authority.
pub const fn binding_id(&self) -> Option<Uuid> {
self.binding_id
}
/// Exact active binding version for delegated authority.
///
/// This is not a lease: later consumers must compare it with current
/// authoritative binding state before reusing a cached snapshot.
pub const fn binding_version(&self) -> Option<BindingVersion> {
self.binding_version
}
/// Cryptographic proof method for the authenticated actor.
pub const fn proof_method(&self) -> AuthMethod {
self.proof_method
@@ -809,8 +859,11 @@ impl fmt::Debug for CapabilitySnapshot {
formatter
.debug_struct("CapabilitySnapshot")
.field("authorization_domain", &"[redacted]")
.field("transport", &"[redacted]")
.field("actor_pubkey", &"[redacted]")
.field("owner_pubkey", &"[redacted]")
.field("binding_id", &"[redacted]")
.field("binding_version", &"[redacted]")
.field("proof_method", &"[redacted]")
.field("principal", &"[redacted]")
.field("profile_id", &"[redacted]")
@@ -851,6 +904,7 @@ impl fmt::Debug for AuthorizationOutcome {
///
/// Unavailability is preserved as a fail-closed outcome. This function never
/// falls back to Nostr-only authorization or applies an implicit grace period.
/// `now_unix_seconds` must come from the server clock.
pub async fn resolve_authorization(
provider: &dyn AuthorizationProvider,
request: &AuthorizationRequest,
@@ -906,10 +960,21 @@ pub async fn resolve_authorization(
AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot {
authorization_domain: allow.authorization_domain,
transport: request.transport,
actor_pubkey: request.actor_pubkey,
owner_pubkey: match &request.authority {
AuthorizationAuthority::Direct => None,
AuthorizationAuthority::Delegated { owner_pubkey } => Some(*owner_pubkey),
AuthorizationAuthority::Delegated { owner_pubkey, .. } => Some(*owner_pubkey),
},
binding_id: match &request.authority {
AuthorizationAuthority::Direct => None,
AuthorizationAuthority::Delegated { binding_id, .. } => Some(*binding_id),
},
binding_version: match &request.authority {
AuthorizationAuthority::Direct => None,
AuthorizationAuthority::Delegated {
binding_version, ..
} => Some(*binding_version),
},
proof_method: request.proof_method,
principal: allow.principal,
+40 -4
View File
@@ -222,8 +222,11 @@ async fn current_allow_returns_request_scoped_snapshot() {
};
assert_eq!(snapshot.authorization_domain(), domain(1));
assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket);
assert_eq!(snapshot.actor_pubkey(), actor.public_key());
assert_eq!(snapshot.owner_pubkey(), None);
assert_eq!(snapshot.binding_id(), None);
assert_eq!(snapshot.binding_version(), None);
assert_eq!(snapshot.proof_method(), AuthMethod::Nip42);
assert_eq!(snapshot.principal(), request.principal());
assert_eq!(snapshot.profile_id(), request.profile_id());
@@ -432,6 +435,33 @@ async fn domain_principal_and_capability_mismatches_deny() {
);
}
#[tokio::test]
async fn invite_mint_does_not_authorize_invite_claim() {
let actor = Keys::generate();
let request = direct_request_with_expiry(
&actor,
200,
capabilities(&[AuthorizationCapability::InviteClaim]),
);
let provider = FakeProvider::returning(allow_for(
&request,
capabilities(&[AuthorizationCapability::InviteMint]),
"version-a",
90,
180,
));
let AuthorizationOutcome::Deny(denial) =
resolve_authorization(&provider, &request, NOW, provider_timeout()).await
else {
panic!("invitation minting must not authorize a claim");
};
assert_eq!(
denial.reason(),
AuthorizationDenialReason::MissingCapability
);
}
#[tokio::test]
async fn assertion_expiry_bounds_provider_freshness() {
let actor = Keys::generate();
@@ -464,7 +494,7 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() {
let request = delegated_request(&actor, &owner, 140);
assert!(matches!(
request.authority(),
AuthorizationAuthority::Delegated { owner_pubkey }
AuthorizationAuthority::Delegated { owner_pubkey, .. }
if *owner_pubkey == owner.public_key()
));
assert_eq!(
@@ -487,6 +517,9 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() {
assert_eq!(snapshot.effective_until(), 140);
assert_eq!(snapshot.actor_pubkey(), actor.public_key());
assert_eq!(snapshot.owner_pubkey(), Some(owner.public_key()));
assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10)));
assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL));
assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket);
}
#[tokio::test]
@@ -811,7 +844,8 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
format!("{request:?}"),
concat!(
"AuthorizationRequest { authorization_domain: \"[redacted]\", ",
"actor_pubkey: \"[redacted]\", proof_method: \"[redacted]\", ",
"transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ",
"proof_method: \"[redacted]\", ",
"authority: \"[redacted]\", principal: \"[redacted]\", ",
"profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ",
"correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ",
@@ -853,8 +887,10 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
format!("{snapshot:?}"),
concat!(
"CapabilitySnapshot { authorization_domain: \"[redacted]\", ",
"actor_pubkey: \"[redacted]\", owner_pubkey: \"[redacted]\", ",
"proof_method: \"[redacted]\", principal: \"[redacted]\", ",
"transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ",
"owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ",
"binding_version: \"[redacted]\", proof_method: \"[redacted]\", ",
"principal: \"[redacted]\", ",
"profile_id: \"[redacted]\", capabilities: \"[redacted]\", ",
"policy_version: \"[redacted]\", issued_at: \"[redacted]\", ",
"fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ",