Files
skills/docs/api-stack/api/api-rbac/src/middleware/middleware.md
2026-05-29 08:20:56 +01:00

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

  • public arm bypasses the gate entirely — no scope check, no rule evaluation, no metric emission.
  • Scope arm performs a strict === contains-check on principal.scopes. Empty principal on a non-public route routes to RBAC.NO_PRINCIPAL (401), not MISSING_SCOPE (403).
  • Rule arm delegates to RuleEngine.evaluate with a context populated from the request method/path/query and the principal / tenant.
  • Every denial increments api.rbac.denials_total with 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 abstract Middleware.
  • 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.