Skip to main content

AccessEvaluation

Enum AccessEvaluation 

Source
#[non_exhaustive]
pub enum AccessEvaluation { Granted { policy_type: Cow<'static, str>, reason: Option<String>, trace: EvalTrace, }, Denied { trace: EvalTrace, reason: String, }, }
Expand description

The complete result of a permission evaluation. Contains both the final decision and a detailed trace for debugging.

§Evaluation Tracing

The permission system provides detailed tracing of policy decisions:

let result = example().await;

match result {
    AccessEvaluation::Granted { policy_type, reason, trace } => {
        println!("Access granted by {}: {:?}", policy_type, reason);
        println!("Full evaluation trace:\n{}", trace.format());
    }
    AccessEvaluation::Denied { reason, trace } => {
        println!("Access denied: {}", reason);
        println!("Full evaluation trace:\n{}", trace.format());
    }
    _ => {
        println!("Access denied: unknown decision variant");
    }
}

Variants (Non-exhaustive)§

This enum is marked as non-exhaustive
Non-exhaustive enums could have additional variants added in future. Therefore, when matching against variants of non-exhaustive enums, an extra wildcard arm must be added to account for any future variants.
§

Granted

Access was granted.

Fields

§policy_type: Cow<'static, str>

The policy that granted access. Cow<'static, str> for the same reason as on PolicyEvalResult: static names pass through with zero allocation.

§reason: Option<String>

Optional reason for granting

§trace: EvalTrace

Full evaluation trace including any rejected policies

§

Denied

Access was denied.

Fields

§trace: EvalTrace

The complete evaluation trace showing all policy decisions

§reason: String

Summary reason for denial

Implementations§

Source§

impl AccessEvaluation

Source

pub fn is_granted(&self) -> bool

Whether access was granted

Examples found in repository?
examples/mfa_freshness_context.rs (line 250)
249fn verdict(eval: &AccessEvaluation) -> &'static str {
250    if eval.is_granted() {
251        "GRANTED"
252    } else {
253        "DENIED"
254    }
255}
More examples
Hide additional examples
examples/axum.rs (line 401)
385pub async fn view_invoice_handler(
386    Path(invoice_id): Path<Uuid>,
387    State(state): State<AppState>,
388    AuthenticatedUser(user): AuthenticatedUser,
389    overrides: InvoiceOverrides,
390) -> impl IntoResponse {
391    // Simulate a DB fetch.
392    let invoice = overrides.build_invoice(invoice_id);
393    let session = state.request_session();
394    let context = RequestContext::now();
395
396    if state
397        .checker
398        .bind(&session, &user, &Action::View, &context)
399        .check(&invoice)
400        .await
401        .is_granted()
402    {
403        (StatusCode::OK, format!("{invoice:?}")).into_response()
404    } else {
405        (
406            StatusCode::FORBIDDEN,
407            "You are not authorized to view this invoice",
408        )
409            .into_response()
410    }
411}
412
413pub async fn list_invoices_handler(
414    State(state): State<AppState>,
415    AuthenticatedUser(user): AuthenticatedUser,
416) -> impl IntoResponse {
417    let session = state.request_session();
418    let candidates = state.invoices.as_ref().clone();
419    let context = RequestContext::now();
420
421    // The session is request-scoped: app state owns the source, this request
422    // registers it, and the batch authorization call uses it for every invoice
423    // — relationship loads are batched and deduplicated.
424    let visible = state
425        .checker
426        .bind(&session, &user, &Action::View, &context)
427        .filter(candidates)
428        .await
429        .into_iter()
430        .map(InvoiceSummary::from)
431        .collect::<Vec<_>>();
432
433    Json(visible).into_response()
434}
435
436pub async fn edit_invoice_handler(
437    Path(invoice_id): Path<Uuid>,
438    State(state): State<AppState>,
439    AuthenticatedUser(user): AuthenticatedUser,
440    overrides: InvoiceOverrides,
441) -> impl IntoResponse {
442    let invoice = overrides.build_invoice(invoice_id);
443    let session = state.request_session();
444    let context = RequestContext::now();
445
446    if state
447        .checker
448        .bind(&session, &user, &Action::Edit, &context)
449        .check(&invoice)
450        .await
451        .is_granted()
452    {
453        (StatusCode::OK, "Invoice edited successfully").into_response()
454    } else {
455        (
456            StatusCode::FORBIDDEN,
457            "You are not authorized to edit this invoice",
458        )
459            .into_response()
460    }
461}
examples/delegating_policy.rs (line 185)
143async fn main() {
144    let owner_id = Uuid::new_v4();
145    let author_id = Uuid::new_v4();
146
147    let comment = Comment {
148        author_id,
149        document: Document { owner_id },
150    };
151
152    let author = Principal {
153        user_id: author_id,
154        is_admin: false,
155    };
156    let document_owner = Principal {
157        user_id: owner_id,
158        is_admin: false,
159    };
160    let admin = Principal {
161        user_id: Uuid::new_v4(),
162        is_admin: true,
163    };
164    let stranger = Principal {
165        user_id: Uuid::new_v4(),
166        is_admin: false,
167    };
168
169    let checker = comment_checker();
170    let session = EvaluationSession::empty();
171    let action = EditComment;
172    let context = ();
173
174    let cases = [
175        ("author", &author),
176        ("document owner (not author)", &document_owner),
177        ("admin (not author/owner)", &admin),
178        ("unrelated user", &stranger),
179    ];
180    for (who, principal) in cases {
181        let granted = checker
182            .bind(&session, principal, &action, &context)
183            .check(&comment)
184            .await
185            .is_granted();
186        println!(
187            "{who:<28} can edit the comment? {}",
188            if granted { "yes" } else { "no" }
189        );
190    }
191
192    // The document owner is not the comment author, so the direct rule denies;
193    // the delegating policy then asks the document checker, which grants. The
194    // trace shows the decision crossing the domain boundary.
195    println!("\nTrace — document owner (not the author) editing the comment:");
196    let decision = checker
197        .bind(&session, &document_owner, &action, &context)
198        .check(&comment)
199        .await;
200    println!("{}", decision.display_trace());
201
202    assert!(checker
203        .bind(&session, &author, &action, &context)
204        .check(&comment)
205        .await
206        .is_granted());
207    assert!(checker
208        .bind(&session, &document_owner, &action, &context)
209        .check(&comment)
210        .await
211        .is_granted());
212    assert!(checker
213        .bind(&session, &admin, &action, &context)
214        .check(&comment)
215        .await
216        .is_granted());
217    assert!(!checker
218        .bind(&session, &stranger, &action, &context)
219        .check(&comment)
220        .await
221        .is_granted());
222}
examples/rbac_policy.rs (line 115)
67async fn main() {
68    // The first resolver reads the requirement off the resource/action; the
69    // second extracts the subject's roles. The role type (`Role`) is inferred
70    // from the closures' return types.
71    let rbac_policy = RbacPolicy::<DocumentDomain, _, _>::new(
72        |_action: &ReadAction, doc: &Document| doc.required_roles.iter().copied().collect(),
73        |user: &User| user.roles.iter().copied().collect(),
74    );
75
76    let mut checker = PermissionChecker::<DocumentDomain>::new();
77    checker.add_policy(rbac_policy);
78
79    let admin = user("admin", [Role::Admin]);
80    let editor = user("editor", [Role::Editor]);
81    let multi_role = user("editor+viewer", [Role::Editor, Role::Viewer]);
82    let no_roles = user("no-roles", []);
83
84    let admin_doc = document("admin handbook", [Role::Admin]);
85    let editor_doc = document("style guide", [Role::Editor]);
86    let shared_doc = document("team wiki", [Role::Editor, Role::Viewer]);
87
88    // (user, document, expected outcome)
89    let cases = [
90        (&admin, &admin_doc, true),
91        (&admin, &editor_doc, false), // admin role is not editor — no hierarchy here
92        (&editor, &admin_doc, false),
93        (&editor, &editor_doc, true),
94        (&multi_role, &editor_doc, true),
95        (&multi_role, &shared_doc, true),
96        (&no_roles, &admin_doc, false),
97        (&no_roles, &shared_doc, false),
98    ];
99
100    let session = EvaluationSession::empty();
101    let action = ReadAction;
102    let context = ();
103
104    println!("{:<16} {:<16} verdict", "user", "document");
105    println!("{}", "-".repeat(42));
106    for (user, document, expected_granted) in cases {
107        let decision = checker
108            .bind(&session, user, &action, &context)
109            .check(document)
110            .await;
111        println!(
112            "{:<16} {:<16} {}",
113            user.name,
114            document.name,
115            if decision.is_granted() {
116                "GRANTED"
117            } else {
118                "DENIED"
119            }
120        );
121        assert_eq!(decision.is_granted(), expected_granted);
122    }
123
124    // Note the admin/style-guide denial above: `RbacPolicy` is a flat
125    // role-match with no built-in hierarchy. If admins should read
126    // everything, either include `Role::Admin` in each document's required
127    // set or add a separate admin-override policy to the checker.
128
129    println!("\nWhy the editor is denied the admin handbook:");
130    let decision = checker
131        .bind(&session, &editor, &action, &context)
132        .check(&admin_doc)
133        .await;
134    println!("{}", decision.display_trace());
135}
examples/policy_builder.rs (line 140)
85async fn main() {
86    let mut checker = PermissionChecker::<AdminDomain>::new();
87    checker.add_policy(scoped_permission_policy());
88    checker.add_policy(global_admin_policy());
89
90    let org1 = Organization { id: "org-1".into() };
91    let org2 = Organization { id: "org-2".into() };
92
93    let org1_admin = StaffUser {
94        name: "org1-admin",
95        permissions: vec![GroupPermission {
96            scope: "edit_user_settings",
97            entity: "org-1".into(),
98        }],
99    };
100    let org2_admin = StaffUser {
101        name: "org2-admin",
102        permissions: vec![GroupPermission {
103            scope: "edit_user_settings",
104            entity: "org-2".into(),
105        }],
106    };
107    let no_grants = StaffUser {
108        name: "no-grants",
109        permissions: vec![],
110    };
111    let global_admin = StaffUser {
112        name: "global-admin",
113        permissions: vec![GroupPermission {
114            scope: "global_admin",
115            entity: String::new(),
116        }],
117    };
118
119    // (user, action, organization, expected outcome)
120    let cases = [
121        // Scoped grant matches its own org…
122        (&org1_admin, AdminAction::EditUserSettings, &org1, true),
123        // …but not another org, and not another scope on the same org.
124        (&org2_admin, AdminAction::EditUserSettings, &org1, false),
125        (&org1_admin, AdminAction::EditOrgSettings, &org1, false),
126        (&org2_admin, AdminAction::EditUserSettings, &org2, true),
127        (&no_grants, AdminAction::EditUserSettings, &org1, false),
128        // The global admin passes via the subject-axis policy on any org.
129        (&global_admin, AdminAction::EditOrgSettings, &org1, true),
130    ];
131
132    for (user, action, org, expected_granted) in cases {
133        let session = EvaluationSession::empty();
134        let decision = checker.bind(&session, user, &action, &()).check(org).await;
135        println!(
136            "{:<12} {:?} on {}: {}",
137            user.name,
138            action,
139            org.id,
140            if decision.is_granted() {
141                "GRANTED"
142            } else {
143                "DENIED"
144            }
145        );
146        assert_eq!(decision.is_granted(), expected_granted);
147    }
148
149    // The trace names the policy that decided; for a denial it shows every
150    // policy that was consulted and why each said no.
151    println!("\nWhy org2-admin cannot edit user settings on org-1:");
152    let session = EvaluationSession::empty();
153    let decision = checker
154        .bind(&session, &org2_admin, &AdminAction::EditUserSettings, &())
155        .check(&org1)
156        .await;
157    println!("{}", decision.display_trace());
158}
examples/in_ram_rebac.rs (line 152)
97async fn main() {
98    let user = User { id: Uuid::new_v4() };
99    let documents = vec![
100        Document {
101            id: Uuid::new_v4(),
102            title: "roadmap",
103        },
104        Document {
105            id: Uuid::new_v4(),
106            title: "incident report",
107        },
108        Document {
109            id: Uuid::new_v4(),
110            title: "finance plan",
111        },
112    ];
113
114    let store = Arc::new(InRamRelationships::default());
115    store.grant(user.id, documents[0].id, Relation::Viewer);
116    store.grant(user.id, documents[1].id, Relation::Viewer);
117    // The store can hold more relation types than any one policy consumes. This
118    // editor grant is never matched below (the checker only asks about Viewer),
119    // and is here to show the source and the policy stack are decoupled.
120    store.grant(user.id, documents[1].id, Relation::Editor);
121    let relationships: Arc<dyn FactSource<RelationshipKey>> = store;
122    let registry = FactRegistry::builder()
123        .with_arc::<RelationshipKey>(Arc::clone(&relationships))
124        .build();
125
126    let checker = build_checker();
127    let context = ();
128
129    let first_request = request_session(&registry);
130    let visible = checker
131        .bind(&first_request, &user, &View, &context)
132        .filter(documents.clone())
133        .await;
134    println!(
135        "batch list — visible documents: {:?}",
136        visible
137            .iter()
138            .map(|document| document.title)
139            .collect::<Vec<_>>()
140    );
141
142    // A fresh session for a single-resource check. The user has no viewer
143    // relationship on the finance plan, so this denies.
144    let second_request = request_session(&registry);
145    let can_view_finance = checker
146        .bind(&second_request, &user, &View, &context)
147        .check(&documents[2])
148        .await;
149    println!(
150        "single check — can view '{}'? {}",
151        documents[2].title,
152        if can_view_finance.is_granted() {
153            "yes"
154        } else {
155            "no"
156        }
157    );
158    assert!(!can_view_finance.is_granted());
159
160    let shared = registry.clone();
161    let concurrent_requests = (0..4)
162        .map(|_| {
163            let checker = checker.clone();
164            let user = user.clone();
165            let documents = documents.clone();
166            let registry = shared.clone();
167            tokio::spawn(async move {
168                let session = request_session(&registry);
169                let context = ();
170                checker
171                    .bind(&session, &user, &View, &context)
172                    .filter(documents)
173                    .await
174                    .len()
175            })
176        })
177        .collect::<Vec<_>>();
178
179    println!("\n4 concurrent requests sharing one FactSource (each builds its own session):");
180    for (index, request) in concurrent_requests.into_iter().enumerate() {
181        let visible_count = request.await.unwrap();
182        println!("  request {index}: {visible_count} visible document(s)");
183        assert_eq!(visible_count, 2);
184    }
185}
Source

pub fn trace(&self) -> &EvalTrace

Returns the evaluation trace regardless of outcome.

Both variants carry an EvalTrace; this accessor saves callers the match when they only need the trace — typically to render it with EvalTrace::format for logs or debugging output.

Examples found in repository?
examples/mfa_freshness_context.rs (line 246)
245fn report(label: &str, eval: &AccessEvaluation) {
246    println!("{label} → {}\n{}", verdict(eval), eval.trace().format());
247}
More examples
Hide additional examples
examples/combinator_policy.rs (line 121)
77async fn main() {
78    let user = User::new();
79    let document = Document::new();
80    let action = ViewAction;
81    let context = ();
82    // These policies have no fact sources, so each evaluation binds
83    // `EvaluationSession::empty()`. The checker contributes its own `OR` root
84    // to the trace; the combinator's short-circuit behaviour (what this example
85    // measures) happens inside it regardless.
86
87    println!("=== AND Policy Short-Circuit Example ===");
88    {
89        let counter = Arc::new(AtomicUsize::new(0));
90
91        // Create an AND policy with a non-grant policy first
92        let and_policy = CountingPolicy {
93            allow: false,
94            name: "DenyFirst".to_string(),
95            counter: counter.clone(),
96        }
97        .and(CountingPolicy {
98            allow: true,
99            name: "AllowSecond".to_string(),
100            counter: counter.clone(),
101        });
102
103        let mut checker = PermissionChecker::<DocumentDomain>::new();
104        checker.add_policy(and_policy);
105
106        println!("Evaluating AND(DenyFirst, AllowSecond):");
107        let session = EvaluationSession::empty();
108        let result = checker
109            .bind(&session, &user, &action, &context)
110            .check(&document)
111            .await;
112        println!(
113            "Result: {}",
114            if result.is_granted() {
115                "Access granted"
116            } else {
117                "Access denied"
118            }
119        );
120        println!("Policies evaluated: {}", counter.load(Ordering::SeqCst));
121        println!("Trace:\n{}", result.trace().format());
122
123        // The second policy should not be evaluated due to short-circuiting
124        assert_eq!(counter.load(Ordering::SeqCst), 1);
125    }
126
127    println!("\n=== OR Policy Short-Circuit Example ===");
128    {
129        let counter = Arc::new(AtomicUsize::new(0));
130
131        // Create an OR policy with an allow policy first
132        let or_policy = CountingPolicy {
133            allow: true,
134            name: "AllowFirst".to_string(),
135            counter: counter.clone(),
136        }
137        .or(CountingPolicy {
138            allow: false,
139            name: "DenySecond".to_string(),
140            counter: counter.clone(),
141        });
142
143        let mut checker = PermissionChecker::<DocumentDomain>::new();
144        checker.add_policy(or_policy);
145
146        println!("Evaluating OR(AllowFirst, DenySecond):");
147        let session = EvaluationSession::empty();
148        let result = checker
149            .bind(&session, &user, &action, &context)
150            .check(&document)
151            .await;
152        println!(
153            "Result: {}",
154            if result.is_granted() {
155                "Access granted"
156            } else {
157                "Access denied"
158            }
159        );
160        println!("Policies evaluated: {}", counter.load(Ordering::SeqCst));
161        println!("Trace:\n{}", result.trace().format());
162
163        // The second policy should not be evaluated due to short-circuiting
164        assert_eq!(counter.load(Ordering::SeqCst), 1);
165    }
166
167    println!("\n=== Complex Nested Policy Example ===");
168    {
169        let counter = Arc::new(AtomicUsize::new(0));
170
171        // Create a complex nested policy: OR(AND(Deny, Allow), Allow)
172        let inner_and = CountingPolicy {
173            allow: false,
174            name: "DenyInner".to_string(),
175            counter: counter.clone(),
176        }
177        .and(CountingPolicy {
178            allow: true,
179            name: "AllowInner".to_string(),
180            counter: counter.clone(),
181        });
182
183        let complex_policy = inner_and.or(CountingPolicy {
184            allow: true,
185            name: "AllowOuter".to_string(),
186            counter: counter.clone(),
187        });
188
189        let mut checker = PermissionChecker::<DocumentDomain>::new();
190        checker.add_policy(complex_policy);
191
192        println!("Evaluating OR(AND(DenyInner, AllowInner), AllowOuter):");
193        let session = EvaluationSession::empty();
194        let result = checker
195            .bind(&session, &user, &action, &context)
196            .check(&document)
197            .await;
198        println!(
199            "Result: {} for document with ID {} for user with ID {}",
200            if result.is_granted() {
201                "Access granted"
202            } else {
203                "Access denied"
204            },
205            document.id,
206            user.id
207        );
208        println!("Policies evaluated: {}", counter.load(Ordering::SeqCst));
209        println!("Trace:\n{}", result.trace().format());
210
211        // The inner AND should evaluate only DenyInner (shorts-circuit),
212        // then the OR continues to AllowOuter which grants access
213        assert_eq!(counter.load(Ordering::SeqCst), 2);
214    }
215}
Source

pub fn granted_policy_type(&self) -> Option<&str>

Returns the granting policy’s name when the evaluation was a grant.

Useful for non-panicking inspection in tests and in production code that branches on which policy made the decision.

Source

pub fn denied_reason(&self) -> Option<&str>

Returns the summary denial reason when the evaluation was a denial.

Mirrors Self::granted_policy_type for the denied case.

Source

pub fn forbidden_by(&self) -> Option<&str>

Returns the name of the policy whose forbid caused this denial, if the denial was a deny-overrides veto rather than a plain “no policy granted” outcome.

Useful for distinguishing “actively blocked” (suspension, legal hold) from “no grant matched” — for example to map the former to a distinct HTTP status or audit event. Returns None for grants and for ordinary denials.

Examples found in repository?
examples/deny_override.rs (line 178)
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}
Source

