bext_plugin_api/authz_policy.rs
1//! AuthzPolicy capability trait. See `plan/ecosystem/02-capabilities.md §AuthzPolicy`.
2//!
3//! An `AuthzPolicyPlugin` answers *"given this request context, is the
4//! action allowed?"* using an externalised policy language — Cedar, CEL,
5//! OPA/Rego, or a custom engine. It sits one layer **above**
6//! [`crate::auth::AuthPlugin`]: auth establishes *who* the caller is,
7//! authz-policy decides *what* they may do with that identity.
8//!
9//! # Why the return shape is an enum, not `Result<_, String>`
10//!
11//! This is the one capability in `bext-plugin-api` where a flat enum is
12//! load-bearing. Callers branch on three genuinely distinct outcomes:
13//!
14//! 1. **Allow.** Let the request proceed untouched.
15//! 2. **Deny.** Short-circuit with a 403, forwarding the reason to the
16//! logs and (optionally) to the client. The reason string is policy
17//! author–controlled ("principal lacks `posts:write` on `posts/123`")
18//! and the host must not discard it.
19//! 3. **Mutate.** Let the request proceed, but **rewrite the response**
20//! on the way out — inject headers (CSP, `X-Tenant`, audit trailers)
21//! or substitute the body (redact fields, stamp a watermark). Cedar's
22//! response-side obligations and a handful of custom engines need
23//! this; without it the trait cannot represent what they do.
24//!
25//! Folding these into `Result<_, String>` would collapse Mutate into
26//! Allow, which silently drops the rewrite. Folding Deny into `Err` would
27//! conflate "the policy said no" with "the policy engine crashed" — the
28//! host treats those completely differently (Deny is a normal 403, engine
29//! failure is a 500 and a wakeup).
30//!
31//! Backend-failure errors still exist, but they live on the outer
32//! [`AuthzPolicyPlugin::evaluate`] signature's companion methods
33//! ([`AuthzPolicyPlugin::reload_policies`]) and in the contract documented
34//! on `evaluate` itself (engines fail-closed: on internal error, return
35//! [`PolicyDecision::Deny`] with the error as the reason). This keeps the
36//! evaluate hot path allocation-free in the success cases — no
37//! `Result::unwrap_or_else` chains at every call site.
38//!
39//! # Backends
40//!
41//! Two reference backends ship alongside this trait in `crates/bext-impls/`:
42//!
43//! - `bext-policy-cedar` — AWS Cedar. Rich entity/resource model,
44//! designed around exactly the Allow / Deny / obligations shape.
45//! - `bext-policy-cel` — Google's Common Expression Language. Simpler
46//! boolean expressions over a flat attribute map; maps to Allow/Deny
47//! with Mutate unused.
48//!
49//! An OPA/Rego backend is planned but not shipped as a reference —
50//! deploying a Rego runtime is heavier than the ref-plugin bar.
51//!
52//! # AuthzPolicy vs Auth, in one paragraph
53//!
54//! [`crate::auth::AuthPlugin`] turns a bearer token or session cookie
55//! into an [`crate::auth::AuthUser`] (id, scopes, attributes). It knows
56//! nothing about *what* the user is trying to do — it cannot, because it
57//! runs before routing. `AuthzPolicyPlugin` runs **after** routing has
58//! identified the `action` and `resource`, and evaluates the per-request
59//! policy against the already-authenticated principal. A site can run
60//! with only Auth (scope strings are enough), only AuthzPolicy (every
61//! request is anonymous but policy-gated), or both stacked (auth fills
62//! `principal`, policy evaluates `action`).
63
64use std::collections::HashMap;
65
66/// Evaluation context for a single authorization decision.
67///
68/// Pure POD to stay WASM-ABI friendly (same convention as
69/// [`crate::feature_flag::FlagContext`] and [`crate::auth::AuthRequestContext`]).
70/// The host populates this from the already-authenticated request: the
71/// router sets `action` and `resource`, [`crate::auth::AuthPlugin`] fills
72/// `principal` via [`crate::auth::AuthUser::user_id`], and anything the
73/// policy needs beyond that (tenant id, request IP, feature flags,
74/// tenant plan tier) lands in `attributes` as flat string pairs.
75#[derive(Debug, Clone, Default)]
76pub struct PolicyContext {
77 /// The action being attempted, in `namespace:verb` form. Examples:
78 /// `"post:read"`, `"invoice:update"`, `"admin.users:delete"`. The
79 /// shape is engine-agnostic — Cedar parses it into its own `Action`
80 /// entity, CEL reads it as a plain string. Callers MUST NOT embed
81 /// resource ids here (that is what `resource` is for).
82 pub action: String,
83 /// The resource the action targets, as an opaque identifier. Typical
84 /// shapes: `"posts/123"`, `"tenant/acme/invoices/inv_9f2"`,
85 /// `"user/u_42/profile"`. Engines that understand hierarchical ids
86 /// (Cedar) parse the slashes; flat engines (CEL) treat the whole
87 /// string as one attribute.
88 pub resource: String,
89 /// The authenticated subject (user id) requesting the action. `None`
90 /// for anonymous requests — policies that require a principal should
91 /// return [`PolicyDecision::Deny`] with a reason rather than erroring.
92 pub principal: Option<String>,
93 /// Free-form attributes the policy can read. Keys are policy-defined
94 /// (`"tenant"`, `"plan"`, `"ip"`, `"mfa"`, ...); values are plain
95 /// strings so the map round-trips cleanly through any sandbox boundary.
96 /// This is the same escape hatch used by
97 /// [`crate::feature_flag::FlagContext::attributes`] and
98 /// [`crate::auth::AuthUser::attributes`].
99 pub attributes: HashMap<String, String>,
100}
101
102impl PolicyContext {
103 /// Construct a fully anonymous context for a given action and
104 /// resource. Callers add principal and attributes with the builder
105 /// helpers below.
106 pub fn new(action: impl Into<String>, resource: impl Into<String>) -> Self {
107 Self {
108 action: action.into(),
109 resource: resource.into(),
110 principal: None,
111 attributes: HashMap::new(),
112 }
113 }
114
115 /// Attach an authenticated subject to this context.
116 pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
117 self.principal = Some(principal.into());
118 self
119 }
120
121 /// Attach a single free-form attribute.
122 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
123 self.attributes.insert(key.into(), value.into());
124 self
125 }
126}
127
128/// Outcome of a single policy evaluation.
129///
130/// See the module docs for why this is a flat enum instead of
131/// `Result<(), String>`. The TL;DR: callers need to branch on three
132/// *normal* outcomes (Allow / Deny / Mutate), and the host must not
133/// conflate "policy said no" with "engine exploded".
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum PolicyDecision {
136 /// Proceed with the request, no changes. The default for 99% of
137 /// successful policy evaluations.
138 Allow,
139 /// Refuse the request. The host MUST forward `reason` to the
140 /// structured log and should surface it to the client when the
141 /// engine is trusted (the Cedar and CEL refs produce reasons safe
142 /// for 403 bodies — custom engines must document their own guarantees).
143 Deny {
144 /// Human-readable explanation produced by the policy author.
145 /// Example: `"principal lacks scope posts:write on posts/123"`.
146 reason: String,
147 },
148 /// Proceed, but apply response-side obligations on the way out.
149 ///
150 /// The host applies these **after** the handler runs — header
151 /// injection before headers flush, body substitution before the
152 /// response hits the wire. Both fields are optional; a `Mutate` with
153 /// neither headers nor body is legal (a no-op, but engines may emit
154 /// it during composition) and the host treats it as `Allow`.
155 Mutate {
156 /// Headers to append to (not replace) the outbound response.
157 /// Empty vec = no header changes. Same-key duplicates are
158 /// permitted — the host appends them in order.
159 headers: Vec<(String, String)>,
160 /// Replacement body. `Some(bytes)` overwrites the handler's body
161 /// wholesale; `None` leaves it untouched. Engines that only
162 /// redact fields typically emit `Some` with the rewritten JSON.
163 body: Option<Vec<u8>>,
164 },
165}
166
167impl PolicyDecision {
168 /// Convenience: a `Deny` with the given reason, owning the string.
169 pub fn deny(reason: impl Into<String>) -> Self {
170 Self::Deny {
171 reason: reason.into(),
172 }
173 }
174
175 /// `true` for `Allow` and for `Mutate` (both proceed). `false` for
176 /// `Deny`. Useful for middleware that only needs the gate decision
177 /// and handles mutation elsewhere.
178 pub fn is_allowed(&self) -> bool {
179 !matches!(self, Self::Deny { .. })
180 }
181}
182
183/// A policy engine plugin.
184///
185/// The runtime holds one instance per configured backend and invokes
186/// `evaluate` on every gated route. Unlike [`crate::auth::AuthPlugin`],
187/// which runs once per request in the auth middleware, `AuthzPolicyPlugin`
188/// runs per *decision point* — a single request may evaluate multiple
189/// policies if it touches multiple resources.
190///
191/// # Fail-closed contract
192///
193/// If the engine cannot evaluate (malformed policy file, internal panic
194/// unwind, missing attribute that the policy declared required),
195/// implementations MUST return [`PolicyDecision::Deny`] with the error
196/// string as the reason rather than allowing. Allow-on-failure is a
197/// well-known authorization anti-pattern and the trait contract forbids it.
198///
199/// Concurrency: implementations MUST be safe to call from multiple
200/// threads simultaneously. The host evaluates policies from every
201/// request-handling thread without serialization.
202pub trait AuthzPolicyPlugin: Send + Sync {
203 /// Unique plugin name (e.g. `"cedar"`, `"cel"`, `"opa"`). Used in
204 /// the dev dashboard, metrics labels, and `cap_conformance`.
205 fn name(&self) -> &str;
206
207 /// Evaluate the configured policy set against `ctx` and return the
208 /// decision.
209 ///
210 /// This is the hot path. Implementations SHOULD:
211 ///
212 /// - Be allocation-free in the `Allow` case when possible.
213 /// - Fail closed (see the type-level fail-closed contract above).
214 /// - NOT block on I/O — all policy state is loaded in
215 /// [`reload_policies`](Self::reload_policies) or at construction
216 /// time. The trait is sync, and the host calls it per request.
217 fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision;
218
219 /// Reload the policy set from the backing store.
220 ///
221 /// Called by the host when the policy source changes — file-watcher
222 /// notifications for disk-backed engines, push events for remote
223 /// stores, or on demand from the dev dashboard. Backends SHOULD
224 /// perform the reload atomically: either the new policy set is
225 /// live, or the old one remains, never a partial state visible to
226 /// concurrent `evaluate` calls.
227 ///
228 /// Returns `Err(message)` on backend failure (file unreadable,
229 /// parse error, remote fetch timeout). The old policy set remains
230 /// active on failure — the host does NOT switch to an empty/fail-open
231 /// set.
232 fn reload_policies(&self) -> Result<(), String>;
233
234 /// Called before the plugin is unloaded. Release backend resources
235 /// (file watchers, remote subscriptions). Default: no-op.
236 fn cleanup(&self) -> Result<(), String> {
237 Ok(())
238 }
239}
240
241/// Fuel budgets for WASM authz-policy plugin calls. Matches the convention
242/// in [`crate::locking::fuel`] and [`crate::scheduled::fuel`].
243pub mod fuel {
244 /// Fuel for a single [`super::AuthzPolicyPlugin::evaluate`] call.
245 /// Per-request hot path — kept tight.
246 pub const EVALUATE: u64 = 75_000_000;
247 /// Fuel for [`super::AuthzPolicyPlugin::reload_policies`]. Called
248 /// out-of-band (file change, dev dashboard), can afford a larger
249 /// budget for compiling the policy AST.
250 pub const RELOAD_POLICIES: u64 = 500_000_000;
251 /// Fuel for [`super::AuthzPolicyPlugin::cleanup`].
252 pub const CLEANUP: u64 = 100_000_000;
253}