Skip to main content

lookup_in_ram/
lookup_in_ram.rs

1//! Lookup-style authorization with an in-memory backend.
2//!
3//! Demonstrates `BoundEvaluator::lookup_page` against an in-RAM
4//! `LookupSource` and a `Hydrator`, composed with a non-lookup policy
5//! (admin override) — the production shape #24 was scoped to enable.
6//!
7//! Domain: a notebook app where a user can list "documents I can see."
8//! Visibility paths:
9//!   - the user is a registered viewer (a relationship, enumerable per user)
10//!   - the user is a global admin (a non-lookup grant axis)
11//!
12//! The `LookupSource` enumerates the viewer relationship only. Admin
13//! overrides are still authorized through the policy stack: an admin's
14//! lookup-driven listing returns the viewer-visible subset, while a point
15//! check on any document returns Granted by the admin axis. The example
16//! prints both so the difference is visible.
17
18use async_trait::async_trait;
19use gatehouse::{
20    EvalCtx, EvaluationSession, LookupPage, LookupSource, PermissionChecker, Policy, PolicyDomain,
21    PolicyEvalResult,
22};
23use std::collections::HashMap;
24use std::fmt;
25use std::num::NonZeroUsize;
26use std::sync::Arc;
27use uuid::Uuid;
28
29// --- Domain ------------------------------------------------------------
30
31#[derive(Clone, Debug)]
32struct User {
33    id: Uuid,
34    is_admin: bool,
35}
36
37#[derive(Clone, Debug)]
38struct Document {
39    id: Uuid,
40    title: String,
41}
42
43#[derive(Clone, Debug)]
44struct View;
45
46struct DocumentDomain;
47
48impl PolicyDomain for DocumentDomain {
49    type Subject = User;
50    type Action = View;
51    type Resource = Document;
52    type Context = ();
53}
54
55// --- Policies ----------------------------------------------------------
56
57/// Grants admins access to any document, regardless of relationships.
58struct AdminPolicy;
59
60#[async_trait]
61impl Policy<DocumentDomain> for AdminPolicy {
62    async fn evaluate(&self, ctx: &EvalCtx<'_, DocumentDomain>) -> PolicyEvalResult {
63        if ctx.subject.is_admin {
64            ctx.grant("admin override")
65        } else {
66            ctx.not_applicable("not admin")
67        }
68    }
69    fn policy_type(&self) -> std::borrow::Cow<'static, str> {
70        std::borrow::Cow::Borrowed("AdminPolicy")
71    }
72}
73
74/// Grants when the user is registered as a viewer of the document.
75/// Matched against the same relationships the lookup source enumerates.
76struct ViewerPolicy {
77    viewers: HashMap<Uuid, Vec<Uuid>>, // doc_id -> users with viewer relation
78}
79
80#[async_trait]
81impl Policy<DocumentDomain> for ViewerPolicy {
82    async fn evaluate(&self, ctx: &EvalCtx<'_, DocumentDomain>) -> PolicyEvalResult {
83        let granted = self
84            .viewers
85            .get(&ctx.resource.id)
86            .map(|users| users.contains(&ctx.subject.id))
87            .unwrap_or(false);
88        if granted {
89            ctx.grant("viewer relation")
90        } else {
91            ctx.not_applicable("no viewer relation")
92        }
93    }
94    fn policy_type(&self) -> std::borrow::Cow<'static, str> {
95        std::borrow::Cow::Borrowed("ViewerPolicy")
96    }
97}
98
99// --- LookupSource ------------------------------------------------------
100
101/// Enumerates the documents `user` is registered as a viewer of, in a
102/// stable per-subject order. Pages by offset; cursor is the next offset
103/// rendered as ASCII bytes.
104///
105/// In a real backend this would be `SELECT doc_id FROM viewers WHERE
106/// user_id = $1 ORDER BY doc_id LIMIT $2 OFFSET decode($3)`.
107struct InMemoryViewerLookup {
108    per_user: HashMap<Uuid, Vec<Uuid>>,
109}
110
111#[derive(Debug)]
112struct ViewerLookupError(String);
113impl fmt::Display for ViewerLookupError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.write_str(&self.0)
116    }
117}
118impl std::error::Error for ViewerLookupError {}
119
120#[async_trait]
121impl LookupSource<DocumentDomain> for InMemoryViewerLookup {
122    type Id = Uuid;
123    type Error = ViewerLookupError;
124
125    async fn lookup_page(
126        &self,
127        subject: &User,
128        _action: &View,
129        _context: &(),
130        cursor: Option<&[u8]>,
131        limit: NonZeroUsize,
132    ) -> Result<LookupPage<Uuid>, ViewerLookupError> {
133        let offset = cursor
134            .map(|c| {
135                std::str::from_utf8(c)
136                    .map_err(|_| ViewerLookupError("non-utf8 cursor".into()))
137                    .and_then(|s| {
138                        s.parse::<usize>()
139                            .map_err(|_| ViewerLookupError("cursor not a number".into()))
140                    })
141            })
142            .transpose()?
143            .unwrap_or(0);
144
145        let all = self.per_user.get(&subject.id).cloned().unwrap_or_default();
146
147        if offset >= all.len() {
148            return Ok(LookupPage {
149                ids: Vec::new(),
150                next_cursor: None,
151            });
152        }
153        let end = (offset + limit.get()).min(all.len());
154        let next_cursor = (end < all.len()).then(|| end.to_string().into_bytes());
155        Ok(LookupPage {
156            ids: all[offset..end].to_vec(),
157            next_cursor,
158        })
159    }
160}
161
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}