pub struct BoundEvaluator<'a, D: PolicyDomain> { /* private fields */ }Expand description
A request-bound evaluator for one checker, subject, action, context, and evaluation session.
Implementations§
Source§impl<'a, D: PolicyDomain> BoundEvaluator<'a, D>
impl<'a, D: PolicyDomain> BoundEvaluator<'a, D>
Sourcepub async fn check(&self, resource: &D::Resource) -> AccessEvaluation
pub async fn check(&self, resource: &D::Resource) -> AccessEvaluation
Evaluates one resource.
Examples found in repository?
examples/actix_web.rs (line 470)
457pub async fn view_post(
458 path: web::Path<Uuid>,
459 req: HttpRequest,
460 AuthenticatedUser(user): AuthenticatedUser,
461 state: web::Data<AppState>,
462) -> impl Responder {
463 let post = load_post(&state, *path, &PostOverrides::from_request(&req));
464 let session = state.request_session();
465 let context = RequestContext::now();
466
467 match state
468 .checker
469 .bind(&session, &user, &Action::View, &context)
470 .check(&post)
471 .await
472 {
473 AccessEvaluation::Granted { .. } => {
474 HttpResponse::Ok().body(format!("Viewing '{}'", post.title))
475 }
476 AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
477 _ => HttpResponse::Forbidden().body("Access denied"),
478 }
479}
480
481pub async fn edit_post(
482 path: web::Path<Uuid>,
483 req: HttpRequest,
484 AuthenticatedUser(user): AuthenticatedUser,
485 state: web::Data<AppState>,
486) -> impl Responder {
487 let post = load_post(&state, *path, &PostOverrides::from_request(&req));
488 let session = state.request_session();
489 let context = RequestContext::now();
490
491 match state
492 .checker
493 .bind(&session, &user, &Action::Edit, &context)
494 .check(&post)
495 .await
496 {
497 AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post updated"),
498 AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
499 _ => HttpResponse::Forbidden().body("Access denied"),
500 }
501}
502
503pub async fn publish_post(
504 path: web::Path<Uuid>,
505 req: HttpRequest,
506 AuthenticatedUser(user): AuthenticatedUser,
507 state: web::Data<AppState>,
508) -> impl Responder {
509 let post = load_post(&state, *path, &PostOverrides::from_request(&req));
510 let session = state.request_session();
511 let context = RequestContext::now();
512
513 match state
514 .checker
515 .bind(&session, &user, &Action::Publish, &context)
516 .check(&post)
517 .await
518 {
519 AccessEvaluation::Granted { .. } => HttpResponse::Ok().body("Post published"),
520 AccessEvaluation::Denied { reason, trace } => forbidden(&reason, &trace),
521 _ => HttpResponse::Forbidden().body("Access denied"),
522 }
523}More examples
examples/axum.rs (line 399)
385pub async fn view_invoice_handler(
386 Path(invoice_id): Path<Uuid>,
387 State(state): State<AppState>,
388 AuthenticatedUser(user): AuthenticatedUser,
389 overrides: InvoiceOverrides,
390) -> impl IntoResponse {
391 // Simulate a DB fetch.
392 let invoice = overrides.build_invoice(invoice_id);
393 let session = state.request_session();
394 let context = RequestContext::now();
395
396 if state
397 .checker
398 .bind(&session, &user, &Action::View, &context)
399 .check(&invoice)
400 .await
401 .is_granted()
402 {
403 (StatusCode::OK, format!("{invoice:?}")).into_response()
404 } else {
405 (
406 StatusCode::FORBIDDEN,
407 "You are not authorized to view this invoice",
408 )
409 .into_response()
410 }
411}
412
413pub async fn list_invoices_handler(
414 State(state): State<AppState>,
415 AuthenticatedUser(user): AuthenticatedUser,
416) -> impl IntoResponse {
417 let session = state.request_session();
418 let candidates = state.invoices.as_ref().clone();
419 let context = RequestContext::now();
420
421 // The session is request-scoped: app state owns the source, this request
422 // registers it, and the batch authorization call uses it for every invoice
423 // — relationship loads are batched and deduplicated.
424 let visible = state
425 .checker
426 .bind(&session, &user, &Action::View, &context)
427 .filter(candidates)
428 .await
429 .into_iter()
430 .map(InvoiceSummary::from)
431 .collect::<Vec<_>>();
432
433 Json(visible).into_response()
434}
435
436pub async fn edit_invoice_handler(
437 Path(invoice_id): Path<Uuid>,
438 State(state): State<AppState>,
439 AuthenticatedUser(user): AuthenticatedUser,
440 overrides: InvoiceOverrides,
441) -> impl IntoResponse {
442 let invoice = overrides.build_invoice(invoice_id);
443 let session = state.request_session();
444 let context = RequestContext::now();
445
446 if state
447 .checker
448 .bind(&session, &user, &Action::Edit, &context)
449 .check(&invoice)
450 .await
451 .is_granted()
452 {
453 (StatusCode::OK, "Invoice edited successfully").into_response()
454 } else {
455 (
456 StatusCode::FORBIDDEN,
457 "You are not authorized to edit this invoice",
458 )
459 .into_response()
460 }
461}examples/delegating_policy.rs (line 183)
143async fn main() {
144 let owner_id = Uuid::new_v4();
145 let author_id = Uuid::new_v4();
146
147 let comment = Comment {
148 author_id,
149 document: Document { owner_id },
150 };
151
152 let author = Principal {
153 user_id: author_id,
154 is_admin: false,
155 };
156 let document_owner = Principal {
157 user_id: owner_id,
158 is_admin: false,
159 };
160 let admin = Principal {
161 user_id: Uuid::new_v4(),
162 is_admin: true,
163 };
164 let stranger = Principal {
165 user_id: Uuid::new_v4(),
166 is_admin: false,
167 };
168
169 let checker = comment_checker();
170 let session = EvaluationSession::empty();
171 let action = EditComment;
172 let context = ();
173
174 let cases = [
175 ("author", &author),
176 ("document owner (not author)", &document_owner),
177 ("admin (not author/owner)", &admin),
178 ("unrelated user", &stranger),
179 ];
180 for (who, principal) in cases {
181 let granted = checker
182 .bind(&session, principal, &action, &context)
183 .check(&comment)
184 .await
185 .is_granted();
186 println!(
187 "{who:<28} can edit the comment? {}",
188 if granted { "yes" } else { "no" }
189 );
190 }
191
192 // The document owner is not the comment author, so the direct rule denies;
193 // the delegating policy then asks the document checker, which grants. The
194 // trace shows the decision crossing the domain boundary.
195 println!("\nTrace — document owner (not the author) editing the comment:");
196 let decision = checker
197 .bind(&session, &document_owner, &action, &context)
198 .check(&comment)
199 .await;
200 println!("{}", decision.display_trace());
201
202 assert!(checker
203 .bind(&session, &author, &action, &context)
204 .check(&comment)
205 .await
206 .is_granted());
207 assert!(checker
208 .bind(&session, &document_owner, &action, &context)
209 .check(&comment)
210 .await
211 .is_granted());
212 assert!(checker
213 .bind(&session, &admin, &action, &context)
214 .check(&comment)
215 .await
216 .is_granted());
217 assert!(!checker
218 .bind(&session, &stranger, &action, &context)
219 .check(&comment)
220 .await
221 .is_granted());
222}examples/mfa_freshness_context.rs (line 196)
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}examples/rbac_policy.rs (line 109)
67async fn main() {
68 // The first resolver reads the requirement off the resource/action; the
69 // second extracts the subject's roles. The role type (`Role`) is inferred
70 // from the closures' return types.
71 let rbac_policy = RbacPolicy::<DocumentDomain, _, _>::new(
72 |_action: &ReadAction, doc: &Document| doc.required_roles.iter().copied().collect(),
73 |user: &User| user.roles.iter().copied().collect(),
74 );
75
76 let mut checker = PermissionChecker::<DocumentDomain>::new();
77 checker.add_policy(rbac_policy);
78
79 let admin = user("admin", [Role::Admin]);
80 let editor = user("editor", [Role::Editor]);
81 let multi_role = user("editor+viewer", [Role::Editor, Role::Viewer]);
82 let no_roles = user("no-roles", []);
83
84 let admin_doc = document("admin handbook", [Role::Admin]);
85 let editor_doc = document("style guide", [Role::Editor]);
86 let shared_doc = document("team wiki", [Role::Editor, Role::Viewer]);
87
88 // (user, document, expected outcome)
89 let cases = [
90 (&admin, &admin_doc, true),
91 (&admin, &editor_doc, false), // admin role is not editor — no hierarchy here
92 (&editor, &admin_doc, false),
93 (&editor, &editor_doc, true),
94 (&multi_role, &editor_doc, true),
95 (&multi_role, &shared_doc, true),
96 (&no_roles, &admin_doc, false),
97 (&no_roles, &shared_doc, false),
98 ];
99
100 let session = EvaluationSession::empty();
101 let action = ReadAction;
102 let context = ();
103
104 println!("{:<16} {:<16} verdict", "user", "document");
105 println!("{}", "-".repeat(42));
106 for (user, document, expected_granted) in cases {
107 let decision = checker
108 .bind(&session, user, &action, &context)
109 .check(document)
110 .await;
111 println!(
112 "{:<16} {:<16} {}",
113 user.name,
114 document.name,
115 if decision.is_granted() {
116 "GRANTED"
117 } else {
118 "DENIED"
119 }
120 );
121 assert_eq!(decision.is_granted(), expected_granted);
122 }
123
124 // Note the admin/style-guide denial above: `RbacPolicy` is a flat
125 // role-match with no built-in hierarchy. If admins should read
126 // everything, either include `Role::Admin` in each document's required
127 // set or add a separate admin-override policy to the checker.
128
129 println!("\nWhy the editor is denied the admin handbook:");
130 let decision = checker
131 .bind(&session, &editor, &action, &context)
132 .check(&admin_doc)
133 .await;
134 println!("{}", decision.display_trace());
135}examples/policy_builder.rs (line 134)
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}Sourcepub async fn evaluate<I>(
&self,
resources: I,
) -> Vec<(I::Item, AccessEvaluation)>
pub async fn evaluate<I>( &self, resources: I, ) -> Vec<(I::Item, AccessEvaluation)>
Evaluates a batch of already-loaded resources, preserving input order.
Sourcepub async fn evaluate_by<I, F>(
&self,
items: I,
resource_of: F,
) -> Vec<(I::Item, AccessEvaluation)>
pub async fn evaluate_by<I, F>( &self, items: I, resource_of: F, ) -> Vec<(I::Item, AccessEvaluation)>
Evaluates a batch of caller-owned items by projecting each item to the resource used for authorization.
Use this for list endpoints that carry wide database rows but authorize a narrower resource projection:
ⓘ
let decisions = bound.evaluate_by(rows, |row| &row.authz_resource).await;Sourcepub async fn filter<I>(&self, resources: I) -> Vec<I::Item>
pub async fn filter<I>(&self, resources: I) -> Vec<I::Item>
Returns only the resources granted by Self::evaluate.
Examples found in repository?
examples/actix_web.rs (line 450)
439pub async fn list_posts(
440 AuthenticatedUser(user): AuthenticatedUser,
441 state: web::Data<AppState>,
442) -> impl Responder {
443 let session = state.request_session();
444 let context = RequestContext::now();
445 let candidates = state.posts.as_ref().clone();
446
447 let visible = state
448 .checker
449 .bind(&session, &user, &Action::View, &context)
450 .filter(candidates)
451 .await;
452
453 let summaries = visible.iter().map(PostSummary::from).collect::<Vec<_>>();
454 HttpResponse::Ok().json(summaries)
455}More examples
examples/axum.rs (line 427)
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}examples/factsource_n_plus_one.rs (line 247)
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}examples/in_ram_rebac.rs (line 132)
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}examples/postgres_bulk_rebac.rs (line 337)
174async fn main() {
175 let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
176 "host=localhost port=15432 user=postgres password=test dbname=awa_test".to_string()
177 });
178
179 let (client, connection) = tokio_postgres::connect(&database_url, NoTls)
180 .await
181 .expect("connect to PostgreSQL");
182 tokio::spawn(async move {
183 if let Err(error) = connection.await {
184 eprintln!("postgres connection error: {error}");
185 }
186 });
187 let client = Arc::new(client);
188
189 let version: String = client
190 .query_one("SELECT version()", &[])
191 .await
192 .expect("version query should succeed")
193 .get(0);
194 println!("{version}");
195
196 client
197 .batch_execute(
198 "
199 DROP TABLE IF EXISTS gatehouse_example_post_relationships;
200 CREATE UNLOGGED TABLE gatehouse_example_post_relationships (
201 subject_id uuid NOT NULL,
202 post_id uuid NOT NULL,
203 relationship text NOT NULL,
204 PRIMARY KEY (subject_id, post_id, relationship)
205 );
206 ",
207 )
208 .await
209 .expect("setup schema");
210
211 let subject = User { id: Uuid::new_v4() };
212 let posts = (0..10_000)
213 .map(|index| Post {
214 id: Uuid::new_v4(),
215 public: index % 5 == 0,
216 })
217 .collect::<Vec<_>>();
218 let granted_ids = posts
219 .iter()
220 .enumerate()
221 .filter_map(|(index, post)| (!post.public && index % 2 == 0).then_some(post.id))
222 .collect::<Vec<_>>();
223 let relationships = vec![Relation::Viewer.as_str(); granted_ids.len()];
224 let subject_ids = vec![subject.id; granted_ids.len()];
225
226 client
227 .execute(
228 "
229 INSERT INTO gatehouse_example_post_relationships (subject_id, post_id, relationship)
230 SELECT *
231 FROM unnest($1::uuid[], $2::uuid[], $3::text[])
232 ",
233 &[&subject_ids, &granted_ids, &relationships],
234 )
235 .await
236 .expect("seed grants");
237
238 let point_stmt = Arc::new(
239 client
240 .prepare(
241 "
242 SELECT EXISTS (
243 SELECT 1
244 FROM gatehouse_example_post_relationships
245 WHERE subject_id = $1
246 AND relationship = $2
247 AND post_id = $3
248 ) AS allowed
249 ",
250 )
251 .await
252 .expect("prepare point query"),
253 );
254 let bulk_stmt = Arc::new(
255 client
256 .prepare(
257 "
258 WITH candidate_relationships AS (
259 SELECT subject_id, post_id, relationship, ord
260 FROM unnest($1::uuid[], $2::uuid[], $3::text[])
261 WITH ORDINALITY AS input(subject_id, post_id, relationship, ord)
262 )
263 SELECT
264 COALESCE(bool_or(g.post_id IS NOT NULL), false) AS allowed
265 FROM candidate_relationships c
266 LEFT JOIN gatehouse_example_post_relationships g
267 ON g.subject_id = c.subject_id
268 AND g.relationship = c.relationship
269 AND g.post_id = c.post_id
270 GROUP BY c.ord, c.subject_id, c.post_id, c.relationship
271 ORDER BY c.ord
272 ",
273 )
274 .await
275 .expect("prepare bulk query"),
276 );
277
278 let source = Arc::new(PgRelationshipSource {
279 client,
280 point_stmt,
281 bulk_stmt,
282 });
283 assert_point_and_bulk_agree(
284 &source,
285 &[
286 RelationshipQuery {
287 subject_id: subject.id,
288 resource_id: posts
289 .iter()
290 .find(|post| granted_ids.contains(&post.id))
291 .expect("fixture should include a granted private post")
292 .id,
293 relation: Relation::Viewer,
294 },
295 RelationshipQuery {
296 subject_id: subject.id,
297 resource_id: posts
298 .iter()
299 .enumerate()
300 .find(|(index, post)| !post.public && index % 2 == 1)
301 .expect("fixture should include a denied private post")
302 .1
303 .id,
304 relation: Relation::Viewer,
305 },
306 ],
307 )
308 .await;
309 let source: Arc<dyn FactSource<RelationshipKey>> = source;
310 let checker = build_checker();
311
312 println!("size,relationship_checks,naive_ms,bulk_ms,allowed,improvement");
313 for &size in &[10usize, 100, 1_000, 5_000, 10_000] {
314 let sample = posts.iter().take(size).cloned().collect::<Vec<_>>();
315 let relationship_checks = sample.iter().filter(|post| !post.public).count();
316 let naive = measure(|| async {
317 let mut allowed = 0usize;
318 for post in &sample {
319 let session = session_with(&source);
320 if checker
321 .bind(&session, &subject, &View, &())
322 .check(post)
323 .await
324 .is_granted()
325 {
326 allowed += 1;
327 }
328 }
329 allowed
330 })
331 .await;
332
333 let bulk = measure(|| async {
334 let session = session_with(&source);
335 checker
336 .bind(&session, &subject, &View, &())
337 .filter(sample.clone())
338 .await
339 .len()
340 })
341 .await;
342
343 assert_eq!(naive.output, bulk.output);
344 println!(
345 "{size},{relationship_checks},{:.3},{:.3},{},x{:.1}",
346 naive.elapsed.as_secs_f64() * 1_000.0,
347 bulk.elapsed.as_secs_f64() * 1_000.0,
348 bulk.output,
349 naive.elapsed.as_secs_f64() / bulk.elapsed.as_secs_f64()
350 );
351 }
352}Sourcepub async fn filter_by<I, F>(&self, items: I, resource_of: F) -> Vec<I::Item>
pub async fn filter_by<I, F>(&self, items: I, resource_of: F) -> Vec<I::Item>
Returns only the caller-owned items granted by Self::evaluate_by.
The returned values are the original input items, not cloned projected resources.
Sourcepub async fn lookup_page<L, H>(
&self,
lookup: &L,
hydrator: &H,
cursor: Option<&[u8]>,
limit: NonZeroUsize,
) -> Result<LookupAuthorizedPage<D::Resource>, LookupAuthorizedError<L::Error, H::Error>>
pub async fn lookup_page<L, H>( &self, lookup: &L, hydrator: &H, cursor: Option<&[u8]>, limit: NonZeroUsize, ) -> Result<LookupAuthorizedPage<D::Resource>, LookupAuthorizedError<L::Error, H::Error>>
Looks up one candidate page, hydrates it, and returns authorized resources from that page.
Examples found in repository?
examples/lookup_in_ram.rs (line 178)
162async fn collect_authorized<H>(
163 checker: &PermissionChecker<DocumentDomain>,
164 session: &EvaluationSession,
165 subject: &User,
166 lookup: &InMemoryViewerLookup,
167 page_size: NonZeroUsize,
168 hydrator: &H,
169) -> Result<Vec<Document>, gatehouse::LookupAuthorizedError<ViewerLookupError, H::Error>>
170where
171 H: gatehouse::Hydrator<Uuid, Resource = Document>,
172{
173 let mut cursor: Option<Vec<u8>> = None;
174 let mut authorized = Vec::new();
175 let bound = checker.bind(session, subject, &View, &());
176 loop {
177 let page = bound
178 .lookup_page(lookup, hydrator, cursor.as_deref(), page_size)
179 .await?;
180 authorized.extend(page.resources);
181 match page.next_cursor {
182 Some(next) => cursor = Some(next),
183 None => return Ok(authorized),
184 }
185 }
186}
187
188// --- Wiring + main -----------------------------------------------------
189
190#[tokio::main]
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}Auto Trait Implementations§
impl<'a, D> !RefUnwindSafe for BoundEvaluator<'a, D>
impl<'a, D> !UnwindSafe for BoundEvaluator<'a, D>
impl<'a, D> Freeze for BoundEvaluator<'a, D>
impl<'a, D> Send for BoundEvaluator<'a, D>
impl<'a, D> Sync for BoundEvaluator<'a, D>
impl<'a, D> Unpin for BoundEvaluator<'a, D>
impl<'a, D> UnsafeUnpin for BoundEvaluator<'a, D>
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Mutably borrows from an owned value. Read more