8.0 KiB
Capability Spec Delta: auth (auth-require-api-permission)
Package: @suaveplan/auth
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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" }))whenreq.apiKeyis 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 forpermission = "user:write" - THEN
nextis 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 forpermission = "user:write" - THEN
nextis called once with aPermissionDeniedErrorwhosecode === "AUTH.PERMISSION_DENIED"ANDcontext.permission === "user:write"
Scenario: missing req.apiKey calls next with ApiKeyInvalidError
- WHEN
req.apiKeyisundefined - THEN
nextis called once with anApiKeyInvalidErrorwhosecode === "AUTH.API_KEY_INVALID"ANDmessagecontains"API key required"
Scenario: wildcard permission grants any
- WHEN
req.apiKey = { permissions: ["*"], ... }and the middleware is called for ANY non-empty permission - THEN
nextis called once with no arguments
Scenario: returned middleware satisfies MiddlewareFn type
- WHEN
expectTypeOf(requireApiPermission("x")).toMatchTypeOf<MiddlewareFn>()is asserted - THEN the type assertion passes (
MiddlewareFnis(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
nextis 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
nextis called once withPermissionDeniedErrorwhosecontext.permission === "user:read | user:write"
Scenario: empty array throws at factory time
- WHEN
requireAnyApiPermission([])is constructed - THEN it throws
ApiKeyInvalidErrorsynchronously (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
nextis 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
nextis called once withPermissionDeniedErrorwhosecontext.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
nextis 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.apiKeyis 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"