Skip to main content

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}