bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! AuthzPolicy capability trait. See `plan/ecosystem/02-capabilities.md §AuthzPolicy`.
//!
//! An `AuthzPolicyPlugin` answers *"given this request context, is the
//! action allowed?"* using an externalised policy language — Cedar, CEL,
//! OPA/Rego, or a custom engine. It sits one layer **above**
//! [`crate::auth::AuthPlugin`]: auth establishes *who* the caller is,
//! authz-policy decides *what* they may do with that identity.
//!
//! # Why the return shape is an enum, not `Result<_, String>`
//!
//! This is the one capability in `bext-plugin-api` where a flat enum is
//! load-bearing. Callers branch on three genuinely distinct outcomes:
//!
//! 1. **Allow.** Let the request proceed untouched.
//! 2. **Deny.** Short-circuit with a 403, forwarding the reason to the
//!    logs and (optionally) to the client. The reason string is policy
//!    author–controlled ("principal lacks `posts:write` on `posts/123`")
//!    and the host must not discard it.
//! 3. **Mutate.** Let the request proceed, but **rewrite the response**
//!    on the way out — inject headers (CSP, `X-Tenant`, audit trailers)
//!    or substitute the body (redact fields, stamp a watermark). Cedar's
//!    response-side obligations and a handful of custom engines need
//!    this; without it the trait cannot represent what they do.
//!
//! Folding these into `Result<_, String>` would collapse Mutate into
//! Allow, which silently drops the rewrite. Folding Deny into `Err` would
//! conflate "the policy said no" with "the policy engine crashed" — the
//! host treats those completely differently (Deny is a normal 403, engine
//! failure is a 500 and a wakeup).
//!
//! Backend-failure errors still exist, but they live on the outer
//! [`AuthzPolicyPlugin::evaluate`] signature's companion methods
//! ([`AuthzPolicyPlugin::reload_policies`]) and in the contract documented
//! on `evaluate` itself (engines fail-closed: on internal error, return
//! [`PolicyDecision::Deny`] with the error as the reason). This keeps the
//! evaluate hot path allocation-free in the success cases — no
//! `Result::unwrap_or_else` chains at every call site.
//!
//! # Backends
//!
//! Two reference backends ship alongside this trait in `crates/bext-impls/`:
//!
//! - `bext-policy-cedar` — AWS Cedar. Rich entity/resource model,
//!   designed around exactly the Allow / Deny / obligations shape.
//! - `bext-policy-cel` — Google's Common Expression Language. Simpler
//!   boolean expressions over a flat attribute map; maps to Allow/Deny
//!   with Mutate unused.
//!
//! An OPA/Rego backend is planned but not shipped as a reference —
//! deploying a Rego runtime is heavier than the ref-plugin bar.
//!
//! # AuthzPolicy vs Auth, in one paragraph
//!
//! [`crate::auth::AuthPlugin`] turns a bearer token or session cookie
//! into an [`crate::auth::AuthUser`] (id, scopes, attributes). It knows
//! nothing about *what* the user is trying to do — it cannot, because it
//! runs before routing. `AuthzPolicyPlugin` runs **after** routing has
//! identified the `action` and `resource`, and evaluates the per-request
//! policy against the already-authenticated principal. A site can run
//! with only Auth (scope strings are enough), only AuthzPolicy (every
//! request is anonymous but policy-gated), or both stacked (auth fills
//! `principal`, policy evaluates `action`).

use std::collections::HashMap;

/// Evaluation context for a single authorization decision.
///
/// Pure POD to stay WASM-ABI friendly (same convention as
/// [`crate::feature_flag::FlagContext`] and [`crate::auth::AuthRequestContext`]).
/// The host populates this from the already-authenticated request: the
/// router sets `action` and `resource`, [`crate::auth::AuthPlugin`] fills
/// `principal` via [`crate::auth::AuthUser::user_id`], and anything the
/// policy needs beyond that (tenant id, request IP, feature flags,
/// tenant plan tier) lands in `attributes` as flat string pairs.
#[derive(Debug, Clone, Default)]
pub struct PolicyContext {
    /// The action being attempted, in `namespace:verb` form. Examples:
    /// `"post:read"`, `"invoice:update"`, `"admin.users:delete"`. The
    /// shape is engine-agnostic — Cedar parses it into its own `Action`
    /// entity, CEL reads it as a plain string. Callers MUST NOT embed
    /// resource ids here (that is what `resource` is for).
    pub action: String,
    /// The resource the action targets, as an opaque identifier. Typical
    /// shapes: `"posts/123"`, `"tenant/acme/invoices/inv_9f2"`,
    /// `"user/u_42/profile"`. Engines that understand hierarchical ids
    /// (Cedar) parse the slashes; flat engines (CEL) treat the whole
    /// string as one attribute.
    pub resource: String,
    /// The authenticated subject (user id) requesting the action. `None`
    /// for anonymous requests — policies that require a principal should
    /// return [`PolicyDecision::Deny`] with a reason rather than erroring.
    pub principal: Option<String>,
    /// Free-form attributes the policy can read. Keys are policy-defined
    /// (`"tenant"`, `"plan"`, `"ip"`, `"mfa"`, ...); values are plain
    /// strings so the map round-trips cleanly through any sandbox boundary.
    /// This is the same escape hatch used by
    /// [`crate::feature_flag::FlagContext::attributes`] and
    /// [`crate::auth::AuthUser::attributes`].
    pub attributes: HashMap<String, String>,
}

impl PolicyContext {
    /// Construct a fully anonymous context for a given action and
    /// resource. Callers add principal and attributes with the builder
    /// helpers below.
    pub fn new(action: impl Into<String>, resource: impl Into<String>) -> Self {
        Self {
            action: action.into(),
            resource: resource.into(),
            principal: None,
            attributes: HashMap::new(),
        }
    }

