Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# `middleware` — `createRbacMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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:
```ts
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.