use async_trait::async_trait;
use gatehouse::{
AccessEvaluation, Effect, EvalCtx, EvaluationSession, PermissionChecker, Policy, PolicyDomain,
PolicyEvalResult,
};
use std::borrow::Cow;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
#[derive(Debug, Clone)]
struct User {
#[allow(dead_code)] id: Uuid,
roles: Vec<String>,
}
#[derive(Debug, Clone)]
struct RefundRequest {
#[allow(dead_code)]
id: Uuid,
amount_cents: u64,
}
#[derive(Debug, Clone)]
struct Approve;
#[derive(Debug, Clone)]
struct ApprovalContext {
current_time: SystemTime,
mfa_verified_at: Option<SystemTime>,
}
struct RefundApprovalDomain;
impl PolicyDomain for RefundApprovalDomain {
type Subject = User;
type Action = Approve;
type Resource = RefundRequest;
type Context = ApprovalContext;
}
struct FinanceCanApproveRefunds;
#[async_trait]
impl Policy<RefundApprovalDomain> for FinanceCanApproveRefunds {
async fn evaluate(&self, ctx: &EvalCtx<'_, RefundApprovalDomain>) -> PolicyEvalResult {
if ctx.subject.roles.iter().any(|r| r == "finance") {
ctx.grant("subject has the finance role")
} else {
ctx.not_applicable("subject lacks the finance role")
}
}
fn policy_type(&self) -> Cow<'static, str> {
Cow::Borrowed("FinanceCanApproveRefunds")
}
}
struct HighValueRequiresFreshMfa {
threshold_cents: u64,
max_age: Duration,
}
#[async_trait]
impl Policy<RefundApprovalDomain> for HighValueRequiresFreshMfa {
async fn evaluate(&self, ctx: &EvalCtx<'_, RefundApprovalDomain>) -> PolicyEvalResult {
if ctx.resource.amount_cents < self.threshold_cents {
return ctx.not_applicable("amount below high-value threshold; rule not applicable");
}
let Some(verified_at) = ctx.context.mfa_verified_at else {
return ctx.forbid(format!(
"high-value refund (>={} cents) requires recent MFA, but this session has none",
self.threshold_cents,
));
};
let age = ctx
.context
.current_time
.duration_since(verified_at)
.unwrap_or_default();
if age <= self.max_age {
ctx.not_applicable(format!(
"MFA reasserted {}s ago, within freshness window; rule not applicable",
age.as_secs(),
))
} else {
ctx.forbid(format!(
"MFA reasserted {}s ago, exceeds freshness window of {}s",
age.as_secs(),
self.max_age.as_secs(),
))
}
}
fn policy_type(&self) -> Cow<'static, str> {
Cow::Borrowed("HighValueRequiresFreshMfa")
}
fn effect(&self) -> Effect {
Effect::Forbid
}
}
fn build_checker() -> PermissionChecker<RefundApprovalDomain> {
let mut checker = PermissionChecker::named("RefundApprovalChecker");
checker.add_policy(FinanceCanApproveRefunds);
checker.add_policy(HighValueRequiresFreshMfa {
threshold_cents: 1_000_000, max_age: Duration::from_secs(5 * 60),
});
checker
}
#[tokio::main]
async fn main() {
let alice = User {
id: Uuid::new_v4(),
roles: vec!["finance".into()],
};
let small_refund = RefundRequest {
id: Uuid::new_v4(),
amount_cents: 5_000, };
let large_refund = RefundRequest {
id: Uuid::new_v4(),
amount_cents: 5_000_000, };
let now = SystemTime::now();
let checker = build_checker();
let session = EvaluationSession::empty();
let small_no_mfa = ApprovalContext {
current_time: now,
mfa_verified_at: None,
};
let r = checker
.bind(&session, &alice, &Approve, &small_no_mfa)
.check(&small_refund)
.await;
report("small refund, no MFA", &r);
r.assert_granted_by("FinanceCanApproveRefunds");
let r = checker
.bind(&session, &alice, &Approve, &small_no_mfa)
.check(&large_refund)
.await;
report("large refund, no MFA", &r);
r.assert_forbidden_by("HighValueRequiresFreshMfa");
let stale = ApprovalContext {
current_time: now,
mfa_verified_at: Some(now - Duration::from_secs(8 * 60)),
};
let r = checker
.bind(&session, &alice, &Approve, &stale)
.check(&large_refund)
.await;
report("large refund, MFA 8m old", &r);
r.assert_forbidden_by("HighValueRequiresFreshMfa");
let fresh = ApprovalContext {
current_time: now,
mfa_verified_at: Some(now - Duration::from_secs(30)),
};
let r = checker
.bind(&session, &alice, &Approve, &fresh)
.check(&large_refund)
.await;
report("large refund, MFA 30s old", &r);
r.assert_granted_by("FinanceCanApproveRefunds");
}
fn report(label: &str, eval: &AccessEvaluation) {
println!("{label} → {}\n{}", verdict(eval), eval.trace().format());
}
fn verdict(eval: &AccessEvaluation) -> &'static str {
if eval.is_granted() {
"GRANTED"
} else {
"DENIED"
}
}