Skip to main content

backbone_auth/
resource_policy.rs

1//! Resource-level permission guards — bind auth context to entity-level access control.
2//!
3//! `ResourcePolicy<E>` is the auth-layer counterpart to `DomainPolicy<E>` in
4//! `backbone-core`.  Where `DomainPolicy` enforces business invariants (is the
5//! entity in a valid state for this operation?), `ResourcePolicy` enforces
6//! **identity-based** rules (does *this caller* have permission to touch *this record*?).
7//!
8//! # Typical wiring
9//!
10//! ```text
11//! HTTP handler
12//!   → AuthMiddleware extracts AuthContext
13//!   → PermissionGuard<E>::check(action, entity, auth_ctx)
14//!       → ResourcePolicy<E>::can(action, entity, auth_ctx)  ← module implements this
15//!   → If Err → 403 Forbidden
16//!   → Else   → service.execute()
17//! ```
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! pub struct OrderResourcePolicy;
23//!
24//! #[async_trait]
25//! impl ResourcePolicy<Order> for OrderResourcePolicy {
26//!     async fn can(&self, action: ResourceAction, entity: &Order, ctx: &AuthContext) -> bool {
27//!         match action {
28//!             ResourceAction::Read => ctx.user_id == entity.customer_id || ctx.has_role("admin"),
29//!             ResourceAction::Update | ResourceAction::Delete => ctx.user_id == entity.customer_id,
30//!             _ => ctx.has_role("admin"),
31//!         }
32//!     }
33//! }
34//! ```
35
36use async_trait::async_trait;
37use std::marker::PhantomData;
38use std::sync::Arc;
39
40use crate::middleware::AuthContext;
41
42// ─── Resource actions ─────────────────────────────────────────────────────────
43
44/// Standard CRUD operations that a resource policy can gate.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum ResourceAction {
47    /// Read / fetch a single entity.
48    Read,
49    /// List / query multiple entities.
50    List,
51    /// Create a new entity.
52    Create,
53    /// Fully update an entity.
54    Update,
55    /// Partially update an entity.
56    Patch,
57    /// Soft-delete an entity.
58    Delete,
59    /// Restore a soft-deleted entity.
60    Restore,
61    /// Permanently delete an entity.
62    HardDelete,
63    /// Custom action for domain-specific operations.
64    Custom(&'static str),
65}
66
67impl ResourceAction {
68    pub fn name(&self) -> &'static str {
69        match self {
70            ResourceAction::Read => "read",
71            ResourceAction::List => "list",
72            ResourceAction::Create => "create",
73            ResourceAction::Update => "update",
74            ResourceAction::Patch => "patch",
75            ResourceAction::Delete => "delete",
76            ResourceAction::Restore => "restore",
77            ResourceAction::HardDelete => "hard_delete",
78            ResourceAction::Custom(name) => name,
79        }
80    }
81}
82
83// ─── Policy trait ─────────────────────────────────────────────────────────────
84
85/// Determines whether the caller described by `auth_ctx` may perform `action`
86/// on `entity`.
87///
88/// Return `true` to permit, `false` to deny.  Use `PermissionGuard` to convert
89/// this into a `Result<(), AccessDenied>` suitable for HTTP handlers.
90///
91/// ## Static permission string methods
92///
93/// The `resource_type()` and `*_permission()` associated functions return the
94/// permission string identifiers used by RBAC systems.  They have a
95/// `where Self: Sized` bound so they can only be called in generic contexts
96/// (not through `dyn ResourcePolicy`), which is intentional — the strings are
97/// known at compile time and used by code generators and RBAC setup code.
98///
99/// ```rust,ignore
100/// // Generated usage:
101/// let required = OrderResourcePolicy::update_permission(); // "orders:update"
102/// rbac.require_permission(ctx, required)?;
103/// ```
104#[async_trait]
105pub trait ResourcePolicy<E: Send + Sync + 'static>: Send + Sync {
106    // ── Static permission strings ─────────────────────────────────────────
107
108    /// The resource type name used in permission strings.
109    ///
110    /// Default: `"resource"`. Override per entity, e.g. `"orders"`.
111    fn resource_type() -> &'static str
112    where
113        Self: Sized,
114    {
115        "resource"
116    }
117
118    /// Permission string required to create this resource.
119    fn create_permission() -> &'static str
120    where
121        Self: Sized,
122    {
123        "create"
124    }
125
126    /// Permission string required to read/fetch this resource.
127    fn read_permission() -> &'static str
128    where
129        Self: Sized,
130    {
131        "read"
132    }
133
134    /// Permission string required to list this resource.
135    fn list_permission() -> &'static str
136    where
137        Self: Sized,
138    {
139        "list"
140    }
141
142    /// Permission string required to fully update this resource.
143    fn update_permission() -> &'static str
144    where
145        Self: Sized,
146    {
147        "update"
148    }
149
150    /// Permission string required to partially patch this resource.
151    fn patch_permission() -> &'static str
152    where
153        Self: Sized,
154    {
155        "patch"
156    }
157
158    /// Permission string required to delete this resource.
159    fn delete_permission() -> &'static str
160    where
161        Self: Sized,
162    {
163        "delete"
164    }
165
166    /// Permission string required to restore a soft-deleted resource.
167    fn restore_permission() -> &'static str
168    where
169        Self: Sized,
170    {
171        "restore"
172    }
173
174    // ── Instance methods ──────────────────────────────────────────────────
175
176    async fn can(
177        &self,
178        action: ResourceAction,
179        entity: &E,
180        ctx: &AuthContext,
181    ) -> bool;
182
183    /// Optional: deny specific actions for all callers (e.g. hard-delete disabled).
184    fn explicitly_disabled_actions(&self) -> Vec<ResourceAction> {
185        vec![]
186    }
187}
188
189// ─── Access denied ────────────────────────────────────────────────────────────
190
191/// Returned when a `PermissionGuard` denies an operation.
192#[derive(Debug, thiserror::Error)]
193#[error("access denied: caller '{caller}' may not perform '{action}' on this resource")]
194pub struct AccessDenied {
195    pub caller: String,
196    pub action: String,
197}
198
199impl AccessDenied {
200    pub fn new(caller: impl Into<String>, action: &ResourceAction) -> Self {
201        Self {
202            caller: caller.into(),
203            action: action.name().into(),
204        }
205    }
206}
207
208impl Default for AccessDenied {
209    fn default() -> Self {
210        Self {
211            caller: "anonymous".into(),
212            action: "unknown".into(),
213        }
214    }
215}
216
217// ─── Permission guard ─────────────────────────────────────────────────────────
218
219/// Wraps a `ResourcePolicy<E>` and enforces it, returning typed errors.
220///
221/// Inject one `Arc<PermissionGuard<E>>` per handler family.
222pub struct PermissionGuard<E> {
223    policy: Arc<dyn ResourcePolicy<E>>,
224}
225
226impl<E: Send + Sync + 'static> PermissionGuard<E> {
227    pub fn new(policy: Arc<dyn ResourcePolicy<E>>) -> Self {
228        Self { policy }
229    }
230
231    /// Check whether `ctx` may perform `action` on `entity`.
232    ///
233    /// Returns `Ok(())` on permit, `Err(AccessDenied)` on deny.
234    pub async fn check(
235        &self,
236        action: ResourceAction,
237        entity: &E,
238        ctx: &AuthContext,
239    ) -> Result<(), AccessDenied> {
240        if self.policy.explicitly_disabled_actions().contains(&action) {
241            return Err(AccessDenied::new(&ctx.user_id, &action));
242        }
243
244        if self.policy.can(action, entity, ctx).await {
245            Ok(())
246        } else {
247            Err(AccessDenied::new(&ctx.user_id, &action))
248        }
249    }
250}
251
252// ─── AuthContextProvider ─────────────────────────────────────────────────────
253
254/// Extracts or provides the current caller's `AuthContext`.
255///
256/// Implement this in your HTTP middleware or request-scope container so that
257/// `ServicePermissionGuard` can retrieve the auth context without being coupled
258/// to Axum or any other framework.
259#[async_trait]
260pub trait AuthContextProvider: Send + Sync {
261    async fn current(&self) -> Option<AuthContext>;
262}
263
264// ─── ServicePermissionGuard ───────────────────────────────────────────────────
265
266/// Generic permission guard that wraps both a service and an auth context provider.
267///
268/// Generated modules emit a type alias:
269///
270/// ```rust,ignore
271/// // Generated (Phase 1):
272/// impl ResourcePolicy<Order> for OrderPolicy {
273///     fn resource_type() -> &'static str { "orders" }
274///     fn create_permission() -> &'static str { "orders:create" }
275///     // ...
276///     async fn can(&self, action, entity, ctx) -> bool { ... }
277/// }
278///
279/// pub type OrderGuard = ServicePermissionGuard<Order, OrderService, OrderPolicy>;
280/// ```
281///
282/// `E` — entity type
283/// `S` — underlying service
284/// `P` — resource policy implementation
285pub struct ServicePermissionGuard<E, S, P>
286where
287    E: Send + Sync + 'static,
288    P: ResourcePolicy<E>,
289{
290    service: Arc<S>,
291    policy: Arc<P>,
292    _phantom: std::marker::PhantomData<E>,
293}
294
295impl<E, S, P> ServicePermissionGuard<E, S, P>
296where
297    E: Send + Sync + 'static,
298    P: ResourcePolicy<E>,
299{
300    pub fn new(service: Arc<S>, policy: Arc<P>) -> Self {
301        Self {
302            service,
303            policy,
304            _phantom: std::marker::PhantomData,
305        }
306    }
307
308    /// Access the underlying service.
309    pub fn service(&self) -> &Arc<S> {
310        &self.service
311    }
312
313    /// Access the underlying policy.
314    pub fn policy(&self) -> &Arc<P> {
315        &self.policy
316    }
317
318    /// Check whether `ctx` may perform `action` on `entity`, returning
319    /// `Ok(())` on permit or `Err(AccessDenied)` on deny.
320    pub async fn check(
321        &self,
322        action: ResourceAction,
323        entity: &E,
324        ctx: &AuthContext,
325    ) -> Result<(), AccessDenied> {
326        if self.policy.explicitly_disabled_actions().contains(&action) {
327            return Err(AccessDenied::new(&ctx.user_id, &action));
328        }
329        if self.policy.can(action, entity, ctx).await {
330            Ok(())
331        } else {
332            Err(AccessDenied::new(&ctx.user_id, &action))
333        }
334    }
335}
336
337// ─── Built-in policies ───────────────────────────────────────────────────────
338
339/// Permits every action for every caller.
340/// Use as the generated default — replace in custom decorators.
341pub struct PermitAllResourcePolicy<E> {
342    _phantom: PhantomData<E>,
343}
344
345impl<E> PermitAllResourcePolicy<E> {
346    pub fn new() -> Self {
347        Self {
348            _phantom: PhantomData,
349        }
350    }
351}
352
353impl<E> Default for PermitAllResourcePolicy<E> {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359#[async_trait]
360impl<E: Send + Sync + 'static> ResourcePolicy<E> for PermitAllResourcePolicy<E> {
361    async fn can(&self, _action: ResourceAction, _entity: &E, _ctx: &AuthContext) -> bool {
362        true
363    }
364}
365
366/// Denies every action for every caller.
367/// Use for deprecated or not-yet-exposed resources.
368pub struct DenyAllResourcePolicy<E> {
369    _phantom: PhantomData<E>,
370}
371
372impl<E> DenyAllResourcePolicy<E> {
373    pub fn new() -> Self {
374        Self {
375            _phantom: PhantomData,
376        }
377    }
378}
379
380#[async_trait]
381impl<E: Send + Sync + 'static> ResourcePolicy<E> for DenyAllResourcePolicy<E> {
382    async fn can(&self, _action: ResourceAction, _entity: &E, _ctx: &AuthContext) -> bool {
383        false
384    }
385}
386
387/// Requires the caller to have one of the listed roles to perform any action.
388pub struct RoleRequiredPolicy<E> {
389    required_roles: Vec<String>,
390    _phantom: PhantomData<E>,
391}
392
393impl<E> RoleRequiredPolicy<E> {
394    pub fn new(required_roles: Vec<impl Into<String>>) -> Self {
395        Self {
396            required_roles: required_roles.into_iter().map(|r| r.into()).collect(),
397            _phantom: PhantomData,
398        }
399    }
400}
401
402#[async_trait]
403impl<E: Send + Sync + 'static> ResourcePolicy<E> for RoleRequiredPolicy<E> {
404    async fn can(&self, _action: ResourceAction, _entity: &E, ctx: &AuthContext) -> bool {
405        self.required_roles
406            .iter()
407            .any(|role| ctx.roles.contains(role))
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[derive(Debug)]
416    struct Document {
417        owner_id: String,
418    }
419
420    struct OwnerPolicy;
421
422    #[async_trait]
423    impl ResourcePolicy<Document> for OwnerPolicy {
424        async fn can(
425            &self,
426            _action: ResourceAction,
427            entity: &Document,
428            ctx: &AuthContext,
429        ) -> bool {
430            ctx.user_id == entity.owner_id
431        }
432    }
433
434    fn auth_ctx(user_id: &str) -> AuthContext {
435        AuthContext::new(user_id.to_string())
436    }
437
438    #[tokio::test]
439    async fn owner_permitted_stranger_denied() {
440        let guard = PermissionGuard::new(Arc::new(OwnerPolicy));
441        let doc = Document {
442            owner_id: "alice".into(),
443        };
444
445        assert!(guard
446            .check(ResourceAction::Update, &doc, &auth_ctx("alice"))
447            .await
448            .is_ok());
449        assert!(guard
450            .check(ResourceAction::Update, &doc, &auth_ctx("bob"))
451            .await
452            .is_err());
453    }
454
455    #[tokio::test]
456    async fn permit_all_always_ok() {
457        let guard: PermissionGuard<Document> =
458            PermissionGuard::new(Arc::new(PermitAllResourcePolicy::new()));
459        let doc = Document {
460            owner_id: "x".into(),
461        };
462        assert!(guard
463            .check(ResourceAction::Delete, &doc, &auth_ctx("anyone"))
464            .await
465            .is_ok());
466    }
467}