pub fn assert_granted_by(&self, expected: &str)

Test helper: panic unless the evaluation is Granted and the granting policy’s name matches expected.

Intended for policy unit tests that would otherwise hand-roll a pattern match over the evaluation. Prefer this over destructuring when the test’s only assertion is “policy X granted access.”

evaluation.assert_granted_by("AllowAll");
Examples found in repository?
examples/mfa_freshness_context.rs (line 199)
170async fn main() {
171    let alice = User {
172        id: Uuid::new_v4(),
173        roles: vec!["finance".into()],
174    };
175    let small_refund = RefundRequest {
176        id: Uuid::new_v4(),
177        amount_cents: 5_000, // $50
178    };
179    let large_refund = RefundRequest {
180        id: Uuid::new_v4(),
181        amount_cents: 5_000_000, // $50,000
182    };
183
184    let now = SystemTime::now();
185    let checker = build_checker();
186    let session = EvaluationSession::empty();
187
188    // Case 1: small refund, no MFA at all. Granted — the high-value
189    // rule doesn't apply below the threshold, so the role grant decides.
190    let small_no_mfa = ApprovalContext {
191        current_time: now,
192        mfa_verified_at: None,
193    };
194    let r = checker
195        .bind(&session, &alice, &Approve, &small_no_mfa)
196        .check(&small_refund)
197        .await;
198    report("small refund, no MFA", &r);
199    r.assert_granted_by("FinanceCanApproveRefunds");
200
201    // Case 2: large refund, no MFA. Forbidden by the freshness rule —
202    // the veto overrides Alice's role grant.
203    let r = checker
204        .bind(&session, &alice, &Approve, &small_no_mfa)
205        .check(&large_refund)
206        .await;
207    report("large refund, no MFA", &r);
208    r.assert_forbidden_by("HighValueRequiresFreshMfa");
209
210    // Case 3: large refund, MFA reasserted 8 minutes ago. Stale → forbidden.
211    let stale = ApprovalContext {
212        current_time: now,
213        mfa_verified_at: Some(now - Duration::from_secs(8 * 60)),
214    };
215    let r = checker
216        .bind(&session, &alice, &Approve, &stale)
217        .check(&large_refund)
218        .await;
219    report("large refund, MFA 8m old", &r);
220    r.assert_forbidden_by("HighValueRequiresFreshMfa");
221
222    // Case 4: large refund, MFA reasserted 30 seconds ago. The deny rule
223    // is not applicable, so the role grant decides.
224    let fresh = ApprovalContext {
225        current_time: now,
226        mfa_verified_at: Some(now - Duration::from_secs(30)),
227    };
228    let r = checker
229        .bind(&session, &alice, &Approve, &fresh)
230        .check(&large_refund)
231        .await;
232    report("large refund, MFA 30s old", &r);
233    r.assert_granted_by("FinanceCanApproveRefunds");
234
235    // The point: cases 2-4 all use the same subject and resource. The
236    // only thing that varies is `ApprovalContext`. That's exactly the
237    // signal that the rule belongs in `Context`, not on User or
238    // RefundRequest.
239}
More examples
Hide additional examples
examples/deny_override.rs (line 187)
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}
examples/lookup_in_ram.rs (line 309)
191async fn main() {
192    // Build a small population.
193    let alice = User {
194        id: Uuid::new_v4(),
195        is_admin: false,
196    };
197    let admin = User {
198        id: Uuid::new_v4(),
199        is_admin: true,
200    };
201    let docs: Vec<Document> = (0..7)
202        .map(|i| Document {
203            id: Uuid::new_v4(),
204            title: format!("doc-{i}"),
205        })
206        .collect();
207
208    // Alice is a viewer of docs[1], docs[3], docs[5].
209    let viewer_doc_ids: Vec<Uuid> = [&docs[1], &docs[3], &docs[5]]
210        .into_iter()
211        .map(|d| d.id)
212        .collect();
213
214    let viewers: HashMap<Uuid, Vec<Uuid>> = viewer_doc_ids
215        .iter()
216        .map(|doc_id| (*doc_id, vec![alice.id]))
217        .collect();
218
219    let viewer_lookup_index: HashMap<Uuid, Vec<Uuid>> =
220        HashMap::from([(alice.id, viewer_doc_ids.clone())]);
221
222    // Document catalog used by the hydrator. In production this is a
223    // database call: `SELECT * FROM docs WHERE id = ANY($1)`.
224    let catalog: Arc<HashMap<Uuid, Document>> =
225        Arc::new(docs.iter().map(|d| (d.id, d.clone())).collect());
226
227    let lookup = InMemoryViewerLookup {
228        per_user: viewer_lookup_index,
229    };
230
231    // Hydrator closure: maps a slice of ids to `Vec<Option<Document>>`.
232    // `None` would represent an id deleted between enumeration and the
233    // catalog fetch; the in-memory catalog here always resolves.
234    let hydrator = {
235        let catalog = Arc::clone(&catalog);
236        move |ids: &[Uuid]| {
237            let catalog = Arc::clone(&catalog);
238            let ids = ids.to_vec();
239            async move {
240                Ok::<_, std::convert::Infallible>(
241                    ids.iter().map(|id| catalog.get(id).cloned()).collect(),
242                )
243            }
244        }
245    };
246
247    // Compose policies: admin override OR viewer relation. The lookup
248    // source only enumerates the viewer axis — admin overrides apply only
249    // to point checks.
250    let mut checker = PermissionChecker::<DocumentDomain>::new();
251    checker.add_policy(AdminPolicy);
252    checker.add_policy(ViewerPolicy { viewers });
253
254    let session = EvaluationSession::empty();
255    let page_size = NonZeroUsize::new(2).unwrap();
256
257    // (1) Alice lists her visible documents by collecting lookup pages.
258    let alice_visible =
259        collect_authorized(&checker, &session, &alice, &lookup, page_size, &hydrator)
260            .await
261            .expect("lookup ok");
262    println!("Alice sees {} document(s):", alice_visible.len());
263    for doc in &alice_visible {
264        println!("  - {} ({})", doc.title, doc.id);
265    }
266    let alice_visible_ids: Vec<Uuid> = alice_visible.iter().map(|doc| doc.id).collect();
267    assert_eq!(
268        alice_visible_ids, viewer_doc_ids,
269        "the lookup + policy stack should authorize exactly the viewer-granted documents, in source order"
270    );
271
272    // (2) Admin lists "their visible documents" via the same lookup.
273    // The viewer lookup does not enumerate documents for the admin (no
274    // viewer relation), so this listing returns empty — correctly,
275    // because lookup is bounded by what it enumerates. To enumerate
276    // "everything an admin can see", the production code would either
277    // route admin requests to a different source or simply skip the
278    // lookup path and list directly.
279    let admin_via_lookup =
280        collect_authorized(&checker, &session, &admin, &lookup, page_size, &hydrator)
281            .await
282            .expect("lookup ok");
283    println!(
284        "\nAdmin via the viewer-lookup sees {} document(s) — this is bounded \
285         by what the source enumerates; admin grants still apply at point checks.",
286        admin_via_lookup.len()
287    );
288    assert!(
289        admin_via_lookup.is_empty(),
290        "the viewer lookup enumerates nothing for the admin, so the listing is empty"
291    );
292
293    // (3) Point check confirms the admin policy is alive: pick a document
294    // the admin has no viewer relation on.
295    let any_doc = &docs[0];
296    let admin_point = checker
297        .bind(&session, &admin, &View, &())
298        .check(any_doc)
299        .await;
300    println!(
301        "\nAdmin point check on '{}': {}",
302        any_doc.title,
303        if admin_point.is_granted() {
304            "Granted"
305        } else {
306            "Denied"
307        }
308    );
309    admin_point.assert_granted_by("AdminPolicy");
310
311    // (4) Page-oriented streaming. Drive the lookup one candidate page at
312    // a time — useful when you want to flush results to a response writer
313    // as they are confirmed.
314    println!("\nStreaming Alice's visible documents page-by-page:");
315    let mut cursor: Option<Vec<u8>> = None;
316    let mut page_index = 0;
317    let mut streamed_total = 0;
318    let alice_bound = checker.bind(&session, &alice, &View, &());
319    loop {
320        let page = alice_bound
321            .lookup_page(&lookup, &hydrator, cursor.as_deref(), page_size)
322            .await
323            .expect("lookup_page ok");
324        println!("  page {page_index}: {} authorized", page.resources.len());
325        page_index += 1;
326        streamed_total += page.resources.len();
327        match page.next_cursor {
328            None => break,
329            Some(next) => cursor = Some(next),
330        }
331    }
332    // 3 candidate ids paged 2-at-a-time: two candidate pages, same total as
333    // the helper loop above.
334    assert_eq!(page_index, 2);
335    assert_eq!(streamed_total, viewer_doc_ids.len());
336}
Source

