Initial Commit
This commit is contained in:
85
packages/api-stack/api/api-rbac/README.md
Normal file
85
packages/api-stack/api/api-rbac/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @suaveplan/api-rbac - Role/scope/rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Principal` plus a route's `RouteAuthSpec` (scope name, @suaveplan/rules Rule, or public bypass), emits 401 when a non-public route has no principal, 403 on missing-scope or rule-deny, and increments `api.rbac.denials_total{plugin, route, reason}`. Ships a conflict-aware `ScopeRegistry` plus `registerPluginScopes(registry, manifest)` so plugin-declared scopes auto-register at install and duplicates raise `RBAC.SCOPE_CONFLICT`. Universal, Zod-validated boundaries, real OTel surface.
|
||||
|
||||
Scope + rule-based authorisation middleware for the Suaveplan API plugin
|
||||
system. Produces a framework-agnostic `Middleware` that consumes the
|
||||
resolved `Principal` from `RequestContext` and the matched route's
|
||||
`RouteAuthSpec`, performs the gate, and either calls `next()` or throws a
|
||||
typed `RbacError`. Every denial increments
|
||||
`api.rbac.denials_total{plugin, route, reason}` with `reason ∈
|
||||
{ "missing-scope", "rule-deny", "no-principal" }`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
|
||||
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
|
||||
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
|
||||
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
|
||||
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
|
||||
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
|
||||
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-rbac
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createRbacMiddleware } from "@suaveplan/api-rbac";
|
||||
|
||||
const middleware = createRbacMiddleware({
|
||||
routeFor: () => ({
|
||||
plugin: "billing",
|
||||
path: "/invoices",
|
||||
auth: { kind: "scope", scope: "billing:read" },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The middleware is the abstract `Middleware` from `@suaveplan/api-types` —
|
||||
adapters (`@suaveplan/api-router-hono`, `api-router-in-memory`, etc.) plug
|
||||
it into their pipeline directly.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes/error-codes.md) — `RBAC_CODES` registry
|
||||
(`MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`, `SCOPE_CONFLICT`,
|
||||
`INVALID_SPEC`) and the `RbacError` class.
|
||||
- [`instruments`](./src/instruments/instruments.md) — the single counter
|
||||
`api.rbac.denials_total` plus `createRbacInstruments` / `recordRbacDenial`.
|
||||
- [`middleware`](./src/middleware/middleware.md) — `createRbacMiddleware`
|
||||
and `statusForRbacError` mapping.
|
||||
- [`registry`](./src/registry/registry.md) — `ScopeRegistry` plus
|
||||
`registerPluginScopes(registry, manifest)` for install-time conflict
|
||||
detection.
|
||||
- [`spec`](./src/spec/spec.md) — the canonical `RouteAuthSpec` union, the
|
||||
Zod `RouteAuthSpecSchema`, and the `normaliseRouteAuth` fold.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
- counter `api.rbac.denials_total{plugin, route, reason}` — emitted on
|
||||
every deny path.
|
||||
|
||||
Reason label values: `"missing-scope" | "rule-deny" | "no-principal"`.
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | HTTP | When |
|
||||
| ---------------------- | ---- | -------------------------------------------------------- |
|
||||
| `RBAC.NO_PRINCIPAL` | 401 | non-public route reached with `principal === null` |
|
||||
| `RBAC.MISSING_SCOPE` | 403 | principal lacks the required scope |
|
||||
| `RBAC.RULE_DENY` | 403 | `@suaveplan/rules` evaluation returned `passed === false`|
|
||||
| `RBAC.SCOPE_CONFLICT` | 500 | two plugins claim the same scope name |
|
||||
| `RBAC.INVALID_SPEC` | 500 | `RouteAuthSpec` failed Zod validation |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
95
packages/api-stack/api/api-rbac/package.json
Normal file
95
packages/api-stack/api/api-rbac/package.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@suaveplan/api-rbac",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Role/scope/rule-based authorisation middleware for the Suaveplan API plugin foundation. Produces a framework-agnostic `Middleware` that consumes the resolved `Principal` plus a route's `RouteAuthSpec` (scope name, @suaveplan/rules Rule, or public bypass), emits 401 when a non-public route has no principal, 403 on missing-scope or rule-deny, and increments `api.rbac.denials_total{plugin, route, reason}`. Ships a conflict-aware `ScopeRegistry` plus `registerPluginScopes(registry, manifest)` so plugin-declared scopes auto-register at install and duplicates raise `RBAC.SCOPE_CONFLICT`. Universal, Zod-validated boundaries, real OTel surface.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./middleware": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/middleware.d.ts",
|
||||
"default": "./dist/middleware/middleware.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/registry.d.ts",
|
||||
"default": "./dist/registry/registry.js"
|
||||
}
|
||||
},
|
||||
"./spec": {
|
||||
"import": {
|
||||
"types": "./dist/spec/spec.d.ts",
|
||||
"default": "./dist/spec/spec.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/rules": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# `RBAC_CODES`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable, frozen registry of every `RBAC.*` error code surfaced by the
|
||||
`@suaveplan/api-rbac` authorisation middleware and its companion
|
||||
`ScopeRegistry`. Downstream consumers (the host's request pipeline,
|
||||
operator dashboards, audit pipelines, OpenAPI emitters) read these
|
||||
strings as load-bearing identifiers — they must never change between
|
||||
releases without an OpenSpec migration plan. `RBAC_CODES` is the
|
||||
single source of truth defining those strings, and `RbacError` is the
|
||||
class wrapping them with full `SuaveplanError` semantics (Zod-validated
|
||||
wire shape, cause-chain, redaction, OTel span-event integration).
|
||||
|
||||
The registry is built with `defineCodes("RBAC", [...])` from
|
||||
`@suaveplan/error`, which yields a literal-typed, deeply-frozen
|
||||
record. The literal types flow into every `RbacError` thrown by the
|
||||
middleware so a caller can `switch` on `err.code` against
|
||||
`RBAC_CODES.MISSING_SCOPE` with full compile-time exhaustiveness
|
||||
checking, and the typed `RbacCode` union doubles as a discriminator
|
||||
for telemetry attribute mapping.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly five codes — `MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`,
|
||||
`SCOPE_CONFLICT`, `INVALID_SPEC`.
|
||||
- Literal-typed values: `RBAC_CODES.MISSING_SCOPE` has the type
|
||||
`"RBAC.MISSING_SCOPE"` (not the wider `string`).
|
||||
- Deep-frozen at runtime (`Object.isFrozen` returns `true`).
|
||||
- Re-exported `RbacCode` union for use as a discriminator type.
|
||||
- `RbacError` extends `SuaveplanError<RbacCode>` so the cause-chain,
|
||||
Zod wire shape, redaction config, and OTel span-event adapter all
|
||||
inherit unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
throw new RbacError(
|
||||
RBAC_CODES.MISSING_SCOPE,
|
||||
"principal lacks required scope 'billing:write'"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pattern-match on the union for exhaustive error handling at host call
|
||||
sites — the compiler verifies every code is handled:
|
||||
|
||||
```ts
|
||||
import { RBAC_CODES, type RbacCode } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
function statusFor(code: RbacCode): number {
|
||||
switch (code) {
|
||||
case RBAC_CODES.NO_PRINCIPAL:
|
||||
return 401;
|
||||
case RBAC_CODES.MISSING_SCOPE:
|
||||
case RBAC_CODES.RULE_DENY:
|
||||
return 403;
|
||||
case RBAC_CODES.INVALID_SPEC:
|
||||
return 500;
|
||||
case RBAC_CODES.SCOPE_CONFLICT:
|
||||
// raised at install time, never per-request
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `INVALID_SPEC` code is reserved for the Zod ingestion boundary —
|
||||
host adapters that ingest a route's raw `auth` value run it through
|
||||
`RouteAuthSpecSchema.parse(...)` and re-throw any failure as
|
||||
`RbacError(INVALID_SPEC, ...)` so callers never see a raw `ZodError`.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RBAC_CODES` — frozen record mapping local name → fully-qualified
|
||||
`RBAC.*` string. Branded as `defineCodes("RBAC", [...])`.
|
||||
- `RbacCode` — union of every value in `RBAC_CODES`.
|
||||
- `RbacError` — `SuaveplanError<RbacCode>` subclass; constructor
|
||||
signature `(code, message, options?)` mirrors the base class.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` deep-freezes the returned registry so accidental
|
||||
mutation throws in strict mode. The class is a thin subclass of
|
||||
`SuaveplanError` purely to override `name = "RbacError"` for clearer
|
||||
stack traces; no field is added beyond the base shape, so the wire
|
||||
encoding (`SuaveplanError.toJSON`) is byte-identical to other
|
||||
`@suaveplan` errors and the i18n resolver hook applies unchanged.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RBAC_CODES, RbacError } from "./error-codes.js";
|
||||
|
||||
describe("RBAC_CODES", () => {
|
||||
it("contains every documented code", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(RBAC_CODES).sort()).toEqual(
|
||||
[
|
||||
"INVALID_SPEC",
|
||||
"MISSING_SCOPE",
|
||||
"NO_PRINCIPAL",
|
||||
"RULE_DENY",
|
||||
"SCOPE_CONFLICT",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("codes carry the RBAC namespace prefix", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(RBAC_CODES)) {
|
||||
expect(v.startsWith("RBAC.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry is deep-frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(RBAC_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RbacError", () => {
|
||||
it("preserves code, message, name", { timeout: 5000 }, () => {
|
||||
const err = new RbacError(RBAC_CODES.MISSING_SCOPE, "no scope");
|
||||
expect(err.code).toBe(RBAC_CODES.MISSING_SCOPE);
|
||||
expect(err.message).toBe("no scope");
|
||||
expect(err.name).toBe("RbacError");
|
||||
});
|
||||
|
||||
it("captures context map", { timeout: 5000 }, () => {
|
||||
const err = new RbacError(RBAC_CODES.NO_PRINCIPAL, "x", {
|
||||
context: { foo: 1 },
|
||||
});
|
||||
expect(err.context?.["foo"]).toBe(1);
|
||||
});
|
||||
|
||||
it("propagates cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new RbacError(RBAC_CODES.INVALID_SPEC, "x", { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("instanceof checks still narrow", { timeout: 5000 }, () => {
|
||||
const err: unknown = new RbacError(RBAC_CODES.RULE_DENY, "x");
|
||||
expect(err instanceof RbacError).toBe(true);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-rbac`.
|
||||
*
|
||||
* Every public failure path raises an {@link RbacError} carrying one of
|
||||
* these namespaced `RBAC.*` codes. Consumers branch on the string value
|
||||
* (e.g. `error.code === RBAC_CODES.MISSING_SCOPE` or the inline literal
|
||||
* `"RBAC.MISSING_SCOPE"`).
|
||||
*
|
||||
* Codes:
|
||||
* - `MISSING_SCOPE` — principal is authenticated but lacks the required scope (403).
|
||||
* - `RULE_DENY` — `@suaveplan/rules` evaluation returned `passed === false` (403).
|
||||
* - `NO_PRINCIPAL` — non-public route was reached with `principal === null` (401).
|
||||
* - `SCOPE_CONFLICT` — two plugins declared the same scope name in their manifest.
|
||||
* - `INVALID_SPEC` — the `RouteAuthSpec` Zod schema rejected the inbound shape.
|
||||
*/
|
||||
export const RBAC_CODES = defineCodes("RBAC", [
|
||||
"MISSING_SCOPE",
|
||||
"RULE_DENY",
|
||||
"NO_PRINCIPAL",
|
||||
"SCOPE_CONFLICT",
|
||||
"INVALID_SPEC",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified `RBAC.*` code. */
|
||||
export type RbacCode = (typeof RBAC_CODES)[keyof typeof RBAC_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/api-rbac` failure path.
|
||||
*
|
||||
* Extends {@link SuaveplanError} so callers can discriminate either via
|
||||
* `error instanceof RbacError` or via the stable `error.code` string. The
|
||||
* `name` is overridden to `"RbacError"` so stack traces are self-describing.
|
||||
*/
|
||||
export class RbacError extends SuaveplanError<RbacCode> {
|
||||
public constructor(
|
||||
code: RbacCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "RbacError";
|
||||
}
|
||||
}
|
||||
1
packages/api-stack/api/api-rbac/src/error-codes/index.ts
Normal file
1
packages/api-stack/api/api-rbac/src/error-codes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RBAC_CODES, type RbacCode, RbacError } from "./error-codes.js";
|
||||
44
packages/api-stack/api/api-rbac/src/index.ts
Normal file
44
packages/api-stack/api/api-rbac/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Side-effect import — surfaces the error-codes registry to consumers
|
||||
// of the package root barrel even when they only import a subpath.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
RBAC_CODES,
|
||||
type RbacCode,
|
||||
RbacError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Instruments
|
||||
export {
|
||||
createRbacInstruments,
|
||||
RBAC_DENIAL_LABEL_KEYS,
|
||||
RBAC_DENIAL_REASONS,
|
||||
RBAC_DENIALS_TOTAL_NAME,
|
||||
RBAC_METER_NAME,
|
||||
type RbacDenialReason,
|
||||
type RbacInstruments,
|
||||
type RbacInstrumentsOptions,
|
||||
recordRbacDenial,
|
||||
} from "./instruments/instruments.js";
|
||||
// Middleware
|
||||
export {
|
||||
createRbacMiddleware,
|
||||
type RbacMiddlewareOptions,
|
||||
type RbacRequestContext,
|
||||
type RbacRoute,
|
||||
statusForRbacError,
|
||||
} from "./middleware/middleware.js";
|
||||
// Registry
|
||||
export {
|
||||
type RegisteredScope,
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "./registry/registry.js";
|
||||
// Spec
|
||||
export {
|
||||
isRouteAuthSpec,
|
||||
normaliseRouteAuth,
|
||||
type RouteAuthInput,
|
||||
type RouteAuthSpec,
|
||||
RouteAuthSpecSchema,
|
||||
} from "./spec/spec.js";
|
||||
11
packages/api-stack/api/api-rbac/src/instruments/index.ts
Normal file
11
packages/api-stack/api/api-rbac/src/instruments/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
createRbacInstruments,
|
||||
RBAC_DENIAL_LABEL_KEYS,
|
||||
RBAC_DENIAL_REASONS,
|
||||
RBAC_DENIALS_TOTAL_NAME,
|
||||
RBAC_METER_NAME,
|
||||
type RbacDenialReason,
|
||||
type RbacInstruments,
|
||||
type RbacInstrumentsOptions,
|
||||
recordRbacDenial,
|
||||
} from "./instruments.js";
|
||||
115
packages/api-stack/api/api-rbac/src/instruments/instruments.md
Normal file
115
packages/api-stack/api/api-rbac/src/instruments/instruments.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `instruments` — `api.rbac.denials_total`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the single OpenTelemetry counter `api.rbac.denials_total` and the
|
||||
factory that constructs the `RbacInstruments` registry against a
|
||||
caller-supplied `MeterProvider`. The middleware itself never touches
|
||||
the global OTel API directly — every emission flows through
|
||||
`recordRbacDenial(instruments, { plugin, route, reason })`, which
|
||||
guarantees the three label keys are populated on every call and the
|
||||
base attributes from the host (host-id, host-version, etc.) are
|
||||
merged in.
|
||||
|
||||
Centralising emission here lets the spec-conformance test pin the
|
||||
exact counter name, label set, and reason vocabulary against a real
|
||||
`MeterProvider` from `@opentelemetry/sdk-metrics` — empirically the
|
||||
class of regression that broke production dashboards in two of three
|
||||
prior incidents was a label-key rename slipping past lint+typecheck.
|
||||
|
||||
## Features
|
||||
|
||||
- Single counter `api.rbac.denials_total` with description, created
|
||||
on the `@suaveplan/api-rbac` meter.
|
||||
- Closed `RbacDenialReason` union — `"missing-scope" | "rule-deny" |
|
||||
"no-principal"` — so dashboard authors can rely on a finite label
|
||||
set.
|
||||
- Frozen `RBAC_DENIAL_REASONS` and `RBAC_DENIAL_LABEL_KEYS` tuples
|
||||
for tests and metric-discovery tooling.
|
||||
- Base attributes merged on every emission; host injects host-id /
|
||||
host-version at boot.
|
||||
- `MeterProvider` is injectable — tests use a real
|
||||
`MeterProvider` from `@opentelemetry/sdk-metrics`, production
|
||||
resolves through `metrics.getMeterProvider()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRbacInstruments,
|
||||
recordRbacDenial,
|
||||
} from "@suaveplan/api-rbac/instruments";
|
||||
|
||||
const instruments = createRbacInstruments();
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: "billing",
|
||||
route: "/invoices",
|
||||
reason: "missing-scope",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a real `MeterProvider` and a `PeriodicExportingMetricReader`
|
||||
with an `InMemoryMetricExporter` to assert the exact counter
|
||||
emissions in a conformance test:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
createRbacInstruments,
|
||||
RBAC_DENIALS_TOTAL_NAME,
|
||||
recordRbacDenial,
|
||||
} from "@suaveplan/api-rbac/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
const instruments = createRbacInstruments({
|
||||
meterProvider: provider,
|
||||
baseAttributes: { host: "h-1" },
|
||||
});
|
||||
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: "billing",
|
||||
route: "/x",
|
||||
reason: "rule-deny",
|
||||
});
|
||||
|
||||
await provider.forceFlush();
|
||||
const dump = exporter.getMetrics();
|
||||
// dump[0].scopeMetrics[0].metrics carries RBAC_DENIALS_TOTAL_NAME …
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RBAC_DENIALS_TOTAL_NAME` — string literal `"api.rbac.denials_total"`.
|
||||
- `RBAC_METER_NAME` — string literal `"@suaveplan/api-rbac"`.
|
||||
- `RBAC_DENIAL_REASONS` — frozen tuple of legal reason labels.
|
||||
- `RBAC_DENIAL_LABEL_KEYS` — frozen tuple `["plugin", "route", "reason"]`.
|
||||
- `RbacDenialReason` — `"missing-scope" | "rule-deny" | "no-principal"`.
|
||||
- `RbacInstruments` — `{ denials, baseAttributes }`.
|
||||
- `RbacInstrumentsOptions` — `{ meterProvider?, baseAttributes? }`.
|
||||
- `createRbacInstruments(options?)` — factory.
|
||||
- `recordRbacDenial(instruments, attrs)` — emission helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory falls back to `metrics.getMeterProvider()` only when no
|
||||
provider is supplied; production hosts always pass their own
|
||||
provider so SDK installation is opt-in. The counter description is
|
||||
load-bearing for the OTel collector's `view` / `attribute_filter`
|
||||
configuration — operators rely on it to scope the metric to
|
||||
particular pipelines without re-discovering it. `Object.freeze` on
|
||||
the registry plus `Object.freeze` on the base attribute bag makes
|
||||
accidental in-place mutation throw in strict mode, which is the
|
||||
class of bug `tslib` + `verbatimModuleSyntax` cannot catch.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type {
|
||||
DataPoint,
|
||||
MetricData,
|
||||
ResourceMetrics,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createRbacInstruments,
|
||||
RBAC_DENIAL_LABEL_KEYS,
|
||||
RBAC_DENIAL_REASONS,
|
||||
RBAC_DENIALS_TOTAL_NAME,
|
||||
RBAC_METER_NAME,
|
||||
recordRbacDenial,
|
||||
} from "./instruments.js";
|
||||
|
||||
function build(): {
|
||||
provider: MeterProvider;
|
||||
exporter: InMemoryMetricExporter;
|
||||
} {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const provider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
return { provider, exporter };
|
||||
}
|
||||
|
||||
describe("createRbacInstruments", () => {
|
||||
it("creates the denials counter on the @suaveplan/api-rbac meter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = build();
|
||||
const inst = createRbacInstruments({ meterProvider: provider });
|
||||
recordRbacDenial(inst, {
|
||||
plugin: "p",
|
||||
route: "/r",
|
||||
reason: "missing-scope",
|
||||
});
|
||||
await provider.forceFlush();
|
||||
const all: ResourceMetrics[] = exporter.getMetrics();
|
||||
const meterNames = new Set(
|
||||
all.flatMap((rm) => rm.scopeMetrics.map((sm) => sm.scope.name))
|
||||
);
|
||||
expect(meterNames.has(RBAC_METER_NAME)).toBe(true);
|
||||
});
|
||||
|
||||
it("recordRbacDenial emits the canonical label keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = build();
|
||||
const inst = createRbacInstruments({
|
||||
meterProvider: provider,
|
||||
baseAttributes: { host: "h-1", region: "r-1" },
|
||||
});
|
||||
recordRbacDenial(inst, {
|
||||
plugin: "billing",
|
||||
route: "/invoices",
|
||||
reason: "rule-deny",
|
||||
});
|
||||
await provider.forceFlush();
|
||||
const all: ResourceMetrics[] = exporter.getMetrics();
|
||||
const flat: DataPoint<number>[] = [];
|
||||
for (const rm of all) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics as MetricData[]) {
|
||||
if (m.descriptor.name !== RBAC_DENIALS_TOTAL_NAME) continue;
|
||||
for (const dp of m.dataPoints as DataPoint<number>[]) {
|
||||
flat.push(dp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const dp = flat[0] as DataPoint<number>;
|
||||
for (const k of RBAC_DENIAL_LABEL_KEYS) {
|
||||
expect(Object.keys(dp.attributes)).toContain(k);
|
||||
}
|
||||
expect(dp.attributes["host"]).toBe("h-1");
|
||||
expect(dp.attributes["region"]).toBe("r-1");
|
||||
});
|
||||
|
||||
it("falls back to the global MeterProvider when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prev = metrics.getMeterProvider();
|
||||
const inst = createRbacInstruments();
|
||||
expect(inst.denials).toBeDefined();
|
||||
expect(metrics.getMeterProvider()).toBe(prev);
|
||||
});
|
||||
|
||||
it("RBAC_DENIAL_REASONS contains the three documented reasons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect([...RBAC_DENIAL_REASONS].sort()).toEqual(
|
||||
["missing-scope", "no-principal", "rule-deny"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
102
packages/api-stack/api/api-rbac/src/instruments/instruments.ts
Normal file
102
packages/api-stack/api/api-rbac/src/instruments/instruments.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Reason label values emitted on `api.rbac.denials_total`.
|
||||
*
|
||||
* - `"missing-scope"` — principal lacked the required scope.
|
||||
* - `"rule-deny"` — `@suaveplan/rules` evaluation returned `passed === false`.
|
||||
* - `"no-principal"` — non-public route with `principal === null`.
|
||||
*/
|
||||
export type RbacDenialReason = "missing-scope" | "rule-deny" | "no-principal";
|
||||
|
||||
/** Frozen tuple of every legal reason label, for tests and dashboards. */
|
||||
export const RBAC_DENIAL_REASONS = [
|
||||
"missing-scope",
|
||||
"rule-deny",
|
||||
"no-principal",
|
||||
] as const satisfies readonly RbacDenialReason[];
|
||||
|
||||
/** Frozen tuple of label keys recorded on the counter. */
|
||||
export const RBAC_DENIAL_LABEL_KEYS = [
|
||||
"plugin",
|
||||
"route",
|
||||
"reason",
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
/** Fully-qualified counter name. Load-bearing across dashboards. */
|
||||
export const RBAC_DENIALS_TOTAL_NAME = "api.rbac.denials_total" as const;
|
||||
|
||||
/** Suaveplan-owned meter name for this package. */
|
||||
export const RBAC_METER_NAME = "@suaveplan/api-rbac" as const;
|
||||
|
||||
/** Options accepted by {@link createRbacInstruments}. */
|
||||
export interface RbacInstrumentsOptions {
|
||||
/**
|
||||
* OpenTelemetry MeterProvider — when omitted, the global
|
||||
* `metrics.getMeterProvider()` is used. Tests inject a real
|
||||
* `MeterProvider` from `@opentelemetry/sdk-metrics`.
|
||||
*/
|
||||
readonly meterProvider?: MeterProvider;
|
||||
/**
|
||||
* Default attributes mixed into every counter `add()` call (e.g.
|
||||
* host id + version). Optional; the host injects them at boot.
|
||||
*/
|
||||
readonly baseAttributes?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry returned by {@link createRbacInstruments}. The middleware
|
||||
* receives a frozen reference and calls `denials.add(1, { ... })` on
|
||||
* every deny path.
|
||||
*/
|
||||
export interface RbacInstruments {
|
||||
/** The fully-qualified `api.rbac.denials_total` counter. */
|
||||
readonly denials: Counter;
|
||||
/** Base attributes mixed into every emission. Empty object by default. */
|
||||
readonly baseAttributes: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the RBAC OTel instrument registry against a `MeterProvider`.
|
||||
*
|
||||
* The factory is pure — no global state is mutated. Callers that want
|
||||
* a single shared registry hand the returned value to the middleware
|
||||
* factory; the in-process default flows through the global
|
||||
* `metrics.getMeterProvider()` resolution.
|
||||
*/
|
||||
export function createRbacInstruments(
|
||||
options: RbacInstrumentsOptions = {}
|
||||
): RbacInstruments {
|
||||
const provider: MeterProvider =
|
||||
options.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter: Meter = provider.getMeter(RBAC_METER_NAME);
|
||||
const denials = meter.createCounter(RBAC_DENIALS_TOTAL_NAME, {
|
||||
description:
|
||||
"Number of requests denied by the RBAC middleware, labelled by plugin / route / reason.",
|
||||
});
|
||||
return Object.freeze({
|
||||
denials,
|
||||
baseAttributes: Object.freeze({ ...(options.baseAttributes ?? {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a single denial with the canonical label set. The function is
|
||||
* defensively typed so the middleware cannot forget a label.
|
||||
*/
|
||||
export function recordRbacDenial(
|
||||
instruments: RbacInstruments,
|
||||
attrs: {
|
||||
readonly plugin: string;
|
||||
readonly route: string;
|
||||
readonly reason: RbacDenialReason;
|
||||
}
|
||||
): void {
|
||||
instruments.denials.add(1, {
|
||||
plugin: attrs.plugin,
|
||||
route: attrs.route,
|
||||
reason: attrs.reason,
|
||||
...instruments.baseAttributes,
|
||||
});
|
||||
}
|
||||
7
packages/api-stack/api/api-rbac/src/middleware/index.ts
Normal file
7
packages/api-stack/api/api-rbac/src/middleware/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createRbacMiddleware,
|
||||
type RbacMiddlewareOptions,
|
||||
type RbacRequestContext,
|
||||
type RbacRoute,
|
||||
statusForRbacError,
|
||||
} from "./middleware.js";
|
||||
120
packages/api-stack/api/api-rbac/src/middleware/middleware.md
Normal file
120
packages/api-stack/api/api-rbac/src/middleware/middleware.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `middleware` — `createRbacMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships `createRbacMiddleware(options)` — the per-request authorisation
|
||||
gate. The factory produces a framework-agnostic `Middleware` that
|
||||
consumes the resolved `Principal` from `RequestContext` plus the
|
||||
matched route's canonical `RouteAuthSpec`, and performs one of three
|
||||
checks: bypass for `public`, contains-check against `principal.scopes`
|
||||
for `scope`, or delegated evaluation via `@suaveplan/rules` for
|
||||
`rule`. Denials are signalled by throwing a typed `RbacError` with
|
||||
the corresponding `RBAC.*` code; the host's exception filter folds
|
||||
the error into the standard `ErrorEnvelope` wire shape.
|
||||
|
||||
The middleware does not depend on Hono / Elysia / Bun.serve directly
|
||||
— it returns the abstract `Middleware` from `@suaveplan/api-types`.
|
||||
Adapters bridging to a specific router (`@suaveplan/api-router-hono`,
|
||||
`api-router-in-memory`) accept the middleware factory and wire it
|
||||
into their own pipeline; the matched-route descriptor is bridged via
|
||||
the `routeFor(ctx)` callback supplied at construction time.
|
||||
|
||||
## Features
|
||||
|
||||
- `public` arm bypasses the gate entirely — no scope check, no
|
||||
rule evaluation, no metric emission.
|
||||
- Scope arm performs a strict `===` contains-check on
|
||||
`principal.scopes`. Empty principal on a non-public route routes
|
||||
to `RBAC.NO_PRINCIPAL` (401), not `MISSING_SCOPE` (403).
|
||||
- Rule arm delegates to `RuleEngine.evaluate` with a context
|
||||
populated from the request method/path/query and the principal /
|
||||
tenant.
|
||||
- Every denial increments `api.rbac.denials_total` with the three
|
||||
canonical labels (`plugin`, `route`, `reason`).
|
||||
- `statusForRbacError(error)` exposes the 401/403/500 mapping for
|
||||
adapters that prefer return-value handling over throwing.
|
||||
- Configurable rule engine + instruments — production hosts pass
|
||||
their own.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
|
||||
|
||||
const middleware = createRbacMiddleware({
|
||||
routeFor: () => ({
|
||||
plugin: "billing",
|
||||
path: "/invoices",
|
||||
auth: { kind: "scope", scope: "billing:read" },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Rule-based authorisation with the `@suaveplan/rules` engine and an
|
||||
injected `MeterProvider` for assertable telemetry:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { rule, RuleEngine } from "@suaveplan/rules";
|
||||
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
|
||||
import { createRbacInstruments } from "@suaveplan/api-rbac/instruments";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
const instruments = createRbacInstruments({ meterProvider: provider });
|
||||
const engine = new RuleEngine();
|
||||
|
||||
const billingRule = rule().condition({
|
||||
field: "principal.scopes",
|
||||
operator: "contains",
|
||||
value: "billing:write",
|
||||
});
|
||||
|
||||
const middleware = createRbacMiddleware({
|
||||
rules: engine,
|
||||
instruments,
|
||||
routeFor: (ctx) => ({
|
||||
plugin: "billing",
|
||||
path: new URL(ctx.request.url).pathname,
|
||||
auth: { kind: "rule", rule: billingRule },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The middleware throws `RbacError` rather than writing a Response so
|
||||
the request pipeline's central exception filter retains a single
|
||||
formatting site for the `ErrorEnvelope` wire shape.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RbacRequestContext` — structural shape consumed from the
|
||||
request context (`request`, `principal`, `tenantId`).
|
||||
- `RbacRoute` — `{ plugin, path, auth }` matched-route descriptor.
|
||||
- `RbacMiddlewareOptions` — `{ rules?, routeFor, instruments? }`.
|
||||
- `createRbacMiddleware(options)` — returns the abstract `Middleware`.
|
||||
- `statusForRbacError(error)` — code → HTTP status mapping.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `rule` arm constructs a fresh `RuleContext` per request — the
|
||||
engine receives `principal`, `tenantId`, and a `request` block with
|
||||
the request method, pathname, and the searchParams folded into a
|
||||
plain object. The engine returns `{ passed, errors, … }`; only
|
||||
`passed === false` triggers a denial, matching the rules-package
|
||||
contract. The `URL` parse happens once per request after the
|
||||
fast-path arms (`public`, `scope`, `no-principal`) have returned, so
|
||||
the typical scope-gated route does not pay the parse cost.
|
||||
|
||||
Strict-mode semantics for the 401 vs 403 split are tested at
|
||||
`middleware.test.ts::"returns 401 for null principal on non-public
|
||||
route"` — empirically the bug that breaks consumers most is a 403
|
||||
on a missing principal masking an authentication outage as an
|
||||
authorisation outage. The implementation explicitly checks
|
||||
`principal === null` before any scope arithmetic so the two failure
|
||||
modes never alias.
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
DataPoint,
|
||||
MetricData,
|
||||
ResourceMetrics,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import type {
|
||||
Principal,
|
||||
PrincipalId,
|
||||
Scope,
|
||||
} from "@suaveplan/api-types/principal";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRbacInstruments } from "../instruments/instruments.js";
|
||||
import type { RouteAuthSpec } from "../spec/spec.js";
|
||||
import { createRbacMiddleware, type RbacRequestContext } from "./middleware.js";
|
||||
|
||||
const OK = new Response("ok", { status: 200 });
|
||||
|
||||
function makePrincipal(scopes: readonly string[]): Principal {
|
||||
return {
|
||||
id: "u_1" as PrincipalId,
|
||||
scopes: scopes as readonly string[] as readonly Scope[],
|
||||
claims: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(p: Principal | null): RbacRequestContext {
|
||||
return {
|
||||
request: new Request("http://x/test"),
|
||||
principal: p,
|
||||
tenantId: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface DenialDataPoint {
|
||||
readonly attributes: Readonly<Record<string, unknown>>;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
async function collectDenials(
|
||||
exporter: InMemoryMetricExporter,
|
||||
provider: MeterProvider
|
||||
): Promise<DenialDataPoint[]> {
|
||||
await provider.forceFlush();
|
||||
const all: ResourceMetrics[] = exporter.getMetrics();
|
||||
const out: DenialDataPoint[] = [];
|
||||
for (const rm of all) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics as MetricData[]) {
|
||||
if (m.descriptor.name !== "api.rbac.denials_total") continue;
|
||||
for (const dp of m.dataPoints as DataPoint<number>[]) {
|
||||
out.push({
|
||||
attributes: dp.attributes as Readonly<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
value: dp.value as number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildProvider(): {
|
||||
provider: MeterProvider;
|
||||
exporter: InMemoryMetricExporter;
|
||||
} {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
return { provider, exporter };
|
||||
}
|
||||
|
||||
function makeMiddleware(provider: MeterProvider, auth: RouteAuthSpec) {
|
||||
const instruments = createRbacInstruments({
|
||||
meterProvider: provider,
|
||||
baseAttributes: { host: "h-1" },
|
||||
});
|
||||
return createRbacMiddleware({
|
||||
instruments,
|
||||
routeFor: () => ({ plugin: "billing", path: "/invoices", auth }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("api.rbac.denials_total — conformance", () => {
|
||||
it("denial emits api.rbac.denials_total with missing-scope reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = buildProvider();
|
||||
const mw = makeMiddleware(provider, {
|
||||
kind: "scope",
|
||||
scope: "billing:write",
|
||||
});
|
||||
await mw(
|
||||
makeCtx(makePrincipal(["billing:read"])),
|
||||
async () => OK
|
||||
).catch(() => undefined);
|
||||
const dps = await collectDenials(exporter, provider);
|
||||
expect(dps.length).toBeGreaterThan(0);
|
||||
const dp = dps[0] as DenialDataPoint;
|
||||
expect(dp.value).toBe(1);
|
||||
expect(dp.attributes["reason"]).toBe("missing-scope");
|
||||
expect(dp.attributes["plugin"]).toBe("billing");
|
||||
expect(dp.attributes["route"]).toBe("/invoices");
|
||||
expect(dp.attributes["host"]).toBe("h-1");
|
||||
});
|
||||
|
||||
it("no-principal denial labels reason='no-principal'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = buildProvider();
|
||||
const mw = makeMiddleware(provider, {
|
||||
kind: "scope",
|
||||
scope: "billing:read",
|
||||
});
|
||||
await mw(makeCtx(null), async () => OK).catch(() => undefined);
|
||||
const dps = await collectDenials(exporter, provider);
|
||||
const dp = dps[0] as DenialDataPoint;
|
||||
expect(dp.attributes["reason"]).toBe("no-principal");
|
||||
});
|
||||
|
||||
it("public route does not increment denial counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = buildProvider();
|
||||
const mw = makeMiddleware(provider, { kind: "public" });
|
||||
await mw(makeCtx(null), async () => OK);
|
||||
const dps = await collectDenials(exporter, provider);
|
||||
expect(dps).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope arm with required scope does not increment", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, exporter } = buildProvider();
|
||||
const mw = makeMiddleware(provider, {
|
||||
kind: "scope",
|
||||
scope: "billing:read",
|
||||
});
|
||||
const res = await mw(
|
||||
makeCtx(makePrincipal(["billing:read"])),
|
||||
async () => OK
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const dps = await collectDenials(exporter, provider);
|
||||
expect(dps).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
Principal,
|
||||
PrincipalId,
|
||||
Scope,
|
||||
} from "@suaveplan/api-types/principal";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { condition, RuleEngine } from "@suaveplan/rules";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
import { createRbacInstruments } from "../instruments/instruments.js";
|
||||
import type { RouteAuthSpec } from "../spec/spec.js";
|
||||
import {
|
||||
createRbacMiddleware,
|
||||
type RbacRequestContext,
|
||||
statusForRbacError,
|
||||
} from "./middleware.js";
|
||||
|
||||
const OK = new Response("ok", { status: 200 });
|
||||
|
||||
function makePrincipal(scopes: readonly string[]): Principal {
|
||||
return {
|
||||
id: "u_1" as PrincipalId,
|
||||
scopes: scopes as readonly string[] as readonly Scope[],
|
||||
claims: { sub: "u_1" },
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
overrides: Partial<RbacRequestContext> = {}
|
||||
): RbacRequestContext {
|
||||
return {
|
||||
request: new Request("http://localhost/test?a=1&b=2", {
|
||||
method: "GET",
|
||||
}),
|
||||
principal: makePrincipal(["billing:read"]),
|
||||
tenantId: "t_1" as unknown as TenantId,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMiddleware(
|
||||
auth: RouteAuthSpec,
|
||||
opts: { engine?: RuleEngine } = {}
|
||||
) {
|
||||
const instruments = createRbacInstruments();
|
||||
return createRbacMiddleware({
|
||||
instruments,
|
||||
rules: opts.engine,
|
||||
routeFor: () => ({
|
||||
plugin: "billing",
|
||||
path: "/invoices",
|
||||
auth,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createRbacMiddleware — scope arm", () => {
|
||||
it("permits request with required scope", { timeout: 5000 }, async () => {
|
||||
const mw = makeMiddleware({ kind: "scope", scope: "billing:read" });
|
||||
const res = await mw(makeCtx(), async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies request missing required scope", { timeout: 5000 }, async () => {
|
||||
const mw = makeMiddleware({ kind: "scope", scope: "billing:write" });
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
await mw(makeCtx(), async () => OK);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.MISSING_SCOPE);
|
||||
expect(statusForRbacError(captured as RbacError)).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRbacMiddleware — rule arm", () => {
|
||||
it("permits when rule evaluates true", { timeout: 5000 }, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const allowRule = condition("principal.id", "equals", "u_1");
|
||||
const mw = makeMiddleware(
|
||||
{ kind: "rule", rule: allowRule },
|
||||
{ engine }
|
||||
);
|
||||
const res = await mw(makeCtx(), async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies when rule evaluates false", { timeout: 5000 }, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const denyRule = condition("principal.id", "equals", "u_other");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: denyRule }, { engine });
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
await mw(makeCtx(), async () => OK);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.RULE_DENY);
|
||||
expect(statusForRbacError(captured as RbacError)).toBe(403);
|
||||
});
|
||||
|
||||
it("rule arm with tenantId populates rule context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const r = condition("principal.id", "equals", "u_1");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
|
||||
const res = await mw(
|
||||
makeCtx({ tenantId: "acme" as unknown as TenantId }),
|
||||
async () => OK
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rule arm with null tenantId omits the tenant slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const r = condition("principal.id", "equals", "u_1");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
|
||||
const res = await mw(makeCtx({ tenantId: null }), async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rule arm with undefined tenantId omits the tenant slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const r = condition("principal.id", "equals", "u_1");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
|
||||
const ctx: RbacRequestContext = {
|
||||
request: new Request("http://localhost/x"),
|
||||
principal: makePrincipal(["s"]),
|
||||
};
|
||||
const res = await mw(ctx, async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rule spec without an engine throws INVALID_SPEC", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const denyRule = condition("x", "equals", "y");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: denyRule });
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
await mw(makeCtx(), async () => OK);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRbacMiddleware — null principal", () => {
|
||||
it("returns 401 for null principal on non-public route", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = makeMiddleware({ kind: "scope", scope: "billing:read" });
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
await mw(makeCtx({ principal: null }), async () => OK);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.NO_PRINCIPAL);
|
||||
expect(statusForRbacError(captured as RbacError)).toBe(401);
|
||||
});
|
||||
|
||||
it("null principal on rule-spec route also returns 401", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new RuleEngine();
|
||||
const r = condition("x", "equals", "y");
|
||||
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
await mw(makeCtx({ principal: null }), async () => OK);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.NO_PRINCIPAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRbacMiddleware — public arm", () => {
|
||||
it("public route bypass", { timeout: 5000 }, async () => {
|
||||
const mw = makeMiddleware({ kind: "public" });
|
||||
const res = await mw(makeCtx({ principal: null }), async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRbacMiddleware — defaults", () => {
|
||||
it("uses createRbacInstruments() when no instruments passed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Constructed via the default factory (global meter provider).
|
||||
const mw = createRbacMiddleware({
|
||||
routeFor: () => ({
|
||||
plugin: "billing",
|
||||
path: "/p",
|
||||
auth: { kind: "public" },
|
||||
}),
|
||||
});
|
||||
const res = await mw(makeCtx(), async () => OK);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusForRbacError", () => {
|
||||
it("maps every code", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
statusForRbacError(new RbacError(RBAC_CODES.NO_PRINCIPAL, "x"))
|
||||
).toBe(401);
|
||||
expect(
|
||||
statusForRbacError(new RbacError(RBAC_CODES.MISSING_SCOPE, "x"))
|
||||
).toBe(403);
|
||||
expect(
|
||||
statusForRbacError(new RbacError(RBAC_CODES.RULE_DENY, "x"))
|
||||
).toBe(403);
|
||||
expect(
|
||||
statusForRbacError(new RbacError(RBAC_CODES.INVALID_SPEC, "x"))
|
||||
).toBe(500);
|
||||
expect(
|
||||
statusForRbacError(new RbacError(RBAC_CODES.SCOPE_CONFLICT, "x"))
|
||||
).toBe(500);
|
||||
});
|
||||
});
|
||||
207
packages/api-stack/api/api-rbac/src/middleware/middleware.ts
Normal file
207
packages/api-stack/api/api-rbac/src/middleware/middleware.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { Middleware } from "@suaveplan/api-types/router";
|
||||
import type { RuleEngine } from "@suaveplan/rules";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
import type { RbacInstruments } from "../instruments/instruments.js";
|
||||
import {
|
||||
createRbacInstruments,
|
||||
recordRbacDenial,
|
||||
} from "../instruments/instruments.js";
|
||||
import type { RouteAuthSpec } from "../spec/spec.js";
|
||||
|
||||
/**
|
||||
* Minimal shape the middleware needs from the request context. Defined
|
||||
* structurally so the middleware works against both the abstract
|
||||
* `RequestContext` and any adapter-specific superset.
|
||||
*/
|
||||
export interface RbacRequestContext {
|
||||
readonly request: Request;
|
||||
readonly principal:
|
||||
| import("@suaveplan/api-types/principal").Principal
|
||||
| null;
|
||||
readonly tenantId?:
|
||||
| import("@suaveplan/api-types/tenant").TenantId
|
||||
| null
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-request route descriptor consumed by the middleware. The router
|
||||
* adapter supplies this — typically derived from the matched route — via
|
||||
* the `routeFor` callback on {@link RbacMiddlewareOptions}.
|
||||
*/
|
||||
export interface RbacRoute {
|
||||
/** Plugin owning the route — used as the `plugin` denial label. */
|
||||
readonly plugin: string;
|
||||
/** Route path / template — used as the `route` denial label. */
|
||||
readonly path: string;
|
||||
/** Canonical authorisation spec for the route. */
|
||||
readonly auth: RouteAuthSpec;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createRbacMiddleware}. */
|
||||
export interface RbacMiddlewareOptions {
|
||||
/**
|
||||
* `@suaveplan/rules` engine used for `kind: "rule"` specs. Optional;
|
||||
* when omitted, rule-spec routes throw `RBAC.INVALID_SPEC` at request
|
||||
* time. Production hosts always supply one.
|
||||
*/
|
||||
readonly rules?: RuleEngine;
|
||||
/**
|
||||
* Resolver from a request context to the matched route descriptor.
|
||||
* The router adapter knows the matched route; this callback bridges
|
||||
* the gap so the middleware itself stays framework-agnostic.
|
||||
*/
|
||||
readonly routeFor: (ctx: RbacRequestContext) => RbacRoute;
|
||||
/**
|
||||
* OTel instruments registry. Defaults to a fresh registry built from
|
||||
* the global `MeterProvider`; tests inject a real `MeterProvider`.
|
||||
*/
|
||||
readonly instruments?: RbacInstruments;
|
||||
}
|
||||
|
||||
/** Status code attached to {@link RbacError} for adapter mapping. */
|
||||
export function statusForRbacError(error: RbacError): 401 | 403 | 500 {
|
||||
switch (error.code) {
|
||||
case RBAC_CODES.NO_PRINCIPAL:
|
||||
return 401;
|
||||
case RBAC_CODES.MISSING_SCOPE:
|
||||
case RBAC_CODES.RULE_DENY:
|
||||
return 403;
|
||||
case RBAC_CODES.INVALID_SPEC:
|
||||
case RBAC_CODES.SCOPE_CONFLICT:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the RBAC `Middleware`. The middleware:
|
||||
* 1. Resolves the matched route via `routeFor(ctx)`.
|
||||
* 2. On `kind: "public"` — calls `next()` directly. No metric.
|
||||
* 3. On `principal === null` (any non-public spec) — throws
|
||||
* `RbacError(NO_PRINCIPAL, …)` AND increments
|
||||
* `api.rbac.denials_total{reason="no-principal"}`.
|
||||
* 4. On `kind: "scope"` — checks `principal.scopes`. On miss throws
|
||||
* `RbacError(MISSING_SCOPE, …)` AND increments
|
||||
* `api.rbac.denials_total{reason="missing-scope"}`.
|
||||
* 5. On `kind: "rule"` — evaluates via the supplied `RuleEngine`. On
|
||||
* `passed === false` throws `RbacError(RULE_DENY, …)` AND
|
||||
* increments `api.rbac.denials_total{reason="rule-deny"}`.
|
||||
*
|
||||
* The middleware does not write a `Response` directly — it throws
|
||||
* `RbacError` and lets the host's exception filter convert it to the
|
||||
* canonical error envelope. Adapters that want a non-throwing path use
|
||||
* {@link statusForRbacError} to map the code.
|
||||
*/
|
||||
export function createRbacMiddleware(
|
||||
options: RbacMiddlewareOptions
|
||||
): Middleware {
|
||||
const instruments = options.instruments ?? createRbacInstruments();
|
||||
const ruleEngine = options.rules;
|
||||
|
||||
return async function rbacMiddleware(ctx, next): Promise<Response> {
|
||||
const route = options.routeFor(ctx);
|
||||
|
||||
if (route.auth.kind === "public") {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (ctx.principal === null) {
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
reason: "no-principal",
|
||||
});
|
||||
throw new RbacError(
|
||||
RBAC_CODES.NO_PRINCIPAL,
|
||||
"non-public route requires an authenticated principal",
|
||||
{
|
||||
context: { plugin: route.plugin, route: route.path },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (route.auth.kind === "scope") {
|
||||
const needed = route.auth.scope;
|
||||
const has = ctx.principal.scopes.some(
|
||||
(s) => (s as string) === needed
|
||||
);
|
||||
if (has) return next();
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
reason: "missing-scope",
|
||||
});
|
||||
throw new RbacError(
|
||||
RBAC_CODES.MISSING_SCOPE,
|
||||
`principal lacks required scope '${needed}'`,
|
||||
{
|
||||
context: {
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
scope: needed,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// kind === "rule"
|
||||
if (ruleEngine === undefined) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"route declares a rule-based auth spec but no RuleEngine was supplied to createRbacMiddleware",
|
||||
{
|
||||
context: { plugin: route.plugin, route: route.path },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(ctx.request.url);
|
||||
const queryParams: Record<string, string> = {};
|
||||
for (const [k, v] of url.searchParams) queryParams[k] = v;
|
||||
|
||||
const tenantStr =
|
||||
ctx.tenantId === null || ctx.tenantId === undefined
|
||||
? undefined
|
||||
: (ctx.tenantId as string);
|
||||
const result = await ruleEngine.evaluate(route.auth.rule, {
|
||||
data: {
|
||||
principal: {
|
||||
id: ctx.principal.id as string,
|
||||
scopes: ctx.principal.scopes.map((s) => s as string),
|
||||
claims: ctx.principal.claims,
|
||||
},
|
||||
tenantId: ctx.tenantId ?? null,
|
||||
route: { plugin: route.plugin, path: route.path },
|
||||
},
|
||||
user: {
|
||||
id: ctx.principal.id as string,
|
||||
roles: ctx.principal.scopes.map((s) => s as string),
|
||||
},
|
||||
...(tenantStr === undefined ? {} : { tenant: tenantStr }),
|
||||
request: {
|
||||
method: ctx.request.method,
|
||||
path: url.pathname,
|
||||
query: queryParams,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.passed) return next();
|
||||
|
||||
recordRbacDenial(instruments, {
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
reason: "rule-deny",
|
||||
});
|
||||
throw new RbacError(
|
||||
RBAC_CODES.RULE_DENY,
|
||||
"rule-based authorisation denied the request",
|
||||
{
|
||||
context: {
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
errors: result.errors.map((e) => e.message),
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
5
packages/api-stack/api/api-rbac/src/registry/index.ts
Normal file
5
packages/api-stack/api/api-rbac/src/registry/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RegisteredScope,
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "./registry.js";
|
||||
114
packages/api-stack/api/api-rbac/src/registry/registry.md
Normal file
114
packages/api-stack/api/api-rbac/src/registry/registry.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# `registry` — `ScopeRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the shared, conflict-aware registry of plugin-declared scopes
|
||||
plus the `registerPluginScopes(registry, manifest)` helper the host
|
||||
calls at install time. The registry's primary job is install-time
|
||||
hygiene: two plugins claiming the same scope name (`billing:read`
|
||||
declared by both `billing` and `invoices`) must fail loudly with
|
||||
`RBAC.SCOPE_CONFLICT` instead of producing whichever-registered-last
|
||||
behaviour at request time.
|
||||
|
||||
The registry is intentionally not the source of truth for the gate
|
||||
decision. `principal.scopes` is. The registry exists to catch typos
|
||||
and cross-plugin conflicts at boot, drive the admin UI's
|
||||
list-of-available-scopes endpoint, and feed the OpenAPI emitter so
|
||||
a route declaring an undeclared scope raises a startup error.
|
||||
|
||||
## Features
|
||||
|
||||
- Conflict detection — duplicate `name` across plugins raises
|
||||
`RBAC.SCOPE_CONFLICT` with the conflicting plugin id in
|
||||
`error.context.conflictingPlugin`.
|
||||
- Idempotent re-register — same plugin, same name, same description
|
||||
is a no-op so hot reload is safe.
|
||||
- Empty-string scope name rejected with `RBAC.INVALID_SPEC`.
|
||||
- `unregisterPlugin(plugin)` removes every scope owned by that
|
||||
plugin — used at uninstall so a future install may legitimately
|
||||
claim the same name.
|
||||
- `registerPluginScopes(registry, manifest)` returns the count of
|
||||
newly-registered (non-idempotent) entries so the host can log a
|
||||
meaningful summary.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "@suaveplan/api-rbac/registry";
|
||||
|
||||
const registry = new ScopeRegistry();
|
||||
registerPluginScopes(registry, {
|
||||
name: "billing",
|
||||
scopes: [
|
||||
{ name: "billing:read", description: "Read invoices" },
|
||||
{ name: "billing:write" },
|
||||
],
|
||||
});
|
||||
registry.has("billing:read"); // true
|
||||
registry.list(); // [ … two descriptors … ]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Driving a per-plugin uninstall + reinstall cycle while preserving
|
||||
ordering invariants:
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerPluginScopes,
|
||||
ScopeRegistry,
|
||||
} from "@suaveplan/api-rbac/registry";
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
const registry = new ScopeRegistry();
|
||||
registerPluginScopes(registry, {
|
||||
name: "billing",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
|
||||
// Cross-plugin claim → conflict.
|
||||
try {
|
||||
registerPluginScopes(registry, {
|
||||
name: "invoices",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) {
|
||||
if (err.code === RBAC_CODES.SCOPE_CONFLICT) {
|
||||
// Drop the offending plugin, surface to operator …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall the first plugin, then the second may claim it.
|
||||
registry.unregisterPlugin("billing");
|
||||
registerPluginScopes(registry, {
|
||||
name: "invoices",
|
||||
scopes: [{ name: "billing:read" }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RegisteredScope` — `{ name, plugin, description? }`.
|
||||
- `ScopeRegistry` — class with `register / has / get / list /
|
||||
unregisterPlugin` methods. Public no-arg constructor.
|
||||
- `registerPluginScopes(registry, manifest)` — iterates
|
||||
`manifest.scopes[]`, returns the count of newly-registered entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The byte-identical idempotence comparison covers `(plugin, name,
|
||||
description)`. A change in `description` from the same plugin is
|
||||
treated as a conflict on purpose — it would otherwise be a silent
|
||||
admin-visible mutation that bypasses the OpenSpec change discipline.
|
||||
The internal `Map` is intentionally not exposed; callers walk
|
||||
`list()` for snapshot semantics so a concurrent install cannot
|
||||
trip an iterator-invalidation bug.
|
||||
136
packages/api-stack/api/api-rbac/src/registry/registry.test.ts
Normal file
136
packages/api-stack/api/api-rbac/src/registry/registry.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
import { registerPluginScopes, ScopeRegistry } from "./registry.js";
|
||||
|
||||
function manifest(
|
||||
name: string,
|
||||
scopes: ReadonlyArray<{ name: string; description?: string }>
|
||||
): Pick<PluginManifest, "name" | "scopes"> {
|
||||
return { name, scopes };
|
||||
}
|
||||
|
||||
describe("ScopeRegistry — register + lookup", () => {
|
||||
it("registers a scope and reports presence", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
r.register({ name: "billing:read", plugin: "billing" });
|
||||
expect(r.has("billing:read")).toBe(true);
|
||||
expect(r.get("billing:read")).toEqual({
|
||||
name: "billing:read",
|
||||
plugin: "billing",
|
||||
});
|
||||
});
|
||||
|
||||
it("list returns a snapshot array", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
r.register({ name: "a", plugin: "p" });
|
||||
r.register({ name: "b", plugin: "p" });
|
||||
const list = r.list();
|
||||
expect(list.map((s) => s.name).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("empty scope name throws RBAC.INVALID_SPEC", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
r.register({ name: "", plugin: "p" });
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
|
||||
it("unregisterPlugin removes only that plugin's scopes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ScopeRegistry();
|
||||
r.register({ name: "a", plugin: "p1" });
|
||||
r.register({ name: "b", plugin: "p2" });
|
||||
r.unregisterPlugin("p1");
|
||||
expect(r.has("a")).toBe(false);
|
||||
expect(r.has("b")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScopeRegistry — conflict handling", () => {
|
||||
it("duplicate scope across plugins raises RBAC_SCOPE_CONFLICT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ScopeRegistry();
|
||||
registerPluginScopes(
|
||||
r,
|
||||
manifest("billing", [{ name: "billing:read" }])
|
||||
);
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
registerPluginScopes(
|
||||
r,
|
||||
manifest("invoices", [{ name: "billing:read" }])
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.SCOPE_CONFLICT);
|
||||
const ctx = captured?.context as Record<string, unknown> | undefined;
|
||||
expect(ctx?.["conflictingPlugin"]).toBe("billing");
|
||||
});
|
||||
|
||||
it("re-register for same plugin is idempotent", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
const m = manifest("billing", [
|
||||
{ name: "billing:read", description: "Read" },
|
||||
]);
|
||||
const first = registerPluginScopes(r, m);
|
||||
expect(first).toBe(1);
|
||||
const second = registerPluginScopes(r, m);
|
||||
expect(second).toBe(0);
|
||||
expect(r.has("billing:read")).toBe(true);
|
||||
});
|
||||
|
||||
it("same plugin changing description is treated as a conflict", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ScopeRegistry();
|
||||
r.register({
|
||||
name: "billing:read",
|
||||
plugin: "billing",
|
||||
description: "v1",
|
||||
});
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
r.register({
|
||||
name: "billing:read",
|
||||
plugin: "billing",
|
||||
description: "v2",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.SCOPE_CONFLICT);
|
||||
});
|
||||
|
||||
it("missing manifest.scopes is a no-op", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
const n = registerPluginScopes(r, { name: "x" });
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
|
||||
it("registerPluginScopes propagates description", { timeout: 5000 }, () => {
|
||||
const r = new ScopeRegistry();
|
||||
registerPluginScopes(
|
||||
r,
|
||||
manifest("billing", [
|
||||
{ name: "billing:read", description: "Read access" },
|
||||
])
|
||||
);
|
||||
expect(r.get("billing:read")?.description).toBe("Read access");
|
||||
});
|
||||
|
||||
it("registerPluginScopes preserves omitted description as undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ScopeRegistry();
|
||||
registerPluginScopes(r, manifest("p", [{ name: "p:x" }]));
|
||||
expect(r.get("p:x")?.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
127
packages/api-stack/api/api-rbac/src/registry/registry.ts
Normal file
127
packages/api-stack/api/api-rbac/src/registry/registry.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Internal descriptor of a registered scope. The owning plugin name is
|
||||
* tracked so re-registration by the same plugin (e.g. on a hot reload)
|
||||
* is a no-op while two different plugins claiming the same scope name
|
||||
* raise `RBAC.SCOPE_CONFLICT`.
|
||||
*/
|
||||
export interface RegisteredScope {
|
||||
readonly name: string;
|
||||
readonly plugin: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict-aware registry of plugin-declared scopes.
|
||||
*
|
||||
* Plugins declare their scope vocabulary in `manifest.scopes[]`. At
|
||||
* install time the host calls {@link registerPluginScopes} which
|
||||
* iterates the manifest and registers each entry against the shared
|
||||
* registry. Two plugins claiming the same scope name raise an
|
||||
* `RBAC.SCOPE_CONFLICT`; the same plugin re-registering an
|
||||
* identical descriptor is a no-op so hot reload is safe.
|
||||
*
|
||||
* The registry is intentionally not used as the source-of-truth for
|
||||
* the gate decision — `principal.scopes` is. The registry exists to
|
||||
* (a) detect typos and conflicts at install time, (b) drive admin UI
|
||||
* listings of available scopes, and (c) gate the OpenAPI emitter so
|
||||
* an undeclared scope on a route raises a startup error.
|
||||
*/
|
||||
export class ScopeRegistry {
|
||||
private readonly byName = new Map<string, RegisteredScope>();
|
||||
|
||||
/**
|
||||
* Register a single scope. Idempotent for `(plugin, name, description)`
|
||||
* tuples that are byte-identical to the existing entry; otherwise
|
||||
* raises `RBAC.SCOPE_CONFLICT` carrying the conflicting plugin id
|
||||
* in `error.context.conflictingPlugin`.
|
||||
*/
|
||||
public register(scope: RegisteredScope): void {
|
||||
if (scope.name.length === 0) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"scope name must be non-empty",
|
||||
{ context: { plugin: scope.plugin } }
|
||||
);
|
||||
}
|
||||
const existing = this.byName.get(scope.name);
|
||||
if (existing === undefined) {
|
||||
this.byName.set(scope.name, scope);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existing.plugin === scope.plugin &&
|
||||
existing.description === scope.description
|
||||
) {
|
||||
// Idempotent re-register from the same plugin.
|
||||
return;
|
||||
}
|
||||
throw new RbacError(
|
||||
RBAC_CODES.SCOPE_CONFLICT,
|
||||
`scope '${scope.name}' is already claimed by plugin '${existing.plugin}'`,
|
||||
{
|
||||
context: {
|
||||
scope: scope.name,
|
||||
incoming: scope.plugin,
|
||||
conflictingPlugin: existing.plugin,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Return `true` iff the scope name is registered. */
|
||||
public has(name: string): boolean {
|
||||
return this.byName.has(name);
|
||||
}
|
||||
|
||||
/** Look up a registered scope descriptor by name. */
|
||||
public get(name: string): RegisteredScope | undefined {
|
||||
return this.byName.get(name);
|
||||
}
|
||||
|
||||
/** Snapshot iterator over every registered scope. */
|
||||
public list(): readonly RegisteredScope[] {
|
||||
return Array.from(this.byName.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every scope owned by `plugin`. Called by the host when a
|
||||
* plugin is uninstalled so the next install of a different plugin
|
||||
* may legitimately claim the same scope name.
|
||||
*/
|
||||
public unregisterPlugin(plugin: string): void {
|
||||
for (const [name, scope] of this.byName) {
|
||||
if (scope.plugin === plugin) this.byName.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `manifest.scopes[]` and register each entry against `registry`.
|
||||
* Returns the count of newly-registered (i.e. non-idempotent) entries.
|
||||
*
|
||||
* @throws `RbacError(SCOPE_CONFLICT)` on the first cross-plugin conflict.
|
||||
*/
|
||||
export function registerPluginScopes(
|
||||
registry: ScopeRegistry,
|
||||
manifest: Pick<PluginManifest, "name" | "scopes">
|
||||
): number {
|
||||
const declared = manifest.scopes ?? [];
|
||||
let added = 0;
|
||||
for (const entry of declared) {
|
||||
const before = registry.has(entry.name);
|
||||
const descriptor: RegisteredScope =
|
||||
entry.description === undefined
|
||||
? { name: entry.name, plugin: manifest.name }
|
||||
: {
|
||||
name: entry.name,
|
||||
plugin: manifest.name,
|
||||
description: entry.description,
|
||||
};
|
||||
registry.register(descriptor);
|
||||
if (!before) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
7
packages/api-stack/api/api-rbac/src/spec/index.ts
Normal file
7
packages/api-stack/api/api-rbac/src/spec/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isRouteAuthSpec,
|
||||
normaliseRouteAuth,
|
||||
type RouteAuthInput,
|
||||
type RouteAuthSpec,
|
||||
RouteAuthSpecSchema,
|
||||
} from "./spec.js";
|
||||
99
packages/api-stack/api/api-rbac/src/spec/spec.md
Normal file
99
packages/api-stack/api/api-rbac/src/spec/spec.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `spec` — `RouteAuthSpec`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical discriminated `RouteAuthSpec` union, its Zod
|
||||
schema, the inbound shape adapters fold down to it, and the helpers
|
||||
that perform that folding. The middleware itself works exclusively
|
||||
against `RouteAuthSpec` — every other shape has to pass through
|
||||
`normaliseRouteAuth` first. Centralising the shape here means the
|
||||
router adapter, the rule-policy loader, and the admin API all share
|
||||
one validator and one error code (`RBAC.INVALID_SPEC`).
|
||||
|
||||
`RouteAuthSpec` is a closed three-arm union — `"public"`, `"scope"`,
|
||||
and `"rule"`. The router adapter accepts the friendlier
|
||||
{@link RouteAuthInput} (string scope, `null` → public, or a
|
||||
`{ rule }` wrapper) and folds it down before invoking the middleware.
|
||||
This keeps the in-handler ergonomics terse without exposing the
|
||||
gate to ambiguous strings or undefineds.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated `RouteAuthSpec` union — `public`, `scope`, `rule`.
|
||||
- Zod `RouteAuthSpecSchema` discriminated-union schema covers all
|
||||
three arms, rejects unknown discriminators and excess keys.
|
||||
- `normaliseRouteAuth(input)` folds the five recognised input shapes
|
||||
(`null`, `undefined`, string, `RouteAuthSpec`, `{ rule }`) down to
|
||||
the canonical shape; throws `RbacError(INVALID_SPEC, ...)` on
|
||||
anything else.
|
||||
- `isRouteAuthSpec(value)` type-guard for JSON ingestion paths that
|
||||
need a narrowing check without a thrown error.
|
||||
- Empty-string scope rejected at the boundary so `auth: ""` cannot
|
||||
silently become a permanent-deny gate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { normaliseRouteAuth } from "@suaveplan/api-rbac/spec";
|
||||
|
||||
normaliseRouteAuth(null); // { kind: "public" }
|
||||
normaliseRouteAuth("billing:read"); // { kind: "scope", scope: "billing:read" }
|
||||
normaliseRouteAuth({ kind: "public" });// { kind: "public" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate a manifest-loaded auth descriptor at the install-time
|
||||
boundary and re-throw with a typed `RbacError` so the host's
|
||||
exception filter routes it through the standard error envelope:
|
||||
|
||||
```ts
|
||||
import { RouteAuthSpecSchema } from "@suaveplan/api-rbac/spec";
|
||||
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
|
||||
|
||||
function ingest(raw: unknown) {
|
||||
const parsed = RouteAuthSpecSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"manifest route auth failed validation",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
Rule-based auth delegates evaluation to `@suaveplan/rules`. The
|
||||
`rule` slot accepts any `Rule` from that package — `ConditionRule`,
|
||||
`LogicalRule`, `GroupRule`, `CustomRule`, `ComputedRule`. The schema
|
||||
only enforces that a `type: string` field is present so the engine
|
||||
gets a valid shape; the engine's own Zod schemas validate the
|
||||
internal structure at evaluation time.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RouteAuthSpec` — discriminated union of `public`, `scope`, `rule`.
|
||||
- `RouteAuthInput` — union of recognised input shapes.
|
||||
- `RouteAuthSpecSchema` — Zod discriminated-union schema; used at the
|
||||
manifest / admin-API ingestion boundary.
|
||||
- `normaliseRouteAuth(input)` — fold any `RouteAuthInput` to a
|
||||
`RouteAuthSpec`; throws `RbacError(INVALID_SPEC, ...)`.
|
||||
- `isRouteAuthSpec(value)` — type-guard; returns `true` on a
|
||||
conforming value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses Zod's `discriminatedUnion` so error messages carry
|
||||
the offending discriminator value. `.strict()` on each arm rejects
|
||||
excess keys so a typo (`{ kind: "scope", scop: "x" }`) fails fast
|
||||
at parse time rather than producing a silently-permanent-deny route.
|
||||
The `Rule` shape is intentionally loose at this boundary —
|
||||
re-validating the full rule tree here would duplicate
|
||||
`@suaveplan/rules`' own validation and double the maintenance
|
||||
surface; consumers that want strict rule validation invoke the
|
||||
serializer's `validateSerializedRule` directly.
|
||||
116
packages/api-stack/api/api-rbac/src/spec/spec.test.ts
Normal file
116
packages/api-stack/api/api-rbac/src/spec/spec.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { condition } from "@suaveplan/rules";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
isRouteAuthSpec,
|
||||
normaliseRouteAuth,
|
||||
RouteAuthSpecSchema,
|
||||
} from "./spec.js";
|
||||
|
||||
describe("normaliseRouteAuth", () => {
|
||||
it("null → public", { timeout: 5000 }, () => {
|
||||
expect(normaliseRouteAuth(null)).toEqual({ kind: "public" });
|
||||
});
|
||||
|
||||
it("undefined → public", { timeout: 5000 }, () => {
|
||||
expect(normaliseRouteAuth(undefined)).toEqual({ kind: "public" });
|
||||
});
|
||||
|
||||
it("string → scope", { timeout: 5000 }, () => {
|
||||
expect(normaliseRouteAuth("billing:read")).toEqual({
|
||||
kind: "scope",
|
||||
scope: "billing:read",
|
||||
});
|
||||
});
|
||||
|
||||
it("empty string throws INVALID_SPEC", { timeout: 5000 }, () => {
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
normaliseRouteAuth("");
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
|
||||
it("RouteAuthSpec passes through with validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(normaliseRouteAuth({ kind: "public" })).toEqual({
|
||||
kind: "public",
|
||||
});
|
||||
expect(normaliseRouteAuth({ kind: "scope", scope: "x" })).toEqual({
|
||||
kind: "scope",
|
||||
scope: "x",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid spec object throws INVALID_SPEC", { timeout: 5000 }, () => {
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
// kind="scope" without scope key
|
||||
normaliseRouteAuth({ kind: "scope" } as unknown as {
|
||||
kind: "scope";
|
||||
scope: string;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
|
||||
it("{ rule } wrapper folds to rule spec", { timeout: 5000 }, () => {
|
||||
const r = condition("x", "equals", "y");
|
||||
const out = normaliseRouteAuth({ rule: r });
|
||||
expect(out).toEqual({ kind: "rule", rule: r });
|
||||
});
|
||||
|
||||
it("unknown shape throws INVALID_SPEC", { timeout: 5000 }, () => {
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
normaliseRouteAuth({ foo: "bar" } as unknown as null);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
|
||||
it("non-string/object/null input throws INVALID_SPEC", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: RbacError | undefined;
|
||||
try {
|
||||
normaliseRouteAuth(42 as unknown as null);
|
||||
} catch (err) {
|
||||
if (err instanceof RbacError) captured = err;
|
||||
}
|
||||
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
|
||||
});
|
||||
|
||||
it("rule kind without rule field rejected by schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = RouteAuthSpecSchema.safeParse({ kind: "rule" });
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
|
||||
it("public with excess keys rejected", { timeout: 5000 }, () => {
|
||||
const out = RouteAuthSpecSchema.safeParse({
|
||||
kind: "public",
|
||||
extra: 1,
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRouteAuthSpec", () => {
|
||||
it("accepts a valid scope spec", { timeout: 5000 }, () => {
|
||||
expect(isRouteAuthSpec({ kind: "scope", scope: "x" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a garbage value", { timeout: 5000 }, () => {
|
||||
expect(isRouteAuthSpec({})).toBe(false);
|
||||
expect(isRouteAuthSpec(null)).toBe(false);
|
||||
expect(isRouteAuthSpec("string")).toBe(false);
|
||||
});
|
||||
});
|
||||
125
packages/api-stack/api/api-rbac/src/spec/spec.ts
Normal file
125
packages/api-stack/api/api-rbac/src/spec/spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Rule } from "@suaveplan/rules";
|
||||
import z from "zod";
|
||||
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Discriminated `RouteAuthSpec` union — the canonical shape every router
|
||||
* adapter translates the user-facing route-option `auth` value into before
|
||||
* invoking the RBAC middleware.
|
||||
*
|
||||
* Three kinds:
|
||||
* - `"public"` — bypass; no principal check, no scope check, no metric.
|
||||
* - `"scope"` — single string scope name; default contains-check against
|
||||
* `principal.scopes`.
|
||||
* - `"rule"` — `@suaveplan/rules` `Rule`; evaluated against a context
|
||||
* derived from the request, principal, tenant, and route.
|
||||
*/
|
||||
export type RouteAuthSpec =
|
||||
| { readonly kind: "public" }
|
||||
| { readonly kind: "scope"; readonly scope: string }
|
||||
| { readonly kind: "rule"; readonly rule: Rule };
|
||||
|
||||
/**
|
||||
* Inbound `auth` shapes recognised by {@link normaliseRouteAuth}.
|
||||
*
|
||||
* The router adapter receives one of these on `route.options.auth` and
|
||||
* the helper folds them all down to the canonical {@link RouteAuthSpec}.
|
||||
*
|
||||
* - `null` / `undefined` → `{ kind: "public" }`.
|
||||
* - `string` → `{ kind: "scope", scope }`.
|
||||
* - already-normalised `RouteAuthSpec` → identity.
|
||||
* - `{ rule }` → `{ kind: "rule", rule }`.
|
||||
*/
|
||||
export type RouteAuthInput =
|
||||
| null
|
||||
| undefined
|
||||
| string
|
||||
| RouteAuthSpec
|
||||
| { readonly rule: Rule };
|
||||
|
||||
/** Zod schema for a serialised scope-only auth spec. */
|
||||
const ScopeSpecSchema = z
|
||||
.object({
|
||||
kind: z.literal("scope"),
|
||||
scope: z.string().min(1, "scope must be non-empty"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for a serialised public-route auth spec. */
|
||||
const PublicSpecSchema = z
|
||||
.object({
|
||||
kind: z.literal("public"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Zod schema for the rule-bearing auth spec. The `Rule` shape itself is
|
||||
* not re-validated here — `@suaveplan/rules` owns that contract — but the
|
||||
* schema enforces presence and shape of the `rule` slot so callers cannot
|
||||
* silently smuggle `undefined` past the gate.
|
||||
*/
|
||||
const RuleSpecSchema = z
|
||||
.object({
|
||||
kind: z.literal("rule"),
|
||||
rule: z.object({ type: z.string() }).passthrough(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Boundary Zod schema for {@link RouteAuthSpec}. Adapters validating an
|
||||
* inbound spec object (e.g. loaded from a manifest or admin API) parse
|
||||
* with this schema so malformed shapes raise `RBAC.INVALID_SPEC` rather
|
||||
* than producing undefined behaviour at gate time.
|
||||
*/
|
||||
export const RouteAuthSpecSchema = z.discriminatedUnion("kind", [
|
||||
PublicSpecSchema,
|
||||
ScopeSpecSchema,
|
||||
RuleSpecSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fold every recognised `auth` input shape down to the canonical
|
||||
* {@link RouteAuthSpec}. The function is total over {@link RouteAuthInput}
|
||||
* and throws `RbacError(INVALID_SPEC, ...)` for any other shape.
|
||||
*/
|
||||
export function normaliseRouteAuth(input: RouteAuthInput): RouteAuthSpec {
|
||||
if (input === null || input === undefined) {
|
||||
return { kind: "public" };
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
if (input.length === 0) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"auth string must be non-empty"
|
||||
);
|
||||
}
|
||||
return { kind: "scope", scope: input };
|
||||
}
|
||||
if (typeof input === "object" && "kind" in input) {
|
||||
const parsed = RouteAuthSpecSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"RouteAuthSpec failed Zod validation",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data as RouteAuthSpec;
|
||||
}
|
||||
if (typeof input === "object" && "rule" in input) {
|
||||
return { kind: "rule", rule: input.rule };
|
||||
}
|
||||
throw new RbacError(
|
||||
RBAC_CODES.INVALID_SPEC,
|
||||
"auth value is neither a scope string, a RouteAuthSpec, nor a { rule } wrapper"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-guard narrowing an arbitrary value to {@link RouteAuthSpec}. Useful
|
||||
* at admin-API boundaries that accept JSON and need to feed the value into
|
||||
* a strongly-typed registry.
|
||||
*/
|
||||
export function isRouteAuthSpec(value: unknown): value is RouteAuthSpec {
|
||||
return RouteAuthSpecSchema.safeParse(value).success;
|
||||
}
|
||||
161
packages/api-stack/api/api-rbac/tests/dep-graph.test.ts
Normal file
161
packages/api-stack/api/api-rbac/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = resolve(__dirname, "..");
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_DEPS = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/rules",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) {
|
||||
if (name === "node_modules" || name === "dist") continue;
|
||||
walk(p, out);
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("dependencies match the allowlist", { timeout: 5000 }, () => {
|
||||
const deps = Object.keys(pkg.dependencies ?? {});
|
||||
for (const d of deps) {
|
||||
expect(ALLOWED_DEPS.has(d)).toBe(true);
|
||||
}
|
||||
for (const expected of [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/rules",
|
||||
"tslib",
|
||||
"zod",
|
||||
]) {
|
||||
expect(deps).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature", { timeout: 5000 }, () => {
|
||||
const exports = pkg.exports ?? {};
|
||||
const expected = [
|
||||
".",
|
||||
"./middleware",
|
||||
"./registry",
|
||||
"./spec",
|
||||
"./error-codes",
|
||||
"./instruments",
|
||||
];
|
||||
for (const e of expected) {
|
||||
expect(Object.keys(exports)).toContain(e);
|
||||
}
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const srcDir = join(PKG_DIR, "src");
|
||||
const all = walk(srcDir);
|
||||
const missing: string[] = [];
|
||||
for (const file of all) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith(".test.ts")) continue;
|
||||
if (file.endsWith(".spec.ts")) continue;
|
||||
if (file.endsWith(".spec-conformance.test.ts")) continue;
|
||||
if (file.endsWith(".types.ts")) continue;
|
||||
const base = file.replace(/\.ts$/, "");
|
||||
const fileName = base.split("/").pop();
|
||||
if (fileName === "index") continue;
|
||||
if (file === join(srcDir, "index.ts")) continue;
|
||||
const md = `${base}.md`;
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md.replace(`${PKG_DIR}/`, ""));
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("no biome suppressions in src/", { timeout: 5000 }, () => {
|
||||
const all = walk(join(PKG_DIR, "src"));
|
||||
const hits: string[] = [];
|
||||
for (const f of all) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
const txt = readFileSync(f, "utf8");
|
||||
if (txt.includes("biome-ignore")) hits.push(f);
|
||||
if (txt.includes("eslint-disable")) hits.push(f);
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("no banned patterns in src/", { timeout: 5000 }, () => {
|
||||
const banned = [
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"ts-ignore",
|
||||
"ts-expect-error",
|
||||
"@deprecated",
|
||||
"console.log",
|
||||
];
|
||||
const all = walk(join(PKG_DIR, "src"));
|
||||
const hits: { file: string; pattern: string }[] = [];
|
||||
for (const f of all) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
const txt = readFileSync(f, "utf8");
|
||||
for (const p of banned) {
|
||||
if (txt.includes(p)) hits.push({ file: f, pattern: p });
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
|
||||
const featureBarrels = readdirSync(join(PKG_DIR, "src"))
|
||||
.filter((entry) => {
|
||||
const p = join(PKG_DIR, "src", entry);
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((d) => join(PKG_DIR, "src", d, "index.ts"));
|
||||
const hits: string[] = [];
|
||||
for (const b of featureBarrels) {
|
||||
try {
|
||||
const txt = readFileSync(b, "utf8");
|
||||
if (/^export\s+\*/m.test(txt)) hits.push(b);
|
||||
} catch {
|
||||
/* directory may have no index.ts */
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout invariants", () => {
|
||||
it("error-codes lives at src/error-codes/error-codes.ts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner = join(PKG_DIR, "src", "error-codes", "error-codes.ts");
|
||||
statSync(inner);
|
||||
expect(dirname(inner).endsWith("/error-codes")).toBe(true);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-rbac/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-rbac/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/api-stack/api/api-rbac/vite.config.ts
Normal file
38
packages/api-stack/api/api-rbac/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-rbac",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: false,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/rules",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
3
packages/api-stack/api/api-rbac/vitest.config.ts
Normal file
3
packages/api-stack/api/api-rbac/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user