mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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]\", ",
|
||||
|
||||
Reference in New Issue
Block a user