pub fn assert_denied(&self)

Test helper: panic unless the evaluation is Denied.

Use Self::assert_denied_with_reason_containing when you also need to assert on the denial reason.

Examples found in repository?
examples/deny_override.rs (line 212)
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}
More examples
Hide additional examples
examples/rebac_policy.rs (line 224)
114async fn main() {
115    println!("=== ReBAC Policy Example ===\n");
116
117    let owner = User {
118        id: Uuid::new_v4(),
119        name: "Alice",
120    };
121    let contributor = User {
122        id: Uuid::new_v4(),
123        name: "Bob",
124    };
125    let viewer = User {
126        id: Uuid::new_v4(),
127        name: "Charlie",
128    };
129    let outsider = User {
130        id: Uuid::new_v4(),
131        name: "Dave",
132    };
133    let project = Project {
134        id: Uuid::new_v4(),
135        name: "Sample Project",
136    };
137
138    let relationships = HashSet::from([
139        ProjectRelationship {
140            subject_id: owner.id,
141            resource_id: project.id,
142            relation: Relation::Owner,
143        },
144        ProjectRelationship {
145            subject_id: contributor.id,
146            resource_id: project.id,
147            relation: Relation::Contributor,
148        },
149        ProjectRelationship {
150            subject_id: viewer.id,
151            resource_id: project.id,
152            relation: Relation::Viewer,
153        },
154    ]);
155
156    let registry = FactRegistry::builder()
157        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships.clone()))
158        .build();
159    let session = registry.session();
160
161    // Editing requires an owner OR contributor relationship; a viewer
162    // relationship exists in the store but grants nothing here.
163    let mut checker = PermissionChecker::<ProjectDomain>::new();
164    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
165        |user: &User| user.id,
166        |project: &Project| project.id,
167        Relation::Owner,
168    ));
169    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
170        |user: &User| user.id,
171        |project: &Project| project.id,
172        Relation::Contributor,
173    ));
174
175    // (user, relationship held, expected outcome)
176    let cases = [
177        (&owner, "owner", true),
178        (&contributor, "contributor", true),
179        (&viewer, "viewer", false),
180        (&outsider, "none", false),
181    ];
182    for (user, held, expected_granted) in cases {
183        println!("Can {} ({held}) edit {}?", user.name, project.name);
184        let decision = checker
185            .bind(&session, user, &EditAction, &())
186            .check(&project)
187            .await;
188        println!(
189            "  -> {}\n",
190            if decision.is_granted() {
191                "GRANTED"
192            } else {
193                "DENIED"
194            }
195        );
196        assert_eq!(decision.is_granted(), expected_granted);
197    }
198
199    // The trace records the facts each policy consulted (the `↳ fact` lines)
200    // alongside its decision — here the viewer's denial shows both
201    // relationship lookups coming back false. Note that no new "loading fact"
202    // lines appear: this re-check runs in the same session, so the facts come
203    // from the session cache.
204    println!("Why {} is denied:", viewer.name);
205    let decision = checker
206        .bind(&session, &viewer, &EditAction, &())
207        .check(&project)
208        .await;
209    println!("{}\n", decision.display_trace());
210
211    println!("=== Error During Relationship Loading ===\n");
212
213    // A failing store must never grant: the load error is carried into the
214    // trace and the decision fails closed to denial — even for the owner.
215    let error_registry = FactRegistry::builder()
216        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships).with_error())
217        .build();
218    let error_session = error_registry.session();
219    let decision = checker
220        .bind(&error_session, &owner, &EditAction, &())
221        .check(&project)
222        .await;
223    println!("{}", decision.display_trace());
224    decision.assert_denied();
225    decision.assert_trace_contains("simulated relationship store error");
226}
Source

