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 4b5e7dc0..d623a02a 100644 --- a/services/platform-service/src/lib/api-key-auth.test.ts +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -4,29 +4,34 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { MemoryDatastoreProvider } from '@bytelyst/datastore'; import { setProvider } from './datastore.js'; import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-auth.js'; +import { clearAll as clearRateLimits } from '../modules/ratelimit/store.js'; const rawApiKey = `wai_${'a'.repeat(64)}`; +const secondRawApiKey = `wai_${'b'.repeat(64)}`; +let provider: MemoryDatastoreProvider; async function seedApiKeyToken( scopes: string[] = ['jobs:read'], overrides: Partial<{ + id: string; + rawKey: string; + userId: string; + userName: string; 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', + id: overrides.id ?? 'tok_api_1', productId: overrides.productId ?? 'lysnrai', - userId: 'svc_jobs', - userName: 'Jobs Service', + userId: overrides.userId ?? 'svc_jobs', + userName: overrides.userName ?? 'Jobs Service', tokenType: overrides.tokenType ?? 'service_api', environment: overrides.environment ?? 'dev', - prefix: rawApiKey.slice(0, 12), - tokenHash: await bcrypt.hash(rawApiKey, 10), + prefix: (overrides.rawKey ?? rawApiKey).slice(0, 12), + tokenHash: await bcrypt.hash(overrides.rawKey ?? rawApiKey, 10), status: 'active', scopes, expiresAt: '2099-01-01T00:00:00.000Z', @@ -36,8 +41,12 @@ async function seedApiKeyToken( describe('api key auth', () => { beforeEach(() => { + provider = new MemoryDatastoreProvider(); + setProvider(provider); delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + delete process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON; delete process.env.PLATFORM_RUNTIME_ENV; + clearRateLimits(); }); it('attaches apiKeyAuth from x-api-key', async () => { @@ -224,6 +233,58 @@ describe('api key auth', () => { expect(second.statusCode).toBe(429); }); + it('rate limits across api keys for the same product and action key', async () => { + process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({ + 'jobs:write': { + maxRequests: 5, + windowSeconds: 60, + }, + }); + process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON = JSON.stringify({ + 'jobs:write': { + maxRequests: 1, + windowSeconds: 60, + }, + }); + + await seedApiKeyToken(['jobs:write'], { + id: 'tok_api_1', + rawKey: rawApiKey, + userId: 'svc_jobs_primary', + userName: 'Jobs Primary', + }); + await seedApiKeyToken(['jobs:write'], { + id: 'tok_api_2', + rawKey: secondRawApiKey, + userId: 'svc_jobs_secondary', + userName: 'Jobs Secondary', + }); + + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.post('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:write'], + rateLimitKey: 'jobs:write', + }); + }); + + const first = await app.inject({ + method: 'POST', + url: '/probe', + headers: { 'x-api-key': rawApiKey }, + }); + expect(first.statusCode).toBe(200); + + const second = await app.inject({ + method: 'POST', + url: '/probe', + headers: { 'x-api-key': secondRawApiKey }, + }); + expect(second.statusCode).toBe(429); + }); + it('allows JWT callers when configured', async () => { const app = Fastify(); await registerOptionalApiKeyContext(app); diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts index 88268eec..a5a3890c 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -104,6 +104,29 @@ function loadApiKeyRateLimitConfig(): ApiKeyRateLimitConfig { } } +function loadApiKeyProductRateLimitConfig( + baseConfig: ApiKeyRateLimitConfig +): ApiKeyRateLimitConfig { + const raw = process.env.API_KEY_PRODUCT_RATE_LIMIT_CONFIG_JSON; + if (raw) { + try { + return JSON.parse(raw) as ApiKeyRateLimitConfig; + } catch { + /* fall back to derived defaults */ + } + } + + return Object.fromEntries( + Object.entries(baseConfig).map(([key, rule]) => [ + key, + { + maxRequests: Math.max(rule.maxRequests * 5, rule.maxRequests), + windowSeconds: rule.windowSeconds, + }, + ]) + ); +} + function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean { if (grantedScopes.includes('*')) return true; @@ -170,14 +193,25 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi if (!rateLimitKey || !req.apiKeyAuth) return; const apiKeyRateLimitConfig = loadApiKeyRateLimitConfig(); - const rule = apiKeyRateLimitConfig[rateLimitKey]; - if (!rule) return; + const keyRule = apiKeyRateLimitConfig[rateLimitKey]; + if (!keyRule) return; const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`; - const result = rateLimitStore.checkAndRecord(compositeKey, rule); - if (!result.allowed) { + const keyResult = rateLimitStore.checkAndRecord(compositeKey, keyRule); + if (!keyResult.allowed) { throw new TooManyRequestsError('API key rate limit exceeded', { - retryAfter: Math.ceil((result.retryAfterMs ?? 0) / 1000), + retryAfter: Math.ceil((keyResult.retryAfterMs ?? 0) / 1000), + }); + } + + const productRule = loadApiKeyProductRateLimitConfig(apiKeyRateLimitConfig)[rateLimitKey]; + if (!productRule) return; + + const productCompositeKey = `api-key-product:${req.apiKeyAuth.productId}:${rateLimitKey}`; + const productResult = rateLimitStore.checkAndRecord(productCompositeKey, productRule); + if (!productResult.allowed) { + throw new TooManyRequestsError('Product API key rate limit exceeded', { + retryAfter: Math.ceil((productResult.retryAfterMs ?? 0) / 1000), }); } }