pub struct EvaluationSession { /* private fields */ }Expand description
Request-scoped fact loading and caching state.
A session is intended to live for one request or one authorization pass. It owns registered fact sources and caches loaded facts by key type. The cache is deliberately not process-global. Cached facts and cached errors are dropped with the session, so permission revocations or backend changes are observed by the next request’s session rather than being held process-wide.
There is intentionally no time-based (TTL) cache: freshness is governed by
session lifetime — drop the session to drop its cache. If you need caching
that outlives a single session (a process-wide cache with a TTL, say), layer
it inside a FactSource implementation. A source can hold its own
expiring cache and be shared across sessions through FactRegistry,
which keeps the session a simple request-scoped layer on top.
Implementations§
Source§impl EvaluationSession
impl EvaluationSession
Sourcepub fn empty() -> Self
pub fn empty() -> Self
Creates an explicitly empty request-scoped session.
This is equivalent to Self::new. It can make call sites clearer when
only fact-free policies are expected and no fact sources are registered.
For very hot fact-free loops, use Self::shared_empty to avoid
allocating a new empty session per call.
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
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}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}219async fn main() {
220 // Same supplier, same hierarchy, same invoices for both shapes.
221 let supplier_org = Uuid::new_v4();
222 let customer = Uuid::new_v4();
223 let supplier = Supplier {
224 user_id: Uuid::new_v4(),
225 org_id: supplier_org,
226 };
227 let routes = std::collections::HashMap::from([(supplier_org, customer)]);
228 let hierarchy = Arc::new(HierarchyService::new(routes));
229
230 let invoices: Vec<Invoice> = (0..25)
231 .map(|_| Invoice {
232 id: Uuid::new_v4(),
233 customer_id: customer,
234 })
235 .collect();
236
237 // ---- WRONG ----
238 let mut wrong_checker = PermissionChecker::<SupplierInvoiceDomain>::new();
239 wrong_checker.add_policy(WrongSupplierPolicy {
240 hierarchy: Arc::clone(&hierarchy),
241 });
242
243 hierarchy.reset();
244 let session = EvaluationSession::empty();
245 let visible = wrong_checker
246 .bind(&session, &supplier, &ViewAction, &())
247 .filter(invoices.clone())
248 .await;
249 let wrong_calls = hierarchy.calls();
250 println!(
251 "[wrong] {} invoices -> {} hierarchy lookups (N+1, redundant)",
252 visible.len(),
253 wrong_calls,
254 );
255 // Check the lesson (call count) before the bookkeeping (item count)
256 // so a regression in the dedup logic surfaces here, not in a
257 // confusing length mismatch.
258 assert_eq!(
259 wrong_calls, 25,
260 "the wrong shape pays one hierarchy call per item",
261 );
262 assert_eq!(visible.len(), 25);
263
264 // ---- RIGHT ----
265 let mut right_checker = PermissionChecker::<SupplierInvoiceDomain>::new();
266 right_checker.add_policy(RightSupplierPolicy);
267
268 hierarchy.reset();
269 let load_many_calls = Arc::new(AtomicUsize::new(0));
270 let session = FactRegistry::builder()
271 .with_arc::<CustomerForOrg>(Arc::new(CustomerForOrgSource {
272 hierarchy: Arc::clone(&hierarchy),
273 load_many_calls: Arc::clone(&load_many_calls),
274 }))
275 .build()
276 .session();
277 let visible = right_checker
278 .bind(&session, &supplier, &ViewAction, &())
279 .filter(invoices)
280 .await;
281 let right_calls = hierarchy.calls();
282 let batch_calls = load_many_calls.load(Ordering::SeqCst);
283 println!(
284 "[right] {} invoices -> {} hierarchy lookup ({} batched load_many call, deduped through the session)",
285 visible.len(),
286 right_calls,
287 batch_calls,
288 );
289 assert_eq!(
290 right_calls, 1,
291 "the session deduplicates: one supplier_org, one backend call",
292 );
293 assert_eq!(
294 batch_calls, 1,
295 "the session batches: one load_many call covering the unique key set",
296 );
297 assert_eq!(visible.len(), 25);
298}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}Returns a process-wide empty session for hot paths that never use fact sources.
This avoids allocating a new empty session for fact-free checks in
tight loops. Only use it when no fact-backed policies are expected.
Fact-backed paths should build a FactRegistry during application
setup and call FactRegistry::session per request.
Sourcepub async fn get<K>(&self, key: K) -> FactLoadResult<K::Value>where
K: FactKey,
pub async fn get<K>(&self, key: K) -> FactLoadResult<K::Value>where
K: FactKey,
Loads one fact through the session cache.
Examples found in repository?
197 async fn evaluate(&self, ctx: &EvalCtx<'_, SupplierInvoiceDomain>) -> PolicyEvalResult {
198 // Ask the session, not the backend service directly. The
199 // first call inside this request triggers `load_many`; every
200 // subsequent call with the same key (e.g. another invoice in
201 // the same batch) hits the request-scoped cache.
202 match ctx.session.get(CustomerForOrg(ctx.subject.org_id)).await {
203 FactLoadResult::Found(Some(customer_id)) if customer_id == ctx.resource.customer_id => {
204 ctx.grant("subject's supplier org bills under the invoice's customer")
205 }
206 _ => ctx.not_applicable(
207 "subject's supplier org does not bill under the invoice's customer",
208 ),
209 }
210 }Sourcepub async fn get_many<K>(&self, keys: &[K]) -> Vec<FactLoadResult<K::Value>>where
K: FactKey,
pub async fn get_many<K>(&self, keys: &[K]) -> Vec<FactLoadResult<K::Value>>where
K: FactKey,
Loads facts through the session cache.
Results preserve input order and duplicate keys. Missing cache entries
are deduplicated before they are loaded, then chunked according to the
source’s FactSource::max_batch_size hint.
Trait Implementations§
Source§impl Clone for EvaluationSession
impl Clone for EvaluationSession
Source§fn clone(&self) -> EvaluationSession
fn clone(&self) -> EvaluationSession
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more