first commit
This commit is contained in:
85
docs/api-stack/api/api-rbac/README.md
Normal file
85
docs/api-stack/api/api-rbac/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @suaveplan/api-rbac - Role/scope/rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Principal` plus a route's `RouteAuthSpec` (scope name, @suaveplan/rules Rule, or public bypass), emits 401 when a non-public route has no principal, 403 on missing-scope or rule-deny, and increments `api.rbac.denials_total{plugin, route, reason}`. Ships a conflict-aware `ScopeRegistry` plus `registerPluginScopes(registry, manifest)` so plugin-declared scopes auto-register at install and duplicates raise `RBAC.SCOPE_CONFLICT`. Universal, Zod-validated boundaries, real OTel surface.
|
||||
|
||||
Scope + rule-based authorisation middleware for the Suaveplan API plugin
|
||||
system. Produces a framework-agnostic `Middleware` that consumes the
|
||||
resolved `Principal` from `RequestContext` and the matched route's
|
||||
`RouteAuthSpec`, performs the gate, and either calls `next()` or throws a
|
||||
typed `RbacError`. Every denial increments
|
||||
`api.rbac.denials_total{plugin, route, reason}` with `reason ∈
|
||||
{ "missing-scope", "rule-deny", "no-principal" }`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
|
||||
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
|
||||
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
|
||||
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
|
||||
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
|
||||
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
|
||||
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-rbac
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createRbacMiddleware } from "@suaveplan/api-rbac";
|
||||
|
||||
const middleware = createRbacMiddleware({
|
||||
routeFor: () => ({
|
||||
plugin: "billing",
|
||||
path: "/invoices",
|
||||
auth: { kind: "scope", scope: "billing:read" },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The middleware is the abstract `Middleware` from `@suaveplan/api-types` —
|
||||
adapters (`@suaveplan/api-router-hono`, `api-router-in-memory`, etc.) plug
|
||||
it into their pipeline directly.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes/error-codes.md) — `RBAC_CODES` registry
|
||||
(`MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`, `SCOPE_CONFLICT`,
|
||||
`INVALID_SPEC`) and the `RbacError` class.
|
||||
- [`instruments`](./src/instruments/instruments.md) — the single counter
|
||||
`api.rbac.denials_total` plus `createRbacInstruments` / `recordRbacDenial`.
|
||||
- [`middleware`](./src/middleware/middleware.md) — `createRbacMiddleware`
|
||||
and `statusForRbacError` mapping.
|
||||
- [`registry`](./src/registry/registry.md) — `ScopeRegistry` plus
|
||||
`registerPluginScopes(registry, manifest)` for install-time conflict
|
||||
detection.
|
||||
- [`spec`](./src/spec/spec.md) — the canonical `RouteAuthSpec` union, the
|
||||
Zod `RouteAuthSpecSchema`, and the `normaliseRouteAuth` fold.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
- counter `api.rbac.denials_total{plugin, route, reason}` — emitted on
|
||||
every deny path.
|
||||
|
||||
Reason label values: `"missing-scope" | "rule-deny" | "no-principal"`.
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | HTTP | When |
|
||||
| ---------------------- | ---- | -------------------------------------------------------- |
|
||||
| `RBAC.NO_PRINCIPAL` | 401 | non-public route reached with `principal === null` |
|
||||
| `RBAC.MISSING_SCOPE` | 403 | principal lacks the required scope |
|
||||
| `RBAC.RULE_DENY` | 403 | `@suaveplan/rules` evaluation returned `passed === false`|
|
||||
| `RBAC.SCOPE_CONFLICT` | 500 | two plugins claim the same scope name |
|
||||
| `RBAC.INVALID_SPEC` | 500 | `RouteAuthSpec` failed Zod validation |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
94
docs/api-stack/api/api-rbac/src/error-codes/error-codes.md
Normal file
94
docs/api-stack/api/api-rbac/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `RBAC_CODES`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable, frozen registry of every `RBAC.*` error code surfaced by the
|
||||
`@suaveplan/api-rbac` authorisation middleware and its companion
|
||||
`ScopeRegistry`. Downstream consumers (the host's request pipeline,
|
||||
operator dashboards, audit pipelines, OpenAPI emitters) read these
|
||||
strings as load-bearing identifiers — they must never change between
|
||||
releases without an OpenSpec migration plan. `RBAC_CODES` is the
|
||||
single source of truth defining those strings, and `RbacError` is the
|
||||
class wrapping them with full `SuaveplanError` semantics (Zod-validated
|
||||
wire shape, cause-chain, redaction, OTel span-event integration).
|
||||
|
||||
The registry is built with `defineCodes("RBAC", [...])` from
|
||||
`@suaveplan/error`, which yields a literal-typed, deeply-frozen
|
||||
record. The literal types flow into every `RbacError` thrown by the
|
||||
middleware so a caller can `switch` on `err.code` against
|
||||
`RBAC_CODES.MISSING_SCOPE` with full compile-time exhaustiveness
|
||||
checking, and the typed `RbacCode` union doubles as a discriminator
|
||||
for telemetry attribute mapping.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly five codes — `MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`,
|
||||
`SCOPE_CONFLICT`, `INVALID_SPEC`.
|
||||
- Literal-typed values: `RBAC_CODES.MISSING_SCOPE` has the type
|
||||
`"RBAC.MISSING_SCOPE"` (not the wider `string`).
|
||||
- Deep-frozen at runtime (`Object.isFrozen` returns `true`).
|
||||
- Re-exported `RbacCode` union for use as a discriminator type.
|
||||
- `RbacError` extends `SuaveplanError<RbacCode>` so the cause-chain,
|
||||
Zod wire shape, redaction config, and OTel span-event adapter all
|
||||
inherit unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
throw new RbacError(
|
||||
RBAC_CODES.MISSING_SCOPE,
|
||||
"principal lacks required scope 'billing:write'"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pattern-match on the union for exhaustive error handling at host call
|
||||
sites — the compiler verifies every code is handled:
|
||||
|
||||
```ts
|
||||
import { RBAC_CODES, type RbacCode } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
function statusFor(code: RbacCode): number {
|
||||
switch (code) {
|
||||
case RBAC_CODES.NO_PRINCIPAL:
|
||||
return 401;
|
||||
case RBAC_CODES.MISSING_SCOPE:
|
||||
case RBAC_CODES.RULE_DENY:
|
||||
return 403;
|
||||
case RBAC_CODES.INVALID_SPEC:
|
||||
return 500;
|
||||
case RBAC_CODES.SCOPE_CONFLICT:
|
||||
// raised at install time, never per-request
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `INVALID_SPEC` code is reserved for the Zod ingestion boundary —
|
||||
host adapters that ingest a route's raw `auth` value run it through
|
||||
`RouteAuthSpecSchema.parse(...)` and re-throw any failure as
|
||||
`RbacError(INVALID_SPEC, ...)` so callers never see a raw `ZodError`.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RBAC_CODES` — frozen record mapping local name → fully-qualified
|
||||
`RBAC.*` string. Branded as `defineCodes("RBAC", [...])`.
|
||||
- `RbacCode` — union of every value in `RBAC_CODES`.
|
||||
- `RbacError` — `SuaveplanError<RbacCode>` subclass; constructor
|
||||
signature `(code, message, options?)` mirrors the base class.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` deep-freezes the returned registry so accidental
|
||||
mutation throws in strict mode. The class is a thin subclass of
|
||||
`SuaveplanError` purely to override `name = "RbacError"` for clearer
|
||||
stack traces; no field is added beyond the base shape, so the wire
|
||||
encoding (`SuaveplanError.toJSON`) is byte-identical to other
|
||||
`@suaveplan` errors and the i18n resolver hook applies unchanged.
|
||||
115
docs/api-stack/api/api-rbac/src/instruments/instruments.md
Normal file
115
docs/api-stack/api/api-rbac/src/instruments/instruments.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `instruments` — `api.rbac.denials_total`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the single OpenTelemetry counter `api.rbac.denials_total` and the
|
||||
factory that constructs the `RbacInstruments` registry against a
|
||||
caller-supplied `MeterProvider`. The middleware itself never touches
|
||||
the global OTel API directly — every emission flows through
|
||||
`recordRbacDenial(instruments, { plugin, route, reason })`, which
|
||||
guarantees the three label keys are populated on every call and the
|
||||
base attributes from the host (host-id, host-version, etc.) are
|
||||
merged in.
|
||||
|
||||
Centralising emission here lets the spec-conformance test pin the
|
||||
exact counter name, label set, and reason vocabulary against a real
|
||||
`MeterProvider` from `@opentelemetry/sdk-metrics` — empirically the
|
||||
class of regression that broke production dashboards in two of three
|
||||
prior incidents was a label-key rename slipping past lint+typecheck.
|
||||
|
||||
## Features
|
||||
|
||||
- Single counter `api.rbac.denials_total` with description, created
|
||||
on the `@suaveplan/api-rbac` meter.
|
||||
- Closed `RbacDenialReason` union — `"missing-scope" | "rule-deny" |
|
||||
"no-principal"` — so dashboard authors can rely on a finite label
|
||||
set.
|
||||
- Frozen `RBAC_DENIAL_REASONS` and `RBAC_DENIAL_LABEL_KEYS` tuples
|
||||
for tests and metric-discovery tooling.
|
||||
- Base attributes merged on every emission; host injects host-id /
|
||||
host-version at boot.
|
||||
- `MeterProvider` is injectable — tests use a real
|
||||
`MeterProvider` from `@opentelemetry/sdk-metrics`, production
|
||||
resolves through `metrics.getMeterProvider()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRbacInstruments,
|
||||
recordRbacDenial,
|
||||
} from "@suaveplan/api-rbac/instruments";
|
||||
|
||||
const instruments = createRbacInstruments();
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: "billing",
|
||||
route: "/invoices",
|
||||
reason: "missing-scope",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a real `MeterProvider` and a `PeriodicExportingMetricReader`
|
||||
with an `InMemoryMetricExporter` to assert the exact counter
|
||||
emissions in a conformance test:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
createRbacInstruments,
|
||||
RBAC_DENIALS_TOTAL_NAME,
|
||||
recordRbacDenial,
|
||||
} from "@suaveplan/api-rbac/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
const instruments = createRbacInstruments({
|
||||
meterProvider: provider,
|
||||
baseAttributes: { host: "h-1" },
|
||||
});
|
||||
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: "billing",
|
||||
route: "/x",
|
||||
reason: "rule-deny",
|
||||
});
|
||||
|
||||
await provider.forceFlush();
|
||||
const dump = exporter.getMetrics();
|
||||
// dump[0].scopeMetrics[0].metrics carries RBAC_DENIALS_TOTAL_NAME …
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RBAC_DENIALS_TOTAL_NAME` — string literal `"api.rbac.denials_total"`.
|
||||
- `RBAC_METER_NAME` — string literal `"@suaveplan/api-rbac"`.
|
||||
- `RBAC_DENIAL_REASONS` — frozen tuple of legal reason labels.
|
||||
- `RBAC_DENIAL_LABEL_KEYS` — frozen tuple `["plugin", "route", "reason"]`.
|
||||
- `RbacDenialReason` — `"missing-scope" | "rule-deny" | "no-principal"`.
|
||||
- `RbacInstruments` — `{ denials, baseAttributes }`.
|
||||
- `RbacInstrumentsOptions` — `{ meterProvider?, baseAttributes? }`.
|
||||
- `createRbacInstruments(options?)` — factory.
|
||||
- `recordRbacDenial(instruments, attrs)` — emission helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory falls back to `metrics.getMeterProvider()` only when no
|
||||
provider is supplied; production hosts always pass their own
|
||||
provider so SDK installation is opt-in. The counter description is
|
||||
load-bearing for the OTel collector's `view` / `attribute_filter`
|
||||
configuration — operators rely on it to scope the metric to
|
||||
particular pipelines without re-discovering it. `Object.freeze` on
|
||||
the registry plus `Object.freeze` on the base attribute bag makes
|
||||
accidental in-place mutation throw in strict mode, which is the
|
||||
class of bug `tslib` + `verbatimModuleSyntax` cannot catch.
|
||||
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.
|
||||
114
docs/api-stack/api/api-rbac/src/registry/registry.md
Normal file
114
docs/api-stack/api/api-rbac/src/registry/registry.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# `registry` — `ScopeRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the shared, conflict-aware registry of plugin-declared scopes
|
||||
plus the `registerPluginScopes(registry, manifest)` helper the host
|
||||
calls at install time. The registry's primary job is install-time
|
||||
hygiene: two plugins claiming the same scope name (`billing:read`
|
||||
declared by both `billing` and `invoices`) must fail loudly with
|
||||
`RBAC.SCOPE_CONFLICT` instead of producing whichever-registered-last
|
||||
behaviour at request time.
|
||||
|
||||
The registry is intentionally not the source of truth for the gate
|
||||
decision. `principal.scopes` is. The registry exists to catch typos
|
||||
and cross-plugin conflicts at boot, drive the admin UI's
|
||||
list-of-available-scopes endpoint, and feed the OpenAPI emitter so
|
||||
a route declaring an undeclared scope raises a startup error.
|
||||
|
||||
## Features
|
||||
|
||||
- Conflict detection — duplicate `name` across plugins raises
|
||||
`RBAC.SCOPE_CONFLICT` with the conflicting plugin id in
|
||||
`error.context.conflictingPlugin`.
|
||||
- Idempotent re-register — same plugin, same name, same description
|
||||
is a no-op so hot reload is safe.
|
||||
- Empty-string scope name rejected with `RBAC.INVALID_SPEC`.
|
||||
- `unregisterPlugin(plugin)` removes every scope owned by that
|
||||
plugin — used at uninstall so a future install may legitimately
|
||||
claim the same name.
|
||||
- `registerPluginScopes(registry, manifest)` returns the count of
|
||||
newly-registered (non-idempotent) entries so the host can log a
|
||||
meaningful summary.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "@suaveplan/api-rbac/registry";
|
||||
|
||||
const registry = new ScopeRegistry();
|
||||
registerPluginScopes(registry, {
|
||||
name: "billing",
|
||||
scopes: [
|
||||
{ name: "billing:read", description: "Read invoices" },
|
||||
{ name: "billing:write" },
|
||||
],
|
||||
});
|
||||
registry.has("billing:read"); // true
|
||||
registry.list(); // [ … two descriptors … ]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Driving a per-plugin uninstall + reinstall cycle while preserving
|
||||
ordering invariants:
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "@suaveplan/api-rbac/registry";
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
const registry = new ScopeRegistry();
|
||||
registerPluginScopes(registry, {
|
||||
name: "billing",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
|
||||
// Cross-plugin claim → conflict.
|
||||
try {
|
||||
registerPluginScopes(registry, {
|
||||
name: "invoices",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) {
|
||||
if (err.code === RBAC_CODES.SCOPE_CONFLICT) {
|
||||
// Drop the offending plugin, surface to operator …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall the first plugin, then the second may claim it.
|
||||
registry.unregisterPlugin("billing");
|
||||
registerPluginScopes(registry, {
|
||||
name: "invoices",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RegisteredScope` — `{ name, plugin, description? }`.
|
||||
- `ScopeRegistry` — class with `register / has / get / list /
|
||||
unregisterPlugin` methods. Public no-arg constructor.
|
||||
- `registerPluginScopes(registry, manifest)` — iterates
|
||||
`manifest.scopes[]`, returns the count of newly-registered entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The byte-identical idempotence comparison covers `(plugin, name,
|
||||
description)`. A change in `description` from the same plugin is
|
||||
treated as a conflict on purpose — it would otherwise be a silent
|
||||
admin-visible mutation that bypasses the OpenSpec change discipline.
|
||||
The internal `Map` is intentionally not exposed; callers walk
|
||||
`list()` for snapshot semantics so a concurrent install cannot
|
||||
trip an iterator-invalidation bug.
|
||||
99
docs/api-stack/api/api-rbac/src/spec/spec.md
Normal file
99
docs/api-stack/api/api-rbac/src/spec/spec.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `spec` — `RouteAuthSpec`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical discriminated `RouteAuthSpec` union, its Zod
|
||||
schema, the inbound shape adapters fold down to it, and the helpers
|
||||
that perform that folding. The middleware itself works exclusively
|
||||
against `RouteAuthSpec` — every other shape has to pass through
|
||||
`normaliseRouteAuth` first. Centralising the shape here means the
|
||||
router adapter, the rule-policy loader, and the admin API all share
|
||||
one validator and one error code (`RBAC.INVALID_SPEC`).
|
||||
|
||||
`RouteAuthSpec` is a closed three-arm union — `"public"`, `"scope"`,
|
||||
and `"rule"`. The router adapter accepts the friendlier
|
||||
{@link RouteAuthInput} (string scope, `null` → public, or a
|
||||
`{ rule }` wrapper) and folds it down before invoking the middleware.
|
||||
This keeps the in-handler ergonomics terse without exposing the
|
||||
gate to ambiguous strings or undefineds.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated `RouteAuthSpec` union — `public`, `scope`, `rule`.
|
||||
- Zod `RouteAuthSpecSchema` discriminated-union schema covers all
|
||||
three arms, rejects unknown discriminators and excess keys.
|
||||
- `normaliseRouteAuth(input)` folds the five recognised input shapes
|
||||
(`null`, `undefined`, string, `RouteAuthSpec`, `{ rule }`) down to
|
||||
the canonical shape; throws `RbacError(INVALID_SPEC, ...)` on
|
||||
anything else.
|
||||
- `isRouteAuthSpec(value)` type-guard for JSON ingestion paths that
|
||||
need a narrowing check without a thrown error.
|
||||
- Empty-string scope rejected at the boundary so `auth: ""` cannot
|
||||
silently become a permanent-deny gate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { normaliseRouteAuth } from "@suaveplan/api-rbac/spec";
|
||||
|
||||
normaliseRouteAuth(null); // { kind: "public" }
|
||||
normaliseRouteAuth("billing:read"); // { kind: "scope", scope: "billing:read" }
|
||||
normaliseRouteAuth({ kind: "public" });// { kind: "public" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate a manifest-loaded auth descriptor at the install-time
|
||||
boundary and re-throw with a typed `RbacError` so the host's
|
||||
exception filter routes it through the standard error envelope:
|
||||
|
||||
```ts
|
||||
import { RouteAuthSpecSchema } from "@suaveplan/api-rbac/spec";
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
function ingest(raw: unknown) {
|
||||
const parsed = RouteAuthSpecSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"manifest route auth failed validation",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
Rule-based auth delegates evaluation to `@suaveplan/rules`. The
|
||||
`rule` slot accepts any `Rule` from that package — `ConditionRule`,
|
||||
`LogicalRule`, `GroupRule`, `CustomRule`, `ComputedRule`. The schema
|
||||
only enforces that a `type: string` field is present so the engine
|
||||
gets a valid shape; the engine's own Zod schemas validate the
|
||||
internal structure at evaluation time.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RouteAuthSpec` — discriminated union of `public`, `scope`, `rule`.
|
||||
- `RouteAuthInput` — union of recognised input shapes.
|
||||
- `RouteAuthSpecSchema` — Zod discriminated-union schema; used at the
|
||||
manifest / admin-API ingestion boundary.
|
||||
- `normaliseRouteAuth(input)` — fold any `RouteAuthInput` to a
|
||||
`RouteAuthSpec`; throws `RbacError(INVALID_SPEC, ...)`.
|
||||
- `isRouteAuthSpec(value)` — type-guard; returns `true` on a
|
||||
conforming value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses Zod's `discriminatedUnion` so error messages carry
|
||||
the offending discriminator value. `.strict()` on each arm rejects
|
||||
excess keys so a typo (`{ kind: "scope", scop: "x" }`) fails fast
|
||||
at parse time rather than producing a silently-permanent-deny route.
|
||||
The `Rule` shape is intentionally loose at this boundary —
|
||||
re-validating the full rule tree here would duplicate
|
||||
`@suaveplan/rules`' own validation and double the maintenance
|
||||
surface; consumers that want strict rule validation invoke the
|
||||
serializer's `validateSerializedRule` directly.
|
||||
Reference in New Issue
Block a user