From 8d78b6ce590791d90e6c50ba598a1dda45ed193b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 09:24:01 +0000 Subject: [PATCH] feat(api-key): audit security events --- .../src/lib/api-key-auth.test.ts | 32 ++++++++++++ .../platform-service/src/lib/api-key-auth.ts | 52 +++++++++++++++++++ 2 files changed, 84 insertions(+) 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 d623a02a..cb71c628 100644 --- a/services/platform-service/src/lib/api-key-auth.test.ts +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -149,6 +149,13 @@ describe('api key auth', () => { }); expect(res.statusCode).toBe(403); + const auditDocs = await provider.getCollection('audit_log', '/productId').findMany({ + filter: { productId: 'lysnrai', action: 'api_key.access_rejected' }, + }); + expect(auditDocs).toHaveLength(1); + expect(auditDocs[0]?.details).toMatchObject({ + event: 'missing_scope', + }); }); it('rejects api key when token type is not permitted for the route', async () => { @@ -313,4 +320,29 @@ describe('api key auth', () => { source: 'jwt', }); }); + + it('audits invalid api key attempts before auth context is established', async () => { + 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': `wai_${'z'.repeat(64)}`, + }, + }); + + expect(res.statusCode).toBe(401); + const auditDocs = await provider.getCollection('audit_log', '/productId').findMany({ + filter: { productId: 'lysnrai', action: 'api_key.access_rejected' }, + }); + expect(auditDocs.some(doc => doc.details?.event === 'invalid_api_key')).toBe(true); + }); }); diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts index fa47e7a9..829245d9 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -4,6 +4,8 @@ import { ForbiddenError, TooManyRequestsError, UnauthorizedError } from './error import { getCollection } from './datastore.js'; import * as rateLimitStore from '../modules/ratelimit/store.js'; import type { RateLimitRule } from '../modules/ratelimit/types.js'; +import * as auditRepo from '../modules/audit/repository.js'; +import type { AuditDoc } from '../modules/audit/types.js'; interface ApiTokenLookupDoc { id: string; @@ -59,11 +61,55 @@ function tokenCollection() { return getCollection('api_tokens', '/id'); } +function resolveAuditProductId(req: FastifyRequest): string { + const headerProductId = req.headers['x-product-id']; + if (req.apiKeyAuth?.productId) return req.apiKeyAuth.productId; + if (typeof headerProductId === 'string' && headerProductId.length > 0) return headerProductId; + return process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai'; +} + +function writeApiKeyAudit( + req: FastifyRequest, + action: 'api_key.access_rejected' | 'api_key.rate_limited', + details: Record +): void { + const doc: AuditDoc = { + id: `aud_${crypto.randomUUID()}`, + productId: resolveAuditProductId(req), + userId: req.apiKeyAuth?.userId ?? 'api_key_unknown', + action, + category: 'security', + details, + ipAddress: req.ip, + userAgent: + typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined, + createdAt: new Date().toISOString(), + }; + + auditRepo.create(doc).catch(err => { + req.log.error({ err, action }, '[api-key] audit write failed'); + }); +} + function logApiKeyWarning( req: FastifyRequest, event: string, details: Record = {} ): void { + writeApiKeyAudit( + req, + event.includes('rate_limited') ? 'api_key.rate_limited' : 'api_key.access_rejected', + { + event, + requestId: req.id, + productIdHeader: req.headers['x-product-id'], + apiKeyPrefix: req.apiKeyAuth?.prefix, + apiKeyTokenId: req.apiKeyAuth?.tokenId, + apiKeyProductId: req.apiKeyAuth?.productId, + ...details, + } + ); + req.log.warn( { event, @@ -345,5 +391,11 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi }, '[api-key] authentication failed' ); + writeApiKeyAudit(req, 'api_key.access_rejected', { + event: rejectionReason ?? 'invalid_api_key', + requestId: req.id, + apiKeyPrefix: prefix, + productIdHeader: req.headers['x-product-id'], + }); }); }