Skip to main content

mfa_freshness_context/
mfa_freshness_context.rs

1//! Real use of the `Context` generic: MFA-freshness for high-value
2//! payment approvals.
3//!
4//! The decision "can Alice approve this $50,000 refund?" depends on
5//! more than Alice's role and the refund's properties:
6//!
7//! - The user record (subject) knows Alice is a finance approver.
8//! - The refund record (resource) knows the amount and the original
9//!   payment.
10//! - But "was MFA reasserted within the last 5 minutes on *this*
11//!   request" is a property of the call, not of Alice or the refund.
12//!   The session token records it; the user record does not.
13//!
14//! That last bit is what `Context` is for. We carry `mfa_verified_at`
15//! and the request's wall-clock time on `ApprovalContext`. The
16//! high-value rule is a forbid-effect policy that **forbids** the approval
17//! when MFA freshness has lapsed; the role policy ignores the field
18//! entirely. Same subject, same resource, different calls → different
19//! decisions.
20
21use async_trait::async_trait;
22use gatehouse::{
23    AccessEvaluation, Effect, EvalCtx, EvaluationSession, PermissionChecker, Policy, PolicyDomain,
24    PolicyEvalResult,
25};
26use std::borrow::Cow;
27use std::time::{Duration, SystemTime};
28use uuid::Uuid;
29
30#[derive(Debug, Clone)]
31struct User {
32    #[allow(dead_code)] // Carried for the audit trail; not consulted by these policies.
33    id: Uuid,
34    roles: Vec<String>,
35}
36
37#[derive(Debug, Clone)]
38struct RefundRequest {
39    #[allow(dead_code)]
40    id: Uuid,
41    amount_cents: u64,
42}
43
44#[derive(Debug, Clone)]
45struct Approve;
46
47/// Request-scoped inputs. Captured at request entry from the auth
48/// middleware and the request's wall clock — not from the user
49/// record, not from the resource.
50#[derive(Debug, Clone)]
51struct ApprovalContext {
52    /// When this request hit the handler. Distinct from
53    /// `SystemTime::now()` inside the policy body for two reasons:
54    /// (1) it makes the policy deterministic under test, and (2) every
55    /// policy in a single evaluation sees the same instant.
56    current_time: SystemTime,
57    /// `Some(t)` if the auth session presented an MFA assertion at
58    /// time `t`; `None` if the request authenticated with a long-lived
59    /// token (API key, password-only login) where MFA was never
60    /// asserted on this session.
61    mfa_verified_at: Option<SystemTime>,
62}
63
64struct RefundApprovalDomain;
65
66impl PolicyDomain for RefundApprovalDomain {
67    type Subject = User;
68    type Action = Approve;
69    type Resource = RefundRequest;
70    type Context = ApprovalContext;
71}
72
73/// Finance approvers can approve refunds. No MFA requirement at this
74/// rule's level — the rule only checks the role on the subject and
75/// ignores `Context` entirely.
76struct FinanceCanApproveRefunds;
77
78#[async_trait]
79impl Policy<RefundApprovalDomain> for FinanceCanApproveRefunds {
80    async fn evaluate(&self, ctx: &EvalCtx<'_, RefundApprovalDomain>) -> PolicyEvalResult {
81        if ctx.subject.roles.iter().any(|r| r == "finance") {
82            ctx.grant("subject has the finance role")
83        } else {
84            ctx.not_applicable("subject lacks the finance role")
85        }
86    }
87    fn policy_type(&self) -> Cow<'static, str> {
88        Cow::Borrowed("FinanceCanApproveRefunds")
89    }
90}
91
92/// Deny rule: a high-value refund without fresh MFA is **forbidden**.
93///
94/// This is the policy that *does* care about `Context`, and it is a
95/// forbid-effect policy in natural polarity: it matches exactly when the
96/// approval must be blocked, and returns `ctx.forbid(...)` for that
97/// case. Everything else — small refunds, fresh MFA — is "not
98/// applicable" (`ctx.not_applicable`), which never blocks and never grants.
99///
100/// Registered flat on the [`PermissionChecker`], the forbid overrides
101/// every grant under deny-overrides semantics. That is the right
102/// strength for an MFA requirement: if an admin-override or
103/// service-account grant path is added later, a stale session still
104/// cannot approve a high-value refund through it.
105///
106/// Note the [`Policy::effect`] override below — a hand-written policy
107/// that can forbid must declare [`Effect::Forbid`] so the checker
108/// schedules it ahead of the grant short-circuit.
109struct HighValueRequiresFreshMfa {
110    threshold_cents: u64,
111    max_age: Duration,
112}
113
114#[async_trait]
115impl Policy<RefundApprovalDomain> for HighValueRequiresFreshMfa {
116    async fn evaluate(&self, ctx: &EvalCtx<'_, RefundApprovalDomain>) -> PolicyEvalResult {
117        // Rule doesn't apply below the threshold: not applicable, and a
118        // non-matching forbid-effect policy blocks nothing.
119        if ctx.resource.amount_cents < self.threshold_cents {
120            return ctx.not_applicable("amount below high-value threshold; rule not applicable");
121        }
122
123        let Some(verified_at) = ctx.context.mfa_verified_at else {
124            return ctx.forbid(format!(
125                "high-value refund (>={} cents) requires recent MFA, but this session has none",
126                self.threshold_cents,
127            ));
128        };
129
130        let age = ctx
131            .context
132            .current_time
133            .duration_since(verified_at)
134            .unwrap_or_default();
135        if age <= self.max_age {
136            ctx.not_applicable(format!(
137                "MFA reasserted {}s ago, within freshness window; rule not applicable",
138                age.as_secs(),
139            ))
140        } else {
141            ctx.forbid(format!(
142                "MFA reasserted {}s ago, exceeds freshness window of {}s",
143                age.as_secs(),
144                self.max_age.as_secs(),
145            ))
146        }
147    }
148    fn policy_type(&self) -> Cow<'static, str> {
149        Cow::Borrowed("HighValueRequiresFreshMfa")
150    }
151    fn effect(&self) -> Effect {
152        Effect::Forbid
153    }
154}
155
156fn build_checker() -> PermissionChecker<RefundApprovalDomain> {
157    // Flat registration: the role grant and the MFA veto are siblings.
158    // The checker's deny-overrides rule does the combining — any forbid
159    // wins, otherwise any grant wins, otherwise default deny.
160    let mut checker = PermissionChecker::named("RefundApprovalChecker");
161    checker.add_policy(FinanceCanApproveRefunds);
162    checker.add_policy(HighValueRequiresFreshMfa {
163        threshold_cents: 1_000_000, // $10,000
164        max_age: Duration::from_secs(5 * 60),
165    });
166    checker
167}
168
169#[tokio::main]
170async fn main() {
171    let alice = User {
172        id: Uuid::new_v4(),
173        roles: vec!["finance".into()],
174    };
175    let small_refund = RefundRequest {
176        id: Uuid::new_v4(),
177        amount_cents: 5_000, // $50
178    };
179    let large_refund = RefundRequest {
180        id: Uuid::new_v4(),
181        amount_cents: 5_000_000, // $50,000
182    };
183
184    let now = SystemTime::now();
185    let checker = build_checker();
186    let session = EvaluationSession::empty();
187
188    // Case 1: small refund, no MFA at all. Granted — the high-value
189    // rule doesn't apply below the threshold, so the role grant decides.
190    let small_no_mfa = ApprovalContext {
191        current_time: now,
192        mfa_verified_at: None,
193    };
194    let r = checker
195        .bind(&session, &alice, &Approve, &small_no_mfa)
196        .check(&small_refund)
197        .await;
198    report("small refund, no MFA", &r);
199    r.assert_granted_by("FinanceCanApproveRefunds");
200
201    // Case 2: large refund, no MFA. Forbidden by the freshness rule —
202    // the veto overrides Alice's role grant.
203    let r = checker
204        .bind(&session, &alice, &Approve, &small_no_mfa)
205        .check(&large_refund)
206        .await;
207    report("large refund, no MFA", &r);
208    r.assert_forbidden_by("HighValueRequiresFreshMfa");
209
210    // Case 3: large refund, MFA reasserted 8 minutes ago. Stale → forbidden.
211    let stale = ApprovalContext {
212        current_time: now,
213        mfa_verified_at: Some(now - Duration::from_secs(8 * 60)),
214    };
215    let r = checker
216        .bind(&session, &alice, &Approve, &stale)
217        .check(&large_refund)
218        .await;
219    report("large refund, MFA 8m old", &r);
220    r.assert_forbidden_by("HighValueRequiresFreshMfa");
221
222    // Case 4: large refund, MFA reasserted 30 seconds ago. The deny rule
223    // is not applicable, so the role grant decides.
224    let fresh = ApprovalContext {
225        current_time: now,
226        mfa_verified_at: Some(now - Duration::from_secs(30)),
227    };
228    let r = checker
229        .bind(&session, &alice, &Approve, &fresh)
230        .check(&large_refund)
231        .await;
232    report("large refund, MFA 30s old", &r);
233    r.assert_granted_by("FinanceCanApproveRefunds");
234
235    // The point: cases 2-4 all use the same subject and resource. The
236    // only thing that varies is `ApprovalContext`. That's exactly the
237    // signal that the rule belongs in `Context`, not on User or
238    // RefundRequest.
239}
240
241/// Print the verdict and the decision trace. The trace is where the freshness
242/// reason ("MFA reasserted 480s ago, exceeds freshness window of 300s") shows
243/// up — the deciding policy puts it there, and it is the whole point of the
244/// `Context` data flowing through.
245fn report(label: &str, eval: &AccessEvaluation) {
246    println!("{label} → {}\n{}", verdict(eval), eval.trace().format());
247}
248
249fn verdict(eval: &AccessEvaluation) -> &'static str {
250    if eval.is_granted() {
251        "GRANTED"
252    } else {
253        "DENIED"
254    }
255}