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

4.2 KiB

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"