pub fn assert_denied_with_reason_containing(&self, needle: &str)

Test helper: panic unless the evaluation is Denied and the top-level summary denial reason contains needle.

needle is matched against the single string on AccessEvaluation::Denied — a summary like "All policies denied access", not the per-policy reasons inside the trace tree. For a multi-policy checker, asserting on a specific policy’s reason needs Self::assert_trace_contains or Self::assert_not_applicable_by.

Substring match keeps tests resilient to minor reason-string rewording. For exact-match assertions, inspect Self::denied_reason directly.

Source

pub fn assert_not_applicable_by(&self, expected: &str)

Test helper: panic unless the evaluation is Denied and some NotApplicable leaf in the trace tree was produced by a policy whose name matches expected.

Symmetric with Self::assert_granted_by but walks the trace rather than checking the top-level decision, because an ordinary final denial has no single denying policy: every policy in the checker declined to grant. Use this to assert that policy expected actually fired and was not applicable. Use Self::assert_forbidden_by for active vetoes.

evaluation.assert_not_applicable_by("StaffOnly");
Source

pub fn assert_forbidden_by(&self, expected: &str)

Test helper: panic unless the evaluation is Denied because of a forbid by the policy named expected.

Stronger than Self::assert_not_applicable_by: this asserts the denial was a deny-overrides veto attributed to expected (via Self::forbidden_by), not merely that expected appears as a not-applicable leaf somewhere in the trace.

