backbone_bucket/auth/mod.rs
1//! Pluggable authentication and authorization surface.
2//!
3//! The bucket module owns *serving mechanics* (which bytes, how delivered)
4//! but deliberately does NOT own identity or domain authorization. Those
5//! are consumer concerns. This module exposes two trait slots the consumer
6//! fills:
7//!
8//! - [`AuthExtractor`] — an Axum [`FromRequestParts`] implementation that
9//! reads whichever token/session/cookie the consumer uses and yields a
10//! typed identity.
11//! - [`AuthzPolicy`] — decides whether a given identity may read a given
12//! file. The module ships [`DefaultOwnerOnlyPolicy`] as a sensible
13//! starting point; the consumer plugs in its own for richer rules.
14//!
15//! Both traits are kept minimal on purpose — the consumer shouldn't need
16//! to pull in the module's internals to implement them.
17
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use axum::extract::FromRequestParts;
22
23use crate::domain::entity::StoredFile;
24use crate::error::BucketError;
25
26/// Per-request identity extractor.
27///
28/// This is just a marker alias for `FromRequestParts` — any Axum extractor
29/// that yields a typed identity (`User`, `SessionToken`, etc.) satisfies
30/// it. Consumers choose the representation.
31///
32/// Rejections must map to [`BucketError::Unauthenticated`] via the
33/// [`AuthExtractor::Rejection`] conversion.
34pub trait AuthExtractor<S = ()>: FromRequestParts<S> + Send + Sync + 'static {}
35
36impl<T, S> AuthExtractor<S> for T where T: FromRequestParts<S> + Send + Sync + 'static {}
37
38/// Authorization decision for a single read.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AuthzDecision {
41 Allow,
42 Deny,
43}
44
45impl AuthzDecision {
46 pub fn is_allowed(self) -> bool {
47 matches!(self, AuthzDecision::Allow)
48 }
49}
50
51/// Domain authorization policy.
52///
53/// `Identity` is the type produced by the consumer's [`AuthExtractor`].
54/// The policy typically calls into the consumer's auth service — e.g.
55/// checking a share token, verifying workspace membership, or evaluating
56/// an RBAC grant. The bucket module treats the result as opaque.
57#[async_trait]
58pub trait AuthzPolicy<Identity>: Send + Sync + 'static
59where
60 Identity: Send + Sync + 'static,
61{
62 async fn decide(
63 &self,
64 identity: &Identity,
65 file: &StoredFile,
66 ) -> Result<AuthzDecision, BucketError>;
67
68 /// Convenience: return `Err(Forbidden)` when `decide` says `Deny`.
69 async fn ensure_can_read(
70 &self,
71 identity: &Identity,
72 file: &StoredFile,
73 ) -> Result<(), BucketError> {
74 match self.decide(identity, file).await? {
75 AuthzDecision::Allow => Ok(()),
76 AuthzDecision::Deny => Err(BucketError::Forbidden),
77 }
78 }
79}
80
81/// Default policy: the identity must equal the file's owner.
82///
83/// Requires the consumer's `Identity` type to expose an owner id reachable
84/// via the [`HasOwnerId`] trait. Consumers with richer rules (sharing,
85/// workspace membership, public files) should implement [`AuthzPolicy`]
86/// directly.
87pub struct DefaultOwnerOnlyPolicy;
88
89#[async_trait]
90impl<I> AuthzPolicy<I> for DefaultOwnerOnlyPolicy
91where
92 I: HasOwnerId + Send + Sync + 'static,
93{
94 async fn decide(
95 &self,
96 identity: &I,
97 file: &StoredFile,
98 ) -> Result<AuthzDecision, BucketError> {
99 if identity.owner_id() == file.owner_id {
100 Ok(AuthzDecision::Allow)
101 } else {
102 Ok(AuthzDecision::Deny)
103 }
104 }
105}
106
107/// Consumer identity types implement this to use [`DefaultOwnerOnlyPolicy`].
108pub trait HasOwnerId {
109 fn owner_id(&self) -> uuid::Uuid;
110}
111
112/// Type-erased policy holder used by the serving handler.
113pub type ArcAuthzPolicy<I> = Arc<dyn AuthzPolicy<I>>;