Initial Commit
This commit is contained in:
47
openspec/archive/2026-05-26-api-rbac/proposal.md
Normal file
47
openspec/archive/2026-05-26-api-rbac/proposal.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# `@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.
|
||||
Reference in New Issue
Block a user