arcly_http/auth/policy.rs
1//! Attribute-Based Access Control (ABAC) policy engine — hot-reloadable,
2//! default-deny, zero locks on the request path.
3//!
4//! ## Why RBAC isn't enough at Tier-1
5//!
6//! `PermissionGuard` answers "does this *role* hold `orders:refund`?". A
7//! fintech audit asks "may *this* senior agent refund *this much* for *this*
8//! tenant *right now*?" — a decision over **attributes** of the subject,
9//! resource, and environment. Those rules change with compliance reviews,
10//! not deploy cycles, so they must live outside the binary and hot-reload.
11//!
12//! ## Zero-lock mechanics
13//!
14//! The whole rule set sits behind one `ArcSwap<PolicySet>` (the same proven
15//! pattern as secret rotation and dynamic tenants): evaluation is one atomic
16//! pointer load, a `HashMap` lookup, and pure compiled predicates. Reloads
17//! (file watcher, OPA bundle poller, admin endpoint) build a fresh
18//! `PolicySet` off-path and swap it in — version-monotonic, so stale loads
19//! are ignored. **No policy-agent network call ever happens on the hot
20//! path.**
21//!
22//! ## Usage
23//!
24//! ```ignore
25//! // boot:
26//! ctx.provide(PolicyEngine::new(my_rules_v1()));
27//!
28//! // route-level (macro): all listed actions must Permit
29//! #[Post("/:id/refund", security("bearer"))]
30//! #[RequirePolicies("orders.refund")]
31//! async fn refund(ctx: RequestContext, #[Body] dto: RefundDto) -> Result<Json<Value>, HttpException> {
32//! // fine-grained re-check with resource attributes:
33//! policy::check_policies(&ctx, &["orders.refund.amount"],
34//! serde_json::json!({ "amount_cents": dto.amount_cents }))?;
35//! /* ... */
36//! }
37//! ```
38
39use std::collections::HashMap;
40use std::sync::Arc;
41
42use arc_swap::ArcSwap;
43
44use crate::web::{Error, RequestContext};
45
46// ─── Decision model ───────────────────────────────────────────────────────────
47
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum Decision {
50 Permit,
51 Deny,
52}
53
54/// Everything a rule may inspect for one decision.
55pub struct PolicyInput<'a> {
56 /// Dotted action name, e.g. `"orders.refund"`.
57 pub action: &'a str,
58 /// The authenticated principal's JWT claims.
59 pub subject: &'a serde_json::Map<String, serde_json::Value>,
60 pub tenant: Option<&'a str>,
61 /// Handler-supplied resource attributes (`Null` for route-level checks).
62 pub resource: serde_json::Value,
63 pub env: EnvAttributes,
64}
65
66pub struct EnvAttributes {
67 pub unix_now: u64,
68 pub route: &'static str,
69 pub method: String,
70}
71
72impl PolicyInput<'_> {
73 /// Convenience: a string claim from the subject.
74 pub fn subject_str(&self, key: &str) -> Option<&str> {
75 self.subject.get(key).and_then(|v| v.as_str())
76 }
77 /// Convenience: a numeric resource attribute.
78 pub fn resource_i64(&self, key: &str) -> Option<i64> {
79 self.resource.get(key).and_then(|v| v.as_i64())
80 }
81}
82
83// ─── Rules & sets ─────────────────────────────────────────────────────────────
84
85/// One compiled rule: a pure predicate — no I/O, no locks, no await.
86pub struct CompiledRule {
87 pub effect: Decision,
88 pub when: Arc<dyn Fn(&PolicyInput) -> bool + Send + Sync>,
89}
90
91/// An immutable, versioned rule set. **Default-deny**: actions with no
92/// matching rule are denied — the zero-trust posture auditors expect.
93pub struct PolicySet {
94 pub version: u64,
95 by_action: HashMap<String, Vec<CompiledRule>>,
96}
97
98impl PolicySet {
99 pub fn new(version: u64) -> Self {
100 Self {
101 version,
102 by_action: HashMap::new(),
103 }
104 }
105
106 /// Register a rule for `action`. Rules evaluate in insertion order;
107 /// first match wins.
108 pub fn rule(
109 mut self,
110 action: &str,
111 effect: Decision,
112 when: impl Fn(&PolicyInput) -> bool + Send + Sync + 'static,
113 ) -> Self {
114 self.by_action
115 .entry(action.to_owned())
116 .or_default()
117 .push(CompiledRule {
118 effect,
119 when: Arc::new(when),
120 });
121 self
122 }
123
124 fn evaluate(&self, input: &PolicyInput) -> Decision {
125 let Some(rules) = self.by_action.get(input.action) else {
126 return Decision::Deny; // default-deny
127 };
128 for rule in rules {
129 if (rule.when)(input) {
130 return rule.effect;
131 }
132 }
133 Decision::Deny
134 }
135}
136
137// ─── Source & engine ──────────────────────────────────────────────────────────
138
139/// External policy origin (rules file, OPA bundle endpoint, DB) — the app
140/// implements it; a background watcher feeds [`PolicyEngine::reload`].
141pub trait PolicySource: Send + Sync + 'static {
142 fn fetch(&self) -> futures::future::BoxFuture<'_, Result<PolicySet, String>>;
143}
144
145/// Hot-swappable decision point. Provide via `ctx.provide(PolicyEngine::new(…))`.
146pub struct PolicyEngine {
147 set: ArcSwap<PolicySet>,
148}
149
150impl PolicyEngine {
151 pub fn new(initial: PolicySet) -> Self {
152 Self {
153 set: ArcSwap::from_pointee(initial),
154 }
155 }
156
157 /// Swap in a new rule set — effective on the very next evaluation.
158 /// Stale (≤ current) versions are ignored, so concurrent watchers and
159 /// duplicate bundle delivery are harmless.
160 pub fn reload(&self, next: PolicySet) {
161 let current = self.set.load().version;
162 if next.version <= current {
163 tracing::warn!(
164 current,
165 offered = next.version,
166 "ignoring stale policy reload"
167 );
168 return;
169 }
170 tracing::info!(version = next.version, "policy set reloaded (live)");
171 self.set.store(Arc::new(next));
172 }
173
174 /// Hot path: one atomic load + map read + pure predicates.
175 pub fn evaluate(&self, input: &PolicyInput) -> Decision {
176 self.set.load().evaluate(input)
177 }
178
179 pub fn version(&self) -> u64 {
180 self.set.load().version
181 }
182}
183
184// ─── Check helper (macro entry point + handler-level re-checks) ───────────────
185
186/// Evaluate every `action` against the engine; all must `Permit`.
187///
188/// `401` when unauthenticated, `403` when the engine is absent (zero-trust:
189/// no engine = no permits) or any action denies.
190pub fn check_policies(
191 ctx: &RequestContext,
192 actions: &[&'static str],
193 resource: serde_json::Value,
194) -> Result<(), Error> {
195 let claims = ctx.claims().ok_or(Error::Unauthorized)?;
196 let engine = ctx.try_inject::<PolicyEngine>().ok_or(Error::Forbidden)?;
197
198 for action in actions {
199 let input = PolicyInput {
200 action,
201 subject: claims,
202 tenant: ctx.tenant().map(|t| t.id.as_str()),
203 resource: resource.clone(),
204 env: EnvAttributes {
205 unix_now: crate::auth::session::unix_now(),
206 route: ctx.route(),
207 method: ctx.method().to_string(),
208 },
209 };
210 if engine.evaluate(&input) != Decision::Permit {
211 metrics::counter!("policy_denials_total", "action" => *action).increment(1);
212 return Err(Error::Forbidden);
213 }
214 }
215 Ok(())
216}