evaluation.assert_forbidden_by("GlobalFreeze");
Examples found in repository?
examples/mfa_freshness_context.rs (line 208)
170async fn main() {
171    let alice = User {
172        id: Uuid::new_v4(),
173        roles: vec!["finance".into()],
174    };
175    let small_refund = RefundRequest {
176        id: Uuid::new_v4(),
177        amount_cents: 5_000, // $50
178    };
179    let large_refund = RefundRequest {
180        id: Uuid::new_v4(),
181        amount_cents: 5_000_000, // $50,000
182    };
183
184    let now = SystemTime::now();
185    let checker = build_checker();
186    let session = EvaluationSession::empty();
187
188    // Case 1: small refund, no MFA at all. Granted — the high-value
189    // rule doesn't apply below the threshold, so the role grant decides.
190    let small_no_mfa = ApprovalContext {
191        current_time: now,
192        mfa_verified_at: None,
193    };
194    let r = checker
195        .bind(&session, &alice, &Approve, &small_no_mfa)
196        .check(&small_refund)
197        .await;
198    report("small refund, no MFA", &r);
199    r.assert_granted_by("FinanceCanApproveRefunds");
200
201    // Case 2: large refund, no MFA. Forbidden by the freshness rule —
202    // the veto overrides Alice's role grant.
203    let r = checker
204        .bind(&session, &alice, &Approve, &small_no_mfa)
205        .check(&large_refund)
206        .await;
207    report("large refund, no MFA", &r);
208    r.assert_forbidden_by("HighValueRequiresFreshMfa");
209
210    // Case 3: large refund, MFA reasserted 8 minutes ago. Stale → forbidden.
211    let stale = ApprovalContext {
212        current_time: now,
213        mfa_verified_at: Some(now - Duration::from_secs(8 * 60)),
214    };
215    let r = checker
216        .bind(&session, &alice, &Approve, &stale)
217        .check(&large_refund)
218        .await;
219    report("large refund, MFA 8m old", &r);
220    r.assert_forbidden_by("HighValueRequiresFreshMfa");
221
222    // Case 4: large refund, MFA reasserted 30 seconds ago. The deny rule
223    // is not applicable, so the role grant decides.
224    let fresh = ApprovalContext {
225        current_time: now,
226        mfa_verified_at: Some(now - Duration::from_secs(30)),
227    };
228    let r = checker
229        .bind(&session, &alice, &Approve, &fresh)
230        .check(&large_refund)
231        .await;
232    report("large refund, MFA 30s old", &r);
233    r.assert_granted_by("FinanceCanApproveRefunds");
234
235    // The point: cases 2-4 all use the same subject and resource. The
236    // only thing that varies is `ApprovalContext`. That's exactly the
237    // signal that the rule belongs in `Context`, not on User or
238    // RefundRequest.
239}
More examples
Hide additional examples
examples/deny_override.rs (line 199)
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}
Source

