From b79c49ac7d4adeeb80ef2e5413be0035a225a256 Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:09:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20declarative=20request=20audit?= =?UTF-8?q?=20interceptor\n\nCloses=20#27\n\n=F0=9F=A4=96=20Generated=20wi?= =?UTF-8?q?th=20Codebuff\nCo-Authored-By:=20Codebuff=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + src/common/decorators/audit-log.decorator.ts | 12 +++ src/common/index.ts | 2 + .../interceptors/audit-log.interceptor.ts | 81 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/common/decorators/audit-log.decorator.ts create mode 100644 src/common/interceptors/audit-log.interceptor.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5b91dad..8d05c49 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { AstroidThrottlerGuard } from './common/guards/throttler.guard'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; +import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AuthModule } from './modules/auth/auth.module'; @@ -111,6 +112,7 @@ import { AiModule } from './modules/ai/ai.module'; { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_GUARD, useClass: AstroidThrottlerGuard }, + { provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, { provide: APP_FILTER, useClass: AllExceptionsFilter }, ], diff --git a/src/common/decorators/audit-log.decorator.ts b/src/common/decorators/audit-log.decorator.ts new file mode 100644 index 0000000..026a258 --- /dev/null +++ b/src/common/decorators/audit-log.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_LOG_KEY = 'astroid:auditLog'; + +export interface AuditLogOptions { + action: string; + entity?: string; + entityIdParam?: string; +} + +export const Audit = (options: AuditLogOptions) => SetMetadata(AUDIT_LOG_KEY, options); +export const AuditLog = Audit; diff --git a/src/common/index.ts b/src/common/index.ts index dcd0031..0812329 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -6,12 +6,14 @@ export * from './interfaces/authenticated-user.interface'; export * from './exceptions/domain.exception'; export * from './filters/all-exceptions.filter'; export * from './interceptors/response.interceptor'; +export * from './interceptors/audit-log.interceptor'; export * from './pipes/zod-validation.pipe'; export * from './helpers/pagination'; export * from './decorators/current-user.decorator'; export * from './decorators/roles.decorator'; export * from './decorators/public.decorator'; export * from './decorators/throttle-tier.decorator'; +export * from './decorators/audit-log.decorator'; export * from './decorators/api-envelope.decorator'; export * from './guards/jwt-auth.guard'; export * from './guards/roles.guard'; diff --git a/src/common/interceptors/audit-log.interceptor.ts b/src/common/interceptors/audit-log.interceptor.ts new file mode 100644 index 0000000..c56c541 --- /dev/null +++ b/src/common/interceptors/audit-log.interceptor.ts @@ -0,0 +1,81 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { Observable, catchError, tap, throwError } from 'rxjs'; +import { AuditService } from '../../modules/audit/audit.service'; +import { + AUDIT_LOG_KEY, + AuditLogOptions, +} from '../decorators/audit-log.decorator'; +import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; + +const SENSITIVE_KEYS = new Set(['password', 'secret', 'privatekey', 'apikey', 'api_key']); + +@Injectable() +export class AuditLogInterceptor { + private readonly logger = new Logger(AuditLogInterceptor.name); + + constructor( + private readonly reflector: Reflector, + private readonly auditService: AuditService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const options = this.reflector.getAllAndOverride(AUDIT_LOG_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!options) return next.handle(); + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const startedAt = Date.now(); + + const persist = (status: number): void => { + const user = request.user; + if (!user?.organizationId) return; + const entityId = options.entityIdParam ? request.params?.[options.entityIdParam] : undefined; + const payload = sanitize(request.body); + setTimeout(() => { + void this.auditService + .record({ + organizationId: user.organizationId, + userId: user.id, + action: options.action, + entity: options.entity ?? context.getClass().name, + entityId: entityId ?? null, + newValue: { body: payload, status, durationMs: Date.now() - startedAt }, + ipAddress: request.ip ?? request.socket?.remoteAddress ?? null, + device: request.get('user-agent') ?? null, + }) + .catch((error: unknown) => { + this.logger.error(`Failed to persist request audit: ${(error as Error).message}`); + }); + }, 0); + }; + + return next.handle().pipe( + tap(() => persist(response.statusCode)), + catchError((error: unknown) => { + persist(typeof (error as { status?: unknown }).status === 'number' ? (error as { status: number }).status : 500); + return throwError(() => error); + }), + ); + } +} + +function sanitize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitize); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + SENSITIVE_KEYS.has(key.toLowerCase()) ? '[REDACTED]' : sanitize(entry), + ]), + ); +} From 5e750363074956e3476a5f09f226b5baa2743c5f Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:23:03 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20implement=20NestInterceptor=20for=20?= =?UTF-8?q?audit=20logging\n\nCloses=20#27\n\n=F0=9F=A4=96=20Generated=20w?= =?UTF-8?q?ith=20Codebuff\nCo-Authored-By:=20Codebuff=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/decorators/audit-log.decorator.ts | 8 +-- .../audit-log.interceptor.spec.ts | 60 +++++++++++++++++++ .../interceptors/audit-log.interceptor.ts | 60 ++++++------------- 3 files changed, 78 insertions(+), 50 deletions(-) create mode 100644 src/common/interceptors/audit-log.interceptor.spec.ts diff --git a/src/common/decorators/audit-log.decorator.ts b/src/common/decorators/audit-log.decorator.ts index 026a258..a247f31 100644 --- a/src/common/decorators/audit-log.decorator.ts +++ b/src/common/decorators/audit-log.decorator.ts @@ -1,12 +1,6 @@ import { SetMetadata } from '@nestjs/common'; export const AUDIT_LOG_KEY = 'astroid:auditLog'; - -export interface AuditLogOptions { - action: string; - entity?: string; - entityIdParam?: string; -} - +export interface AuditLogOptions { action: string; entity?: string; entityIdParam?: string; } export const Audit = (options: AuditLogOptions) => SetMetadata(AUDIT_LOG_KEY, options); export const AuditLog = Audit; diff --git a/src/common/interceptors/audit-log.interceptor.spec.ts b/src/common/interceptors/audit-log.interceptor.spec.ts new file mode 100644 index 0000000..5fdc785 --- /dev/null +++ b/src/common/interceptors/audit-log.interceptor.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { AuditLogInterceptor } from './audit-log.interceptor'; +import { AUDIT_LOG_KEY } from '../decorators/audit-log.decorator'; + +function context(metadata: unknown, request: Record, response = { statusCode: 201 }) { + const reflector = { getAllAndOverride: vi.fn().mockReturnValue(metadata) }; + const executionContext = { + getHandler: vi.fn(), getClass: vi.fn().mockReturnValue(class TestController {}), + switchToHttp: vi.fn().mockReturnValue({ getRequest: () => request, getResponse: () => response }), + }; + return { reflector, executionContext }; +} + +describe('AuditLogInterceptor', () => { + it('passes undecorated handlers through without recording', () => { + const { reflector, executionContext } = context(undefined, {}); + const service = { record: vi.fn() }; + const interceptor = new AuditLogInterceptor(reflector as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }); + expect(service.record).not.toHaveBeenCalled(); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(AUDIT_LOG_KEY, expect.any(Array)); + }); + + it('sanitizes nested sensitive fields and persists success asynchronously', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'KEY_ROTATION', entity: 'agent', entityIdParam: 'id' }, { + params: { id: 'agent-1' }, body: { password: 'secret', nested: { privateKey: 'key', safe: true } }, + user: { id: 'user-1', organizationId: 'org-1' }, ip: '127.0.0.1', get: () => 'Vitest', + }); + const service = { record: vi.fn().mockResolvedValue(undefined) }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'KEY_ROTATION', entity: 'agent', entityIdParam: 'id' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }).subscribe(); + await vi.runAllTimersAsync(); + expect(service.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'KEY_ROTATION', entityId: 'agent-1', newValue: expect.objectContaining({ body: { password: '[REDACTED]', nested: { privateKey: '[REDACTED]', safe: true } }, status: 201 }) })); + vi.useRealTimers(); + }); + + it('records failed requests with their HTTP status', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'DELETE', entity: 'agent' }, { body: {}, user: { id: 'user-1', organizationId: 'org-1' }, get: () => undefined }); + const service = { record: vi.fn().mockResolvedValue(undefined) }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'DELETE', entity: 'agent' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => throwError(() => ({ status: 403 })) }).subscribe({ error: () => undefined }); + await vi.runAllTimersAsync(); + expect(service.record).toHaveBeenCalledWith(expect.objectContaining({ newValue: expect.objectContaining({ status: 403 }) })); + vi.useRealTimers(); + }); + + it('does not persist without an authenticated organization', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'UPDATE' }, { body: {}, user: { id: 'user-1' } }); + const service = { record: vi.fn() }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'UPDATE' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }).subscribe(); + await vi.runAllTimersAsync(); + expect(service.record).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); diff --git a/src/common/interceptors/audit-log.interceptor.ts b/src/common/interceptors/audit-log.interceptor.ts index c56c541..ed93562 100644 --- a/src/common/interceptors/audit-log.interceptor.ts +++ b/src/common/interceptors/audit-log.interceptor.ts @@ -1,64 +1,43 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - Logger, -} from '@nestjs/common'; +import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request, Response } from 'express'; import { Observable, catchError, tap, throwError } from 'rxjs'; +import { Prisma } from '@prisma/client'; import { AuditService } from '../../modules/audit/audit.service'; -import { - AUDIT_LOG_KEY, - AuditLogOptions, -} from '../decorators/audit-log.decorator'; +import { AUDIT_LOG_KEY, AuditLogOptions } from '../decorators/audit-log.decorator'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; const SENSITIVE_KEYS = new Set(['password', 'secret', 'privatekey', 'apikey', 'api_key']); @Injectable() -export class AuditLogInterceptor { +export class AuditLogInterceptor implements NestInterceptor { private readonly logger = new Logger(AuditLogInterceptor.name); - constructor( - private readonly reflector: Reflector, - private readonly auditService: AuditService, - ) {} + constructor(private readonly reflector: Reflector, private readonly auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { - const options = this.reflector.getAllAndOverride(AUDIT_LOG_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const options = this.reflector.getAllAndOverride(AUDIT_LOG_KEY, [context.getHandler(), context.getClass()]); if (!options) return next.handle(); - const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); const startedAt = Date.now(); - const persist = (status: number): void => { const user = request.user; if (!user?.organizationId) return; const entityId = options.entityIdParam ? request.params?.[options.entityIdParam] : undefined; - const payload = sanitize(request.body); setTimeout(() => { - void this.auditService - .record({ - organizationId: user.organizationId, - userId: user.id, - action: options.action, - entity: options.entity ?? context.getClass().name, - entityId: entityId ?? null, - newValue: { body: payload, status, durationMs: Date.now() - startedAt }, - ipAddress: request.ip ?? request.socket?.remoteAddress ?? null, - device: request.get('user-agent') ?? null, - }) - .catch((error: unknown) => { - this.logger.error(`Failed to persist request audit: ${(error as Error).message}`); - }); + void this.auditService.record({ + organizationId: user.organizationId, + userId: user.id, + action: options.action, + entity: options.entity ?? context.getClass().name, + entityId: typeof entityId === 'string' ? entityId : null, + newValue: sanitize({ body: sanitize(request.body), status, durationMs: Date.now() - startedAt }) as Prisma.InputJsonValue, + ipAddress: null, + device: undefined, + }).catch((error: unknown) => this.logger.error(`Failed to persist request audit: ${(error as Error).message}`)); }, 0); }; - return next.handle().pipe( tap(() => persist(response.statusCode)), catchError((error: unknown) => { @@ -72,10 +51,5 @@ export class AuditLogInterceptor { function sanitize(value: unknown): unknown { if (Array.isArray(value)) return value.map(sanitize); if (!value || typeof value !== 'object') return value; - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - key, - SENSITIVE_KEYS.has(key.toLowerCase()) ? '[REDACTED]' : sanitize(entry), - ]), - ); + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, SENSITIVE_KEYS.has(key.toLowerCase()) ? '[REDACTED]' : sanitize(entry)])); }