Files
suaveplan/openspec/changes/auth-require-api-permission/specs/auth/spec.md
2026-05-27 23:29:53 +01:00

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. (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"