Skip to main content

axum/
axum.rs

1// Axum service that authorizes multiple resource types (Invoices, Payments)
2// using a single PermissionChecker. Demonstrates multiple policies and actions.
3
4use 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// --------------------
17// 1) Domain Modeling
18// --------------------
19
20#[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/// Main "Action" enum. Your app might have more actions:
153///   - Edit an invoice
154///   - Approve a payment
155///   - Refund a payment
156///   - View a resource, etc.
157#[derive(Debug, Clone)]
158pub enum Action {
159    Edit,           // e.g. editing an invoice
160    ApprovePayment, // e.g. approving a payment resource
161    RefundPayment,  // e.g. refunding a payment
162    View,           // a generic "view" action
163}
164
165/// Two resource types in our app: invoices and payments. We wrap them
166/// in a single enum to share one PermissionChecker across different routes/resources.
167#[derive(Debug, Clone)]
168pub enum Resource {
169    Invoice(Invoice),
170    Payment(Payment),
171}
172
173/// An invoice resource. For example, you can only edit it if it isn't locked and
174/// it's within 30 days of creation (unless you're an admin, which overrides).
175#[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/// A payment resource. Let’s suppose we can only approve or refund it if we have
184/// certain roles (e.g., "finance_manager" or "admin").
185#[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/// Extra context. Could include "current_time", "feature flags", "organization info", etc.
194#[derive(Debug, Clone)]
195pub struct RequestContext {
196    pub current_time: SystemTime,
197}
198
199/// --------------------------
200/// 2) Building Our Policies
201/// --------------------------
202/// We'll create multiple policies that each handle a slice of the logic.
203/// Then we combine them with OR or AND as needed.
204///
205/// (A) `AdminOverridePolicy`
206///     Allows any action on any resource if user has the "admin" role.
207fn 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
213/// (B) `InvoiceEditingPolicy`
214///     Allows editing an invoice if:
215///       - It's an `Invoice` resource and the requested action is `Action::Edit`,
216///       - The user is the invoice owner,
217///       - The invoice is NOT locked,
218///       - The invoice is < 30 days old.
219///     (If any of these fail, it denies.)
220/// We do this by creating four separate sub-policies, `IsInvoiceAndEdit`, `IsOwnerOfInvoice`
221/// `InvoiceNotLocked`, `InvoiceAgeUnder30Days`.
222/// Then we AND them together. If any sub-policy fails, you’ll see which one did
223/// in the evaluation trace (with its own reason).
224fn invoice_editing_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
225    // Sub-policy #1: Check that (resource=Invoice) and (action=Edit).
226    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    // Sub-policy #2: Must be the owner of the invoice.
234    // We must ensure we only run the check *if* it's an Invoice; else we treat it as failing.
235    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    // Sub-policy #3: Invoice must not be locked
243    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    // Sub-policy #4: Invoice must be < 30 days old
252    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    // Now AND them together:
269    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    // Return as a boxed dyn Policy
278    Box::new(and_policy)
279}
280
281/// (C) `PaymentApprovePolicy`
282///     Allows approving a payment if:
283///       - It's a `Payment` resource
284///       - Action is `Action::ApprovePayment`
285///       - The user has "finance_manager" (or "admin", but we have AdminOverride separately)
286///       - The payment has not been refunded (already-approved payments can be re-approved)
287fn 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
300/// (D) `PaymentRefundPolicy`
301///     Allows refunding a payment if:
302///       - It's a `Payment` resource
303///       - Action is `Action::RefundPayment`
304///       - The user has "finance_manager"
305///         OR some other "refund" special role.  (We’ll keep it simple.)
306fn payment_refund_policy() -> Box<dyn Policy<User, Resource, Action, RequestContext>> {
307    // Alternatively, we can just do a single condition for finance_manager,
308    // or combine them. Here let's say "finance_manager" or "refund_specialist".
309    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
322/// (E) Combine all relevant policies into a single `PermissionChecker`.
323///     The checker uses OR semantics by default: if ANY policy returns Granted,
324///     the request is allowed.
325pub fn build_permission_checker() -> PermissionChecker<User, Resource, Action, RequestContext> {
326    let mut checker = PermissionChecker::new();
327
328    // We add them in the order we want them to be evaluated,
329    // but note that OR short-circuits on the first Granted. So
330    // e.g. if "AdminOverridePolicy" passes, we never evaluate the others.
331    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
339// ---------------------------------
340// 3) Using in Axum Route Handlers
341// ---------------------------------
342
343pub 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    // Simulate DB fetch
350    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    // Simulate DB fetch
381    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        // do the editing...
395        (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    // Simulate DB fetch
412    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        // do the approval...
427        (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// ----------------------------------------
438// 4) The Axum App with Our PermissionChecker
439// ----------------------------------------
440
441#[tokio::main]
442async fn main() {
443    // Build our single permission checker and share it with handlers as Extension state.
444    let checker = build_permission_checker();
445
446    // Construct Axum Router
447    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    // Run Axum App
457    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    // Helper to quickly build an invoice with desired properties
469    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    // Helper to quickly build a payment
479    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    // Helper to build a RequestContext
489    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        // Attempt any action on any resource
504        let invoice = make_invoice(
505            admin_user.id,
506            /*locked=*/ true,
507            /*age_in_days=*/ 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        // Invoice is not locked, 10 days old
538        let invoice = make_invoice(owner_id, /*locked=*/ false, /*age_in_days=*/ 10);
539        let resource = Resource::Invoice(invoice);
540
541        // The user is the owner, the invoice is unlocked, <30 days old => should be granted
542        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        // Invoice is locked, 10 days old
562        let invoice = make_invoice(owner_id, /*locked=*/ true, /*age_in_days=*/ 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        // We can also look at trace to see which sub-policy failed
575        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            /*locked=*/ false,
599            /*age_in_days=*/ 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        // 31 days old => should fail the "InvoiceAgeUnder30Days" sub-policy
631        let invoice = make_invoice(owner_id, /*locked=*/ false, /*age_in_days=*/ 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        // finance_manager role is allowed to ApprovePayment
648        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            /*is_refunded=*/ false,
655            /*approved=*/ 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            /*is_refunded=*/ false,
680            /*approved=*/ 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        // Not finance_manager or admin => deny
706        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        // 1) finance_manager can refund
732        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        // 2) refund_specialist can refund
743        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        // Should be denied
766        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        // We'll pretend the path param is some random UUID
795        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        // With the admin role header set we expect 200 OK
804        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}