4.1 KiB
Capability Spec: api-rbac
Package: @suaveplan/api-rbac
Status: ACTIVE
Every requirement below carries a
**Verified by:**line.
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 declaringauth: "billing:read" - THEN the middleware permits the request
Scenario: principal missing required scope
- WHEN a request with principal scopes
["billing:read"]hits a route declaringauth: "billing:write" - THEN the response is 403 with
code === "RBAC_MISSING_SCOPE"ANDapi.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"ANDapi.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 === nullhits a non-public route - THEN the response is 401 with
code === "RBAC_NO_PRINCIPAL"ANDapi.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_totaldoes 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"