# 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()` is asserted - **THEN** the type assertion passes (`MiddlewareFn` is `(req, res, next) => void | Promise`) **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: , 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"`