Skip to main content

deny_override/
deny_override.rs

1//! Deny-overrides-allow: suspensions and legal holds that win over every grant.
2//!
3//! Almost every real authorization system eventually needs a rule that
4//! overrides all the others: a suspended account is locked out regardless of
5//! role, a document under legal hold is frozen even for its owner and for
6//! admins. In gatehouse this is exactly what [`Effect::Forbid`] does: a policy
7//! built with `.forbid()` **forbids** the request when its
8//! predicate matches, and [`PermissionChecker`] honors a forbid over any
9//! grant from sibling policies — deny-overrides semantics, in the style of
10//! Cedar and AWS IAM.
11//!
12//! The shape is flat: block rules are ordinary policies registered with
13//! `add_policy`, in natural polarity (predicate matches ⇒ forbidden), in any
14//! order. The decision rule is fixed:
15//!
16//! 1. any matching `Effect::Forbid` policy ⇒ **denied** (the trace names it);
17//! 2. otherwise any granting policy ⇒ **granted**;
18//! 3. otherwise ⇒ **denied** (default deny).
19//!
20//! Active forbids propagate through combinators and delegation. See the
21//! scoped-exclusion section at the bottom for the combinator shape that covers
22//! "this exclusion should only gate one grant path" without creating a global
23//! veto.
24//!
25//! Run with:
26//!
27//! ```text
28//! cargo run --example deny_override
29//! ```
30
31use gatehouse::{
32    AndPolicy, EvaluationSession, NotPolicy, PermissionChecker, Policy, PolicyBuilder, PolicyDomain,
33};
34use std::sync::Arc;
35use uuid::Uuid;
36
37// ---- domain --------------------------------------------------------
38
39#[derive(Debug, Clone)]
40struct User {
41    id: Uuid,
42    is_admin: bool,
43    /// Account-level block. When set, no grant should let the user through.
44    suspended: bool,
45}
46
47#[derive(Debug, Clone)]
48struct Document {
49    owner_id: Uuid,
50    /// Resource-level block. A document under legal hold is frozen even for
51    /// its owner and for admins.
52    legal_hold: bool,
53}
54
55#[derive(Debug, Clone)]
56struct Access;
57
58// No request-scoped facts here, so the context is the unit type. (`()` is the
59// idiomatic "no context" — reserve a context struct for data that genuinely
60// varies per request, as in the mfa_freshness_context example.)
61struct DocumentDomain;
62
63impl PolicyDomain for DocumentDomain {
64    type Subject = User;
65    type Action = Access;
66    type Resource = Document;
67    type Context = ();
68}
69
70type DocPolicy = Box<dyn Policy<DocumentDomain>>;
71
72// ---- the allow set -------------------------------------------------
73
74fn admin_override() -> DocPolicy {
75    PolicyBuilder::<DocumentDomain>::new("AdminOverride")
76        .subjects(|user| user.is_admin)
77        .build()
78}
79
80fn document_owner() -> DocPolicy {
81    PolicyBuilder::<DocumentDomain>::new("DocumentOwner")
82        .when(|user, _action, document, _ctx| user.id == document.owner_id)
83        .build()
84}
85
86// ---- the block rules -----------------------------------------------
87
88/// Account suspension: predicate matches ⇒ the request is forbidden. The
89/// effect travels with the policy, so this reads exactly as it behaves —
90/// no inverted polarity, no special registration call.
91fn account_suspended() -> DocPolicy {
92    PolicyBuilder::<DocumentDomain>::new("AccountSuspended")
93        .subjects(|user| user.suspended)
94        .forbid()
95        .build()
96}
97
98/// Legal hold: a frozen document is blocked for everyone, owner and admin
99/// included.
100fn legal_hold() -> DocPolicy {
101    PolicyBuilder::<DocumentDomain>::new("LegalHold")
102        .resources(|document| document.legal_hold)
103        .forbid()
104        .build()
105}
106
107fn document_checker() -> PermissionChecker<DocumentDomain> {
108    let mut checker = PermissionChecker::named("DocumentChecker");
109    // Flat registration, any order: the deny policies' effect is declared
110    // on the policies themselves, and the checker evaluates forbid-effect
111    // policies first so a veto can never be raced by a grant.
112    checker.add_policy(admin_override());
113    checker.add_policy(document_owner());
114    checker.add_policy(account_suspended());
115    checker.add_policy(legal_hold());
116    checker
117}
118
119// ---- driver --------------------------------------------------------
120
121#[tokio::main]
122async fn main() {
123    let owner_id = Uuid::new_v4();
124    let admin = User {
125        id: Uuid::new_v4(),
126        is_admin: true,
127        suspended: false,
128    };
129    let suspended_owner = User {
130        id: owner_id,
131        is_admin: false,
132        suspended: true,
133    };
134    let owner = User {
135        id: owner_id,
136        is_admin: false,
137        suspended: false,
138    };
139    let stranger = User {
140        id: Uuid::new_v4(),
141        is_admin: false,
142        suspended: false,
143    };
144
145    let normal_doc = Document {
146        owner_id,
147        legal_hold: false,
148    };
149    let held_doc = Document {
150        owner_id,
151        legal_hold: true,
152    };
153
154    let checker = document_checker();
155    let session = EvaluationSession::empty();
156    let action = Access;
157    let context = ();
158
159    // (subject, resource, label)
160    let cases = [
161        (&admin, &normal_doc, "admin, normal doc"),
162        (&owner, &normal_doc, "owner, own normal doc"),
163        (&admin, &held_doc, "admin, LEGAL-HOLD doc"),
164        (&suspended_owner, &normal_doc, "SUSPENDED owner, own doc"),
165        (&stranger, &normal_doc, "stranger, someone else's doc"),
166    ];
167
168    println!("{:<32} {:>10} forbidden by", "case", "decision");
169    println!("{}", "-".repeat(60));
170    for (subject, document, label) in cases {
171        let decision = checker
172            .bind(&session, subject, &action, &context)
173            .check(document)
174            .await;
175        println!(
176            "{label:<32} {:>10} {}",
177            verdict(decision.is_granted()),
178            decision.forbidden_by().unwrap_or("-"),
179        );
180    }
181
182    // The grants still work where nothing blocks them.
183    checker
184        .bind(&session, &admin, &action, &context)
185        .check(&normal_doc)
186        .await
187        .assert_granted_by("AdminOverride");
188    checker
189        .bind(&session, &owner, &action, &context)
190        .check(&normal_doc)
191        .await
192        .assert_granted_by("DocumentOwner");
193
194    // The block rules override every grant — even the admin override.
195    checker
196        .bind(&session, &admin, &action, &context)
197        .check(&held_doc)
198        .await
199        .assert_forbidden_by("LegalHold");
200    checker
201        .bind(&session, &suspended_owner, &action, &context)
202        .check(&normal_doc)
203        .await
204        .assert_forbidden_by("AccountSuspended");
205
206    // Default deny is untouched: no grant, no access — and no forbid
207    // either, which `forbidden_by()` distinguishes for the caller.
208    let stranger_decision = checker
209        .bind(&session, &stranger, &action, &context)
210        .check(&normal_doc)
211        .await;
212    stranger_decision.assert_denied();
213    assert_eq!(stranger_decision.forbidden_by(), None);
214
215    // Show the mechanism on the headline case: the forbid-effect policy is
216    // evaluated first and ends the evaluation; the allow set is never
217    // consulted.
218    println!("\nWhy 'admin, LEGAL-HOLD doc' is blocked:");
219    let decision = checker
220        .bind(&session, &admin, &action, &context)
221        .check(&held_doc)
222        .await;
223    println!("{}", decision.display_trace());
224
225    scoped_exclusion_demo().await;
226}
227
228// ---- scoped exclusion: a deny that gates only one grant path --------
229
230/// `Effect::Forbid` is a *global* veto: it blocks every grant path in the
231/// checker. When a block rule should only gate one grant path — here,
232/// muted users lose collaborator access but owners and admins keep
233/// theirs — scope it with combinators instead:
234/// `AndPolicy[ grant_arm, NotPolicy(block) ]`. The block policy in this local
235/// shape should be an ordinary grant-style predicate, not `.forbid()`;
236/// `Forbidden` is active and would still veto globally.
237async fn scoped_exclusion_demo() {
238    #[derive(Debug, Clone)]
239    struct Member {
240        is_owner: bool,
241        is_collaborator: bool,
242        muted: bool,
243    }
244    #[derive(Debug, Clone)]
245    struct Thread;
246
247    struct ThreadDomain;
248
249    impl PolicyDomain for ThreadDomain {
250        type Subject = Member;
251        type Action = Access;
252        type Resource = Thread;
253        type Context = ();
254    }
255
256    let owner_policy = PolicyBuilder::<ThreadDomain>::new("ThreadOwner")
257        .subjects(|member| member.is_owner)
258        .build();
259    let collaborator_policy: Arc<dyn Policy<ThreadDomain>> = Arc::from(
260        PolicyBuilder::<ThreadDomain>::new("Collaborator")
261            .subjects(|member| member.is_collaborator)
262            .build(),
263    );
264    // The block rule for the scoped case *grants when it matches* so that
265    // `NotPolicy` can invert it into a local gate. Compare with the
266    // checker-level rules above, where `Effect::Forbid` keeps natural
267    // polarity — this inversion is the price of scoping, which is why a
268    // global block should prefer `Effect::Forbid`.
269    let muted = PolicyBuilder::<ThreadDomain>::new("Muted")
270        .subjects(|member| member.muted)
271        .build();
272
273    let collaborator_unless_muted = AndPolicy::try_new(vec![
274        collaborator_policy,
275        Arc::new(NotPolicy::new(muted)) as Arc<dyn Policy<ThreadDomain>>,
276    ])
277    .expect("gate has the grant arm and the guard");
278
279    let mut checker = PermissionChecker::<ThreadDomain>::named("ThreadChecker");
280    checker.add_policy(owner_policy);
281    checker.add_policy(collaborator_unless_muted);
282
283    let muted_collaborator = Member {
284        is_owner: false,
285        is_collaborator: true,
286        muted: true,
287    };
288    let muted_owner = Member {
289        is_owner: true,
290        is_collaborator: false,
291        muted: true,
292    };
293
294    let session = EvaluationSession::empty();
295    let action = Access;
296    let context = ();
297
298    // The mute only gates the collaborator path...
299    checker
300        .bind(&session, &muted_collaborator, &action, &context)
301        .check(&Thread)
302        .await
303        .assert_denied();
304    // ...the owner path is untouched, which a global Effect::Forbid mute
305    // could not express.
306    checker
307        .bind(&session, &muted_owner, &action, &context)
308        .check(&Thread)
309        .await
310        .assert_granted_by("ThreadOwner");
311
312    println!("\nScoped exclusion: muted collaborator blocked, muted owner unaffected.");
313}
314
315fn verdict(granted: bool) -> &'static str {
316    if granted {
317        "GRANTED"
318    } else {
319        "DENIED"
320    }
321}