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}