first commit
This commit is contained in:
120
docs/api-stack/api/api-rbac/src/middleware/middleware.md
Normal file
120
docs/api-stack/api/api-rbac/src/middleware/middleware.md
Normal 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.
|
||||
Reference in New Issue
Block a user