feat(api-key): audit security events

This commit is contained in:
root 2026-03-15 09:24:01 +00:00
parent 2f7163b856
commit 8d78b6ce59
2 changed files with 84 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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<ApiTokenLookupDoc>('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<string, unknown>
): 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<string, unknown> = {}
): 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'],
});
});
}