feat(api-key): audit security events
This commit is contained in:
parent
2f7163b856
commit
8d78b6ce59
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user