89 lines
4.1 KiB
Markdown
89 lines
4.1 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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 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"`
|