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 3002d912..c9e898c3 100644 --- a/services/platform-service/src/lib/api-key-auth.test.ts +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -7,15 +7,24 @@ import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-aut const rawApiKey = `wai_${'a'.repeat(64)}`; -async function seedApiKeyToken(scopes: string[] = ['jobs:read']) { +async function seedApiKeyToken( + scopes: string[] = ['jobs:read'], + overrides: Partial<{ + tokenType: 'user_api' | 'product_api' | 'service_api'; + environment: 'dev' | 'staging' | 'prod'; + productId: string; + }> = {} +) { const provider = new MemoryDatastoreProvider(); setProvider(provider); const collection = provider.getCollection('api_tokens', '/id'); await collection.create({ id: 'tok_api_1', - productId: 'lysnrai', + productId: overrides.productId ?? 'lysnrai', userId: 'svc_jobs', userName: 'Jobs Service', + tokenType: overrides.tokenType ?? 'service_api', + environment: overrides.environment ?? 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), status: 'active', @@ -28,6 +37,7 @@ async function seedApiKeyToken(scopes: string[] = ['jobs:read']) { describe('api key auth', () => { beforeEach(() => { delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + delete process.env.PLATFORM_RUNTIME_ENV; }); it('attaches apiKeyAuth from x-api-key', async () => { @@ -40,7 +50,7 @@ describe('api key auth', () => { apiKeyScopes: ['jobs:read'], rateLimitKey: 'jobs:read', }); - return actor; + return { actor, apiKeyAuth: req.apiKeyAuth }; }); const res = await app.inject({ @@ -53,12 +63,63 @@ describe('api key auth', () => { expect(res.statusCode).toBe(200); expect(JSON.parse(res.body)).toMatchObject({ - actorId: 'svc_jobs', - productId: 'lysnrai', - source: 'api_key', + actor: { + actorId: 'svc_jobs', + productId: 'lysnrai', + source: 'api_key', + }, + apiKeyAuth: { + tokenType: 'service_api', + environment: 'dev', + }, }); }); + it('rejects user_api tokens for machine authentication', async () => { + await seedApiKeyToken(['jobs:read'], { tokenType: 'user_api' }); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:read'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('rejects api keys issued for another environment', async () => { + process.env.PLATFORM_RUNTIME_ENV = 'prod'; + await seedApiKeyToken(['jobs:read'], { environment: 'staging' }); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:read'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + }, + }); + + expect(res.statusCode).toBe(401); + }); + it('rejects api key without required scopes', 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 6c52a0c5..7d987153 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -10,6 +10,8 @@ interface ApiTokenLookupDoc { productId: string; userId: string; userName: string; + tokenType: 'user_api' | 'product_api' | 'service_api'; + environment: 'dev' | 'staging' | 'prod'; prefix: string; tokenHash: string; status: 'active' | 'revoked' | 'expired'; @@ -23,6 +25,8 @@ export interface ApiKeyAuthPayload { productId: string; userId: string; userName: string; + tokenType: 'product_api' | 'service_api'; + environment: 'dev' | 'staging' | 'prod'; scopes: string[]; prefix: string; } @@ -112,6 +116,22 @@ function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean }); } +function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' { + const explicit = process.env.PLATFORM_RUNTIME_ENV; + if (explicit === 'dev' || explicit === 'staging' || explicit === 'prod') { + return explicit; + } + + switch (process.env.NODE_ENV) { + case 'production': + return 'prod'; + case 'test': + return 'dev'; + default: + return 'dev'; + } +} + function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload { const apiKey = req.apiKeyAuth; if (!apiKey) { @@ -203,11 +223,21 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi const ok = await bcrypt.compare(rawKey, candidate.tokenHash); if (!ok) continue; + if (candidate.tokenType === 'user_api') { + continue; + } + + if (candidate.environment !== getRuntimeEnvironment()) { + continue; + } + req.apiKeyAuth = { tokenId: candidate.id, productId: candidate.productId, userId: candidate.userId, userName: candidate.userName, + tokenType: candidate.tokenType, + environment: candidate.environment, scopes: candidate.scopes, prefix: candidate.prefix, }; 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 179fbaac..065381c7 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 @@ -35,6 +35,8 @@ async function seedApiKey(scopes: string[]) { productId: 'lysnrai', userId: 'svc_jobs', userName: 'Jobs Service', + tokenType: 'service_api', + environment: 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), status: 'active', 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 d328583e..7c8f947f 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 @@ -24,6 +24,8 @@ async function seedApiKey(scopes: string[]) { productId: 'lysnrai', userId: 'svc_runs', userName: 'Runs Service', + tokenType: 'service_api', + environment: 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), status: 'active', diff --git a/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts b/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts index c30361db..0594ba41 100644 --- a/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts +++ b/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts @@ -32,6 +32,8 @@ async function seedApiKey(scopes: string[]) { productId: 'lysnrai', userId: 'svc_webhooks', userName: 'Webhook Service', + tokenType: 'service_api', + environment: 'dev', prefix: rawApiKey.slice(0, 12), tokenHash: await bcrypt.hash(rawApiKey, 10), status: 'active',