1use axum::{
5 extract::{Extension, FromRequestParts, Path},
6 http::{request::Parts, HeaderMap, StatusCode},
7 response::IntoResponse,
8 routing::{get, post},
9 Router,
10};
11use gatehouse::*;
12use std::sync::Arc;
13use std::time::{Duration, SystemTime};
14use uuid::Uuid;
15
16#[derive(Debug, Clone)]
21pub struct User {
22 pub id: Uuid,
23 pub roles: Vec<String>,
24}
25
26#[derive(Debug, Clone)]
27pub struct AuthenticatedUser(pub User);
28
29impl<S> FromRequestParts<S> for AuthenticatedUser
30where
31 S: Send + Sync,
32{
33 type Rejection = (StatusCode, String);
34
35 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
36 let id = parts
37 .headers
38 .get("x-user-id")
39 .and_then(|value| value.to_str().ok())
40 .and_then(|raw| Uuid::parse_str(raw).ok())
41 .unwrap_or_else(Uuid::nil);
42
43 let roles = parts
44 .headers
45 .get("x-roles")
46 .and_then(|value| value.to_str().ok())
47 .map(|raw| {
48 raw.split(',')
49 .map(|role| role.trim().to_ascii_lowercase())
50 .filter(|role| !role.is_empty())
51 .collect::<Vec<_>>()
52 })
53 .unwrap_or_else(|| vec!["viewer".to_string()]);
54
55 Ok(AuthenticatedUser(User { id, roles }))
56 }
57}
58
59fn parse_bool(value: &str) -> Option<bool> {
60 match value.trim().to_ascii_lowercase().as_str() {
61 "true" | "1" | "yes" => Some(true),
62 "false" | "0" | "no" => Some(false),
63 _ => None,
64 }
65}
66
67#[derive(Debug, Default, Clone)]
68pub struct InvoiceOverrides {
69 locked: Option<bool>,
70 age_days: Option<u64>,
71}
72
73impl InvoiceOverrides {
74 pub fn from_headers(headers: &HeaderMap) -> Self {
75 let locked = headers
76 .get("x-invoice-locked")
77 .and_then(|value| value.to_str().ok())
78 .and_then(parse_bool);
79
80 let age_days = headers
81 .get("x-invoice-age-days")
82 .and_then(|value| value.to_str().ok())
83 .and_then(|raw| raw.parse::<u64>().ok());
84
85 Self { locked, age_days }
86 }
87
88 fn build_invoice(&self, invoice_id: Uuid) -> Invoice {
89 Invoice {
90 id: invoice_id,
91 owner_id: Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
92 locked: self.locked.unwrap_or(false),
93 created_at: SystemTime::now()
94 - Duration::from_secs(self.age_days.unwrap_or(10) * 24 * 60 * 60),
95 }
96 }
97}
98
99impl<S> FromRequestParts<S> for InvoiceOverrides
100where
101 S: Send + Sync,
102{
103 type Rejection = (StatusCode, String);
104
105 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
106 Ok(Self::from_headers(&parts.headers))
107 }
108}
109
110#[derive(Debug, Default, Clone)]
111pub struct PaymentOverrides {
112 refunded: Option<bool>,
113 approved: Option<bool>,
114}
115
116impl PaymentOverrides {
117 pub fn from_headers(headers: &HeaderMap) -> Self {
118 let refunded = headers
119 .get("x-payment-refunded")
120 .and_then(|value| value.to_str().ok())
121 .and_then(parse_bool);
122
123 let approved = headers
124 .get("x-payment-approved")
125 .and_then(|value| value.to_str().ok())
126 .and_then(parse_bool);
127
128 Self { refunded, approved }
129 }
130
131 fn build_payment(&self, payment_id: Uuid) -> Payment {
132 Payment {
133 id: payment_id,
134 invoice_id: Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(),
135 is_refunded: self.refunded.unwrap_or(false),
136 approved: self.approved.unwrap_or(false),
137 }
138 }
139}
140
141impl<S> FromRequestParts<S> for PaymentOverrides
142where
143 S: Send + Sync,
144{
145 type Rejection = (StatusCode, String);
146
147 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
148 Ok(Self::from_headers(&parts.headers))
149 }
150}
151
152#[derive(Debug, Clone)]
158pub enum Action {
159 Edit, ApprovePayment, RefundPayment, View, }
164
165#[derive(Debug, Clone)]
168pub enum Resource {
169 Invoice(Invoice),
170 Payment(Payment),
171}
172
173#[derive(Debug, Clone)]
176pub struct Invoice {
177 pub id: Uuid,
178 pub owner_id: Uuid,
179 pub locked: bool,
180 pub created_at: SystemTime,
181}
182
183#[derive(Debug, Clone)]
186pub struct Payment {
187 pub id: Uuid,
188 pub invoice_id: Uuid,
189 pub is_refunded: bool,
190 pub approved: bool,
191}
192
193#[derive(Debug, Clone)]
195pub struct RequestContext {
196 pub current_time: SystemTime,
197}
198
199fn admin_override_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
208 PolicyBuilder::<User, Resource, Action, RequestContext>::new("AdminOverridePolicy")
209 .when(|user, _action, _resource, _ctx| user.roles.contains(&"admin".to_string()))
210 .build()
211}
212
213fn invoice_editing_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
225 let is_invoice_and_edit =
227 PolicyBuilder::<User, Resource, Action, RequestContext>::new("IsInvoiceAndEdit")
228 .when(|_user, action, resource, _ctx| {
229 matches!(action, Action::Edit) && matches!(resource, Resource::Invoice(_))
230 })
231 .build();
232
233 let is_owner = PolicyBuilder::<User, Resource, Action, RequestContext>::new("IsOwnerOfInvoice")
236 .when(|user, _action, resource, _ctx| match resource {
237 Resource::Invoice(inv) => user.id == inv.owner_id,
238 _ => false,
239 })
240 .build();
241
242 let invoice_not_locked =
244 PolicyBuilder::<User, Resource, Action, RequestContext>::new("InvoiceNotLocked")
245 .when(|_user, _action, resource, _ctx| match resource {
246 Resource::Invoice(inv) => !inv.locked,
247 _ => false,
248 })
249 .build();
250
251 const THIRTY_DAYS: u64 = 30 * 24 * 60 * 60;
253 let invoice_age_under_30_days =
254 PolicyBuilder::<User, Resource, Action, RequestContext>::new("InvoiceAgeUnder30Days")
255 .when(move |_user, _action, resource, ctx| match resource {
256 Resource::Invoice(inv) => {
257 let age_secs = ctx
258 .current_time
259 .duration_since(inv.created_at)
260 .unwrap_or_default()
261 .as_secs();
262 age_secs <= THIRTY_DAYS
263 }
264 _ => false,
265 })
266 .build();
267
268 let and_policy = AndPolicy::try_new(vec![
270 Arc::from(is_invoice_and_edit),
271 Arc::from(is_owner),
272 Arc::from(invoice_not_locked),
273 Arc::from(invoice_age_under_30_days),
274 ])
275 .expect("Should have at least one policy in the AND set");
276
277 Box::new(and_policy)
279}
280
281fn payment_approve_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
288 PolicyBuilder::<User, Resource, Action, RequestContext>::new("PaymentApprovePolicy")
289 .when(|user, action, resource, _ctx| match resource {
290 Resource::Payment(payment) => {
291 matches!(action, Action::ApprovePayment)
292 && user.roles.contains(&"finance_manager".to_string())
293 && !payment.is_refunded
294 }
295 _ => false,
296 })
297 .build()
298}
299
300fn payment_refund_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
307 Box::new(AbacPolicy::new(
310 |user: &User, resource: &Resource, action: &Action, _ctx: &RequestContext| {
311 if let Resource::Payment(_) = resource {
312 if matches!(action, Action::RefundPayment) {
313 return user.roles.contains(&"finance_manager".into())
314 || user.roles.contains(&"refund_specialist".into());
315 }
316 }
317 false
318 },
319 ))
320}
321
322pub fn build_permission_checker() -> PermissionChecker<User, Resource, Action, RequestContext> {
326 let mut checker = PermissionChecker::new();
327
328 checker.add_policy(admin_override_policy());
332 checker.add_policy(invoice_editing_policy());
333 checker.add_policy(payment_approve_policy());
334 checker.add_policy(payment_refund_policy());
335
336 checker
337}
338
339pub async fn view_invoice_handler(
344 Path(invoice_id): Path<Uuid>,
345 Extension(checker): Extension<PermissionChecker<User, Resource, Action, RequestContext>>,
346 AuthenticatedUser(user): AuthenticatedUser,
347 overrides: InvoiceOverrides,
348) -> impl IntoResponse {
349 let invoice = overrides.build_invoice(invoice_id);
351
352 if checker
353 .evaluate_access(
354 &user,
355 &Action::View,
356 &Resource::Invoice(invoice.clone()),
357 &RequestContext {
358 current_time: SystemTime::now(),
359 },
360 )
361 .await
362 .is_granted()
363 {
364 (StatusCode::OK, format!("{:?}", invoice)).into_response()
365 } else {
366 (
367 StatusCode::FORBIDDEN,
368 "You are not authorized to edit this invoice",
369 )
370 .into_response()
371 }
372}
373
374pub async fn edit_invoice_handler(
375 Path(invoice_id): Path<Uuid>,
376 Extension(checker): Extension<PermissionChecker<User, Resource, Action, RequestContext>>,
377 AuthenticatedUser(user): AuthenticatedUser,
378 overrides: InvoiceOverrides,
379) -> impl IntoResponse {
380 let invoice = overrides.build_invoice(invoice_id);
382
383 let resource = Resource::Invoice(invoice);
384 let action = Action::Edit;
385 let context = RequestContext {
386 current_time: SystemTime::now(),
387 };
388
389 let decision = checker
390 .evaluate_access(&user, &action, &resource, &context)
391 .await;
392
393 if decision.is_granted() {
394 (StatusCode::OK, "Invoice edited successfully").into_response()
396 } else {
397 (
398 StatusCode::FORBIDDEN,
399 "You are not authorized to edit this invoice",
400 )
401 .into_response()
402 }
403}
404
405pub async fn approve_payment_handler(
406 Path(payment_id): Path<Uuid>,
407 Extension(checker): Extension<PermissionChecker<User, Resource, Action, RequestContext>>,
408 AuthenticatedUser(user): AuthenticatedUser,
409 headers: HeaderMap,
410) -> impl IntoResponse {
411 let overrides = PaymentOverrides::from_headers(&headers);
413 let payment = overrides.build_payment(payment_id);
414
415 let resource = Resource::Payment(payment);
416 let action = Action::ApprovePayment;
417 let context = RequestContext {
418 current_time: SystemTime::now(),
419 };
420
421 let decision = checker
422 .evaluate_access(&user, &action, &resource, &context)
423 .await;
424
425 if decision.is_granted() {
426 (StatusCode::OK, "Payment approved").into_response()
428 } else {
429 (
430 StatusCode::FORBIDDEN,
431 "You are not authorized to approve this payment",
432 )
433 .into_response()
434 }
435}
436
437#[tokio::main]
442async fn main() {
443 let checker = build_permission_checker();
445
446 let app = Router::new()
448 .route("/invoices/{invoice_id}", get(view_invoice_handler))
449 .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler))
450 .route(
451 "/payments/{payment_id}/approve",
452 post(approve_payment_handler),
453 )
454 .layer(Extension(checker));
455
456 let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
458 println!("Listening on http://0.0.0.0:8000");
459 axum::serve(listener, app).await.unwrap();
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use gatehouse::AccessEvaluation;
466 use std::time::{Duration, SystemTime};
467
468 fn make_invoice(owner_id: Uuid, locked: bool, age_in_days: u64) -> Invoice {
470 Invoice {
471 id: Uuid::new_v4(),
472 owner_id,
473 locked,
474 created_at: SystemTime::now() - Duration::from_secs(age_in_days * 24 * 60 * 60),
475 }
476 }
477
478 fn make_payment(invoice_id: Uuid, is_refunded: bool, approved: bool) -> Payment {
480 Payment {
481 id: Uuid::new_v4(),
482 invoice_id,
483 is_refunded,
484 approved,
485 }
486 }
487
488 fn context_now() -> RequestContext {
490 RequestContext {
491 current_time: SystemTime::now(),
492 }
493 }
494
495 #[tokio::test]
496 async fn test_admin_override() {
497 let checker = build_permission_checker();
498 let admin_user = User {
499 id: Uuid::new_v4(),
500 roles: vec!["admin".to_string()],
501 };
502
503 let invoice = make_invoice(
505 admin_user.id,
506 true,
507 60,
508 );
509 let resource = Resource::Invoice(invoice);
510
511 let result = checker
512 .evaluate_access(&admin_user, &Action::Edit, &resource, &context_now())
513 .await;
514
515 assert!(
516 result.is_granted(),
517 "AdminOverridePolicy should allow admin to do anything"
518 );
519
520 match result {
521 AccessEvaluation::Granted { policy_type, .. } => {
522 assert_eq!(&policy_type, "AdminOverridePolicy");
523 }
524 _ => panic!("Expected admin override to be granted"),
525 }
526 }
527
528 #[tokio::test]
529 async fn test_invoice_editing_owner_unlocked_recent() {
530 let checker = build_permission_checker();
531 let owner_id = Uuid::new_v4();
532 let user = User {
533 id: owner_id,
534 roles: vec!["user".to_string()],
535 };
536
537 let invoice = make_invoice(owner_id, false, 10);
539 let resource = Resource::Invoice(invoice);
540
541 let result = checker
543 .evaluate_access(&user, &Action::Edit, &resource, &context_now())
544 .await;
545
546 assert!(
547 result.is_granted(),
548 "Invoice editing policy should allow owner if under 30 days, unlocked"
549 );
550 }
551
552 #[tokio::test]
553 async fn test_invoice_editing_denied_if_locked() {
554 let checker = build_permission_checker();
555 let owner_id = Uuid::new_v4();
556 let user = User {
557 id: owner_id,
558 roles: vec!["user".to_string()],
559 };
560
561 let invoice = make_invoice(owner_id, true, 10);
563 let resource = Resource::Invoice(invoice);
564
565 let result = checker
566 .evaluate_access(&user, &Action::Edit, &resource, &context_now())
567 .await;
568
569 assert!(
570 !result.is_granted(),
571 "Should be denied if invoice is locked"
572 );
573
574 if let AccessEvaluation::Denied { trace, .. } = result {
576 let trace_str = trace.format();
577 assert!(
578 trace_str.contains("InvoiceNotLocked"),
579 "Expected InvoiceNotLocked sub-policy to fail in trace: \n{}",
580 trace_str
581 );
582 }
583 }
584
585 #[tokio::test]
586 async fn test_invoice_editing_denied_if_not_owner() {
587 let checker = build_permission_checker();
588 let actual_owner_id = Uuid::new_v4();
589 let another_user_id = Uuid::new_v4();
590
591 let user = User {
592 id: another_user_id,
593 roles: vec!["user".to_string()],
594 };
595
596 let invoice = make_invoice(
597 actual_owner_id,
598 false,
599 10,
600 );
601 let resource = Resource::Invoice(invoice);
602
603 let result = checker
604 .evaluate_access(&user, &Action::Edit, &resource, &context_now())
605 .await;
606
607 assert!(
608 !result.is_granted(),
609 "Should be denied if user is not the owner"
610 );
611
612 if let AccessEvaluation::Denied { trace, .. } = result {
613 let trace_str = trace.format();
614 assert!(
615 trace_str.contains("IsOwnerOfInvoice"),
616 "Expected IsOwnerOfInvoice sub-policy to fail"
617 );
618 }
619 }
620
621 #[tokio::test]
622 async fn test_invoice_editing_denied_if_too_old() {
623 let checker = build_permission_checker();
624 let owner_id = Uuid::new_v4();
625 let user = User {
626 id: owner_id,
627 roles: vec!["user".to_string()],
628 };
629
630 let invoice = make_invoice(owner_id, false, 31);
632 let resource = Resource::Invoice(invoice);
633
634 let result = checker
635 .evaluate_access(&user, &Action::Edit, &resource, &context_now())
636 .await;
637 assert!(
638 !result.is_granted(),
639 "Should be denied if invoice is older than 30 days"
640 );
641 }
642
643 #[tokio::test]
644 async fn test_payment_approve_finance_manager() {
645 let checker = build_permission_checker();
646
647 let user = User {
649 id: Uuid::new_v4(),
650 roles: vec!["finance_manager".to_string()],
651 };
652 let payment = make_payment(
653 Uuid::new_v4(),
654 false,
655 false,
656 );
657 let resource = Resource::Payment(payment);
658
659 let result = checker
660 .evaluate_access(&user, &Action::ApprovePayment, &resource, &context_now())
661 .await;
662
663 assert!(
664 result.is_granted(),
665 "PaymentApprovePolicy should allow finance_manager to approve"
666 );
667 }
668
669 #[tokio::test]
670 async fn test_payment_approve_finance_manager_idempotent() {
671 let checker = build_permission_checker();
672
673 let user = User {
674 id: Uuid::new_v4(),
675 roles: vec!["finance_manager".to_string()],
676 };
677 let payment = make_payment(
678 Uuid::new_v4(),
679 false,
680 true,
681 );
682 let resource = Resource::Payment(payment);
683
684 let result = checker
685 .evaluate_access(&user, &Action::ApprovePayment, &resource, &context_now())
686 .await;
687
688 assert!(
689 result.is_granted(),
690 "PaymentApprovePolicy should allow finance_manager to re-approve",
691 );
692 }
693
694 #[tokio::test]
695 async fn test_payment_approve_denied_for_regular_user() {
696 let checker = build_permission_checker();
697
698 let user = User {
699 id: Uuid::new_v4(),
700 roles: vec!["regular_user".to_string()],
701 };
702 let payment = make_payment(Uuid::new_v4(), false, false);
703 let resource = Resource::Payment(payment);
704
705 let result = checker
707 .evaluate_access(&user, &Action::ApprovePayment, &resource, &context_now())
708 .await;
709 assert!(
710 !result.is_granted(),
711 "Regular user should not be able to approve"
712 );
713 }
714
715 #[tokio::test]
716 async fn test_payment_refund_finance_or_refund_specialist() {
717 let checker = build_permission_checker();
718
719 let user_finance = User {
720 id: Uuid::new_v4(),
721 roles: vec!["finance_manager".to_string()],
722 };
723 let user_refund_specialist = User {
724 id: Uuid::new_v4(),
725 roles: vec!["refund_specialist".to_string()],
726 };
727
728 let payment = make_payment(Uuid::new_v4(), false, false);
729 let resource = Resource::Payment(payment);
730
731 let res1 = checker
733 .evaluate_access(
734 &user_finance,
735 &Action::RefundPayment,
736 &resource,
737 &context_now(),
738 )
739 .await;
740 assert!(res1.is_granted(), "finance_manager is allowed to refund");
741
742 let res2 = checker
744 .evaluate_access(
745 &user_refund_specialist,
746 &Action::RefundPayment,
747 &resource,
748 &context_now(),
749 )
750 .await;
751 assert!(res2.is_granted(), "refund_specialist is allowed to refund");
752 }
753
754 #[tokio::test]
755 async fn test_payment_refund_denied_for_regular_user() {
756 let checker = build_permission_checker();
757
758 let user = User {
759 id: Uuid::new_v4(),
760 roles: vec!["user".to_string()],
761 };
762 let payment = make_payment(Uuid::new_v4(), false, false);
763 let resource = Resource::Payment(payment);
764
765 let result = checker
767 .evaluate_access(&user, &Action::RefundPayment, &resource, &context_now())
768 .await;
769 assert!(!result.is_granted(), "Regular user can't refund payment");
770 }
771}
772
773#[cfg(test)]
774mod integration_tests {
775 use super::*;
776 use axum::{
777 body::Body,
778 http::{Request, StatusCode},
779 Router,
780 };
781 use tower::ServiceExt;
782
783 fn test_app() -> Router {
784 let checker = build_permission_checker();
785 Router::new()
786 .route("/invoices/{invoice_id}/edit", post(edit_invoice_handler))
787 .layer(Extension(checker))
788 }
789
790 #[tokio::test]
791 async fn test_edit_invoice_handler_allows_admin() {
792 let app = test_app();
793
794 let req = Request::builder()
796 .method("POST")
797 .uri("/invoices/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit")
798 .header("x-roles", "admin")
799 .body(Body::empty())
800 .unwrap();
801
802 let response = app.clone().oneshot(req).await.unwrap();
803 assert_eq!(response.status(), StatusCode::OK);
805 }
806
807 #[tokio::test]
808 async fn test_edit_invoice_handler_denies_regular_user_if_locked() {
809 let app = test_app();
810
811 let req = Request::builder()
812 .method("POST")
813 .uri("/invoices/cccccccc-cccc-cccc-cccc-cccccccccccc/edit")
814 .header("x-user-id", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
815 .header("x-roles", "author")
816 .header("x-invoice-locked", "true")
817 .body(Body::empty())
818 .unwrap();
819
820 let response = app.clone().oneshot(req).await.unwrap();
821
822 assert_eq!(response.status(), StatusCode::FORBIDDEN);
823 }
824}