pub fn assert_trace_contains(&self, needle: &str)

Test helper: panic unless needle appears anywhere in the formatted evaluation trace.

Substring match against the string produced by Self::display_trace, which includes every per-policy reason (granted and denied) the checker actually evaluated. Use this when the assertion is “some policy in the trace produced this specific reason” — the per-policy reasons live in the trace, not on the top-level summary that Self::assert_denied_with_reason_containing inspects.

Examples found in repository?
examples/rebac_policy.rs (line 225)
114async fn main() {
115    println!("=== ReBAC Policy Example ===\n");
116
117    let owner = User {
118        id: Uuid::new_v4(),
119        name: "Alice",
120    };
121    let contributor = User {
122        id: Uuid::new_v4(),
123        name: "Bob",
124    };
125    let viewer = User {
126        id: Uuid::new_v4(),
127        name: "Charlie",
128    };
129    let outsider = User {
130        id: Uuid::new_v4(),
131        name: "Dave",
132    };
133    let project = Project {
134        id: Uuid::new_v4(),
135        name: "Sample Project",
136    };
137
138    let relationships = HashSet::from([
139        ProjectRelationship {
140            subject_id: owner.id,
141            resource_id: project.id,
142            relation: Relation::Owner,
143        },
144        ProjectRelationship {
145            subject_id: contributor.id,
146            resource_id: project.id,
147            relation: Relation::Contributor,
148        },
149        ProjectRelationship {
150            subject_id: viewer.id,
151            resource_id: project.id,
152            relation: Relation::Viewer,
153        },
154    ]);
155
156    let registry = FactRegistry::builder()
157        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships.clone()))
158        .build();
159    let session = registry.session();
160
161    // Editing requires an owner OR contributor relationship; a viewer
162    // relationship exists in the store but grants nothing here.
163    let mut checker = PermissionChecker::<ProjectDomain>::new();
164    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
165        |user: &User| user.id,
166        |project: &Project| project.id,
167        Relation::Owner,
168    ));
169    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
170        |user: &User| user.id,
171        |project: &Project| project.id,
172        Relation::Contributor,
173    ));
174
175    // (user, relationship held, expected outcome)
176    let cases = [
177        (&owner, "owner", true),
178        (&contributor, "contributor", true),
179        (&viewer, "viewer", false),
180        (&outsider, "none", false),
181    ];
182    for (user, held, expected_granted) in cases {
183        println!("Can {} ({held}) edit {}?", user.name, project.name);
184        let decision = checker
185            .bind(&session, user, &EditAction, &())
186            .check(&project)
187            .await;
188        println!(
189            "  -> {}\n",
190            if decision.is_granted() {
191                "GRANTED"
192            } else {
193                "DENIED"
194            }
195        );
196        assert_eq!(decision.is_granted(), expected_granted);
197    }
198
199    // The trace records the facts each policy consulted (the `↳ fact` lines)
200    // alongside its decision — here the viewer's denial shows both
201    // relationship lookups coming back false. Note that no new "loading fact"
202    // lines appear: this re-check runs in the same session, so the facts come
203    // from the session cache.
204    println!("Why {} is denied:", viewer.name);
205    let decision = checker
206        .bind(&session, &viewer, &EditAction, &())
207        .check(&project)
208        .await;
209    println!("{}\n", decision.display_trace());
210
211    println!("=== Error During Relationship Loading ===\n");
212
213    // A failing store must never grant: the load error is carried into the
214    // trace and the decision fails closed to denial — even for the owner.
215    let error_registry = FactRegistry::builder()
216        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships).with_error())
217        .build();
218    let error_session = error_registry.session();
219    let decision = checker
220        .bind(&error_session, &owner, &EditAction, &())
221        .check(&project)
222        .await;
223    println!("{}", decision.display_trace());
224    decision.assert_denied();
225    decision.assert_trace_contains("simulated relationship store error");
226}
Source

