diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 970e2bac2..8a5733eaa 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -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 { + 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, + binding_id: Option, + binding_version: Option, 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 { + 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 { + 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, diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 733ff520a..e0deba136 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -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]\", ",