Skip to main content

backbone_core/
policy.rs

1//! Domain policy — enforces pure business rules that govern whether CRUD
2//! operations are valid for a given entity state.
3//!
4//! `DomainPolicy<E>` is intentionally free of authentication context.
5//! It answers questions like "is this entity in a state that allows deletion?"
6//! or "is this transition valid?".  Identity-based rules live in
7//! `backbone-auth::resource_policy::ResourcePolicy<E>`.
8//!
9//! Policies are composable via `AllOfPolicy` / `AnyOfPolicy`.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use backbone_core::policy::{DomainPolicy, PolicyDecision};
15//!
16//! struct OrderCancelPolicy;
17//!
18//! #[async_trait::async_trait]
19//! impl DomainPolicy<Order> for OrderCancelPolicy {
20//!     async fn can_delete(&self, entity: &Order) -> PolicyDecision {
21//!         if entity.status == OrderStatus::Delivered {
22//!             Err("cannot cancel a delivered order".into())
23//!         } else {
24//!             Ok(true)
25//!         }
26//!     }
27//! }
28//! ```
29
30use async_trait::async_trait;
31use std::marker::PhantomData;
32use std::sync::Arc;
33
34/// The result of a policy evaluation.
35///
36/// - `Ok(true)`  — operation is **permitted**.
37/// - `Ok(false)` — operation is **denied** (soft deny, no message).
38/// - `Err(msg)`  — operation is **denied** with a human-readable reason.
39pub type PolicyDecision = Result<bool, String>;
40
41// ─── PolicyContext (kept for compatibility / optional use in custom policies) ─
42
43/// Contextual information available to custom policies that need it.
44///
45/// `DomainPolicy` methods do NOT take `PolicyContext` — this struct is
46/// available for custom policy implementations that need richer context
47/// beyond the entity state (e.g. tenant quota checks).
48#[derive(Debug, Clone, Default)]
49pub struct PolicyContext {
50    /// The authenticated user's ID, if any.
51    pub user_id: Option<String>,
52    /// Roles held by the current principal.
53    pub roles: Vec<String>,
54    /// Permissions held by the current principal.
55    pub permissions: Vec<String>,
56    /// Arbitrary key-value metadata (tenant id, feature flags, etc.).
57    pub metadata: std::collections::HashMap<String, String>,
58}
59
60impl PolicyContext {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
66        self.user_id = Some(user_id.into());
67        self
68    }
69
70    pub fn with_role(mut self, role: impl Into<String>) -> Self {
71        self.roles.push(role.into());
72        self
73    }
74
75    pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
76        self.permissions.push(perm.into());
77        self
78    }
79
80    pub fn has_role(&self, role: &str) -> bool {
81        self.roles.iter().any(|r| r == role)
82    }
83
84    pub fn has_permission(&self, perm: &str) -> bool {
85        self.permissions.iter().any(|p| p == perm)
86    }
87}
88
89// ─── PolicyOutcome (kept for compatibility) ───────────────────────────────────
90
91/// Legacy outcome type — retained for code that uses it directly.
92/// Prefer `PolicyDecision` (`Result<bool, String>`) in new code.
93#[derive(Debug, Clone, PartialEq)]
94pub enum PolicyOutcome {
95    /// The operation is allowed.
96    Permit,
97    /// The operation is denied.  The string is a human-readable reason.
98    Deny(String),
99}
100
101impl PolicyOutcome {
102    pub fn is_permit(&self) -> bool {
103        matches!(self, PolicyOutcome::Permit)
104    }
105
106    pub fn is_deny(&self) -> bool {
107        matches!(self, PolicyOutcome::Deny(_))
108    }
109
110    pub fn reason(&self) -> Option<&str> {
111        match self {
112            PolicyOutcome::Deny(reason) => Some(reason.as_str()),
113            PolicyOutcome::Permit => None,
114        }
115    }
116
117    pub fn into_decision(self) -> PolicyDecision {
118        match self {
119            PolicyOutcome::Permit => Ok(true),
120            PolicyOutcome::Deny(reason) => Err(reason),
121        }
122    }
123}
124
125// ─── DomainPolicy ─────────────────────────────────────────────────────────────
126
127/// Core domain policy trait — governs whether CRUD operations are valid
128/// for a given entity `E` based on **entity state alone** (no auth context).
129///
130/// All methods default to `Ok(true)` (permit).  Override only the methods
131/// relevant to the entity's domain invariants.
132#[async_trait]
133pub trait DomainPolicy<E: Send + Sync + 'static>: Send + Sync {
134    /// Can a new entity in the given state be persisted?
135    async fn can_create(&self, _entity: &E) -> PolicyDecision {
136        Ok(true)
137    }
138
139    /// Can the entity transition from `current` to `updated` state?
140    async fn can_update(&self, _current: &E, _updated: &E) -> PolicyDecision {
141        Ok(true)
142    }
143
144    /// Can the entity be soft-deleted?
145    async fn can_delete(&self, _entity: &E) -> PolicyDecision {
146        Ok(true)
147    }
148
149    /// Can a soft-deleted entity be restored?
150    async fn can_restore(&self, _entity: &E) -> PolicyDecision {
151        Ok(true)
152    }
153
154    /// Convenience: enforce `can_create` and map to `Ok(())` or `Err(reason)`.
155    async fn enforce_create(&self, entity: &E) -> Result<(), String> {
156        self.can_create(entity).await?.then_some(()).ok_or_else(|| "create denied".into())
157    }
158
159    /// Convenience: enforce `can_delete` and map to `Ok(())` or `Err(reason)`.
160    async fn enforce_delete(&self, entity: &E) -> Result<(), String> {
161        self.can_delete(entity).await?.then_some(()).ok_or_else(|| "delete denied".into())
162    }
163}
164
165// ─── Built-in implementations ───────────────────────────────────────────────
166
167/// Permits every operation — useful as the generated default.
168/// Replace with a real policy in your custom decorator.
169pub struct PermitAllPolicy<E> {
170    _phantom: PhantomData<E>,
171}
172
173impl<E> PermitAllPolicy<E> {
174    pub fn new() -> Self {
175        Self {
176            _phantom: PhantomData,
177        }
178    }
179}
180
181impl<E> Default for PermitAllPolicy<E> {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187#[async_trait]
188impl<E: Send + Sync + 'static> DomainPolicy<E> for PermitAllPolicy<E> {
189    // All methods use the `Ok(true)` defaults — no override needed.
190}
191
192/// Denies every operation — useful for protecting deprecated endpoints.
193pub struct DenyAllPolicy<E> {
194    reason: String,
195    _phantom: PhantomData<E>,
196}
197
198impl<E> DenyAllPolicy<E> {
199    pub fn new(reason: impl Into<String>) -> Self {
200        Self {
201            reason: reason.into(),
202            _phantom: PhantomData,
203        }
204    }
205}
206
207#[async_trait]
208impl<E: Send + Sync + 'static> DomainPolicy<E> for DenyAllPolicy<E> {
209    async fn can_create(&self, _entity: &E) -> PolicyDecision {
210        Err(self.reason.clone())
211    }
212    async fn can_update(&self, _current: &E, _updated: &E) -> PolicyDecision {
213        Err(self.reason.clone())
214    }
215    async fn can_delete(&self, _entity: &E) -> PolicyDecision {
216        Err(self.reason.clone())
217    }
218    async fn can_restore(&self, _entity: &E) -> PolicyDecision {
219        Err(self.reason.clone())
220    }
221}
222
223/// Combines multiple policies with AND semantics:
224/// all policies must permit an operation.
225pub struct AllOfPolicy<E> {
226    policies: Vec<Arc<dyn DomainPolicy<E>>>,
227}
228
229impl<E: Send + Sync + 'static> AllOfPolicy<E> {
230    pub fn new(policies: Vec<Arc<dyn DomainPolicy<E>>>) -> Self {
231        Self { policies }
232    }
233}
234
235#[async_trait]
236impl<E: Send + Sync + 'static> DomainPolicy<E> for AllOfPolicy<E> {
237    async fn can_create(&self, entity: &E) -> PolicyDecision {
238        for p in &self.policies {
239            match p.can_create(entity).await {
240                Ok(true) => {}
241                other => return other,
242            }
243        }
244        Ok(true)
245    }
246
247    async fn can_update(&self, current: &E, updated: &E) -> PolicyDecision {
248        for p in &self.policies {
249            match p.can_update(current, updated).await {
250                Ok(true) => {}
251                other => return other,
252            }
253        }
254        Ok(true)
255    }
256
257    async fn can_delete(&self, entity: &E) -> PolicyDecision {
258        for p in &self.policies {
259            match p.can_delete(entity).await {
260                Ok(true) => {}
261                other => return other,
262            }
263        }
264        Ok(true)
265    }
266
267    async fn can_restore(&self, entity: &E) -> PolicyDecision {
268        for p in &self.policies {
269            match p.can_restore(entity).await {
270                Ok(true) => {}
271                other => return other,
272            }
273        }
274        Ok(true)
275    }
276}
277
278/// Combines multiple policies with OR semantics:
279/// any policy permitting is sufficient.
280pub struct AnyOfPolicy<E> {
281    policies: Vec<Arc<dyn DomainPolicy<E>>>,
282    deny_reason: String,
283}
284
285impl<E: Send + Sync + 'static> AnyOfPolicy<E> {
286    pub fn new(policies: Vec<Arc<dyn DomainPolicy<E>>>, deny_reason: impl Into<String>) -> Self {
287        Self {
288            policies,
289            deny_reason: deny_reason.into(),
290        }
291    }
292}
293
294#[async_trait]
295impl<E: Send + Sync + 'static> DomainPolicy<E> for AnyOfPolicy<E> {
296    async fn can_create(&self, entity: &E) -> PolicyDecision {
297        for p in &self.policies {
298            if matches!(p.can_create(entity).await, Ok(true)) {
299                return Ok(true);
300            }
301        }
302        Err(self.deny_reason.clone())
303    }
304
305    async fn can_update(&self, current: &E, updated: &E) -> PolicyDecision {
306        for p in &self.policies {
307            if matches!(p.can_update(current, updated).await, Ok(true)) {
308                return Ok(true);
309            }
310        }
311        Err(self.deny_reason.clone())
312    }
313
314    async fn can_delete(&self, entity: &E) -> PolicyDecision {
315        for p in &self.policies {
316            if matches!(p.can_delete(entity).await, Ok(true)) {
317                return Ok(true);
318            }
319        }
320        Err(self.deny_reason.clone())
321    }
322
323    async fn can_restore(&self, entity: &E) -> PolicyDecision {
324        for p in &self.policies {
325            if matches!(p.can_restore(entity).await, Ok(true)) {
326                return Ok(true);
327            }
328        }
329        Err(self.deny_reason.clone())
330    }
331}
332
333/// Inverts a policy.
334pub struct NotPolicy<E> {
335    inner: Arc<dyn DomainPolicy<E>>,
336    deny_reason: String,
337}
338
339impl<E: Send + Sync + 'static> NotPolicy<E> {
340    pub fn new(inner: Arc<dyn DomainPolicy<E>>, deny_reason: impl Into<String>) -> Self {
341        Self {
342            inner,
343            deny_reason: deny_reason.into(),
344        }
345    }
346}
347
348#[async_trait]
349impl<E: Send + Sync + 'static> DomainPolicy<E> for NotPolicy<E> {
350    async fn can_create(&self, entity: &E) -> PolicyDecision {
351        match self.inner.can_create(entity).await {
352            Ok(true) => Err(self.deny_reason.clone()),
353            _ => Ok(true),
354        }
355    }
356
357    async fn can_delete(&self, entity: &E) -> PolicyDecision {
358        match self.inner.can_delete(entity).await {
359            Ok(true) => Err(self.deny_reason.clone()),
360            _ => Ok(true),
361        }
362    }
363
364    async fn can_update(&self, current: &E, updated: &E) -> PolicyDecision {
365        match self.inner.can_update(current, updated).await {
366            Ok(true) => Err(self.deny_reason.clone()),
367            _ => Ok(true),
368        }
369    }
370
371    async fn can_restore(&self, entity: &E) -> PolicyDecision {
372        match self.inner.can_restore(entity).await {
373            Ok(true) => Err(self.deny_reason.clone()),
374            _ => Ok(true),
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[derive(Debug)]
384    struct FakeEntity {
385        owner_id: String,
386        is_locked: bool,
387    }
388
389    struct LockedPolicy;
390
391    #[async_trait]
392    impl DomainPolicy<FakeEntity> for LockedPolicy {
393        async fn can_delete(&self, entity: &FakeEntity) -> PolicyDecision {
394            if entity.is_locked {
395                Err("entity is locked".into())
396            } else {
397                Ok(true)
398            }
399        }
400    }
401
402    #[tokio::test]
403    async fn permit_all_always_permits() {
404        let policy = PermitAllPolicy::<FakeEntity>::new();
405        let entity = FakeEntity {
406            owner_id: "u1".into(),
407            is_locked: false,
408        };
409        assert!(matches!(policy.can_create(&entity).await, Ok(true)));
410        assert!(matches!(policy.can_delete(&entity).await, Ok(true)));
411    }
412
413    #[tokio::test]
414    async fn deny_all_always_denies_with_reason() {
415        let policy = DenyAllPolicy::<FakeEntity>::new("deprecated");
416        let entity = FakeEntity {
417            owner_id: "u1".into(),
418            is_locked: false,
419        };
420        assert!(policy.can_create(&entity).await.is_err());
421        assert!(policy.can_delete(&entity).await.is_err());
422    }
423
424    #[tokio::test]
425    async fn locked_policy_denies_delete_for_locked_entity() {
426        let policy = LockedPolicy;
427        let locked = FakeEntity {
428            owner_id: "u1".into(),
429            is_locked: true,
430        };
431        let unlocked = FakeEntity {
432            owner_id: "u1".into(),
433            is_locked: false,
434        };
435        assert!(policy.can_delete(&locked).await.is_err());
436        assert!(matches!(policy.can_delete(&unlocked).await, Ok(true)));
437    }
438
439    #[tokio::test]
440    async fn all_of_denies_when_any_denies() {
441        let entity = FakeEntity {
442            owner_id: "u1".into(),
443            is_locked: true,
444        };
445        let policy: AllOfPolicy<FakeEntity> = AllOfPolicy::new(vec![
446            Arc::new(PermitAllPolicy::new()),
447            Arc::new(LockedPolicy),
448        ]);
449        assert!(policy.can_delete(&entity).await.is_err());
450    }
451
452    #[tokio::test]
453    async fn any_of_permits_when_one_permits() {
454        let entity = FakeEntity {
455            owner_id: "u1".into(),
456            is_locked: false,
457        };
458        let policy: AnyOfPolicy<FakeEntity> = AnyOfPolicy::new(
459            vec![Arc::new(LockedPolicy)],
460            "all policies denied",
461        );
462        assert!(matches!(policy.can_delete(&entity).await, Ok(true)));
463    }
464}