Skip to main content

axum/
axum.rs

1// Axum service showing how Gatehouse fits into extractors, shared app state,
2// request-scoped sessions, and route handlers.
3//
4// The app authorizes one resource type — invoices — across a few actions and a
5// batch list endpoint. Keeping it to a single resource type means a single
6// `PermissionChecker` is the right shape here. A larger service with several
7// unrelated resource types would use one checker per resource type and share
8// cross-cutting policies (an admin override, say) across them, rather than
9// widening one checker over a `Resource` enum.
10//
11// Authorization paths:
12//   - an admin may do anything (cross-cutting override),
13//   - the owner may edit an unlocked invoice that is under 30 days old,
14//   - a user with a `viewer` relationship may view an invoice (the relationship
15//     is loaded through a request-scoped `EvaluationSession` + `FactSource`).
16
17use 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// --------------------
34// 1) Domain Modeling
35// --------------------
36
37#[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/// Header overrides so a single demo invoice can be coerced into different
85/// shapes (locked, older than the edit window) from `curl`.
86#[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/// Actions an invoice supports in this demo.
130#[derive(Debug, Clone)]
131pub enum Action {
132    Edit,
133    View,
134}
135
136/// An invoice. It can be edited only if it isn't locked and is within 30 days
137/// of creation (unless you're an admin, which overrides).
138#[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/// Extra request-scoped context. Could include feature flags, organization
147/// info, etc.; here it carries the request's wall clock for the age check.
148#[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// --------------------------
225// 2) Shared application state
226// --------------------------
227
228/// The long-lived pieces are built once at startup: the checker and fact
229/// registry. Each request derives a fresh `EvaluationSession` from the
230/// registry.
231#[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        // The demo viewer has a `viewer` relationship on every invoice they
243        // don't already own.
244        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
298// --------------------------
299// 3) Building Our Policies
300// --------------------------
301// Each policy handles a slice of the logic; the checker ORs them together.
302
303/// (A) Admins may do anything — the cross-cutting override.
304fn 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
310/// (B) A user with a `viewer` relationship may view the invoice. The
311/// relationship is loaded through the request-scoped `EvaluationSession`; in a
312/// real service the source wraps a database pool or graph client.
313fn 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
332/// (C) The owner may edit the invoice if it is unlocked and under 30 days old.
333/// Built from small sub-policies AND-ed together, so a denial trace names the
334/// sub-policy that failed.
335fn 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
370/// (D) Combine the policies into a single `PermissionChecker`. With no
371/// forbid-effect policies registered, deny-overrides reduces to OR semantics:
372/// if any policy grants, access is allowed (and evaluation short-circuits).
373pub 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
381// ---------------------------------
382// 4) Using in Axum Route Handlers
383// ---------------------------------
384
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}
462
463// ----------------------------------------
464// 5) The Axum App with Our PermissionChecker
465// ----------------------------------------
466
467#[tokio::main]
468async fn main() {
469    // Build the long-lived checker and relationship source once, then create a
470    // fresh EvaluationSession inside each handler.
471    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        // A locked, 60-day-old invoice the admin doesn't own.
514        let invoice = make_invoice(
515            Uuid::new_v4(),
516            /*locked=*/ true,
517            /*age_in_days=*/ 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, /*locked=*/ false, /*age_in_days=*/ 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, /*locked=*/ true, /*age_in_days=*/ 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            /*locked=*/ false,
600            /*age_in_days=*/ 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        // 31 days old => fails InvoiceAgeUnder30Days.
629        let invoice = make_invoice(owner_id, /*locked=*/ false, /*age_in_days=*/ 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}