Files
2026-05-27 23:29:53 +01:00

48 lines
3.2 KiB
Markdown

# `@suaveplan/api-rbac` — scope + rule-based authorisation middleware
**Package**: `@suaveplan/api-rbac`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §6 step 5, the per-request pipeline includes an RBAC gate that compares the route's declared `auth` against the resolved `principal.scopes`. Plugins declare scopes in their manifest (§3) and assign them per route via `router.get("/x", { auth: "billing:read" }, …)`. Centralising the gate in a single middleware keeps "forgot the auth middleware" out of the class-of-bug list, and gives operators a single place to wire complex policy via `@suaveplan/rules`.
## What changes
- ADDED: `createRbacMiddleware({ rules })` — produces a `Middleware` that consumes `RequestContext.principal` and the route's `auth` option. If `auth` is a string scope name, performs a default scope check (`principal.scopes.includes(scope)`); if `auth` is a `RuleSpec`, delegates evaluation to `@suaveplan/rules`. 403 on deny, pass-through on allow, 401 if `auth` is required and the principal is `null` (non-public route with no resolved principal).
- ADDED: `RouteAuthSpec` — discriminated union: `{ kind: "scope", scope: string } | { kind: "rule", rule: RuleSpec } | { kind: "public" }`. The router translates the route option's `auth` value into this shape before invoking the middleware.
- ADDED: Manifest-scope registration helper `registerPluginScopes(registry, manifest)` — adds every scope from `manifest.scopes[]` to the host's `ScopeRegistry`. Conflicts (two plugins claiming the same scope name) raise `RBAC_SCOPE_CONFLICT`.
- ADDED: `api.rbac.denials_total{plugin, route, reason}` counter — incremented on every deny. `reason` is `missing-scope` or `rule-deny` or `no-principal`.
- ADDED: Error codes (`RBAC_*`): `MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`, `SCOPE_CONFLICT`.
## Out of scope
- Session resolution (that's `api-auth-*` adapters, design doc §2.3).
- Tenant resolution (that's `api-tenant-resolver-*` adapters).
- Rate limiting (that's `api-rate-limit`).
- The router itself — this is a `Middleware` consumed by any router adapter.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Per-request RBAC gate | §6 step 5 |
| Plugin-declared scopes auto-registered | §3 (scopes block), §5 |
| Rule-policy delegation to `@suaveplan/rules` | §6 step 5 |
| `api.rbac.denials_total` counter | §12.3 (host-emitted), §12.4 |
## Verification approach
- `src/middleware/middleware.test.ts` — allow / deny matrices for `scope`, `rule`, and `public` route specs.
- `src/middleware/middleware.spec-conformance.test.ts` — pins observability: denials emit `api.rbac.denials_total` with the correct `reason` label, asserted against a real in-memory `MeterProvider`.
- `src/registry/registry.test.ts` — duplicate-scope registration raises `RBAC_SCOPE_CONFLICT`.
- `tests/dep-graph.test.ts` — runtime deps (`api-types`, `error`, `logger`, `rules`).
## Migration impact
None — additive only. `api-host` wires this middleware at boot.