From 2f7163b8565c0a0c1bd62c2e0db5a3e41106b554 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 09:08:38 +0000 Subject: [PATCH] feat(api-key): restrict job operations to service tokens --- .../src/modules/jobs/jobs.api-key.test.ts | 26 +++++++++++++++++-- .../src/modules/jobs/routes.ts | 2 ++ .../src/modules/runs/routes.ts | 2 ++ .../src/modules/runs/runs.api-key.test.ts | 26 +++++++++++++++++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/services/platform-service/src/modules/jobs/jobs.api-key.test.ts b/services/platform-service/src/modules/jobs/jobs.api-key.test.ts index 065381c7..3aaa5461 100644 --- a/services/platform-service/src/modules/jobs/jobs.api-key.test.ts +++ b/services/platform-service/src/modules/jobs/jobs.api-key.test.ts @@ -26,7 +26,10 @@ vi.mock('./repository.js', () => repoMock); vi.mock('./registry.js', () => registryMock); vi.mock('./runner.js', () => runnerMock); -async function seedApiKey(scopes: string[]) { +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'); @@ -35,7 +38,7 @@ async function seedApiKey(scopes: string[]) { productId: 'lysnrai', userId: 'svc_jobs', userName: 'Jobs Service', - tokenType: 'service_api', + tokenType, environment: 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), @@ -97,4 +100,23 @@ describe('jobRoutes api key integration', () => { expect(repoMock.getJobDefinition).toHaveBeenCalledWith('job_reindex', 'lysnrai'); expect(runnerMock.executeJob).toHaveBeenCalled(); }); + + it('rejects product_api keys on job routes', async () => { + await seedApiKey(['jobs:read'], 'product_api'); + repoMock.listJobDefinitions.mockResolvedValue([{ id: 'job_sync', productId: 'lysnrai' }]); + + const { jobRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(jobRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/jobs', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(403); + expect(repoMock.listJobDefinitions).not.toHaveBeenCalled(); + }); }); diff --git a/services/platform-service/src/modules/jobs/routes.ts b/services/platform-service/src/modules/jobs/routes.ts index d8456e1c..033617f1 100644 --- a/services/platform-service/src/modules/jobs/routes.ts +++ b/services/platform-service/src/modules/jobs/routes.ts @@ -11,6 +11,7 @@ export async function jobRoutes(app: FastifyInstance) { const access = requireJwtOrApiKey(req, { allowJwt: true, apiKeyScopes: ['jobs:read'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'jobs:read', }); return access.productId; @@ -20,6 +21,7 @@ export async function jobRoutes(app: FastifyInstance) { const access = requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['jobs:write'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'jobs:write', }); return access.productId; diff --git a/services/platform-service/src/modules/runs/routes.ts b/services/platform-service/src/modules/runs/routes.ts index 3a7b02a2..81fe3182 100644 --- a/services/platform-service/src/modules/runs/routes.ts +++ b/services/platform-service/src/modules/runs/routes.ts @@ -16,6 +16,7 @@ export async function runRoutes(app: FastifyInstance) { const access = requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], apiKeyScopes: ['jobs:read'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'jobs:read', }); return access.productId; @@ -27,6 +28,7 @@ export async function runRoutes(app: FastifyInstance) { } { const access = requireJwtOrApiKey(req, { jwtRoles: ['super_admin', 'admin'], + apiKeyTokenTypes: ['service_api'], rateLimitKey: 'jobs:write', }); return { productId: access.productId, actorId: access.actorId }; diff --git a/services/platform-service/src/modules/runs/runs.api-key.test.ts b/services/platform-service/src/modules/runs/runs.api-key.test.ts index 7c8f947f..09dcc377 100644 --- a/services/platform-service/src/modules/runs/runs.api-key.test.ts +++ b/services/platform-service/src/modules/runs/runs.api-key.test.ts @@ -15,7 +15,10 @@ const repoMock = { vi.mock('./repository.js', () => repoMock); -async function seedApiKey(scopes: string[]) { +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'); @@ -24,7 +27,7 @@ async function seedApiKey(scopes: string[]) { productId: 'lysnrai', userId: 'svc_runs', userName: 'Runs Service', - tokenType: 'service_api', + tokenType, environment: 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), @@ -59,4 +62,23 @@ describe('runRoutes api key integration', () => { expect(res.statusCode).toBe(200); expect(repoMock.listRuns).toHaveBeenCalledWith('lysnrai', { limit: 10 }); }); + + it('rejects product_api keys on run routes', async () => { + await seedApiKey(['jobs:read'], 'product_api'); + repoMock.listRuns.mockResolvedValue([{ id: 'run_1', productId: 'lysnrai' }]); + + const { runRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(runRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/runs?limit=10', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(403); + expect(repoMock.listRuns).not.toHaveBeenCalled(); + }); });