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

89 lines
4.2 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-rbac (api-rbac)
**Package**: `@suaveplan/api-rbac`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Scope-based authorisation
The package SHALL produce a `Middleware` that, when the route's `auth` is a string scope name, permits the request only if `principal.scopes` contains that scope. Denials respond with HTTP 403 and the `RBAC_MISSING_SCOPE` error envelope.
#### Scenario: principal carries required scope
- WHEN a request with principal scopes `["billing:read"]` hits a route declaring `auth: "billing:read"`
- THEN the middleware permits the request
#### Scenario: principal missing required scope
- WHEN a request with principal scopes `["billing:read"]` hits a route declaring `auth: "billing:write"`
- THEN the response is 403 with `code === "RBAC_MISSING_SCOPE"` AND `api.rbac.denials_total{reason="missing-scope"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"permits request with required scope"` AND `src/middleware/middleware.test.ts::"denies request missing required scope"` AND `src/middleware/middleware.spec-conformance.test.ts::"denial emits api.rbac.denials_total with missing-scope reason"`
---
### Requirement: Rule-based authorisation delegates to `@suaveplan/rules`
The package SHALL accept a `RouteAuthSpec` of kind `"rule"` and delegate evaluation to `@suaveplan/rules`. The middleware passes a context object containing `principal`, `tenant`, `request` (method/path/params/query), and `route` to the rule engine.
#### Scenario: rule evaluates to allow
- WHEN the rule evaluates true for the request context
- THEN the middleware permits the request
#### Scenario: rule evaluates to deny
- WHEN the rule evaluates false for the request context
- THEN the response is 403 with `code === "RBAC_RULE_DENY"` AND `api.rbac.denials_total{reason="rule-deny"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"permits when rule evaluates true"` AND `src/middleware/middleware.test.ts::"denies when rule evaluates false"`
---
### Requirement: Missing principal on non-public route returns 401
The package SHALL distinguish "no principal" (401) from "principal lacks scope" (403). When the route is not declared public and no principal was resolved, the middleware returns 401 with `RBAC_NO_PRINCIPAL`.
#### Scenario: null principal on private route
- WHEN a request with `principal === null` hits a non-public route
- THEN the response is 401 with `code === "RBAC_NO_PRINCIPAL"` AND `api.rbac.denials_total{reason="no-principal"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"returns 401 for null principal on non-public route"`
---
### Requirement: Plugin scopes auto-registered into a conflict-aware registry
The package SHALL expose `registerPluginScopes(registry, manifest)` that adds every scope from `manifest.scopes[]` to a shared `ScopeRegistry`. Two plugins declaring the same scope name raise `RBAC_SCOPE_CONFLICT`. Re-registering an identical descriptor for the same plugin is a no-op.
#### Scenario: duplicate scope across plugins raises conflict
- WHEN two manifests both declare `scopes = [{ name: "billing:read" }]`
- THEN the second registration throws `RBAC_SCOPE_CONFLICT`
#### Scenario: idempotent re-register for same plugin
- WHEN the same plugin manifest is registered twice (e.g. on reload)
- THEN the second call is a no-op
**Verified by:** `src/registry/registry.test.ts::"duplicate scope across plugins raises RBAC_SCOPE_CONFLICT"` AND `src/registry/registry.test.ts::"re-register for same plugin is idempotent"`
---
### Requirement: Public routes bypass the gate entirely
The package SHALL treat `RouteAuthSpec` of kind `"public"` as bypass — no scope check, no rule evaluation, no metric increment.
#### Scenario: public route always passes
- WHEN a request hits a route declaring `kind: "public"`
- THEN the middleware permits the request AND `api.rbac.denials_total` does not increment
**Verified by:** `src/middleware/middleware.test.ts::"public route bypass"` AND `src/middleware/middleware.spec-conformance.test.ts::"public route does not increment denial counter"`