From 8240f6060d685d02132ed0d083de675a404c6c70 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 06:24:08 +0000 Subject: [PATCH] feat(api-key): restrict ops routes to service tokens --- .../src/lib/api-key-auth.test.ts | 24 ++++++ .../platform-service/src/lib/api-key-auth.ts | 17 +++- .../modules/exports/exports.api-key.test.ts | 83 ++++++++++++++++++ .../src/modules/exports/routes.ts | 2 + .../modules/ip-rules/ip-rules.api-key.test.ts | 84 ++++++++++++++++++ .../src/modules/ip-rules/routes.ts | 2 + .../maintenance/maintenance.api-key.test.ts | 85 +++++++++++++++++++ .../src/modules/maintenance/routes.ts | 2 + 8 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 services/platform-service/src/modules/exports/exports.api-key.test.ts create mode 100644 services/platform-service/src/modules/ip-rules/ip-rules.api-key.test.ts create mode 100644 services/platform-service/src/modules/maintenance/maintenance.api-key.test.ts diff --git a/services/platform-service/src/lib/api-key-auth.test.ts b/services/platform-service/src/lib/api-key-auth.test.ts index c9e898c3..4b5e7dc0 100644 --- a/services/platform-service/src/lib/api-key-auth.test.ts +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -142,6 +142,30 @@ describe('api key auth', () => { expect(res.statusCode).toBe(403); }); + it('rejects api key when token type is not permitted for the route', async () => { + await seedApiKeyToken(['maintenance:read'], { tokenType: 'product_api' }); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['maintenance:read'], + apiKeyTokenTypes: ['service_api'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + }, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toBe('Forbidden'); + }); + it('rejects api key when x-product-id targets another product', async () => { await seedApiKeyToken(['jobs:read']); const app = Fastify(); diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts index 7d987153..88268eec 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -35,6 +35,7 @@ interface AccessOptions { allowJwt?: boolean; jwtRoles?: string[]; apiKeyScopes?: string[]; + apiKeyTokenTypes?: ApiKeyAuthPayload['tokenType'][]; rateLimitKey?: string; } @@ -132,7 +133,11 @@ function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' { } } -function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload { +function ensureApiKeyScopes( + req: FastifyRequest, + requiredScopes: string[] = [], + allowedTokenTypes?: ApiKeyAuthPayload['tokenType'][] +): ApiKeyAuthPayload { const apiKey = req.apiKeyAuth; if (!apiKey) { throw new UnauthorizedError('API key required'); @@ -152,6 +157,12 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): throw new ForbiddenError('API key missing required scopes'); } + if (allowedTokenTypes && allowedTokenTypes.length > 0) { + if (!allowedTokenTypes.includes(apiKey.tokenType)) { + throw new ForbiddenError('API key token type is not permitted for this route'); + } + } + return apiKey; } @@ -173,7 +184,7 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi export function requireJwtOrApiKey( req: FastifyRequest, - { allowJwt = false, jwtRoles, apiKeyScopes, rateLimitKey }: AccessOptions = {} + { allowJwt = false, jwtRoles, apiKeyScopes, apiKeyTokenTypes, rateLimitKey }: AccessOptions = {} ): AccessActor { const jwt = req.jwtPayload; if (jwt?.sub) { @@ -192,7 +203,7 @@ export function requireJwtOrApiKey( }; } - const apiKey = ensureApiKeyScopes(req, apiKeyScopes); + const apiKey = ensureApiKeyScopes(req, apiKeyScopes, apiKeyTokenTypes); enforceApiKeyRateLimit(req, rateLimitKey); return { diff --git a/services/platform-service/src/modules/exports/exports.api-key.test.ts b/services/platform-service/src/modules/exports/exports.api-key.test.ts new file mode 100644 index 00000000..84a20b1a --- /dev/null +++ b/services/platform-service/src/modules/exports/exports.api-key.test.ts @@ -0,0 +1,83 @@ +import Fastify from 'fastify'; +import bcrypt from 'bcryptjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from '../../lib/datastore.js'; +import { registerOptionalApiKeyContext } from '../../lib/api-key-auth.js'; + +const rawApiKey = `wai_${'e'.repeat(64)}`; + +const repoMock = { + createExportJob: vi.fn(), + listExportJobs: vi.fn(), + getExportJob: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function seedApiKey( + scopes: string[], + tokenType: 'product_api' | 'service_api' = 'service_api' +) { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); + const collection = provider.getCollection('api_tokens', '/id'); + await collection.create({ + id: 'tok_exports_1', + productId: 'lysnrai', + userId: 'svc_exports', + userName: 'Export Service', + tokenType, + environment: 'dev', + prefix: rawApiKey.slice(0, 12), + tokenHash: await bcrypt.hash(rawApiKey, 10), + status: 'active', + scopes, + expiresAt: '2099-01-01T00:00:00.000Z', + lastUsed: null, + }); +} + +describe('exportRoutes api key integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + }); + + it('allows export reads via service_api keys', async () => { + await seedApiKey(['exports:read'], 'service_api'); + repoMock.listExportJobs.mockResolvedValue([{ id: 'exp_1', productId: 'lysnrai' }]); + + const { exportRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(exportRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/exports', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.listExportJobs).toHaveBeenCalledWith('lysnrai', 20); + }); + + it('rejects product_api keys on export routes', async () => { + await seedApiKey(['exports:read'], 'product_api'); + + const { exportRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(exportRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/exports', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(403); + expect(repoMock.listExportJobs).not.toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/exports/routes.ts b/services/platform-service/src/modules/exports/routes.ts index f6f12778..9e7dd11b 100644 --- a/services/platform-service/src/modules/exports/routes.ts +++ b/services/platform-service/src/modules/exports/routes.ts @@ -10,6 +10,7 @@ export async function exportRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['exports:read'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'exports:read', }); } @@ -18,6 +19,7 @@ export async function exportRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['exports:write'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'exports:write', }); } diff --git a/services/platform-service/src/modules/ip-rules/ip-rules.api-key.test.ts b/services/platform-service/src/modules/ip-rules/ip-rules.api-key.test.ts new file mode 100644 index 00000000..180ca287 --- /dev/null +++ b/services/platform-service/src/modules/ip-rules/ip-rules.api-key.test.ts @@ -0,0 +1,84 @@ +import Fastify from 'fastify'; +import bcrypt from 'bcryptjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from '../../lib/datastore.js'; +import { registerOptionalApiKeyContext } from '../../lib/api-key-auth.js'; + +const rawApiKey = `wai_${'f'.repeat(64)}`; + +const repoMock = { + listRules: vi.fn(), + createRule: vi.fn(), + deleteRule: vi.fn(), + checkIP: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function seedApiKey( + scopes: string[], + tokenType: 'product_api' | 'service_api' = 'service_api' +) { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); + const collection = provider.getCollection('api_tokens', '/id'); + await collection.create({ + id: 'tok_ip_rules_1', + productId: 'lysnrai', + userId: 'svc_security', + userName: 'Security Service', + tokenType, + environment: 'dev', + prefix: rawApiKey.slice(0, 12), + tokenHash: await bcrypt.hash(rawApiKey, 10), + status: 'active', + scopes, + expiresAt: '2099-01-01T00:00:00.000Z', + lastUsed: null, + }); +} + +describe('ipRuleRoutes api key integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + }); + + it('allows ip rule reads via service_api keys', async () => { + await seedApiKey(['ip-rules:read'], 'service_api'); + repoMock.listRules.mockResolvedValue([{ id: 'ipr_1', productId: 'lysnrai' }]); + + const { ipRuleRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(ipRuleRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/ratelimit/ip-rules', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.listRules).toHaveBeenCalledWith('lysnrai'); + }); + + it('rejects product_api keys on ip rule routes', async () => { + await seedApiKey(['ip-rules:read'], 'product_api'); + + const { ipRuleRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(ipRuleRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/ratelimit/ip-rules', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(403); + expect(repoMock.listRules).not.toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/ip-rules/routes.ts b/services/platform-service/src/modules/ip-rules/routes.ts index 77c1a8c4..e0e368f8 100644 --- a/services/platform-service/src/modules/ip-rules/routes.ts +++ b/services/platform-service/src/modules/ip-rules/routes.ts @@ -10,6 +10,7 @@ export async function ipRuleRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['ip-rules:read'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'ip-rules:read', }); } @@ -18,6 +19,7 @@ export async function ipRuleRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['ip-rules:write'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'ip-rules:write', }); } diff --git a/services/platform-service/src/modules/maintenance/maintenance.api-key.test.ts b/services/platform-service/src/modules/maintenance/maintenance.api-key.test.ts new file mode 100644 index 00000000..1eb4eb9d --- /dev/null +++ b/services/platform-service/src/modules/maintenance/maintenance.api-key.test.ts @@ -0,0 +1,85 @@ +import Fastify from 'fastify'; +import bcrypt from 'bcryptjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from '../../lib/datastore.js'; +import { registerOptionalApiKeyContext } from '../../lib/api-key-auth.js'; + +const rawApiKey = `wai_${'d'.repeat(64)}`; + +const repoMock = { + getMaintenanceConfig: vi.fn(), + listUpcomingWindows: vi.fn(), + updateMaintenanceConfig: vi.fn(), + createWindow: vi.fn(), + deleteWindow: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function seedApiKey( + scopes: string[], + tokenType: 'product_api' | 'service_api' = 'service_api' +) { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); + const collection = provider.getCollection('api_tokens', '/id'); + await collection.create({ + id: 'tok_maintenance_1', + productId: 'lysnrai', + userId: 'svc_ops', + userName: 'Ops Service', + tokenType, + environment: 'dev', + prefix: rawApiKey.slice(0, 12), + tokenHash: await bcrypt.hash(rawApiKey, 10), + status: 'active', + scopes, + expiresAt: '2099-01-01T00:00:00.000Z', + lastUsed: null, + }); +} + +describe('maintenanceRoutes api key integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + }); + + it('allows maintenance reads via service_api keys', async () => { + await seedApiKey(['maintenance:read'], 'service_api'); + repoMock.getMaintenanceConfig.mockResolvedValue({ mode: 'off', productId: 'lysnrai' }); + + const { maintenanceRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(maintenanceRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/settings/maintenance/full', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.getMaintenanceConfig).toHaveBeenCalledWith('lysnrai'); + }); + + it('rejects product_api keys on maintenance admin routes', async () => { + await seedApiKey(['maintenance:read'], 'product_api'); + + const { maintenanceRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(maintenanceRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/settings/maintenance/full', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(403); + expect(repoMock.getMaintenanceConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/maintenance/routes.ts b/services/platform-service/src/modules/maintenance/routes.ts index 7bf8a79d..b47ac747 100644 --- a/services/platform-service/src/modules/maintenance/routes.ts +++ b/services/platform-service/src/modules/maintenance/routes.ts @@ -32,6 +32,7 @@ export async function maintenanceRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['maintenance:read'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'maintenance:read', }); } @@ -40,6 +41,7 @@ export async function maintenanceRoutes(app: FastifyInstance) { return requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['maintenance:write'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'maintenance:write', }); }