#[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
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.
Denied
Access was denied.
Implementations§
Source§impl AccessEvaluation
impl AccessEvaluation
Sourcepub fn is_granted(&self) -> bool
pub fn is_granted(&self) -> bool
Whether access was granted
Examples found in repository?
More examples
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}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}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}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}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(®istry);
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(®istry);
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(®istry);
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}Sourcepub fn trace(&self) -> &EvalTrace
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?
More examples
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}Sourcepub fn granted_policy_type(&self) -> Option<&str>
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.
Sourcepub fn denied_reason(&self) -> Option<&str>
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.
Sourcepub fn forbidden_by(&self) -> Option<&str>
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?
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}Sourcepub fn assert_granted_by(&self, expected: &str)
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?
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
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}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}Sourcepub fn assert_denied(&self)
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?
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
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}Sourcepub fn assert_denied_with_reason_containing(&self, needle: &str)
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.
Sourcepub fn assert_not_applicable_by(&self, expected: &str)
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");Sourcepub fn assert_forbidden_by(&self, expected: &str)
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?
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
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}Sourcepub fn assert_trace_contains(&self, needle: &str)
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?
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}Sourcepub fn to_result<E>(&self, error_fn: impl FnOnce(&str) -> E) -> Result<(), E>
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());Sourcepub fn display_trace(&self) -> String
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?
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
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}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}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}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
impl Clone for AccessEvaluation
Source§fn clone(&self) -> AccessEvaluation
fn clone(&self) -> AccessEvaluation
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreSource§impl Debug for AccessEvaluation
impl Debug for AccessEvaluation
Source§impl Display for AccessEvaluation
A concise line about the final decision.
impl Display for AccessEvaluation
A concise line about the final decision.