Skip to main content

BoundEvaluator

Struct BoundEvaluator 

Source
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>

Source

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
Hide additional 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}
Source

pub async fn evaluate<I>( &self, resources: I, ) -> Vec<(I::Item, AccessEvaluation)>
where I: IntoIterator, I::Item: Borrow<D::Resource>,

Evaluates a batch of already-loaded resources, preserving input order.

Source

pub async fn evaluate_by<I, F>( &self, items: I, resource_of: F, ) -> Vec<(I::Item, AccessEvaluation)>
where I: IntoIterator, F: for<'item> Fn(&'item I::Item) -> &'item D::Resource,

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;
Source

pub async fn filter<I>(&self, resources: I) -> Vec<I::Item>
where I: IntoIterator, I::Item: Borrow<D::Resource>,

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
Hide additional 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(&registry);
130    let visible = checker
131        .bind(&first_request, &user, &View, &context)
132        .filter(documents.clone())
133        .await;
134    println!(
135        "batch list — visible documents: {:?}",
136        visible
137            .iter()
138            .map(|document| document.title)
139            .collect::<Vec<_>>()
140    );
141
142    // A fresh session for a single-resource check. The user has no viewer
143    // relationship on the finance plan, so this denies.
144    let second_request = request_session(&registry);
145    let can_view_finance = checker
146        .bind(&second_request, &user, &View, &context)
147        .check(&documents[2])
148        .await;
149    println!(
150        "single check — can view '{}'? {}",
151        documents[2].title,
152        if can_view_finance.is_granted() {
153            "yes"
154        } else {
155            "no"
156        }
157    );
158    assert!(!can_view_finance.is_granted());
159
160    let shared = registry.clone();
161    let concurrent_requests = (0..4)
162        .map(|_| {
163            let checker = checker.clone();
164            let user = user.clone();
165            let documents = documents.clone();
166            let registry = shared.clone();
167            tokio::spawn(async move {
168                let session = request_session(&registry);
169                let context = ();
170                checker
171                    .bind(&session, &user, &View, &context)
172                    .filter(documents)
173                    .await
174                    .len()
175            })
176        })
177        .collect::<Vec<_>>();
178
179    println!("\n4 concurrent requests sharing one FactSource (each builds its own session):");
180    for (index, request) in concurrent_requests.into_iter().enumerate() {
181        let visible_count = request.await.unwrap();
182        println!("  request {index}: {visible_count} visible document(s)");
183        assert_eq!(visible_count, 2);
184    }
185}
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}
Source

pub async fn filter_by<I, F>(&self, items: I, resource_of: F) -> Vec<I::Item>
where I: IntoIterator, F: for<'item> Fn(&'item I::Item) -> &'item D::Resource,

Returns only the caller-owned items granted by Self::evaluate_by.

The returned values are the original input items, not cloned projected resources.

Source

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>>
where L: LookupSource<D>, H: Hydrator<L::Id, Resource = D::Resource>,

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> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

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

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

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

Source§

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

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

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

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

fn in_current_span(self) -> Instrumented<Self>

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

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

Source§

fn into(self) -> U

Calls U::from(self).

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

Source§

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

Source§

type Error = Infallible

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

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

Performs the conversion.
Source§

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

Source§

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

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

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

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

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

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

fn with_current_subscriber(self) -> WithDispatch<Self>

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