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 via Self::register_arc,
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 RBAC/ABAC policies are expected and no fact sources are registered.
For very hot RBAC/ABAC-only loops, use Self::shared_empty to avoid
allocating a new empty session per call.
Examples found in repository?
212async fn main() {
213 // Same supplier, same hierarchy, same invoices for both shapes.
214 let supplier_org = Uuid::new_v4();
215 let customer = Uuid::new_v4();
216 let supplier = Supplier {
217 user_id: Uuid::new_v4(),
218 org_id: supplier_org,
219 };
220 let routes = std::collections::HashMap::from([(supplier_org, customer)]);
221 let hierarchy = Arc::new(HierarchyService::new(routes));
222
223 let invoices: Vec<Invoice> = (0..25)
224 .map(|_| Invoice {
225 id: Uuid::new_v4(),
226 customer_id: customer,
227 })
228 .collect();
229
230 // ---- WRONG ----
231 let mut wrong_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
232 wrong_checker.add_policy(WrongSupplierPolicy {
233 hierarchy: Arc::clone(&hierarchy),
234 });
235
236 hierarchy.reset();
237 let session = EvaluationSession::empty();
238 let visible = wrong_checker
239 .filter_authorized_in_session_by_resource(
240 &session,
241 &supplier,
242 &ViewAction,
243 invoices.clone(),
244 &(),
245 |i| i,
246 )
247 .await;
248 let wrong_calls = hierarchy.calls();
249 println!(
250 "[wrong] {} invoices -> {} hierarchy lookups (N+1, redundant)",
251 visible.len(),
252 wrong_calls,
253 );
254 // Check the lesson (call count) before the bookkeeping (item count)
255 // so a regression in the dedup logic surfaces here, not in a
256 // confusing length mismatch.
257 assert_eq!(
258 wrong_calls, 25,
259 "the wrong shape pays one hierarchy call per item",
260 );
261 assert_eq!(visible.len(), 25);
262
263 // ---- RIGHT ----
264 let mut right_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
265 right_checker.add_policy(RightSupplierPolicy);
266
267 hierarchy.reset();
268 let load_many_calls = Arc::new(AtomicUsize::new(0));
269 let session = EvaluationSession::builder()
270 .with_arc::<CustomerForOrg>(Arc::new(CustomerForOrgSource {
271 hierarchy: Arc::clone(&hierarchy),
272 load_many_calls: Arc::clone(&load_many_calls),
273 }))
274 .build();
275 let visible = right_checker
276 .filter_authorized_in_session_by_resource(
277 &session,
278 &supplier,
279 &ViewAction,
280 invoices,
281 &(),
282 |i| i,
283 )
284 .await;
285 let right_calls = hierarchy.calls();
286 let batch_calls = load_many_calls.load(Ordering::SeqCst);
287 println!(
288 "[right] {} invoices -> {} hierarchy lookup ({} batched load_many call, deduped through the session)",
289 visible.len(),
290 right_calls,
291 batch_calls,
292 );
293 assert_eq!(
294 right_calls, 1,
295 "the session deduplicates: one supplier_org, one backend call",
296 );
297 assert_eq!(
298 batch_calls, 1,
299 "the session batches: one load_many call covering the unique key set",
300 );
301 assert_eq!(visible.len(), 25);
302}More examples
155async fn main() {
156 // Build a small population.
157 let alice = User {
158 id: Uuid::new_v4(),
159 is_admin: false,
160 };
161 let admin = User {
162 id: Uuid::new_v4(),
163 is_admin: true,
164 };
165 let docs: Vec<Document> = (0..7)
166 .map(|i| Document {
167 id: Uuid::new_v4(),
168 title: format!("doc-{i}"),
169 })
170 .collect();
171
172 // Alice is a viewer of docs[1], docs[3], docs[5].
173 let viewer_doc_ids: Vec<Uuid> = [&docs[1], &docs[3], &docs[5]]
174 .into_iter()
175 .map(|d| d.id)
176 .collect();
177
178 let viewers: HashMap<Uuid, Vec<Uuid>> = viewer_doc_ids
179 .iter()
180 .map(|doc_id| (*doc_id, vec![alice.id]))
181 .collect();
182
183 let viewer_lookup_index: HashMap<Uuid, Vec<Uuid>> =
184 HashMap::from([(alice.id, viewer_doc_ids.clone())]);
185
186 // Document catalog used by the hydrator. In production this is a
187 // database call: `SELECT * FROM docs WHERE id = ANY($1)`.
188 let catalog: Arc<HashMap<Uuid, Document>> =
189 Arc::new(docs.iter().map(|d| (d.id, d.clone())).collect());
190
191 let lookup = InMemoryViewerLookup {
192 per_user: viewer_lookup_index,
193 };
194
195 // Hydrator closure: maps a slice of ids to `Vec<Option<Document>>`.
196 // `None` would represent an id deleted between enumeration and the
197 // catalog fetch; the in-memory catalog here always resolves.
198 let hydrator = {
199 let catalog = Arc::clone(&catalog);
200 move |ids: &[Uuid]| {
201 let catalog = Arc::clone(&catalog);
202 let ids = ids.to_vec();
203 async move {
204 Ok::<_, std::convert::Infallible>(
205 ids.iter().map(|id| catalog.get(id).cloned()).collect(),
206 )
207 }
208 }
209 };
210
211 // Compose policies: admin override OR viewer relation. The lookup
212 // source only enumerates the viewer axis — admin overrides apply only
213 // to point checks.
214 let mut checker = PermissionChecker::<User, Document, View, ()>::new();
215 checker.add_policy(AdminPolicy);
216 checker.add_policy(ViewerPolicy { viewers });
217
218 let session = EvaluationSession::empty();
219 let page_size = NonZeroUsize::new(2).unwrap();
220
221 // (1) Alice lists her visible documents via lookup_authorized.
222 let alice_visible = checker
223 .lookup_authorized(&session, &alice, &View, &(), &lookup, page_size, &hydrator)
224 .await
225 .expect("lookup ok");
226 println!("Alice sees {} document(s):", alice_visible.len());
227 for doc in &alice_visible {
228 println!(" - {} ({})", doc.title, doc.id);
229 }
230 let alice_visible_ids: Vec<Uuid> = alice_visible.iter().map(|doc| doc.id).collect();
231 assert_eq!(
232 alice_visible_ids, viewer_doc_ids,
233 "the lookup + policy stack should authorize exactly the viewer-granted documents, in source order"
234 );
235
236 // (2) Admin lists "their visible documents" via the same lookup.
237 // The viewer lookup does not enumerate documents for the admin (no
238 // viewer relation), so this listing returns empty — correctly,
239 // because lookup is bounded by what it enumerates. To enumerate
240 // "everything an admin can see", the production code would either
241 // route admin requests to a different source or simply skip the
242 // lookup path and list directly.
243 let admin_via_lookup = checker
244 .lookup_authorized(&session, &admin, &View, &(), &lookup, page_size, &hydrator)
245 .await
246 .expect("lookup ok");
247 println!(
248 "\nAdmin via the viewer-lookup sees {} document(s) — this is bounded \
249 by what the source enumerates; admin grants still apply at point checks.",
250 admin_via_lookup.len()
251 );
252 assert!(
253 admin_via_lookup.is_empty(),
254 "the viewer lookup enumerates nothing for the admin, so the listing is empty"
255 );
256
257 // (3) Point check confirms the admin policy is alive: pick a document
258 // the admin has no viewer relation on.
259 let any_doc = &docs[0];
260 let admin_point = checker
261 .evaluate_in_session(&session, &admin, &View, any_doc, &())
262 .await;
263 println!(
264 "\nAdmin point check on '{}': {}",
265 any_doc.title,
266 if admin_point.is_granted() {
267 "Granted"
268 } else {
269 "Denied"
270 }
271 );
272 admin_point.assert_granted_by("AdminPolicy");
273
274 // (4) Page-oriented streaming. Drive the lookup one candidate page at
275 // a time — useful when you want to flush results to a response writer
276 // as they are confirmed.
277 println!("\nStreaming Alice's visible documents page-by-page:");
278 let mut cursor: Option<Vec<u8>> = None;
279 let mut page_index = 0;
280 let mut streamed_total = 0;
281 loop {
282 let page = checker
283 .lookup_authorized_page(
284 &session,
285 &alice,
286 &View,
287 &(),
288 &lookup,
289 cursor.as_deref(),
290 page_size,
291 &hydrator,
292 )
293 .await
294 .expect("lookup_authorized_page ok");
295 println!(" page {page_index}: {} authorized", page.resources.len());
296 page_index += 1;
297 streamed_total += page.resources.len();
298 match page.next_cursor {
299 None => break,
300 Some(next) => cursor = Some(next),
301 }
302 }
303 // 3 candidate ids paged 2-at-a-time: two candidate pages, same total as
304 // the collecting `lookup_authorized` call above.
305 assert_eq!(page_index, 2);
306 assert_eq!(streamed_total, viewer_doc_ids.len());
307}Returns a process-wide empty session for hot paths that never use fact sources.
This avoids allocating a new empty session for RBAC/ABAC-only checks in
tight loops. It is only safe when no fact-backed policies are expected:
calling Self::register, Self::register_arc, Self::replace, or
Self::replace_arc on this session panics.
The panic is deliberate. Registering on the shared handle is a
programming error, not a runtime condition — code that registers sources
should own a Self::new session, and this handle exists only for
fact-free hot paths. The non-panicking Self::try_register /
Self::try_replace family still returns
FactSourceRegistrationError::SharedEmptySession here rather than
panicking, for callers that want to handle it. A type-state split (a
separate “registrable” type) was considered but would push that
distinction into every signature that accepts a &EvaluationSession,
defeating the drop-in substitutability that makes this handle useful.
Sourcepub fn builder() -> EvaluationSessionBuilder
pub fn builder() -> EvaluationSessionBuilder
Starts building a request-scoped session with all fact sources declared in one place.
Examples found in repository?
More examples
212async fn main() {
213 // Same supplier, same hierarchy, same invoices for both shapes.
214 let supplier_org = Uuid::new_v4();
215 let customer = Uuid::new_v4();
216 let supplier = Supplier {
217 user_id: Uuid::new_v4(),
218 org_id: supplier_org,
219 };
220 let routes = std::collections::HashMap::from([(supplier_org, customer)]);
221 let hierarchy = Arc::new(HierarchyService::new(routes));
222
223 let invoices: Vec<Invoice> = (0..25)
224 .map(|_| Invoice {
225 id: Uuid::new_v4(),
226 customer_id: customer,
227 })
228 .collect();
229
230 // ---- WRONG ----
231 let mut wrong_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
232 wrong_checker.add_policy(WrongSupplierPolicy {
233 hierarchy: Arc::clone(&hierarchy),
234 });
235
236 hierarchy.reset();
237 let session = EvaluationSession::empty();
238 let visible = wrong_checker
239 .filter_authorized_in_session_by_resource(
240 &session,
241 &supplier,
242 &ViewAction,
243 invoices.clone(),
244 &(),
245 |i| i,
246 )
247 .await;
248 let wrong_calls = hierarchy.calls();
249 println!(
250 "[wrong] {} invoices -> {} hierarchy lookups (N+1, redundant)",
251 visible.len(),
252 wrong_calls,
253 );
254 // Check the lesson (call count) before the bookkeeping (item count)
255 // so a regression in the dedup logic surfaces here, not in a
256 // confusing length mismatch.
257 assert_eq!(
258 wrong_calls, 25,
259 "the wrong shape pays one hierarchy call per item",
260 );
261 assert_eq!(visible.len(), 25);
262
263 // ---- RIGHT ----
264 let mut right_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
265 right_checker.add_policy(RightSupplierPolicy);
266
267 hierarchy.reset();
268 let load_many_calls = Arc::new(AtomicUsize::new(0));
269 let session = EvaluationSession::builder()
270 .with_arc::<CustomerForOrg>(Arc::new(CustomerForOrgSource {
271 hierarchy: Arc::clone(&hierarchy),
272 load_many_calls: Arc::clone(&load_many_calls),
273 }))
274 .build();
275 let visible = right_checker
276 .filter_authorized_in_session_by_resource(
277 &session,
278 &supplier,
279 &ViewAction,
280 invoices,
281 &(),
282 |i| i,
283 )
284 .await;
285 let right_calls = hierarchy.calls();
286 let batch_calls = load_many_calls.load(Ordering::SeqCst);
287 println!(
288 "[right] {} invoices -> {} hierarchy lookup ({} batched load_many call, deduped through the session)",
289 visible.len(),
290 right_calls,
291 batch_calls,
292 );
293 assert_eq!(
294 right_calls, 1,
295 "the session deduplicates: one supplier_org, one backend call",
296 );
297 assert_eq!(
298 batch_calls, 1,
299 "the session batches: one load_many call covering the unique key set",
300 );
301 assert_eq!(visible.len(), 25);
302}106async fn main() {
107 println!("=== ReBAC Policy Example ===\n");
108
109 let owner = User {
110 id: Uuid::new_v4(),
111 name: "Alice",
112 };
113 let contributor = User {
114 id: Uuid::new_v4(),
115 name: "Bob",
116 };
117 let viewer = User {
118 id: Uuid::new_v4(),
119 name: "Charlie",
120 };
121 let outsider = User {
122 id: Uuid::new_v4(),
123 name: "Dave",
124 };
125 let project = Project {
126 id: Uuid::new_v4(),
127 name: "Sample Project",
128 };
129
130 let relationships = HashSet::from([
131 ProjectRelationship {
132 subject_id: owner.id,
133 resource_id: project.id,
134 relation: Relation::Owner,
135 },
136 ProjectRelationship {
137 subject_id: contributor.id,
138 resource_id: project.id,
139 relation: Relation::Contributor,
140 },
141 ProjectRelationship {
142 subject_id: viewer.id,
143 resource_id: project.id,
144 relation: Relation::Viewer,
145 },
146 ]);
147
148 let session = EvaluationSession::builder()
149 .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships.clone()))
150 .build();
151
152 // Editing requires an owner OR contributor relationship; a viewer
153 // relationship exists in the store but grants nothing here.
154 let mut checker = PermissionChecker::<User, Project, EditAction, ()>::new();
155 checker.add_policy(RebacPolicy::new(
156 |user: &User| user.id,
157 |project: &Project| project.id,
158 Relation::Owner,
159 ));
160 checker.add_policy(RebacPolicy::new(
161 |user: &User| user.id,
162 |project: &Project| project.id,
163 Relation::Contributor,
164 ));
165
166 // (user, relationship held, expected outcome)
167 let cases = [
168 (&owner, "owner", true),
169 (&contributor, "contributor", true),
170 (&viewer, "viewer", false),
171 (&outsider, "none", false),
172 ];
173 for (user, held, expected_granted) in cases {
174 println!("Can {} ({held}) edit {}?", user.name, project.name);
175 let decision = checker
176 .evaluate_in_session(&session, user, &EditAction, &project, &())
177 .await;
178 println!(
179 " -> {}\n",
180 if decision.is_granted() {
181 "GRANTED"
182 } else {
183 "DENIED"
184 }
185 );
186 assert_eq!(decision.is_granted(), expected_granted);
187 }
188
189 // The trace records the facts each policy consulted (the `↳ fact` lines)
190 // alongside its decision — here the viewer's denial shows both
191 // relationship lookups coming back false. Note that no new "loading fact"
192 // lines appear: this re-check runs in the same session, so the facts come
193 // from the session cache.
194 println!("Why {} is denied:", viewer.name);
195 let decision = checker
196 .evaluate_in_session(&session, &viewer, &EditAction, &project, &())
197 .await;
198 println!("{}\n", decision.display_trace());
199
200 println!("=== Error During Relationship Loading ===\n");
201
202 // A failing store must never grant: the load error is carried into the
203 // trace and the decision fails closed to denial — even for the owner.
204 let error_session = EvaluationSession::builder()
205 .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships).with_error())
206 .build();
207 let decision = checker
208 .evaluate_in_session(&error_session, &owner, &EditAction, &project, &())
209 .await;
210 println!("{}", decision.display_trace());
211 decision.assert_denied();
212 decision.assert_trace_contains("simulated relationship store error");
213}Sourcepub fn register<K, S>(&self, source: S)where
K: FactKey,
S: FactSource<K> + 'static,
pub fn register<K, S>(&self, source: S)where
K: FactKey,
S: FactSource<K> + 'static,
Registers a fact source for one key type.
Panics if a source for K is already registered. Use Self::replace
when replacing a source is intentional. Register sources during session
setup; registering while loads for the same key type are in flight is
not a supported operation and will panic.
This panicking form is meant for session setup, where a failed
registration (a duplicate source, or a load already in flight) is a
configuration bug that should fail loudly and immediately. Use
Self::try_register when registration is driven by runtime input and
you want to handle FactSourceRegistrationError rather than panic.
Sourcepub fn register_arc<K>(&self, source: Arc<dyn FactSource<K>>)where
K: FactKey,
pub fn register_arc<K>(&self, source: Arc<dyn FactSource<K>>)where
K: FactKey,
Registers a shared fact source for one key type.
Panics if a source for K is already registered. Register sources
during session setup; use Self::replace_arc only when overwriting is
deliberate. Registering while loads for the same key type are in flight
is not a supported operation and will panic.
Use this form to share one source instance across many sessions: build
the Arc once and Arc::clone it into each request’s session (see
EvaluationSessionBuilder::with_arc). Self::register takes the
source by value and wraps it in a fresh Arc per call, so it cannot
share a single instance; reach for register_arc when the source holds
expensive shared state such as a connection pool or its own cache. Like
Self::register, it panics on invalid registration; use
Self::try_register_arc to handle the error instead.
The registry is keyed by the exact Rust fact key type. If two production
backends serve the same logical shape, such as the same
RelationshipQuery<UserId, ConversationId, ParticipantRelation>, they
cannot both be registered under that exact type in one session. Wrap one
ID/relation type or define distinct fact keys so each backend has a
separate registry entry.
Sourcepub fn try_register<K, S>(
&self,
source: S,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
S: FactSource<K> + 'static,
pub fn try_register<K, S>(
&self,
source: S,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
S: FactSource<K> + 'static,
Registers a fact source for one key type, returning an error instead of panicking if registration is invalid.
Sourcepub fn try_register_arc<K>(
&self,
source: Arc<dyn FactSource<K>>,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
pub fn try_register_arc<K>(
&self,
source: Arc<dyn FactSource<K>>,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
Registers a shared fact source for one key type, returning an error instead of panicking if registration is invalid.
Sourcepub fn replace<K, S>(&self, source: S)where
K: FactKey,
S: FactSource<K> + 'static,
pub fn replace<K, S>(&self, source: S)where
K: FactKey,
S: FactSource<K> + 'static,
Explicitly replaces a fact source for one key type.
Replacing a source clears any cached facts for that key type in this session. Replacing while loads for the same key type are in flight is not a supported operation and will panic.
Sourcepub fn replace_arc<K>(&self, source: Arc<dyn FactSource<K>>)where
K: FactKey,
pub fn replace_arc<K>(&self, source: Arc<dyn FactSource<K>>)where
K: FactKey,
Explicitly replaces a shared fact source for one key type.
Replacing a source clears any cached facts for that key type in this session. Replacing while loads for the same key type are in flight is not a supported operation and will panic.
Sourcepub fn try_replace<K, S>(
&self,
source: S,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
S: FactSource<K> + 'static,
pub fn try_replace<K, S>(
&self,
source: S,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
S: FactSource<K> + 'static,
Explicitly replaces a fact source for one key type, returning an error instead of panicking if replacement is invalid.
Sourcepub fn try_replace_arc<K>(
&self,
source: Arc<dyn FactSource<K>>,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
pub fn try_replace_arc<K>(
&self,
source: Arc<dyn FactSource<K>>,
) -> Result<(), FactSourceRegistrationError>where
K: FactKey,
Explicitly replaces a shared fact source for one key type, returning an error instead of panicking if replacement is invalid.
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?
189 async fn evaluate(
190 &self,
191 ctx: &EvalCtx<'_, Supplier, Invoice, ViewAction, ()>,
192 ) -> PolicyEvalResult {
193 // Ask the session, not the backend service directly. The
194 // first call inside this request triggers `load_many`; every
195 // subsequent call with the same key (e.g. another invoice in
196 // the same batch) hits the request-scoped cache.
197 match ctx.session.get(CustomerForOrg(ctx.subject.org_id)).await {
198 FactLoadResult::Found(Some(customer_id)) if customer_id == ctx.resource.customer_id => {
199 ctx.grant("subject's supplier org bills under the invoice's customer")
200 }
201 _ => ctx.deny("subject's supplier org does not bill under the invoice's customer"),
202 }
203 }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