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