AuthzPolicy
The AuthzPolicy capability is how a plugin asks "given this request, is the action allowed?" using an externalised policy language — AWS Cedar, Google's CEL, OPA/Rego, or a custom engine. It runs one layer above Auth: auth establishes who the caller is; authz-policy decides what they may do with that identity.
It is the E2 shape for sites that need more than scope-string checks. When "admin can do anything, user can do their own stuff" grows into "user on the pro plan can view invoices in their tenant unless the tenant has payment holds, and auditors can view everything read-only," the right answer is not more middleware — it is an externalised rule set that the policy author can iterate on without touching handler code.
The trait
pub trait AuthzPolicyPlugin: Send + Sync {
fn name(&self) -> &str;
fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision;
fn reload_policies(&self) -> Result<(), String>;
fn cleanup(&self) -> Result<(), String> { Ok(()) }
}
Three things are worth calling out about the shape.
evaluate returns PolicyDecision directly, not Result<PolicyDecision, String>. This is the one capability in bext-plugin-api where a flat enum is load-bearing. Callers branch on three genuinely distinct normal outcomes — Allow, Deny, Mutate — and the host must not conflate "policy said no" with "engine exploded." Folding engine errors into Err(..) would force every call site into a match tree where the Err branch has to become a Deny anyway. Pushing fail-closed into the plugin contract removes that boilerplate and makes allow-on-error impossible by construction.
PolicyDecision has a Mutate variant. Cedar-style engines can attach response-side obligations: inject a Content-Security-Policy header, stamp a watermark, redact fields from a JSON body. The Mutate variant carries a list of header pairs and an optional replacement body, both applied by the host after the handler runs. Engines that only gate (CEL, OPA) never emit Mutate — they use Allow and Deny exclusively — and that is fine; the variant is opt-in.
reload_policies exists so policies can hot-reload without restarting the server. Disk-backed engines re-read the file; remote engines pull a fresh ruleset. Implementations perform the reload atomically — either the new set is live or the old one remains, never a partial state — and the host triggers the call from file-watcher events or the dev dashboard.
The decision enum
pub enum PolicyDecision {
Allow,
Deny {
reason: String,
},
Mutate {
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
},
}
Allow and Deny are the common cases. The Deny.reason is policy author–controlled and the host MUST forward it to structured logs and SHOULD surface it to the client — the reference Cedar and CEL backends produce reasons that are safe for 403 bodies, and custom engines document their own guarantees.
Mutate is the rewrite path. The host applies headers by appending them to the outbound response (same-key duplicates are permitted and appended in order) and, if body is Some, replaces the handler's body wholesale before the response hits the wire. A Mutate with neither headers nor body is legal — engines may emit one during rule composition — and the host treats it as Allow.
The is_allowed helper returns true for Allow and Mutate, false for Deny, for middleware that only cares about the gate decision and handles mutation elsewhere.
Fail-closed, with ops observability
The trait contract forbids allow-on-failure. Engines that cannot evaluate — malformed policy file, internal panic unwind, missing attribute that a policy declared required — MUST return PolicyDecision::Deny with the error string as the reason, not Allow. Allow-on-failure is a well-known authorization anti-pattern and the plugin ABI refuses to represent it.
To keep ops observability distinct, reference backends prefix engine-error denies with engine_error: :
- User-facing deny (a normal policy rejection):
"denied by policy policy1 (action=post:read, resource=posts/123)"- Engine-error deny (fail-closed fallback):"engine_error: failed to parse cedar policy: unexpected token at line 3"
Ops wire a log metric on the engine_error: prefix and page on 500-level spikes without needing a separate error channel. Custom engines are encouraged to follow the same convention — it is documented in the module-level docs of bext-plugin-api::authz_policy and in both reference plugins.
The two reference backends
Two reference plugins ship alongside the trait. Pick the one that matches your policy complexity.
@bext/policy-cedar
AWS Cedar. Cedar was designed around exactly the Allow / Deny / obligations shape this capability exposes, and it is the natural first pick for sites that need rich entity models, response-side rewriting, or policy schema validation. The plugin reads a single .cedar policy file at construction and on every reload_policies call.
Field mapping:
- PolicyContext.principal → User::"<id>". None becomes User::"anonymous" so policies can still match (typical rule: deny unless principal is non-anonymous).
- PolicyContext.action → Action::"<action_string>". The full namespace:verb is the action name; Cedar treats the colon as part of the name, so rules reference Action::"post:read" literally.
- PolicyContext.resource → Resource::"<resource_string>". Slashes are not parsed into hierarchy — policies use like for prefix matching.
- PolicyContext.attributes → Cedar Context record of strings, accessed as context.tenant, context.plan, etc.
The parsed PolicySet lives behind a parking_lot::RwLock so evaluate reads in parallel from every request thread without contention; reload_policies takes the write lock only for the final pointer swap, and a reload that fails to parse leaves the old set live.
@bext/policy-cel
Google's Common Expression Language. CEL is simpler than Cedar — a policy is a single boolean expression over a flat variable bag, not a rule engine — and it suits "is the principal in the allowed list AND the tenant matches" style checks without the Cedar schema overhead. Kubernetes admission webhooks, Envoy RBAC filters, and a long tail of cloud-native authorization use it, so a lot of policies are portable between those systems and a bext site.
Variables available to the expression:
- action — string, the namespace:verb action.
- resource — string, the opaque resource identifier.
- principal — string, the authenticated subject. None becomes the empty string, so principal == "" is the anonymous check.
- attributes — map<string, string>, accessed as attributes.tenant, attributes.plan, etc.
Example:
principal != "" && (action == "post:read" || attributes.plan == "pro")
The plugin compiles the expression eagerly so syntax errors surface at startup. The compiled Program lives behind a parking_lot::RwLock; reload_policies recompiles from disk and swaps atomically, preserving the old program on compile failure.
CEL is a decision language, not a transformation language, so this plugin never emits PolicyDecision::Mutate — only Allow and Deny. Sites that need response rewriting should pick the Cedar backend.
Cedar vs CEL — which should I pick?
The two engines are not interchangeable. Pick by the shape of your policies.
Pick Cedar when:
- Your authorization model has entities with relationships — users belong to groups, resources belong to tenants, groups grant roles on resources — and you want the engine to track them.
- You need response-side obligations: header injection, field redaction, watermarking.
- You expect the policy set to grow past a few dozen rules and want the static analysis that comes with a schema-validated policy language.
- Your team already writes Cedar elsewhere (AWS Verified Permissions, for example) and you want portable policies.
Pick CEL when:
- Your policies are "is this boolean true of the request" — membership checks, attribute comparisons, tenant matching.
- You want portability with Kubernetes admission, Envoy RBAC, or Google Cloud's IAM Conditions, all of which use CEL.
- You prefer a single expression over a multi-rule language, and you do not need Mutate.
- You are iterating fast and do not want to maintain a Cedar schema file alongside the policies.
Pick neither — write a custom AuthzPolicyPlugin — when you have an existing policy engine (Rego, a home-grown rules table, a database-backed rule store) that the ref plugins cannot express. The trait is deliberately narrow so the ABI is not a bottleneck for that case.
AuthzPolicy vs Auth — who owns what
This is worth spelling out because it is going to be the most common mistake plugin authors make.
AuthPlugin turns a bearer token or session cookie into an AuthUser (id, scopes, attributes). It runs in the auth middleware, once per request, and it knows nothing about what the user is trying to do — it cannot, because it runs before routing. Scope strings on the user are a lightweight form of authorization that stops at "this token is allowed to call this endpoint class," and for most sites that is enough.
AuthzPolicyPlugin runs after routing has identified the action and resource, and evaluates a per-request policy against the already-authenticated principal. It is per decision point, not per request: a single handler may evaluate multiple policies if it touches multiple resources.
A site can run with only Auth (scope strings are enough), only AuthzPolicy (every request is anonymous but policy-gated — a public API with rate limiting, say), or both stacked (Auth fills principal, AuthzPolicy evaluates action).
Configuration
Both reference plugins take a policy source path in their plugin config block:
[plugins.authz_policy]
type = "cedar"
policy_file = "./policies/site.cedar"
# or, for CEL:
# type = "cel"
# policy_file = "./policies/site.cel"
The plugins also expose a from_source constructor for tests and manifest-embedded policies; in that mode reload_policies returns an error (there is no file to re-read) and policies are immutable for the life of the plugin.
Writing your own policy plugin
Implement the trait on a Send + Sync type, return PolicyDecision::Allow / Deny / Mutate per evaluation, and follow the fail-closed contract on internal errors. Match the engine_error: reason prefix convention if you want your plugin to drop into the same ops alert pipeline as the ref plugins.
The full trait definition with module-level design notes lives in bext-plugin-api/src/authz_policy.rs.