1use 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#[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
55struct 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
74struct ViewerPolicy {
77 viewers: HashMap<Uuid, Vec<Uuid>>, }
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
99struct 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#[tokio::main]
191async fn main() {
192 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 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 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 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 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 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 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 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 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 assert_eq!(page_index, 2);
335 assert_eq!(streamed_total, viewer_doc_ids.len());
336}