Skip to main content

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}