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}