Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
# `@suaveplan/api-rbac` — scope + rule-based authorisation middleware
**Package**: `@suaveplan/api-rbac`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §6 step 5, the per-request pipeline includes an RBAC gate that compares the route's declared `auth` against the resolved `principal.scopes`. Plugins declare scopes in their manifest (§3) and assign them per route via `router.get("/x", { auth: "billing:read" }, …)`. Centralising the gate in a single middleware keeps "forgot the auth middleware" out of the class-of-bug list, and gives operators a single place to wire complex policy via `@suaveplan/rules`.
## What changes
- ADDED: `createRbacMiddleware({ rules })` — produces a `Middleware` that consumes `RequestContext.principal` and the route's `auth` option. If `auth` is a string scope name, performs a default scope check (`principal.scopes.includes(scope)`); if `auth` is a `RuleSpec`, delegates evaluation to `@suaveplan/rules`. 403 on deny, pass-through on allow, 401 if `auth` is required and the principal is `null` (non-public route with no resolved principal).
- ADDED: `RouteAuthSpec` — discriminated union: `{ kind: "scope", scope: string } | { kind: "rule", rule: RuleSpec } | { kind: "public" }`. The router translates the route option's `auth` value into this shape before invoking the middleware.
- ADDED: Manifest-scope registration helper `registerPluginScopes(registry, manifest)` — adds every scope from `manifest.scopes[]` to the host's `ScopeRegistry`. Conflicts (two plugins claiming the same scope name) raise `RBAC_SCOPE_CONFLICT`.
- ADDED: `api.rbac.denials_total{plugin, route, reason}` counter — incremented on every deny. `reason` is `missing-scope` or `rule-deny` or `no-principal`.
- ADDED: Error codes (`RBAC_*`): `MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`, `SCOPE_CONFLICT`.
## Out of scope
- Session resolution (that's `api-auth-*` adapters, design doc §2.3).
- Tenant resolution (that's `api-tenant-resolver-*` adapters).
- Rate limiting (that's `api-rate-limit`).
- The router itself — this is a `Middleware` consumed by any router adapter.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Per-request RBAC gate | §6 step 5 |
| Plugin-declared scopes auto-registered | §3 (scopes block), §5 |
| Rule-policy delegation to `@suaveplan/rules` | §6 step 5 |
| `api.rbac.denials_total` counter | §12.3 (host-emitted), §12.4 |
## Verification approach
- `src/middleware/middleware.test.ts` — allow / deny matrices for `scope`, `rule`, and `public` route specs.
- `src/middleware/middleware.spec-conformance.test.ts` — pins observability: denials emit `api.rbac.denials_total` with the correct `reason` label, asserted against a real in-memory `MeterProvider`.
- `src/registry/registry.test.ts` — duplicate-scope registration raises `RBAC_SCOPE_CONFLICT`.
- `tests/dep-graph.test.ts` — runtime deps (`api-types`, `error`, `logger`, `rules`).
## Migration impact
None — additive only. `api-host` wires this middleware at boot.

View 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"`

View File

@@ -0,0 +1,66 @@
# Tasks — `@suaveplan/api-rbac`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc (§3, §6, §12.3, §12.4)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Draft `proposal.md`
- [x] 1.4 Draft `specs/api-rbac/spec.md`
- [x] 1.5 Every requirement carries `**Verified by:**`
## 2. Implementation
- [x] 2.1 Scaffold `packages/api-stack/api/api-rbac/`
- [x] 2.2 `src/middleware/` — `createRbacMiddleware`
- [x] 2.3 `src/registry/` — `registerPluginScopes`, `ScopeRegistry`
- [x] 2.4 `src/spec/` — `RouteAuthSpec` discriminated union + Zod schema
- [x] 2.5 `src/error-codes/error-codes.ts` (folder-form per CLAUDE.md feature layout)
- [x] 2.6 `src/instruments/` — `api.rbac.denials_total`
- [x] 2.7 `src/index.ts` — explicit named re-exports
## 3. Tests
- [x] 3.1 `src/middleware/middleware.test.ts`
- [x] 3.2 `src/middleware/middleware.spec-conformance.test.ts`
- [x] 3.3 `src/registry/registry.test.ts`
- [x] 3.4 `tests/dep-graph.test.ts`
- [x] 3.5 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation `.ts`
- [x] 4.2 `README.md` `## Modules`
- [x] 4.3 Update `CLAUDE.md` package listing
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check`
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-rbac`
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-rbac` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts packages/api-stack/api/api-rbac` (PASS)
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-rbac`
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics threaded; `RouteAuthSpec` discriminated union exported and consumed at every boundary
- [x] 6.2 Zod schemas wired at `RouteAuthSpec` ingestion boundary (`RouteAuthSpecSchema`)
- [x] 6.3 Every `RBAC_*` code thrown by a production path
- [x] 6.4 Strict-mode behaviours: missing principal on non-public route → 401, not 403
- [x] 6.5 OTel `api.rbac.denials_total` asserted with real `MeterProvider`
- [x] 6.6 Lifecycle dedup — `ScopeRegistry` register is idempotent on identical descriptors
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-rbac/spec.md`
- [x] 7.2 Move folder to archive
- [x] 7.3 Tick `8.4` in umbrella tasks
- [x] 7.4 Refresh `MANIFEST.md`