feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { ManualOperationAuditMiddleware } from './manual-operation-audit.middleware';
|
||||
|
||||
describe('ManualOperationAuditMiddleware', () => {
|
||||
it('records a successful authenticated mutation without storing request bodies', async () => {
|
||||
const prisma = { operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) } };
|
||||
const middleware = new ManualOperationAuditMiddleware(prisma as never);
|
||||
let finish: (() => void) | undefined;
|
||||
const response = { statusCode: 200, once: (_event: 'finish', listener: () => void) => { finish = listener; } };
|
||||
const next = jest.fn();
|
||||
|
||||
middleware.use({
|
||||
method: 'PUT',
|
||||
originalUrl: '/api/admin/enterprise-applications/c12345678901234567890?view=full',
|
||||
sessionUserId: 'user-1',
|
||||
header: () => 'jest',
|
||||
}, response, next);
|
||||
finish?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
action: 'manual_operation.put',
|
||||
resource: 'enterprise-applications',
|
||||
resourceId: 'c12345678901234567890',
|
||||
detail: { method: 'PUT', path: '/api/admin/enterprise-applications/c12345678901234567890', statusCode: 200 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not record unauthenticated, read-only, or failed requests', () => {
|
||||
const prisma = { operationLog: { create: jest.fn() } };
|
||||
const middleware = new ManualOperationAuditMiddleware(prisma as never);
|
||||
const response = { statusCode: 500, once: (_event: 'finish', listener: () => void) => listener() };
|
||||
middleware.use({ method: 'POST', originalUrl: '/api/admin/test', sessionUserId: 'user-1', header: () => undefined }, response, jest.fn());
|
||||
middleware.use({ method: 'GET', originalUrl: '/api/admin/test', sessionUserId: 'user-1', header: () => undefined }, response, jest.fn());
|
||||
middleware.use({ method: 'POST', originalUrl: '/api/admin/test', header: () => undefined }, response, jest.fn());
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { requestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type AuditRequest = {
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
sessionUserId?: string;
|
||||
header(name: string): string | undefined;
|
||||
};
|
||||
|
||||
type AuditResponse = {
|
||||
statusCode?: number;
|
||||
once(event: 'finish', listener: () => void): void;
|
||||
};
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
@Injectable()
|
||||
export class ManualOperationAuditMiddleware implements NestMiddleware {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
use(request: AuditRequest, response: AuditResponse, next: () => void) {
|
||||
const method = (request.method ?? '').toUpperCase();
|
||||
const userId = request.sessionUserId;
|
||||
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
||||
if (!userId || !MUTATING_METHODS.has(method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const { resource, resourceId } = operationResource(path);
|
||||
const ipAddress = requestContext.getStore()?.ipAddress;
|
||||
const userAgent = request.header('user-agent');
|
||||
response.once('finish', () => {
|
||||
const statusCode = response.statusCode ?? 200;
|
||||
if (statusCode >= 400) return;
|
||||
void this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: `manual_operation.${method.toLowerCase()}`,
|
||||
resource,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
detail: { method, path, statusCode },
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
function operationResource(path: string) {
|
||||
const segments = path.split('/').filter(Boolean).filter((segment) => !['api', 'admin', 'client'].includes(segment));
|
||||
const resource = segments[0] ?? 'manual_operation';
|
||||
const resourceId = segments.find((segment, index) => index > 0 && looksLikeResourceId(segment));
|
||||
return { resource, resourceId };
|
||||
}
|
||||
|
||||
function looksLikeResourceId(value: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value) || /^c[a-z0-9]{20,}$/i.test(value);
|
||||
}
|
||||
Reference in New Issue
Block a user