119 lines
8.0 KiB
Markdown
119 lines
8.0 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: auth (auth-require-api-permission)
|
|
|
|
**Package**: `@suaveplan/auth`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: requireApiPermission middleware factory
|
|
|
|
The package SHALL expose `requireApiPermission(permission: string, options?: ApiKeyMiddlewareOptions): MiddlewareFn`. The returned middleware SHALL read `req.apiKey` (attached by `validateApiKeyMiddleware` upstream in the chain), check `hasPermission(apiKey, permission)`, and:
|
|
|
|
- Call `next()` (no arguments) when the API key holds the permission (or holds the wildcard `*`).
|
|
- Call `next(new PermissionDeniedError({ permission, userId: apiKey.userId }))` when the API key is present but lacks the permission.
|
|
- Call `next(new ApiKeyInvalidError({ message: "API key required for permission check" }))` when `req.apiKey` is absent or undefined.
|
|
|
|
The middleware SHALL be framework-agnostic — the returned function matches the genesis `MiddlewareFn` shape (compatible with both Express-style `(req, res, next)` and Hono-style `(c, next)` adapter wrappers).
|
|
|
|
#### Scenario: permission grant calls next with no args
|
|
|
|
- **WHEN** `req.apiKey = { permissions: ["user:write"], ... }` and the middleware is called for `permission = "user:write"`
|
|
- **THEN** `next` is called once with no arguments
|
|
|
|
#### Scenario: missing permission calls next with PermissionDeniedError
|
|
|
|
- **WHEN** `req.apiKey = { permissions: ["user:read"], userId: "u-1" }` and the middleware is called for `permission = "user:write"`
|
|
- **THEN** `next` is called once with a `PermissionDeniedError` whose `code === "AUTH.PERMISSION_DENIED"` AND `context.permission === "user:write"`
|
|
|
|
#### Scenario: missing req.apiKey calls next with ApiKeyInvalidError
|
|
|
|
- **WHEN** `req.apiKey` is `undefined`
|
|
- **THEN** `next` is called once with an `ApiKeyInvalidError` whose `code === "AUTH.API_KEY_INVALID"` AND `message` contains `"API key required"`
|
|
|
|
#### Scenario: wildcard permission grants any
|
|
|
|
- **WHEN** `req.apiKey = { permissions: ["*"], ... }` and the middleware is called for ANY non-empty permission
|
|
- **THEN** `next` is called once with no arguments
|
|
|
|
#### Scenario: returned middleware satisfies MiddlewareFn type
|
|
|
|
- **WHEN** `expectTypeOf(requireApiPermission("x")).toMatchTypeOf<MiddlewareFn>()` is asserted
|
|
- **THEN** the type assertion passes (`MiddlewareFn` is `(req, res, next) => void | Promise<void>`)
|
|
|
|
**Verified by:** `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"calls next with no args when permission granted"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"calls next with PermissionDeniedError when permission missing"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"calls next with ApiKeyInvalidError when req.apiKey missing"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"wildcard permission grants any"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.spec-conformance.test.ts::"return type matches MiddlewareFn"`
|
|
|
|
---
|
|
|
|
### Requirement: requireAnyApiPermission accepts a list (OR semantics)
|
|
|
|
The package SHALL expose `requireAnyApiPermission(permissions: readonly string[], options?: ApiKeyMiddlewareOptions): MiddlewareFn`. The returned middleware SHALL pass when the API key holds AT LEAST one of the listed permissions (or the wildcard `*`). On no match, it SHALL call `next(new PermissionDeniedError({ permission: permissions.join(" | "), userId }))`. An empty `permissions` array SHALL throw `ApiKeyInvalidError` with `message: "permissions array must be non-empty"` at factory-call time (not at request time).
|
|
|
|
#### Scenario: subset match grants
|
|
|
|
- **WHEN** `req.apiKey.permissions = ["user:read"]` and the middleware is called with `["user:read", "user:write"]`
|
|
- **THEN** `next` is called once with no arguments
|
|
|
|
#### Scenario: no match denies
|
|
|
|
- **WHEN** `req.apiKey.permissions = ["billing:read"]` and the middleware is called with `["user:read", "user:write"]`
|
|
- **THEN** `next` is called once with `PermissionDeniedError` whose `context.permission === "user:read | user:write"`
|
|
|
|
#### Scenario: empty array throws at factory time
|
|
|
|
- **WHEN** `requireAnyApiPermission([])` is constructed
|
|
- **THEN** it throws `ApiKeyInvalidError` synchronously (NOT lazily on request)
|
|
|
|
**Verified by:** `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAnyApiPermission grants on subset match"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAnyApiPermission denies on no match"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAnyApiPermission throws on empty array"`
|
|
|
|
---
|
|
|
|
### Requirement: requireAllApiPermissions accepts a list (AND semantics)
|
|
|
|
The package SHALL expose `requireAllApiPermissions(permissions: readonly string[], options?: ApiKeyMiddlewareOptions): MiddlewareFn`. The returned middleware SHALL pass when the API key holds EVERY listed permission (the wildcard `*` satisfies all checks). On any miss, it SHALL call `next(new PermissionDeniedError({ permission: <first-missing>, userId }))`. An empty `permissions` array SHALL throw `ApiKeyInvalidError` at factory-call time.
|
|
|
|
#### Scenario: full match grants
|
|
|
|
- **WHEN** `req.apiKey.permissions = ["user:read", "user:write"]` and the middleware is called with `["user:read", "user:write"]`
|
|
- **THEN** `next` is called once with no arguments
|
|
|
|
#### Scenario: partial match denies on first missing
|
|
|
|
- **WHEN** `req.apiKey.permissions = ["user:read"]` and the middleware is called with `["user:read", "user:write"]`
|
|
- **THEN** `next` is called once with `PermissionDeniedError` whose `context.permission === "user:write"` (the first missing)
|
|
|
|
#### Scenario: wildcard satisfies all
|
|
|
|
- **WHEN** `req.apiKey.permissions = ["*"]` and the middleware is called with `["user:read", "user:write", "billing:read"]`
|
|
- **THEN** `next` is called once with no arguments
|
|
|
|
**Verified by:** `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAllApiPermissions grants on full match"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAllApiPermissions denies on partial match"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.test.ts::"requireAllApiPermissions accepts wildcard"`
|
|
|
|
---
|
|
|
|
### Requirement: Permission check emits OTel counter
|
|
|
|
The package SHALL emit `auth.apikey.permission_check_total` (counter) tagged `{ result: "granted" | "denied" | "missing-key", permission?: string }` from each call to the three middleware functions. Counter increments SHALL be observable through a real in-memory `MeterProvider`.
|
|
|
|
#### Scenario: granted check increments counter with result=granted
|
|
|
|
- **WHEN** the middleware passes (apiKey holds the permission)
|
|
- **THEN** the counter records exactly one increment with attributes `{ result: "granted" }`
|
|
|
|
#### Scenario: denied check increments counter with result=denied
|
|
|
|
- **WHEN** the middleware denies (apiKey lacks the permission)
|
|
- **THEN** the counter records exactly one increment with attributes `{ result: "denied" }`
|
|
|
|
#### Scenario: missing-key increments counter with result=missing-key
|
|
|
|
- **WHEN** `req.apiKey` is undefined
|
|
- **THEN** the counter records exactly one increment with attributes `{ result: "missing-key" }`
|
|
|
|
**Verified by:** `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.spec-conformance.test.ts::"emits granted increment on success"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.spec-conformance.test.ts::"emits denied increment on permission miss"` AND `packages/auth/auth/src/api-keys/require-api-permission/require-api-permission.spec-conformance.test.ts::"emits missing-key increment when req.apiKey absent"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|