4.6 KiB
middleware — createRbacMiddleware
(no submodules)
Purpose
Ships createRbacMiddleware(options) — the per-request authorisation
gate. The factory produces a framework-agnostic Middleware that
consumes the resolved Principal from RequestContext plus the
matched route's canonical RouteAuthSpec, and performs one of three
checks: bypass for public, contains-check against principal.scopes
for scope, or delegated evaluation via @suaveplan/rules for
rule. Denials are signalled by throwing a typed RbacError with
the corresponding RBAC.* code; the host's exception filter folds
the error into the standard ErrorEnvelope wire shape.
The middleware does not depend on Hono / Elysia / Bun.serve directly
— it returns the abstract Middleware from @suaveplan/api-types.
Adapters bridging to a specific router (@suaveplan/api-router-hono,
api-router-in-memory) accept the middleware factory and wire it
into their own pipeline; the matched-route descriptor is bridged via
the routeFor(ctx) callback supplied at construction time.
Features
publicarm bypasses the gate entirely — no scope check, no rule evaluation, no metric emission.- Scope arm performs a strict
===contains-check onprincipal.scopes. Empty principal on a non-public route routes toRBAC.NO_PRINCIPAL(401), notMISSING_SCOPE(403). - Rule arm delegates to
RuleEngine.evaluatewith a context populated from the request method/path/query and the principal / tenant. - Every denial increments
api.rbac.denials_totalwith the three canonical labels (plugin,route,reason). statusForRbacError(error)exposes the 401/403/500 mapping for adapters that prefer return-value handling over throwing.- Configurable rule engine + instruments — production hosts pass their own.
Usage: Basic
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
const middleware = createRbacMiddleware({
routeFor: () => ({
plugin: "billing",
path: "/invoices",
auth: { kind: "scope", scope: "billing:read" },
}),
});
Usage: Advanced
Rule-based authorisation with the @suaveplan/rules engine and an
injected MeterProvider for assertable telemetry:
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { rule, RuleEngine } from "@suaveplan/rules";
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
import { createRbacInstruments } from "@suaveplan/api-rbac/instruments";
const provider = new MeterProvider();
const instruments = createRbacInstruments({ meterProvider: provider });
const engine = new RuleEngine();
const billingRule = rule().condition({
field: "principal.scopes",
operator: "contains",
value: "billing:write",
});
const middleware = createRbacMiddleware({
rules: engine,
instruments,
routeFor: (ctx) => ({
plugin: "billing",
path: new URL(ctx.request.url).pathname,
auth: { kind: "rule", rule: billingRule },
}),
});
The middleware throws RbacError rather than writing a Response so
the request pipeline's central exception filter retains a single
formatting site for the ErrorEnvelope wire shape.
API Reference
RbacRequestContext— structural shape consumed from the request context (request,principal,tenantId).RbacRoute—{ plugin, path, auth }matched-route descriptor.RbacMiddlewareOptions—{ rules?, routeFor, instruments? }.createRbacMiddleware(options)— returns the abstractMiddleware.statusForRbacError(error)— code → HTTP status mapping.
Implementation Notes
The rule arm constructs a fresh RuleContext per request — the
engine receives principal, tenantId, and a request block with
the request method, pathname, and the searchParams folded into a
plain object. The engine returns { passed, errors, … }; only
passed === false triggers a denial, matching the rules-package
contract. The URL parse happens once per request after the
fast-path arms (public, scope, no-principal) have returned, so
the typical scope-gated route does not pay the parse cost.
Strict-mode semantics for the 401 vs 403 split are tested at
middleware.test.ts::"returns 401 for null principal on non-public route" — empirically the bug that breaks consumers most is a 403
on a missing principal masking an authentication outage as an
authorisation outage. The implementation explicitly checks
principal === null before any scope arithmetic so the two failure
modes never alias.