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);
|
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 () => {
|
it('rejects api key when token type is not permitted for the route', async () => {
|
||||||
@ -313,4 +320,29 @@ describe('api key auth', () => {
|
|||||||
source: 'jwt',
|
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 { getCollection } from './datastore.js';
|
||||||
import * as rateLimitStore from '../modules/ratelimit/store.js';
|
import * as rateLimitStore from '../modules/ratelimit/store.js';
|
||||||
import type { RateLimitRule } from '../modules/ratelimit/types.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 {
|
interface ApiTokenLookupDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@ -59,11 +61,55 @@ function tokenCollection() {
|
|||||||
return getCollection<ApiTokenLookupDoc>('api_tokens', '/id');
|
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(
|
function logApiKeyWarning(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
event: string,
|
event: string,
|
||||||
details: Record<string, unknown> = {}
|
details: Record<string, unknown> = {}
|
||||||
): void {
|
): 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(
|
req.log.warn(
|
||||||
{
|
{
|
||||||
event,
|
event,
|
||||||
@ -345,5 +391,11 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
|
|||||||
},
|
},
|
||||||
'[api-key] authentication failed'
|
'[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