    /// Attach an authenticated subject to this context.
    pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
        self.principal = Some(principal.into());
        self
    }

    /// Attach a single free-form attribute.
    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.attributes.insert(key.into(), value.into());
        self
    }
}

/// Outcome of a single policy evaluation.
///
/// See the module docs for why this is a flat enum instead of
/// `Result<(), String>`. The TL;DR: callers need to branch on three
/// *normal* outcomes (Allow / Deny / Mutate), and the host must not
/// conflate "policy said no" with "engine exploded".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
    /// Proceed with the request, no changes. The default for 99% of
    /// successful policy evaluations.
    Allow,
    /// Refuse the request. The host MUST forward `reason` to the
    /// structured log and should surface it to the client when the
    /// engine is trusted (the Cedar and CEL refs produce reasons safe
    /// for 403 bodies — custom engines must document their own guarantees).
    Deny {
        /// Human-readable explanation produced by the policy author.
        /// Example: `"principal lacks scope posts:write on posts/123"`.
        reason: String,
    },
    /// Proceed, but apply response-side obligations on the way out.
    ///
    /// The host applies these **after** the handler runs — header
    /// injection before headers flush, body substitution before the
    /// response hits the wire. Both fields are optional; a `Mutate` with
    /// neither headers nor body is legal (a no-op, but engines may emit
    /// it during composition) and the host treats it as `Allow`.
    Mutate {
        /// Headers to append to (not replace) the outbound response.
        /// Empty vec = no header changes. Same-key duplicates are
        /// permitted — the host appends them in order.
        headers: Vec<(String, String)>,
        /// Replacement body. `Some(bytes)` overwrites the handler's body
        /// wholesale; `None` leaves it untouched. Engines that only
        /// redact fields typically emit `Some` with the rewritten JSON.
        body: Option<Vec<u8>>,
    },
}

impl PolicyDecision {
    /// Convenience: a `Deny` with the given reason, owning the string.
    pub fn deny(reason: impl Into<String>) -> Self {
        Self::Deny {
            reason: reason.into(),
        }
    }

    /// `true` for `Allow` and for `Mutate` (both proceed). `false` for
    /// `Deny`. Useful for middleware that only needs the gate decision
    /// and handles mutation elsewhere.
    pub fn is_allowed(&self) -> bool {
        !matches!(self, Self::Deny { .. })
    }
}

/// A policy engine plugin.
///
/// The runtime holds one instance per configured backend and invokes
/// `evaluate` on every gated route. Unlike [`crate::auth::AuthPlugin`],
/// which runs once per request in the auth middleware, `AuthzPolicyPlugin`
/// runs per *decision point* — a single request may evaluate multiple
/// policies if it touches multiple resources.
///
/// # Fail-closed contract
///
/// If the engine cannot evaluate (malformed policy file, internal panic
/// unwind, missing attribute that the policy declared required),
/// implementations MUST return [`PolicyDecision::Deny`] with the error
/// string as the reason rather than allowing. Allow-on-failure is a
/// well-known authorization anti-pattern and the trait contract forbids it.
///
/// Concurrency: implementations MUST be safe to call from multiple
/// threads simultaneously. The host evaluates policies from every
/// request-handling thread without serialization.
pub trait AuthzPolicyPlugin: Send + Sync {
    /// Unique plugin name (e.g. `"cedar"`, `"cel"`, `"opa"`). Used in
    /// the dev dashboard, metrics labels, and `cap_conformance`.
    fn name(&self) -> &str;

    /// Evaluate the configured policy set against `ctx` and return the
    /// decision.
    ///
    /// This is the hot path. Implementations SHOULD:
    ///
    /// - Be allocation-free in the `Allow` case when possible.
    /// - Fail closed (see the type-level fail-closed contract above).
    /// - NOT block on I/O — all policy state is loaded in
    ///   [`reload_policies`](Self::reload_policies) or at construction
    ///   time. The trait is sync, and the host calls it per request.
    fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision;

    /// Reload the policy set from the backing store.
    ///
    /// Called by the host when the policy source changes — file-watcher
    /// notifications for disk-backed engines, push events for remote
    /// stores, or on demand from the dev dashboard. Backends SHOULD
    /// perform the reload atomically: either the new policy set is
    /// live, or the old one remains, never a partial state visible to
    /// concurrent `evaluate` calls.
    ///
    /// Returns `Err(message)` on backend failure (file unreadable,
    /// parse error, remote fetch timeout). The old policy set remains
    /// active on failure — the host does NOT switch to an empty/fail-open
    /// set.
    fn reload_policies(&self) -> Result<(), String>;

    /// Called before the plugin is unloaded. Release backend resources
    /// (file watchers, remote subscriptions). Default: no-op.
    fn cleanup(&self) -> Result<(), String> {
        Ok(())
    }
}

/// Fuel budgets for WASM authz-policy plugin calls. Matches the convention
/// in [`crate::locking::fuel`] and [`crate::scheduled::fuel`].
pub mod fuel {
    /// Fuel for a single [`super::AuthzPolicyPlugin::evaluate`] call.
    /// Per-request hot path — kept tight.
    pub const EVALUATE: u64 = 75_000_000;
    /// Fuel for [`super::AuthzPolicyPlugin::reload_policies`]. Called
    /// out-of-band (file change, dev dashboard), can afford a larger
    /// budget for compiling the policy AST.
    pub const RELOAD_POLICIES: u64 = 500_000_000;
    /// Fuel for [`super::AuthzPolicyPlugin::cleanup`].
    pub const CLEANUP: u64 = 100_000_000;
}