1use async_trait::async_trait;
18use axum::{
19 extract::{FromRequestParts, Path, State},
20 http::{request::Parts, HeaderMap, StatusCode},
21 response::IntoResponse,
22 routing::{get, post},
23 Json, Router,
24};
25use gatehouse::*;
26use serde::Serialize;
27use std::collections::HashSet;
28use std::fmt;
29use std::sync::Arc;
30use std::time::{Duration, SystemTime};
31use uuid::Uuid;
32
33#[derive(Debug, Clone)]
38pub struct User {
39 pub id: Uuid,
40 pub roles: Vec<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct AuthenticatedUser(pub User);
45
46impl<S> FromRequestParts<S> for AuthenticatedUser
47where
48 S: Send + Sync,
49{
50 type Rejection = (StatusCode, String);
51
52 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
53 let id = parts
54 .headers
55 .get("x-user-id")
56 .and_then(|value| value.to_str().ok())
57 .and_then(|raw| Uuid::parse_str(raw).ok())
58 .unwrap_or_else(Uuid::nil);
59
60 let roles = parts
61 .headers
62 .get("x-roles")
63 .and_then(|value| value.to_str().ok())
64 .map(|raw| {
65 raw.split(',')
66 .map(|role| role.trim().to_ascii_lowercase())
67 .filter(|role| !role.is_empty())
68 .collect::<Vec<_>>()
69 })
70 .unwrap_or_else(|| vec!["viewer".to_string()]);
71
72 Ok(AuthenticatedUser(User { id, roles }))
73 }
74}
75
76fn parse_bool(value: &str) -> Option<bool> {
77 match value.trim().to_ascii_lowercase().as_str() {
78 "true" | "1" | "yes" => Some(true),
79 "false" | "0" | "no" => Some(false),
80 _ => None,
81 }
82}
83
84#[derive(Debug, Default, Clone)]
87pub struct InvoiceOverrides {
88 locked: Option<bool>,
89 age_days: Option<u64>,
90}
91
92impl InvoiceOverrides {
93 pub fn from_headers(headers: &HeaderMap) -> Self {
94 let locked = headers
95 .get("x-invoice-locked")
96 .and_then(|value| value.to_str().ok())
97 .and_then(parse_bool);
98
99 let age_days = headers
100 .get("x-invoice-age-days")
101 .and_then(|value| value.to_str().ok())
102 .and_then(|raw| raw.parse::<u64>().ok());
103
104 Self { locked, age_days }
105 }
106
107 fn build_invoice(&self, invoice_id: Uuid) -> Invoice {
108 Invoice {
109 id: invoice_id,
110 owner_id: demo_owner_id(),
111 locked: self.locked.unwrap_or(false),
112 created_at: SystemTime::now()
113 - Duration::from_secs(self.age_days.unwrap_or(10) * 24 * 60 * 60),
114 }
115 }
116}
117
118impl<S> FromRequestParts<S> for InvoiceOverrides
119where
120 S: Send + Sync,
121{
122 type Rejection = (StatusCode, String);
123
124 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
125 Ok(Self::from_headers(&parts.headers))
126 }
127}
128
129#[derive(Debug, Clone)]
131pub enum Action {
132 Edit,
133 View,
134}
135
136#[derive(Debug, Clone)]
139pub struct Invoice {
140 pub id: Uuid,
141 pub owner_id: Uuid,
142 pub locked: bool,
143 pub created_at: SystemTime,
144}
145
146#[derive(Debug, Clone)]
149pub struct RequestContext {
150 pub current_time: SystemTime,
151}
152
153impl RequestContext {
154 fn now() -> Self {
155 Self {
156 current_time: SystemTime::now(),
157 }
158 }
159}
160
161pub struct InvoiceDomain;
162
163impl PolicyDomain for InvoiceDomain {
164 type Subject = User;
165 type Action = Action;
166 type Resource = Invoice;
167 type Context = RequestContext;
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum Relation {
172 Viewer,
173}
174
175impl fmt::Display for Relation {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 Self::Viewer => f.write_str("viewer"),
179 }
180 }
181}
182
183type InvoiceRelationship = RelationshipQuery<Uuid, Uuid, Relation>;
184
185#[derive(Clone)]
186pub struct InMemoryRelationshipSource {
187 grants: Arc<HashSet<InvoiceRelationship>>,
188}
189
190impl InMemoryRelationshipSource {
191 fn new(grants: impl IntoIterator<Item = InvoiceRelationship>) -> Self {
192 Self {
193 grants: Arc::new(grants.into_iter().collect()),
194 }
195 }
196}
197
198#[async_trait]
199impl FactSource<InvoiceRelationship> for InMemoryRelationshipSource {
200 async fn load_many(&self, keys: &[InvoiceRelationship]) -> Vec<FactLoadResult<bool>> {
201 keys.iter()
202 .map(|key| FactLoadResult::Found(self.grants.contains(key)))
203 .collect()
204 }
205}
206
207#[derive(Debug, Clone, Serialize)]
208pub struct InvoiceSummary {
209 pub id: Uuid,
210 pub owner_id: Uuid,
211 pub locked: bool,
212}
213
214impl From<Invoice> for InvoiceSummary {
215 fn from(invoice: Invoice) -> Self {
216 Self {
217 id: invoice.id,
218 owner_id: invoice.owner_id,
219 locked: invoice.locked,
220 }
221 }
222}
223
224#[derive(Clone)]
232pub struct AppState {
233 checker: PermissionChecker<InvoiceDomain>,
234 fact_registry: FactRegistry,
235 invoices: Arc<Vec<Invoice>>,
236}
237
238impl AppState {
239 pub fn demo() -> Self {
240 let viewer_id = demo_viewer_id();
241 let invoices = Arc::new(demo_invoices());
242 let grants = invoices
245 .iter()
246 .filter(|invoice| invoice.owner_id != demo_owner_id())
247 .map(|invoice| InvoiceRelationship {
248 subject_id: viewer_id,
249 resource_id: invoice.id,
250 relation: Relation::Viewer,
251 });
252
253 Self {
254 checker: build_permission_checker(),
255 fact_registry: FactRegistry::builder()
256 .with_arc::<InvoiceRelationship>(Arc::new(InMemoryRelationshipSource::new(grants)))
257 .build(),
258 invoices,
259 }
260 }
261
262 fn request_session(&self) -> EvaluationSession {
263 self.fact_registry.session()
264 }
265}
266
267fn demo_owner_id() -> Uuid {
268 Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()
269}
270
271fn demo_viewer_id() -> Uuid {
272 Uuid::parse_str("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee").unwrap()
273}
274
275fn demo_invoices() -> Vec<Invoice> {
276 vec![
277 Invoice {
278 id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
279 owner_id: demo_owner_id(),
280 locked: false,
281 created_at: SystemTime::now() - Duration::from_secs(10 * 24 * 60 * 60),
282 },
283 Invoice {
284 id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
285 owner_id: Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
286 locked: false,
287 created_at: SystemTime::now() - Duration::from_secs(5 * 24 * 60 * 60),
288 },
289 Invoice {
290 id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
291 owner_id: Uuid::parse_str("dddddddd-dddd-dddd-dddd-dddddddddddd").unwrap(),
292 locked: true,
293 created_at: SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60),
294 },
295 ]
296}
297
298fn admin_override_policy() -> Box<dyn Policy<InvoiceDomain>> {
305 PolicyBuilder::<InvoiceDomain>::new("AdminOverridePolicy")
306 .when(|user, _action, _invoice, _ctx| user.roles.contains(&"admin".to_string()))
307 .build()
308}
309
310fn invoice_viewer_policy() -> Box<dyn Policy<InvoiceDomain>> {
314 let is_view: Arc<dyn Policy<InvoiceDomain>> = Arc::from(
315 PolicyBuilder::<InvoiceDomain>::new("IsView")
316 .when(|_user, action, _invoice, _ctx| matches!(action, Action::View))
317 .build(),
318 );
319 let viewer_relationship: Arc<dyn Policy<InvoiceDomain>> =
320 Arc::new(RebacPolicy::<InvoiceDomain, Uuid, Uuid, Relation>::new(
321 |user: &User| user.id,
322 |invoice: &Invoice| invoice.id,
323 Relation::Viewer,
324 ));
325
326 Box::new(
327 AndPolicy::try_new(vec![is_view, viewer_relationship])
328 .expect("invoice viewer policy has the guard and relationship checks"),
329 )
330}
331
332fn invoice_editing_policy() -> Box<dyn Policy<InvoiceDomain>> {
336 let is_edit = PolicyBuilder::<InvoiceDomain>::new("IsEdit")
337 .when(|_user, action, _invoice, _ctx| matches!(action, Action::Edit))
338 .build();
339
340 let is_owner = PolicyBuilder::<InvoiceDomain>::new("IsOwnerOfInvoice")
341 .when(|user, _action, invoice, _ctx| user.id == invoice.owner_id)
342 .build();
343
344 let invoice_not_locked = PolicyBuilder::<InvoiceDomain>::new("InvoiceNotLocked")
345 .when(|_user, _action, invoice, _ctx| !invoice.locked)
346 .build();
347
348 const THIRTY_DAYS: u64 = 30 * 24 * 60 * 60;
349 let invoice_age_under_30_days = PolicyBuilder::<InvoiceDomain>::new("InvoiceAgeUnder30Days")
350 .when(move |_user, _action, invoice, ctx| {
351 ctx.current_time
352 .duration_since(invoice.created_at)
353 .unwrap_or_default()
354 .as_secs()
355 <= THIRTY_DAYS
356 })
357 .build();
358
359 Box::new(
360 AndPolicy::try_new(vec![
361 Arc::from(is_edit),
362 Arc::from(is_owner),
363 Arc::from(invoice_not_locked),
364 Arc::from(invoice_age_under_30_days),
365 ])
366 .expect("invoice editing policy has at least one rule"),
367 )
368}
369
370pub fn build_permission_checker() -> PermissionChecker<InvoiceDomain> {
374 let mut checker = PermissionChecker::named("InvoiceChecker");
375 checker.add_policy(admin_override_policy());
376 checker.add_policy(invoice_viewer_policy());
377 checker.add_policy(invoice_editing_policy());
378 checker
379}
380
381pub 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 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 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}
462
463#[tokio::main]
468async fn main() {
469 let state = AppState::demo();
472
473 let app = Router::new()
474 .route("/invoices", get(list_invoices_handler))
475 .route("/invoices/{invoice_id}", get(view_invoice_handler))
476 .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler))
477 .with_state(state);
478
479 let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
480 println!("Listening on http://0.0.0.0:8000");
481 axum::serve(listener, app).await.unwrap();
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use gatehouse::AccessEvaluation;
488 use std::time::{Duration, SystemTime};
489
490 fn make_invoice(owner_id: Uuid, locked: bool, age_in_days: u64) -> Invoice {
491 Invoice {
492 id: Uuid::new_v4(),
493 owner_id,
494 locked,
495 created_at: SystemTime::now() - Duration::from_secs(age_in_days * 24 * 60 * 60),
496 }
497 }
498
499 fn context_now() -> RequestContext {
500 RequestContext {
501 current_time: SystemTime::now(),
502 }
503 }
504
505 #[tokio::test]
506 async fn admin_override_allows_anything() {
507 let checker = build_permission_checker();
508 let admin = User {
509 id: Uuid::new_v4(),
510 roles: vec!["admin".to_string()],
511 };
512
513 let invoice = make_invoice(
515 Uuid::new_v4(),
516 true,
517 60,
518 );
519
520 let session = EvaluationSession::empty();
521 let context = context_now();
522 let result = checker
523 .bind(&session, &admin, &Action::Edit, &context)
524 .check(&invoice)
525 .await;
526
527 assert!(result.is_granted(), "admin override should allow anything");
528 match result {
529 AccessEvaluation::Granted { policy_type, .. } => {
530 assert_eq!(&policy_type, "AdminOverridePolicy");
531 }
532 _ => panic!("expected admin override to grant"),
533 }
534 }
535
536 #[tokio::test]
537 async fn owner_can_edit_unlocked_recent_invoice() {
538 let checker = build_permission_checker();
539 let owner_id = Uuid::new_v4();
540 let user = User {
541 id: owner_id,
542 roles: vec!["user".to_string()],
543 };
544
545 let invoice = make_invoice(owner_id, false, 10);
546
547 let session = EvaluationSession::empty();
548 let context = context_now();
549 let result = checker
550 .bind(&session, &user, &Action::Edit, &context)
551 .check(&invoice)
552 .await;
553
554 assert!(
555 result.is_granted(),
556 "owner should edit an unlocked invoice under 30 days old"
557 );
558 }
559
560 #[tokio::test]
561 async fn locked_invoice_cannot_be_edited() {
562 let checker = build_permission_checker();
563 let owner_id = Uuid::new_v4();
564 let user = User {
565 id: owner_id,
566 roles: vec!["user".to_string()],
567 };
568
569 let invoice = make_invoice(owner_id, true, 10);
570
571 let session = EvaluationSession::empty();
572 let context = context_now();
573 let result = checker
574 .bind(&session, &user, &Action::Edit, &context)
575 .check(&invoice)
576 .await;
577
578 assert!(!result.is_granted(), "a locked invoice should be denied");
579
580 if let AccessEvaluation::Denied { trace, .. } = result {
581 let trace_str = trace.format();
582 assert!(
583 trace_str.contains("InvoiceNotLocked"),
584 "expected InvoiceNotLocked to fail in trace:\n{trace_str}"
585 );
586 }
587 }
588
589 #[tokio::test]
590 async fn non_owner_cannot_edit() {
591 let checker = build_permission_checker();
592 let user = User {
593 id: Uuid::new_v4(),
594 roles: vec!["user".to_string()],
595 };
596
597 let invoice = make_invoice(
598 Uuid::new_v4(),
599 false,
600 10,
601 );
602
603 let session = EvaluationSession::empty();
604 let context = context_now();
605 let result = checker
606 .bind(&session, &user, &Action::Edit, &context)
607 .check(&invoice)
608 .await;
609
610 assert!(!result.is_granted(), "a non-owner should be denied");
611 if let AccessEvaluation::Denied { trace, .. } = result {
612 assert!(
613 trace.format().contains("IsOwnerOfInvoice"),
614 "expected IsOwnerOfInvoice to fail in trace"
615 );
616 }
617 }
618
619 #[tokio::test]
620 async fn stale_invoice_cannot_be_edited() {
621 let checker = build_permission_checker();
622 let owner_id = Uuid::new_v4();
623 let user = User {
624 id: owner_id,
625 roles: vec!["user".to_string()],
626 };
627
628 let invoice = make_invoice(owner_id, false, 31);
630
631 let session = EvaluationSession::empty();
632 let context = context_now();
633 let result = checker
634 .bind(&session, &user, &Action::Edit, &context)
635 .check(&invoice)
636 .await;
637 assert!(
638 !result.is_granted(),
639 "an invoice older than 30 days should be denied"
640 );
641 }
642}
643
644#[cfg(test)]
645mod integration_tests {
646 use super::*;
647 use axum::{
648 body::Body,
649 http::{Request, StatusCode},
650 Router,
651 };
652 use tower::ServiceExt;
653
654 fn test_app() -> Router {
655 Router::new()
656 .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler))
657 .with_state(AppState::demo())
658 }
659
660 #[tokio::test]
661 async fn edit_invoice_handler_allows_admin() {
662 let app = test_app();
663
664 let req = Request::builder()
665 .method("POST")
666 .uri("/invoices/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit")
667 .header("x-roles", "admin")
668 .body(Body::empty())
669 .unwrap();
670
671 let response = app.clone().oneshot(req).await.unwrap();
672 assert_eq!(response.status(), StatusCode::OK);
673 }
674
675 #[tokio::test]
676 async fn edit_invoice_handler_denies_regular_user_if_locked() {
677 let app = test_app();
678
679 let req = Request::builder()
680 .method("POST")
681 .uri("/invoices/cccccccc-cccc-cccc-cccc-cccccccccccc/edit")
682 .header("x-user-id", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
683 .header("x-roles", "author")
684 .header("x-invoice-locked", "true")
685 .body(Body::empty())
686 .unwrap();
687
688 let response = app.clone().oneshot(req).await.unwrap();
689 assert_eq!(response.status(), StatusCode::FORBIDDEN);
690 }
691}