Skip to main content

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>>;