pub fn to_result<E>(&self, error_fn: impl FnOnce(&str) -> E) -> Result<(), E>

Converts the evaluation into a Result, mapping a denial into an error.

error_fn receives the denial reason string and should return your application’s error type.

Note that this uses the summary denial reason stored on AccessEvaluation::Denied, not the individual child policy reasons from the trace tree. If you need the per-policy reasons, inspect EvalTrace first.

let checker = PermissionChecker::<Domain>::new();
let session = EvaluationSession::empty();
let result = checker.bind(&session, &User, &Action, &Ctx).check(&Resource).await;

// Map a denial into a standard error:
let outcome: Result<(), String> = result.to_result(|reason| reason.to_string());
assert!(outcome.is_err());
Source

pub fn display_trace(&self) -> String

Returns a human-readable string containing both the decision headline and the full evaluation trace tree.

Useful for logging or debugging. The output includes the Display representation (e.g. [GRANTED] by AdminPolicy - User is admin) followed by the indented trace from EvalTrace::format.

Examples found in repository?
examples/delegating_policy.rs (line 200)
143async fn main() {
144    let owner_id = Uuid::new_v4();
145    let author_id = Uuid::new_v4();
146
147    let comment = Comment {
148        author_id,
149        document: Document { owner_id },
150    };
151
152    let author = Principal {
153        user_id: author_id,
154        is_admin: false,
155    };
156    let document_owner = Principal {
157        user_id: owner_id,
158        is_admin: false,
159    };
160    let admin = Principal {
161        user_id: Uuid::new_v4(),
162        is_admin: true,
163    };
164    let stranger = Principal {
165        user_id: Uuid::new_v4(),
166        is_admin: false,
167    };
168
169    let checker = comment_checker();
170    let session = EvaluationSession::empty();
171    let action = EditComment;
172    let context = ();
173
174    let cases = [
175        ("author", &author),
176        ("document owner (not author)", &document_owner),
177        ("admin (not author/owner)", &admin),
178        ("unrelated user", &stranger),
179    ];
180    for (who, principal) in cases {
181        let granted = checker
182            .bind(&session, principal, &action, &context)
183            .check(&comment)
184            .await
185            .is_granted();
186        println!(
187            "{who:<28} can edit the comment? {}",
188            if granted { "yes" } else { "no" }
189        );
190    }
191
192    // The document owner is not the comment author, so the direct rule denies;
193    // the delegating policy then asks the document checker, which grants. The
194    // trace shows the decision crossing the domain boundary.
195    println!("\nTrace — document owner (not the author) editing the comment:");
196    let decision = checker
197        .bind(&session, &document_owner, &action, &context)
198        .check(&comment)
199        .await;
200    println!("{}", decision.display_trace());
201
202    assert!(checker
203        .bind(&session, &author, &action, &context)
204        .check(&comment)
205        .await
206        .is_granted());
207    assert!(checker
208        .bind(&session, &document_owner, &action, &context)
209        .check(&comment)
210        .await
211        .is_granted());
212    assert!(checker
213        .bind(&session, &admin, &action, &context)
214        .check(&comment)
215        .await
216        .is_granted());
217    assert!(!checker
218        .bind(&session, &stranger, &action, &context)
219        .check(&comment)
220        .await
221        .is_granted());
222}
More examples
Hide additional examples
examples/rbac_policy.rs (line 134)
67async fn main() {
68    // The first resolver reads the requirement off the resource/action; the
69    // second extracts the subject's roles. The role type (`Role`) is inferred
70    // from the closures' return types.
71    let rbac_policy = RbacPolicy::<DocumentDomain, _, _>::new(
72        |_action: &ReadAction, doc: &Document| doc.required_roles.iter().copied().collect(),
73        |user: &User| user.roles.iter().copied().collect(),
74    );
75
76    let mut checker = PermissionChecker::<DocumentDomain>::new();
77    checker.add_policy(rbac_policy);
78
79    let admin = user("admin", [Role::Admin]);
80    let editor = user("editor", [Role::Editor]);
81    let multi_role = user("editor+viewer", [Role::Editor, Role::Viewer]);
82    let no_roles = user("no-roles", []);
83
84    let admin_doc = document("admin handbook", [Role::Admin]);
85    let editor_doc = document("style guide", [Role::Editor]);
86    let shared_doc = document("team wiki", [Role::Editor, Role::Viewer]);
87
88    // (user, document, expected outcome)
89    let cases = [
90        (&admin, &admin_doc, true),
91        (&admin, &editor_doc, false), // admin role is not editor — no hierarchy here
92        (&editor, &admin_doc, false),
93        (&editor, &editor_doc, true),
94        (&multi_role, &editor_doc, true),
95        (&multi_role, &shared_doc, true),
96        (&no_roles, &admin_doc, false),
97        (&no_roles, &shared_doc, false),
98    ];
99
100    let session = EvaluationSession::empty();
101    let action = ReadAction;
102    let context = ();
103
104    println!("{:<16} {:<16} verdict", "user", "document");
105    println!("{}", "-".repeat(42));
106    for (user, document, expected_granted) in cases {
107        let decision = checker
108            .bind(&session, user, &action, &context)
109            .check(document)
110            .await;
111        println!(
112            "{:<16} {:<16} {}",
113            user.name,
114            document.name,
115            if decision.is_granted() {
116                "GRANTED"
117            } else {
118                "DENIED"
119            }
120        );
121        assert_eq!(decision.is_granted(), expected_granted);
122    }
123
124    // Note the admin/style-guide denial above: `RbacPolicy` is a flat
125    // role-match with no built-in hierarchy. If admins should read
126    // everything, either include `Role::Admin` in each document's required
127    // set or add a separate admin-override policy to the checker.
128
129    println!("\nWhy the editor is denied the admin handbook:");
130    let decision = checker
131        .bind(&session, &editor, &action, &context)
132        .check(&admin_doc)
133        .await;
134    println!("{}", decision.display_trace());
135}
examples/policy_builder.rs (line 157)
85async fn main() {
86    let mut checker = PermissionChecker::<AdminDomain>::new();
87    checker.add_policy(scoped_permission_policy());
88    checker.add_policy(global_admin_policy());
89
90    let org1 = Organization { id: "org-1".into() };
91    let org2 = Organization { id: "org-2".into() };
92
93    let org1_admin = StaffUser {
94        name: "org1-admin",
95        permissions: vec![GroupPermission {
96            scope: "edit_user_settings",
97            entity: "org-1".into(),
98        }],
99    };
100    let org2_admin = StaffUser {
101        name: "org2-admin",
102        permissions: vec![GroupPermission {
103            scope: "edit_user_settings",
104            entity: "org-2".into(),
105        }],
106    };
107    let no_grants = StaffUser {
108        name: "no-grants",
109        permissions: vec![],
110    };
111    let global_admin = StaffUser {
112        name: "global-admin",
113        permissions: vec![GroupPermission {
114            scope: "global_admin",
115            entity: String::new(),
116        }],
117    };
118
119    // (user, action, organization, expected outcome)
120    let cases = [
121        // Scoped grant matches its own org…
122        (&org1_admin, AdminAction::EditUserSettings, &org1, true),
123        // …but not another org, and not another scope on the same org.
124        (&org2_admin, AdminAction::EditUserSettings, &org1, false),
125        (&org1_admin, AdminAction::EditOrgSettings, &org1, false),
126        (&org2_admin, AdminAction::EditUserSettings, &org2, true),
127        (&no_grants, AdminAction::EditUserSettings, &org1, false),
128        // The global admin passes via the subject-axis policy on any org.
129        (&global_admin, AdminAction::EditOrgSettings, &org1, true),
130    ];
131
132    for (user, action, org, expected_granted) in cases {
133        let session = EvaluationSession::empty();
134        let decision = checker.bind(&session, user, &action, &()).check(org).await;
135        println!(
136            "{:<12} {:?} on {}: {}",
137            user.name,
138            action,
139            org.id,
140            if decision.is_granted() {
141                "GRANTED"
142            } else {
143                "DENIED"
144            }
145        );
146        assert_eq!(decision.is_granted(), expected_granted);
147    }
148
149    // The trace names the policy that decided; for a denial it shows every
150    // policy that was consulted and why each said no.
151    println!("\nWhy org2-admin cannot edit user settings on org-1:");
152    let session = EvaluationSession::empty();
153    let decision = checker
154        .bind(&session, &org2_admin, &AdminAction::EditUserSettings, &())
155        .check(&org1)
156        .await;
157    println!("{}", decision.display_trace());
158}
examples/deny_override.rs (line 223)
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}
examples/rebac_policy.rs (line 209)
114async fn main() {
115    println!("=== ReBAC Policy Example ===\n");
116
117    let owner = User {
118        id: Uuid::new_v4(),
119        name: "Alice",
120    };
121    let contributor = User {
122        id: Uuid::new_v4(),
123        name: "Bob",
124    };
125    let viewer = User {
126        id: Uuid::new_v4(),
127        name: "Charlie",
128    };
129    let outsider = User {
130        id: Uuid::new_v4(),
131        name: "Dave",
132    };
133    let project = Project {
134        id: Uuid::new_v4(),
135        name: "Sample Project",
136    };
137
138    let relationships = HashSet::from([
139        ProjectRelationship {
140            subject_id: owner.id,
141            resource_id: project.id,
142            relation: Relation::Owner,
143        },
144        ProjectRelationship {
145            subject_id: contributor.id,
146            resource_id: project.id,
147            relation: Relation::Contributor,
148        },
149        ProjectRelationship {
150            subject_id: viewer.id,
151            resource_id: project.id,
152            relation: Relation::Viewer,
153        },
154    ]);
155
156    let registry = FactRegistry::builder()
157        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships.clone()))
158        .build();
159    let session = registry.session();
160
161    // Editing requires an owner OR contributor relationship; a viewer
162    // relationship exists in the store but grants nothing here.
163    let mut checker = PermissionChecker::<ProjectDomain>::new();
164    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
165        |user: &User| user.id,
166        |project: &Project| project.id,
167        Relation::Owner,
168    ));
169    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
170        |user: &User| user.id,
171        |project: &Project| project.id,
172        Relation::Contributor,
173    ));
174
175    // (user, relationship held, expected outcome)
176    let cases = [
177        (&owner, "owner", true),
178        (&contributor, "contributor", true),
179        (&viewer, "viewer", false),
180        (&outsider, "none", false),
181    ];
182    for (user, held, expected_granted) in cases {
183        println!("Can {} ({held}) edit {}?", user.name, project.name);
184        let decision = checker
185            .bind(&session, user, &EditAction, &())
186            .check(&project)
187            .await;
188        println!(
189            "  -> {}\n",
190            if decision.is_granted() {
191                "GRANTED"
192            } else {
193                "DENIED"
194            }
195        );
196        assert_eq!(decision.is_granted(), expected_granted);
197    }
198
199    // The trace records the facts each policy consulted (the `↳ fact` lines)
200    // alongside its decision — here the viewer's denial shows both
201    // relationship lookups coming back false. Note that no new "loading fact"
202    // lines appear: this re-check runs in the same session, so the facts come
203    // from the session cache.
204    println!("Why {} is denied:", viewer.name);
205    let decision = checker
206        .bind(&session, &viewer, &EditAction, &())
207        .check(&project)
208        .await;
209    println!("{}\n", decision.display_trace());
210
211    println!("=== Error During Relationship Loading ===\n");
212
213    // A failing store must never grant: the load error is carried into the
214    // trace and the decision fails closed to denial — even for the owner.
215    let error_registry = FactRegistry::builder()
216        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships).with_error())
217        .build();
218    let error_session = error_registry.session();
219    let decision = checker
220        .bind(&error_session, &owner, &EditAction, &())
221        .check(&project)
222        .await;
223    println!("{}", decision.display_trace());
224    decision.assert_denied();
225    decision.assert_trace_contains("simulated relationship store error");
226}

Trait Implementations§

Source§

impl Clone for AccessEvaluation

Source§

fn clone(&self) -> AccessEvaluation

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AccessEvaluation

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for AccessEvaluation

A concise line about the final decision.

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Serialize for AccessEvaluation

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T> ToString for T
where T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more