diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts index ac7d35c..999c058 100644 --- a/src/audit/audit.module.ts +++ b/src/audit/audit.module.ts @@ -1,17 +1,51 @@ import { Module, Global } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; +import { BullModule } from '@nestjs/bullmq'; import { AuditLog } from './entities/audit-log.entity'; import { AuditTrailService } from './services/audit-trail.service'; import { AuditRetentionService } from './services/audit-retention.service'; +import { AuditQueueService, AUDIT_QUEUE_NAME } from './services/audit-queue.service'; +import { ComplianceService } from './services/compliance.service'; +import { SecurityMonitoringService } from './services/security-monitoring.service'; +import { AuditMetricsService } from './services/audit-metrics.service'; import { AuditController } from './controllers/audit-log.controller'; import { AuditLoggingInterceptor } from './interceptors/audit-logging.interceptor'; +import { AuditLogProcessor } from './processors/audit-log.processor'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([AuditLog]), ScheduleModule], - providers: [AuditTrailService, AuditLoggingInterceptor, AuditRetentionService], + imports: [ + TypeOrmModule.forFeature([AuditLog]), + ScheduleModule, + BullModule.registerQueue({ + name: AUDIT_QUEUE_NAME, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + removeOnFail: false, + }, + }), + ], + providers: [ + AuditTrailService, + AuditLoggingInterceptor, + AuditRetentionService, + AuditQueueService, + ComplianceService, + SecurityMonitoringService, + AuditMetricsService, + AuditLogProcessor, + ], controllers: [AuditController], - exports: [AuditTrailService, AuditLoggingInterceptor], + exports: [ + AuditTrailService, + AuditLoggingInterceptor, + AuditQueueService, + ComplianceService, + SecurityMonitoringService, + AuditMetricsService, + ], }) export class AuditModule {} diff --git a/src/audit/config/audit.config.ts b/src/audit/config/audit.config.ts new file mode 100644 index 0000000..ea562ce --- /dev/null +++ b/src/audit/config/audit.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config'; + +export interface AuditConfig { + retentionDays: number; + archivalEnabled: boolean; + archivalBucket: string; + asyncWritesEnabled: boolean; + batchSize: number; + maxBatchWaitMs: number; + metricPrefix: string; +} + +export default registerAs('audit', (): AuditConfig => ({ + retentionDays: parseInt(process.env.AUDIT_LOG_RETENTION_DAYS ?? '365', 10), + archivalEnabled: process.env.AUDIT_ARCHIVAL_ENABLED === 'true', + archivalBucket: process.env.AUDIT_ARCHIVAL_BUCKET ?? 'audit-archive', + asyncWritesEnabled: process.env.AUDIT_ASYNC_WRITES_ENABLED !== 'false', + batchSize: parseInt(process.env.AUDIT_BATCH_SIZE ?? '50', 10), + maxBatchWaitMs: parseInt(process.env.AUDIT_MAX_BATCH_WAIT_MS ?? '1000', 10), + metricPrefix: process.env.AUDIT_METRIC_PREFIX ?? 'audit', +})); diff --git a/src/audit/config/index.ts b/src/audit/config/index.ts new file mode 100644 index 0000000..f60457b --- /dev/null +++ b/src/audit/config/index.ts @@ -0,0 +1,2 @@ +export { default as auditConfig } from './audit.config'; +export type { AuditConfig } from './audit.config'; diff --git a/src/audit/controllers/audit-log.controller.ts b/src/audit/controllers/audit-log.controller.ts index a7145e4..fed698a 100644 --- a/src/audit/controllers/audit-log.controller.ts +++ b/src/audit/controllers/audit-log.controller.ts @@ -1,118 +1,401 @@ -import { Controller, Get, Query, Param, UseInterceptors } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { AuditTrailService } from '../services/audit-trail.service'; +import { + Controller, + Get, + Query, + Param, + Post, + Res, + Headers, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ApiTags, ApiOperation, ApiResponse, ApiProduces } from '@nestjs/swagger'; +import { AuditTrailService, AuditQueryFilters } from '../services/audit-trail.service'; +import { ComplianceService } from '../services/compliance.service'; +import { SecurityMonitoringService } from '../services/security-monitoring.service'; +import { AuditMetricsService } from '../services/audit-metrics.service'; +import { AuditQueueService } from '../services/audit-queue.service'; import { AuditLog, AuditActionType, AuditEntityType } from '../entities/audit-log.entity'; +import { AuditQueryDto, ExportAuditDto, ComplianceReportDto } from '../dto/audit-query.dto'; +import { AuditPaginatedResponse, AuditResponse } from '../interfaces/audit-response.interface'; @ApiTags('audit') @Controller('audit') export class AuditController { - constructor(private readonly auditTrailService: AuditTrailService) {} + constructor( + private readonly auditTrailService: AuditTrailService, + private readonly complianceService: ComplianceService, + private readonly securityMonitoringService: SecurityMonitoringService, + private readonly auditMetricsService: AuditMetricsService, + private readonly auditQueueService: AuditQueueService, + ) {} - /** - * Get all audit logs with optional filters - * GET /audit?entityType=CLAIM&actionType=CLAIM_CREATED&userId=xxx&limit=50&offset=0 - */ @Get() - async getAuditLogs( - @Query('entityType') entityType?: AuditEntityType, - @Query('actionType') actionType?: AuditActionType, - @Query('userId') userId?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - ): Promise<{ logs: AuditLog[]; total: number }> { - const parsedLimit = limit ? Math.min(parseInt(limit, 10), 500) : 100; - const parsedOffset = offset ? Math.max(parseInt(offset, 10), 0) : 0; + @ApiOperation({ summary: 'Query audit logs with filters and pagination' }) + @ApiResponse({ status: 200, description: 'Paginated audit logs' }) + async queryAuditLogs( + @Query() query: AuditQueryDto, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const filters: AuditQueryFilters = { + entityType: query.entityType, + actionType: query.actionType, + severity: query.severity, + category: query.category, + userId: query.userId, + source: query.source, + requestId: query.requestId, + correlationId: query.correlationId, + search: query.search, + startDate: query.startDate, + endDate: query.endDate, + page: query.page, + limit: query.limit, + }; - return this.auditTrailService.getAuditLogs( - entityType, - actionType, - userId, - parsedLimit, - parsedOffset, - ); + const result = await this.auditTrailService.query(filters); + + return { + success: true, + data: result.logs, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: result.totalPages, + hasNext: result.page < result.totalPages, + hasPrevious: result.page > 1, + }, + timestamp: new Date().toISOString(), + requestId, + }; } - /** - * Get audit logs for a specific entity - * GET /audit/entity/CLAIM/claim-id-123 - */ @Get('entity/:entityType/:entityId') + @ApiOperation({ summary: 'Get audit logs for a specific entity' }) async getEntityAuditLogs( @Param('entityType') entityType: AuditEntityType, @Param('entityId') entityId: string, - ): Promise { - return this.auditTrailService.getEntityAuditLogs(entityType, entityId); + @Headers('x-request-id') requestId?: string, + ): Promise> { + const logs = await this.auditTrailService.getEntityAuditLogs(entityType, entityId); + return { + success: true, + data: logs, + timestamp: new Date().toISOString(), + requestId, + }; } - /** - * Get audit logs for a specific user - * GET /audit/user/user-id-123?limit=50 - */ @Get('user/:userId') + @ApiOperation({ summary: 'Get audit logs for a specific user' }) async getUserAuditLogs( @Param('userId') userId: string, + @Query('page') page?: string, @Query('limit') limit?: string, - @Query('offset') offset?: string, - ): Promise<{ logs: AuditLog[]; total: number }> { + @Headers('x-request-id') requestId?: string, + ): Promise> { const parsedLimit = limit ? Math.min(parseInt(limit, 10), 500) : 100; - const parsedOffset = offset ? Math.max(parseInt(offset, 10), 0) : 0; + const parsedPage = page ? Math.max(parseInt(page, 10), 1) : 1; + const offset = (parsedPage - 1) * parsedLimit; - return this.auditTrailService.getUserAuditLogs( + const { logs, total } = await this.auditTrailService.getUserAuditLogs( userId, parsedLimit, - parsedOffset, + offset, ); + + const totalPages = Math.ceil(total / parsedLimit); + + return { + success: true, + data: logs, + pagination: { + page: parsedPage, + limit: parsedLimit, + total, + totalPages, + hasNext: parsedPage < totalPages, + hasPrevious: parsedPage > 1, + }, + timestamp: new Date().toISOString(), + requestId, + }; } - /** - * Get audit logs for a specific action type - * GET /audit/action/CLAIM_CREATED?limit=50 - */ @Get('action/:actionType') + @ApiOperation({ summary: 'Get audit logs for a specific action type' }) async getActionAuditLogs( @Param('actionType') actionType: AuditActionType, + @Query('page') page?: string, @Query('limit') limit?: string, - @Query('offset') offset?: string, - ): Promise<{ logs: AuditLog[]; total: number }> { + @Headers('x-request-id') requestId?: string, + ): Promise> { const parsedLimit = limit ? Math.min(parseInt(limit, 10), 500) : 100; - const parsedOffset = offset ? Math.max(parseInt(offset, 10), 0) : 0; + const parsedPage = page ? Math.max(parseInt(page, 10), 1) : 1; + const offset = (parsedPage - 1) * parsedLimit; - return this.auditTrailService.getActionAuditLogs( + const { logs, total } = await this.auditTrailService.getActionAuditLogs( actionType, parsedLimit, - parsedOffset, + offset, ); + + const totalPages = Math.ceil(total / parsedLimit); + + return { + success: true, + data: logs, + pagination: { + page: parsedPage, + limit: parsedLimit, + total, + totalPages, + hasNext: parsedPage < totalPages, + hasPrevious: parsedPage > 1, + }, + timestamp: new Date().toISOString(), + requestId, + }; } - /** - * Get change history for a specific entity - * GET /audit/changes/CLAIM/claim-id-123 - */ @Get('changes/:entityType/:entityId') + @ApiOperation({ summary: 'Get change history for a specific entity' }) async getChangeHistory( @Param('entityType') entityType: AuditEntityType, @Param('entityId') entityId: string, + @Headers('x-request-id') requestId?: string, ): Promise< - Array<{ - timestamp: Date; - action: AuditActionType; - userId: string; - changes: Record; - }> + AuditResponse< + Array<{ + timestamp: Date; + action: AuditActionType; + userId: string; + changes: Record; + }> + > > { - return this.auditTrailService.getChangeHistory(entityType, entityId); + const history = await this.auditTrailService.getChangeHistory(entityType, entityId); + return { + success: true, + data: history, + timestamp: new Date().toISOString(), + requestId, + }; } - /** - * Get audit summary for a specific entity type - * GET /audit/summary?entityType=CLAIM&days=7 - */ @Get('summary') + @ApiOperation({ summary: 'Get audit summary by action type' }) async getAuditSummary( @Query('entityType') entityType?: AuditEntityType, @Query('days') days?: string, - ): Promise> { + @Headers('x-request-id') requestId?: string, + ): Promise>> { + const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 7; + const summary = await this.auditTrailService.getAuditSummary(entityType, parsedDays); + return { + success: true, + data: summary, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('event/:eventId') + @ApiOperation({ summary: 'Get audit log by event ID' }) + async getByEventId( + @Param('eventId') eventId: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const log = await this.auditTrailService.getAuditLogsByEventId(eventId); + return { + success: true, + data: log, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('correlation/:correlationId') + @ApiOperation({ summary: 'Get audit logs by correlation ID' }) + async getByCorrelationId( + @Param('correlationId') correlationId: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const logs = await this.auditTrailService.getAuditLogsByCorrelationId(correlationId); + return { + success: true, + data: logs, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('stats/storage') + @ApiOperation({ summary: 'Get audit storage statistics' }) + async getStorageStats( + @Headers('x-request-id') requestId?: string, + ): Promise> { + const stats = await this.auditTrailService.getStorageStats(); + return { + success: true, + data: stats, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Post('export') + @ApiOperation({ summary: 'Export audit logs' }) + @ApiProduces('application/json', 'text/csv') + async exportAuditLogs( + @Query() query: ExportAuditDto, + @Res() res: Response, + @Headers('x-request-id') requestId?: string, + ): Promise { + const result = await this.complianceService.exportAuditLogs(query); + + res.setHeader('Content-Type', result.format); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.setHeader('X-Request-Id', requestId || ''); + + if (query.format === 'csv') { + res.send(result.data); + } else { + res.json(result.data); + } + } + + @Get('reports') + @ApiOperation({ summary: 'Generate compliance report' }) + async generateReport( + @Query() query: ComplianceReportDto, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const report = await this.complianceService.generateReport(query); + return { + success: true, + data: report, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('reports/daily') + @ApiOperation({ summary: 'Get daily audit activity' }) + async getDailyActivity( + @Query('days') days?: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 30; + const activity = await this.complianceService.getDailyActivity(parsedDays); + return { + success: true, + data: activity, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('reports/categories') + @ApiOperation({ summary: 'Get audit category summary' }) + async getCategorySummary( + @Query('days') days?: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 30; + const summary = await this.complianceService.getCategorySummary(parsedDays); + return { + success: true, + data: summary, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('security/events') + @ApiOperation({ summary: 'Get recent security events' }) + async getSecurityEvents( + @Query('minutes') minutes?: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const parsedMinutes = minutes ? Math.max(parseInt(minutes, 10), 1) : 60; + const events = await this.securityMonitoringService.getRecentSecurityEvents(parsedMinutes); + return { + success: true, + data: events, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('security/failed-logins') + @ApiOperation({ summary: 'Get failed login report' }) + async getFailedLoginReport( + @Query('days') days?: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 7; - return this.auditTrailService.getAuditSummary(entityType, parsedDays); + const report = await this.securityMonitoringService.getFailedLoginReport(parsedDays); + return { + success: true, + data: report, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('security/admin-activity') + @ApiOperation({ summary: 'Get admin activity report' }) + async getAdminActivityReport( + @Query('days') days?: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 30; + const report = await this.securityMonitoringService.getAdminActivityReport(parsedDays); + return { + success: true, + data: report, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('security/check/:userId') + @ApiOperation({ summary: 'Check for security incidents for a user' }) + async checkUserSecurity( + @Param('userId') userId: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const [failedLogin, permissionEscalation] = await Promise.all([ + this.securityMonitoringService.checkFailedLogins(userId), + this.securityMonitoringService.checkPermissionEscalation(userId), + ]); + + const incidents = [failedLogin, permissionEscalation].filter(Boolean); + + return { + success: true, + data: { userId, incidents, hasIncidents: incidents.length > 0 }, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('metrics') + @ApiOperation({ summary: 'Get audit system metrics' }) + async getMetrics( + @Headers('x-request-id') requestId?: string, + ): Promise> { + await this.auditMetricsService.updateStorageMetrics(); + const metrics = { + storage: await this.auditTrailService.getStorageStats(), + queue: await this.auditQueueService.getQueueStats(), + }; + return { + success: true, + data: metrics, + timestamp: new Date().toISOString(), + requestId, + }; } } diff --git a/src/audit/dto/audit-query.dto.ts b/src/audit/dto/audit-query.dto.ts new file mode 100644 index 0000000..0a46983 --- /dev/null +++ b/src/audit/dto/audit-query.dto.ts @@ -0,0 +1,145 @@ +import { IsOptional, IsString, IsEnum, IsInt, Min, Max, IsDateString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { AuditActionType, AuditEntityType, AuditSeverity, AuditCategory } from '../entities/audit-log.entity'; + +export class AuditQueryDto { + @ApiPropertyOptional({ enum: AuditEntityType }) + @IsOptional() + @IsEnum(AuditEntityType) + entityType?: AuditEntityType; + + @ApiPropertyOptional({ enum: AuditActionType }) + @IsOptional() + @IsEnum(AuditActionType) + actionType?: AuditActionType; + + @ApiPropertyOptional({ enum: AuditSeverity }) + @IsOptional() + @IsEnum(AuditSeverity) + severity?: AuditSeverity; + + @ApiPropertyOptional({ enum: AuditCategory }) + @IsOptional() + @IsEnum(AuditCategory) + category?: AuditCategory; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + source?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + requestId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + correlationId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(500) + limit?: number = 50; +} + +export class ExportAuditDto { + @ApiPropertyOptional({ enum: AuditEntityType }) + @IsOptional() + @IsEnum(AuditEntityType) + entityType?: AuditEntityType; + + @ApiPropertyOptional({ enum: AuditActionType }) + @IsOptional() + @IsEnum(AuditActionType) + actionType?: AuditActionType; + + @ApiPropertyOptional({ enum: AuditSeverity }) + @IsOptional() + @IsEnum(AuditSeverity) + severity?: AuditSeverity; + + @ApiPropertyOptional({ enum: AuditCategory }) + @IsOptional() + @IsEnum(AuditCategory) + category?: AuditCategory; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ enum: ['csv', 'json'] }) + @IsOptional() + @IsString() + format?: 'csv' | 'json' = 'json'; +} + +export class ComplianceReportDto { + @ApiPropertyOptional({ enum: ['admin-activity', 'moderation-actions', 'login-history', 'governance-actions', 'permission-changes', 'security-incidents', 'audit-summary'] }) + @IsOptional() + @IsString() + type?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ enum: ['json', 'csv'] }) + @IsOptional() + @IsString() + format?: 'json' | 'csv' = 'json'; +} diff --git a/src/audit/entities/audit-log.entity.ts b/src/audit/entities/audit-log.entity.ts index 0ce5345..6fd52ca 100644 --- a/src/audit/entities/audit-log.entity.ts +++ b/src/audit/entities/audit-log.entity.ts @@ -10,29 +10,53 @@ import { import { User } from '../../entities/user.entity'; export enum AuditActionType { - // Claim actions CLAIM_CREATED = 'CLAIM_CREATED', CLAIM_UPDATED = 'CLAIM_UPDATED', CLAIM_RESOLVED = 'CLAIM_RESOLVED', CLAIM_FINALIZED = 'CLAIM_FINALIZED', - - // Evidence/Verification actions EVIDENCE_SUBMITTED = 'EVIDENCE_SUBMITTED', EVIDENCE_UPDATED = 'EVIDENCE_UPDATED', EVIDENCE_FLAGGED = 'EVIDENCE_FLAGGED', EVIDENCE_VERIFIED = 'EVIDENCE_VERIFIED', VERIFICATION_COMPLETED = 'VERIFICATION_COMPLETED', - - // Reward actions REWARD_CALCULATED = 'REWARD_CALCULATED', REWARD_DISTRIBUTED = 'REWARD_DISTRIBUTED', REWARD_CLAIMED = 'REWARD_CLAIMED', - - // User actions USER_CREATED = 'USER_CREATED', USER_UPDATED = 'USER_UPDATED', + USER_DELETED = 'USER_DELETED', + WALLET_LINKED = 'WALLET_LINKED', WALLET_UNLINKED = 'WALLET_UNLINKED', VERIFICATION_INITIATED = 'VERIFICATION_INITIATED', + LOGIN_SUCCESS = 'LOGIN_SUCCESS', + LOGIN_FAILED = 'LOGIN_FAILED', + LOGOUT = 'LOGOUT', + TOKEN_REFRESHED = 'TOKEN_REFRESHED', + PASSWORD_RESET_REQUESTED = 'PASSWORD_RESET_REQUESTED', + PASSWORD_RESET_COMPLETED = 'PASSWORD_RESET_COMPLETED', + PERMISSION_CHANGED = 'PERMISSION_CHANGED', + ROLE_ASSIGNED = 'ROLE_ASSIGNED', + ROLE_REVOKED = 'ROLE_REVOKED', + ADMIN_ACTION = 'ADMIN_ACTION', + MODERATOR_ACTION = 'MODERATOR_ACTION', + GOVERNANCE_PROPOSAL_CREATED = 'GOVERNANCE_PROPOSAL_CREATED', + GOVERNANCE_VOTE_CAST = 'GOVERNANCE_VOTE_CAST', + GOVERNANCE_PROPOSAL_EXECUTED = 'GOVERNANCE_PROPOSAL_EXECUTED', + GOVERNANCE_PROPOSAL_CANCELLED = 'GOVERNANCE_PROPOSAL_CANCELLED', + DISPUTE_OPENED = 'DISPUTE_OPENED', + DISPUTE_RESOLVED = 'DISPUTE_RESOLVED', + DISPUTE_ESCALATED = 'DISPUTE_ESCALATED', + REPUTATION_CHANGED = 'REPUTATION_CHANGED', + CONFIGURATION_CHANGED = 'CONFIGURATION_CHANGED', + API_ACCESS = 'API_ACCESS', + NOTIFICATION_SENT = 'NOTIFICATION_SENT', + NOTIFICATION_FAILED = 'NOTIFICATION_FAILED', + AI_ASSISTANT_INTERACTION = 'AI_ASSISTANT_INTERACTION', + DATA_EXPORT = 'DATA_EXPORT', + LEGAL_HOLD_APPLIED = 'LEGAL_HOLD_APPLIED', + LEGAL_HOLD_REMOVED = 'LEGAL_HOLD_REMOVED', + RETENTION_EXECUTED = 'RETENTION_EXECUTED', + ARCHIVAL_COMPLETED = 'ARCHIVAL_COMPLETED', } export enum AuditEntityType { @@ -41,6 +65,41 @@ export enum AuditEntityType { REWARD = 'REWARD', USER = 'USER', WALLET = 'WALLET', + DISPUTE = 'DISPUTE', + PROPOSAL = 'PROPOSAL', + VOTE = 'VOTE', + MODERATOR = 'MODERATOR', + ADMIN = 'ADMIN', + CONFIGURATION = 'CONFIGURATION', + NOTIFICATION = 'NOTIFICATION', + AI_INTERACTION = 'AI_INTERACTION', + PERMISSION = 'PERMISSION', + ROLE = 'ROLE', + AUDIT_LOG = 'AUDIT_LOG', + REPORT = 'REPORT', +} + +export enum AuditSeverity { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + CRITICAL = 'CRITICAL', +} + +export enum AuditCategory { + AUTHENTICATION = 'AUTHENTICATION', + AUTHORIZATION = 'AUTHORIZATION', + ADMINISTRATIVE = 'ADMINISTRATIVE', + MODERATION = 'MODERATION', + GOVERNANCE = 'GOVERNANCE', + API_ACCESS = 'API_ACCESS', + NOTIFICATION = 'NOTIFICATION', + AI_ASSISTANT = 'AI_ASSISTANT', + CONFIGURATION = 'CONFIGURATION', + SECURITY = 'SECURITY', + COMPLIANCE = 'COMPLIANCE', + DATA_MANAGEMENT = 'DATA_MANAGEMENT', + OPERATIONS = 'OPERATIONS', } @Entity('audit_logs') @@ -49,12 +108,21 @@ export enum AuditEntityType { @Index(['actionType']) @Index(['createdAt']) @Index(['entityId']) +@Index(['severity']) +@Index(['category']) +@Index(['source']) +@Index(['requestId']) @Index(['userId', 'createdAt']) @Index(['actionType', 'createdAt']) +@Index(['category', 'createdAt']) +@Index(['severity', 'createdAt']) export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ unique: true }) + eventId: string; + @Column({ type: 'varchar', enum: AuditActionType, @@ -78,29 +146,44 @@ export class AuditLog { user: User | null; @Column({ nullable: true }) - walletAddress: string; + walletAddress: string | null; + + @Column({ type: 'varchar', default: 'LOW', enum: AuditSeverity }) + severity: AuditSeverity; + + @Column({ type: 'varchar', default: 'OPERATIONS', enum: AuditCategory }) + category: AuditCategory; + + @Column({ nullable: true }) + source: string | null; + + @Column({ nullable: true }) + requestId: string | null; @Column({ type: 'text', nullable: true }) - description: string; + description: string | null; @Column({ type: 'json', nullable: true }) - beforeState: Record; + beforeState: Record | null; @Column({ type: 'json', nullable: true }) - afterState: Record; + afterState: Record | null; @Column({ type: 'json', nullable: true }) - metadata: Record; + metadata: Record | null; @Column({ nullable: true }) - ipAddress: string; + ipAddress: string | null; @Column({ nullable: true }) - userAgent: string; + userAgent: string | null; @CreateDateColumn() createdAt: Date; @Column({ nullable: true }) correlationId: string; + + @Column({ type: 'datetime', nullable: true }) + retentionUntil: Date | null; } diff --git a/src/audit/interfaces/audit-response.interface.ts b/src/audit/interfaces/audit-response.interface.ts new file mode 100644 index 0000000..4127fa6 --- /dev/null +++ b/src/audit/interfaces/audit-response.interface.ts @@ -0,0 +1,63 @@ +import { AuditLog } from '../entities/audit-log.entity'; + +export interface AuditResponse { + success: boolean; + data: T; + timestamp: string; + requestId?: string; +} + +export interface AuditPaginatedResponse { + success: boolean; + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; + }; + timestamp: string; + requestId?: string; +} + +export interface AuditErrorResponse { + success: false; + error: { + code: string; + message: string; + details?: any; + }; + timestamp: string; + requestId?: string; +} + +export interface ComplianceReport { + id: string; + type: string; + title: string; + generatedAt: string; + generatedBy: string; + dateRange: { + start: string; + end: string; + }; + summary: Record; + records: any[]; + totalRecords: number; +} + +export interface SecurityIncident { + id: string; + type: string; + severity: string; + description: string; + timestamp: string; + actor: string; + ipAddress?: string; + metadata?: Record; + resolved: boolean; + resolvedAt?: string; + resolvedBy?: string; +} diff --git a/src/audit/interfaces/index.ts b/src/audit/interfaces/index.ts new file mode 100644 index 0000000..8905caf --- /dev/null +++ b/src/audit/interfaces/index.ts @@ -0,0 +1 @@ +export * from './audit-response.interface'; diff --git a/src/audit/processors/audit-log.processor.ts b/src/audit/processors/audit-log.processor.ts new file mode 100644 index 0000000..bb867d7 --- /dev/null +++ b/src/audit/processors/audit-log.processor.ts @@ -0,0 +1,52 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../entities/audit-log.entity'; +import { AuditLogInput } from '../services/audit-trail.service'; +import { maskIp } from '../utils/ip-masking'; +import { AUDIT_QUEUE_NAME } from '../services/audit-queue.service'; +import { randomUUID } from 'crypto'; + +@Processor(AUDIT_QUEUE_NAME) +export class AuditLogProcessor extends WorkerHost { + private readonly logger = new Logger(AuditLogProcessor.name); + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) { + super(); + } + + async process(job: Job): Promise { + try { + const input = job.data; + const auditLog = this.auditLogRepo.create({ + eventId: randomUUID(), + actionType: input.actionType, + entityType: input.entityType, + entityId: input.entityId, + userId: input.userId, + walletAddress: input.walletAddress, + severity: (input as any).severity, + category: (input as any).category, + source: (input as any).source, + requestId: (input as any).requestId, + description: input.description, + beforeState: input.beforeState, + afterState: input.afterState, + metadata: input.metadata, + correlationId: input.correlationId, + ipAddress: input.ipAddress ? maskIp(input.ipAddress) : undefined, + userAgent: input.userAgent, + }); + + await this.auditLogRepo.save(auditLog); + } catch (error) { + this.logger.error(`Failed to process audit job ${job.id}: ${error.message}`); + throw error; + } + } +} diff --git a/src/audit/services/audit-metrics.service.spec.ts b/src/audit/services/audit-metrics.service.spec.ts new file mode 100644 index 0000000..644fd3c --- /dev/null +++ b/src/audit/services/audit-metrics.service.spec.ts @@ -0,0 +1,116 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuditMetricsService } from './audit-metrics.service'; +import { AuditLog } from '../entities/audit-log.entity'; +import { Repository } from 'typeorm'; +import * as client from 'prom-client'; + +describe('AuditMetricsService', () => { + let service: AuditMetricsService; + let repository: jest.Mocked>; + + beforeEach(async () => { + client.register.clear(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditMetricsService, + { + provide: getRepositoryToken(AuditLog), + useValue: { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AuditMetricsService); + repository = module.get>( + getRepositoryToken(AuditLog), + ) as jest.Mocked>; + + await service.onModuleInit(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('incrementWrite', () => { + it('should increment counters without throwing', () => { + expect(() => { + service.incrementWrite('CLAIM_CREATED', 'LOW', 'OPERATIONS'); + }).not.toThrow(); + }); + }); + + describe('observeWriteDuration', () => { + it('should observe duration without throwing', () => { + expect(() => { + service.observeWriteDuration(0.05); + }).not.toThrow(); + }); + }); + + describe('observeSearchDuration', () => { + it('should observe search duration without throwing', () => { + expect(() => { + service.observeSearchDuration(0.1); + }).not.toThrow(); + }); + }); + + describe('incrementFailedWrite', () => { + it('should increment failed write counter', () => { + expect(() => { + service.incrementFailedWrite(); + }).not.toThrow(); + }); + }); + + describe('incrementExport', () => { + it('should increment export counter', () => { + expect(() => { + service.incrementExport(); + }).not.toThrow(); + }); + }); + + describe('incrementRetention', () => { + it('should increment retention counter', () => { + expect(() => { + service.incrementRetention(); + }).not.toThrow(); + }); + }); + + describe('updateStorageMetrics', () => { + it('should update storage gauges', async () => { + (repository.count as jest.Mock).mockResolvedValue(500); + + const mockQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ createdAt: new Date('2024-01-01') }), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + await expect(service.updateStorageMetrics()).resolves.toBeUndefined(); + }); + + it('should handle errors gracefully', async () => { + (repository.count as jest.Mock).mockRejectedValue(new Error('DB error')); + + await expect(service.updateStorageMetrics()).resolves.toBeUndefined(); + }); + }); + + describe('getMetrics', () => { + it('should return prometheus metrics string', async () => { + const metrics = await service.getMetrics(); + expect(typeof metrics).toBe('string'); + expect(metrics.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/audit/services/audit-metrics.service.ts b/src/audit/services/audit-metrics.service.ts new file mode 100644 index 0000000..20b137b --- /dev/null +++ b/src/audit/services/audit-metrics.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan } from 'typeorm'; +import { AuditLog } from '../entities/audit-log.entity'; +import * as client from 'prom-client'; + +@Injectable() +export class AuditMetricsService implements OnModuleInit { + private readonly logger = new Logger(AuditMetricsService.name); + + private auditEventsTotal: client.Counter; + private auditEventsByAction: client.Counter; + private auditEventsBySeverity: client.Counter; + private auditEventsByCategory: client.Counter; + private auditWriteDuration: client.Histogram; + private auditSearchDuration: client.Histogram; + private auditStorageSize: client.Gauge; + private auditOldestRecord: client.Gauge; + private auditFailedWrites: client.Counter; + private auditExportOperations: client.Counter; + private auditRetentionOperations: client.Counter; + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + onModuleInit(): void { + const prefix = 'audit_'; + + this.auditEventsTotal = new client.Counter({ + name: `${prefix}events_total`, + help: 'Total number of audit events recorded', + }); + + this.auditEventsByAction = new client.Counter({ + name: `${prefix}events_by_action_total`, + help: 'Audit events by action type', + labelNames: ['action'], + }); + + this.auditEventsBySeverity = new client.Counter({ + name: `${prefix}events_by_severity_total`, + help: 'Audit events by severity', + labelNames: ['severity'], + }); + + this.auditEventsByCategory = new client.Counter({ + name: `${prefix}events_by_category_total`, + help: 'Audit events by category', + labelNames: ['category'], + }); + + this.auditWriteDuration = new client.Histogram({ + name: `${prefix}write_duration_seconds`, + help: 'Duration of audit log write operations', + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1], + }); + + this.auditSearchDuration = new client.Histogram({ + name: `${prefix}search_duration_seconds`, + help: 'Duration of audit search operations', + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], + }); + + this.auditStorageSize = new client.Gauge({ + name: `${prefix}storage_records_total`, + help: 'Total number of audit log records in storage', + }); + + this.auditOldestRecord = new client.Gauge({ + name: `${prefix}oldest_record_age_days`, + help: 'Age in days of the oldest audit record', + }); + + this.auditFailedWrites = new client.Counter({ + name: `${prefix}failed_writes_total`, + help: 'Total number of failed audit write operations', + }); + + this.auditExportOperations = new client.Counter({ + name: `${prefix}export_operations_total`, + help: 'Total number of audit export operations', + }); + + this.auditRetentionOperations = new client.Counter({ + name: `${prefix}retention_operations_total`, + help: 'Total number of audit retention operations', + }); + } + + incrementWrite(action: string, severity: string, category: string): void { + this.auditEventsTotal.inc(); + this.auditEventsByAction.inc({ action }); + this.auditEventsBySeverity.inc({ severity }); + this.auditEventsByCategory.inc({ category }); + } + + observeWriteDuration(seconds: number): void { + this.auditWriteDuration.observe(seconds); + } + + observeSearchDuration(seconds: number): void { + this.auditSearchDuration.observe(seconds); + } + + incrementFailedWrite(): void { + this.auditFailedWrites.inc(); + } + + incrementExport(): void { + this.auditExportOperations.inc(); + } + + incrementRetention(): void { + this.auditRetentionOperations.inc(); + } + + async updateStorageMetrics(): Promise { + try { + const totalRecords = await this.auditLogRepo.count(); + this.auditStorageSize.set(totalRecords); + + const oldest = await this.auditLogRepo + .createQueryBuilder('audit') + .orderBy('audit.createdAt', 'ASC') + .getOne(); + + if (oldest) { + const ageDays = (Date.now() - oldest.createdAt.getTime()) / (1000 * 60 * 60 * 24); + this.auditOldestRecord.set(ageDays); + } + } catch (error) { + this.logger.error(`Failed to update storage metrics: ${error.message}`); + } + } + + getMetrics(): Promise { + return client.register.metrics(); + } +} diff --git a/src/audit/services/audit-queue.service.spec.ts b/src/audit/services/audit-queue.service.spec.ts new file mode 100644 index 0000000..7f27fa5 --- /dev/null +++ b/src/audit/services/audit-queue.service.spec.ts @@ -0,0 +1,131 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditQueueService, AUDIT_QUEUE_NAME } from './audit-queue.service'; +import { getQueueToken } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bullmq'; +import { AuditActionType, AuditEntityType } from '../entities/audit-log.entity'; + +describe('AuditQueueService', () => { + let service: AuditQueueService; + let queue: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(async () => { + queue = { + add: jest.fn(), + addBulk: jest.fn(), + getWaitingCount: jest.fn(), + getActiveCount: jest.fn(), + getCompletedCount: jest.fn(), + getFailedCount: jest.fn(), + getDelayedCount: jest.fn(), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn().mockImplementation((key: string, defaultValue?: any) => { + if (key === 'audit.asyncWritesEnabled') return true; + return defaultValue; + }), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditQueueService, + { + provide: getQueueToken(AUDIT_QUEUE_NAME), + useValue: queue, + }, + { + provide: ConfigService, + useValue: configService, + }, + ], + }).compile(); + + service = module.get(AuditQueueService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enqueue', () => { + it('should add a job to the queue', async () => { + const input = { + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + }; + + await service.enqueue(input); + + expect(queue.add).toHaveBeenCalledWith('write', input, expect.objectContaining({ + attempts: 3, + })); + }); + + it('should handle queue errors gracefully', async () => { + (queue.add as jest.Mock).mockRejectedValue(new Error('Queue unavailable')); + + await expect(service.enqueue({ + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + })).resolves.toBeUndefined(); + }); + }); + + describe('enqueueBatch', () => { + it('should add bulk jobs to the queue', async () => { + const inputs = [ + { + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + }, + { + actionType: AuditActionType.CLAIM_UPDATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + }, + ]; + + await service.enqueueBatch(inputs); + + expect(queue.addBulk).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'write', + data: inputs[0], + }), + ]), + ); + }); + + it('should not add bulk when inputs are empty', async () => { + await service.enqueueBatch([]); + + expect(queue.addBulk).not.toHaveBeenCalled(); + }); + }); + + describe('getQueueStats', () => { + it('should return queue statistics', async () => { + (queue.getWaitingCount as jest.Mock).mockResolvedValue(5); + (queue.getActiveCount as jest.Mock).mockResolvedValue(2); + (queue.getCompletedCount as jest.Mock).mockResolvedValue(100); + (queue.getFailedCount as jest.Mock).mockResolvedValue(3); + (queue.getDelayedCount as jest.Mock).mockResolvedValue(1); + + const stats = await service.getQueueStats(); + + expect(stats).toEqual({ + waiting: 5, + active: 2, + completed: 100, + failed: 3, + delayed: 1, + }); + }); + }); +}); diff --git a/src/audit/services/audit-queue.service.ts b/src/audit/services/audit-queue.service.ts new file mode 100644 index 0000000..db54b06 --- /dev/null +++ b/src/audit/services/audit-queue.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { AuditLogInput } from './audit-trail.service'; +import { ConfigService } from '@nestjs/config'; + +export const AUDIT_QUEUE_NAME = 'audit-log'; + +@Injectable() +export class AuditQueueService { + private readonly logger = new Logger(AuditQueueService.name); + private readonly asyncEnabled: boolean; + + constructor( + @InjectQueue(AUDIT_QUEUE_NAME) + private readonly auditQueue: Queue, + private readonly configService: ConfigService, + ) { + this.asyncEnabled = this.configService.get('audit.asyncWritesEnabled', true); + } + + async enqueue(input: AuditLogInput): Promise { + if (!this.asyncEnabled) { + return; + } + + try { + await this.auditQueue.add('write', input, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + removeOnFail: false, + }); + } catch (error) { + this.logger.error(`Failed to enqueue audit log: ${error.message}`); + } + } + + async enqueueBatch(inputs: AuditLogInput[]): Promise { + if (!this.asyncEnabled || inputs.length === 0) { + return; + } + + try { + await this.auditQueue.addBulk( + inputs.map((input) => ({ + name: 'write', + data: input, + opts: { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + removeOnFail: false, + }, + })), + ); + } catch (error) { + this.logger.error(`Failed to enqueue batch audit logs: ${error.message}`); + } + } + + async getQueueStats(): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + }> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + this.auditQueue.getWaitingCount(), + this.auditQueue.getActiveCount(), + this.auditQueue.getCompletedCount(), + this.auditQueue.getFailedCount(), + this.auditQueue.getDelayedCount(), + ]); + + return { waiting, active, completed, failed, delayed }; + } +} diff --git a/src/audit/services/audit-trail.service.spec.ts b/src/audit/services/audit-trail.service.spec.ts index 93432e7..64ee59b 100644 --- a/src/audit/services/audit-trail.service.spec.ts +++ b/src/audit/services/audit-trail.service.spec.ts @@ -2,10 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AuditTrailService } from './audit-trail.service'; -import { AuditLog } from '../entities/audit-log.entity'; +import { AuditLog, AuditActionType, AuditEntityType, AuditSeverity, AuditCategory } from '../entities/audit-log.entity'; import { Repository } from 'typeorm'; import { REQUEST } from '@nestjs/core'; -import { AuditActionType, AuditEntityType } from '../entities/audit-log.entity'; +import { AuditQueueService } from './audit-queue.service'; import { maskIp } from '../utils/ip-masking'; interface MockRequestType { @@ -15,11 +15,37 @@ interface MockRequestType { get: jest.Mock; } -describe('AuditTrailService - IP Security and Masking', () => { +describe('AuditTrailService', () => { let service: AuditTrailService; let repository: jest.Mocked>; + let queueService: jest.Mocked; let mockRequest: MockRequestType; + const mockAuditLog = (overrides: Partial = {}): AuditLog => ({ + id: 'audit-1', + eventId: 'evt-1', + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + userId: 'user-1', + walletAddress: '0x123', + severity: AuditSeverity.LOW, + category: AuditCategory.OPERATIONS, + source: 'api:localhost', + requestId: 'req-1', + description: 'Test audit log', + beforeState: null, + afterState: null, + metadata: null, + ipAddress: '203.0.113.0', + userAgent: 'test-agent', + correlationId: 'corr-1', + retentionUntil: null, + user: null, + createdAt: new Date(), + ...overrides, + }); + beforeEach(async () => { mockRequest = { headers: {}, @@ -28,6 +54,12 @@ describe('AuditTrailService - IP Security and Masking', () => { get: jest.fn(), }; + queueService = { + enqueue: jest.fn(), + enqueueBatch: jest.fn(), + getQueueStats: jest.fn(), + } as unknown as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ providers: [ AuditTrailService, @@ -38,9 +70,15 @@ describe('AuditTrailService - IP Security and Masking', () => { save: jest.fn(), find: jest.fn(), findAndCount: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), createQueryBuilder: jest.fn(), }, }, + { + provide: AuditQueueService, + useValue: queueService, + }, { provide: REQUEST, useValue: mockRequest, @@ -54,248 +92,251 @@ describe('AuditTrailService - IP Security and Masking', () => { ) as jest.Mocked>; }); - describe('getClientIp() - IP Spoofing Protection and Masking', () => { - it('should ignore x-forwarded-for from untrusted clients when trust proxy is false and return masked IP', async () => { - // Simulate direct connection with spoofed x-forwarded-for - mockRequest.headers['x-forwarded-for'] = '192.168.1.100'; - mockRequest.headers['x-real-ip'] = '10.0.0.1'; - mockRequest.socket.remoteAddress = '203.0.113.45'; // Real client IP - mockRequest.ip = '203.0.113.45'; // Express sets this when trust proxy is false - - const auditInput = { + describe('log', () => { + it('should create and save an audit log entry', async () => { + const input = { actionType: AuditActionType.CLAIM_CREATED, entityType: AuditEntityType.CLAIM, - entityId: 'test-123', - description: 'Test audit log', + entityId: 'claim-1', + userId: 'user-1', + description: 'Claim created', }; - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '203.0.113.0', - }); - (repository.save as jest.Mock).mockResolvedValue({ id: 'audit-1' }); + const createdLog = mockAuditLog(); + (repository.create as jest.Mock).mockReturnValue(createdLog); + (repository.save as jest.Mock).mockResolvedValue(createdLog); - await service.log(auditInput); + await service.log(input); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ - ipAddress: '203.0.113.0', // Should use real IP masked, not spoofed header + actionType: input.actionType, + entityType: input.entityType, + entityId: input.entityId, + userId: input.userId, + description: input.description, + severity: AuditSeverity.LOW, + category: AuditCategory.OPERATIONS, }), ); + expect(repository.save).toHaveBeenCalledWith(createdLog); }); - it('should use req.ip when trust proxy is properly configured and return masked IP', async () => { - // Simulate trusted proxy scenario - mockRequest.headers['x-forwarded-for'] = '203.0.113.45'; - mockRequest.ip = '203.0.113.45'; // Express sets this to trusted forwarded IP - mockRequest.socket.remoteAddress = '127.0.0.1'; // Proxy IP - - const auditInput = { - actionType: AuditActionType.CLAIM_UPDATED, - entityType: AuditEntityType.CLAIM, - entityId: 'test-456', - description: 'Test update', + it('should use provided severity and category', async () => { + const input = { + actionType: AuditActionType.LOGIN_FAILED, + entityType: AuditEntityType.USER, + entityId: 'user-1', + severity: AuditSeverity.HIGH, + category: AuditCategory.AUTHENTICATION, }; - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '203.0.113.0', - }); - (repository.save as jest.Mock).mockResolvedValue({ id: 'audit-2' }); + const createdLog = mockAuditLog(input); + (repository.create as jest.Mock).mockReturnValue(createdLog); + (repository.save as jest.Mock).mockResolvedValue(createdLog); - await service.log(auditInput); + await service.log(input); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ - ipAddress: '203.0.113.0', // Should use trusted forwarded IP masked + severity: AuditSeverity.HIGH, + category: AuditCategory.AUTHENTICATION, }), ); }); - it('should fall back to socket.remoteAddress when req.ip is undefined and return masked IP', async () => { - mockRequest.ip = undefined; - mockRequest.socket.remoteAddress = '198.51.100.23'; - mockRequest.headers['x-forwarded-for'] = '1.2.3.4'; // Should be ignored + it('should not throw when save fails', async () => { + (repository.create as jest.Mock).mockReturnValue(mockAuditLog()); + (repository.save as jest.Mock).mockRejectedValue(new Error('DB error')); - const auditInput = { - actionType: AuditActionType.EVIDENCE_FLAGGED, - entityType: AuditEntityType.EVIDENCE, - entityId: 'test-789', - description: 'Test delete', - }; + await expect(service.log({ + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + })).resolves.toBeUndefined(); + }); + }); - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '198.51.100.0', - }); - (repository.save as jest.Mock).mockResolvedValue({ id: 'audit-3' }); + describe('logBatch', () => { + it('should create and save multiple audit logs', async () => { + const inputs = [ + { + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + userId: 'user-1', + }, + { + actionType: AuditActionType.CLAIM_UPDATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + userId: 'user-2', + }, + ]; - await service.log(auditInput); + const createdLogs = inputs.map((_, i) => mockAuditLog({ id: `audit-${i}` })); + (repository.create as jest.Mock).mockReturnValue(createdLogs[0]); + (repository.save as jest.Mock).mockResolvedValue(createdLogs); - expect(repository.create).toHaveBeenCalledWith( - expect.objectContaining({ - ipAddress: '198.51.100.0', // Should fall back to socket address masked - }), - ); + await service.logBatch(inputs); + + expect(repository.create).toHaveBeenCalledTimes(2); + expect(repository.save).toHaveBeenCalled(); }); + }); - it('should return undefined when no request object is available', async () => { - // Test with null request - const moduleWithoutRequest: TestingModule = - await Test.createTestingModule({ - providers: [ - AuditTrailService, - { - provide: getRepositoryToken(AuditLog), - useValue: { - create: jest.fn(), - save: jest.fn(), - find: jest.fn(), - findAndCount: jest.fn(), - createQueryBuilder: jest.fn(), - }, - }, - { - provide: REQUEST, - useValue: null, - }, - ], - }).compile(); - - const serviceWithoutRequest = - moduleWithoutRequest.get(AuditTrailService); - const innerRepo = moduleWithoutRequest.get(getRepositoryToken(AuditLog)) as any; - const tempRepository = moduleWithoutRequest.get>( - getRepositoryToken(AuditLog), - ) as jest.Mocked>; - - const auditInput = { + describe('logAsync', () => { + it('should enqueue audit log to the queue', async () => { + const input = { actionType: AuditActionType.CLAIM_CREATED, entityType: AuditEntityType.CLAIM, - entityId: 'test-no-request', - description: 'Test without request', + entityId: 'claim-1', }; - innerRepo.create.mockReturnValue({ - ...auditInput, - ipAddress: undefined, - }); - innerRepo.save.mockResolvedValue({ id: 'audit-4' }); + await service.logAsync(input); - await serviceWithoutRequest.log(auditInput); + expect(queueService.enqueue).toHaveBeenCalledWith(input); + }); + }); - expect(innerRepo.create).toHaveBeenCalledWith( - (tempRepository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: undefined, - }); - (tempRepository.save as jest.Mock).mockResolvedValue({ id: 'audit-4' }); + describe('query', () => { + it('should return paginated results with default filters', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAuditLog()], 1]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); - await serviceWithoutRequest.log(auditInput); + const result = await service.query({}); - expect(tempRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ - ipAddress: undefined, - }), - ); + expect(result.logs).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(50); + expect(result.totalPages).toBe(1); }); - it('should handle multiple IP addresses in x-forwarded-for correctly when trusted and return masked IP', async () => { - // Simulate chain of proxies: client -> proxy1 -> proxy2 -> server - mockRequest.headers['x-forwarded-for'] = - '203.0.113.45, 192.168.1.1, 10.0.0.1'; - mockRequest.ip = '203.0.113.45'; // Express extracts the leftmost (original client) IP - mockRequest.socket.remoteAddress = '127.0.0.1'; // Last proxy + it('should apply all filters correctly', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + } as any; - const auditInput = { - actionType: AuditActionType.CLAIM_CREATED, - entityType: AuditEntityType.CLAIM, - entityId: 'test-multi-ip', - description: 'Test multi IP', - }; + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '203.0.113.0', + await service.query({ + entityType: AuditEntityType.CLAIM, + actionType: AuditActionType.CLAIM_CREATED, + severity: AuditSeverity.HIGH, + category: AuditCategory.SECURITY, + userId: 'user-1', + source: 'api', + requestId: 'req-1', + correlationId: 'corr-1', + startDate: '2024-01-01', + endDate: '2024-12-31', + search: 'test', + page: 2, + limit: 25, }); - (repository.save as jest.Mock).mockResolvedValue({ id: 'audit-5' }); - - await service.log(auditInput); - expect(repository.create).toHaveBeenCalledWith( - expect.objectContaining({ - ipAddress: '203.0.113.0', // Should use the original client IP masked - }), + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'audit.entityType = :entityType', { entityType: AuditEntityType.CLAIM }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'audit.severity = :severity', { severity: AuditSeverity.HIGH }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'audit.category = :category', { category: AuditCategory.SECURITY }, ); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(25); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(25); }); }); - describe('IP Spoofing Attack Scenarios (Masked Results)', () => { - it('should prevent basic IP spoofing attack and store masked IP', async () => { - // Attacker tries to spoof their IP as a legitimate address - mockRequest.headers['x-forwarded-for'] = '8.8.8.8'; // Google DNS - trying to look legitimate - mockRequest.socket.remoteAddress = '203.0.113.45'; // Attacker's real IP - mockRequest.ip = '203.0.113.45'; // Express uses real IP when trust proxy is false + describe('getEntityAuditLogs', () => { + it('should return logs for a specific entity', async () => { + const logs = [mockAuditLog()]; + (repository.find as jest.Mock).mockResolvedValue(logs); - const auditInput = { - actionType: AuditActionType.CLAIM_CREATED, - entityType: AuditEntityType.CLAIM, - entityId: 'attack-1', - description: 'Malicious activity attempt', - }; + const result = await service.getEntityAuditLogs( + AuditEntityType.CLAIM, + 'claim-1', + ); - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '203.0.113.0', - }); - (repository.save as jest.Mock).mockResolvedValue({ - id: 'audit-attack-1', + expect(repository.find).toHaveBeenCalledWith({ + where: { entityType: AuditEntityType.CLAIM, entityId: 'claim-1' }, + order: { createdAt: 'DESC' }, + relations: ['user'], }); + expect(result).toEqual(logs); + }); + }); - await service.log(auditInput); + describe('getUserAuditLogs', () => { + it('should return paginated user logs', async () => { + const logs = [mockAuditLog()]; + (repository.findAndCount as jest.Mock).mockResolvedValue([logs, 1]); - // Verify the real IP is logged (masked), not the spoofed one - expect(repository.create).toHaveBeenCalledWith( - expect.objectContaining({ - ipAddress: '203.0.113.0', // Real attacker IP masked, not 8.8.8.8 - }), - ); + const result = await service.getUserAuditLogs('user-1', 50, 0); + + expect(result.logs).toHaveLength(1); + expect(result.total).toBe(1); }); + }); - it('should prevent CF-Connecting-IP spoofing and store masked IP', async () => { - // Attacker tries to spoof Cloudflare IP header - mockRequest.headers['cf-connecting-ip'] = '1.1.1.1'; // Cloudflare DNS - mockRequest.headers['x-forwarded-for'] = '8.8.8.8'; - mockRequest.socket.remoteAddress = '203.0.113.45'; - mockRequest.ip = '203.0.113.45'; + describe('getAuditLogsByEventId', () => { + it('should return log by event ID', async () => { + const log = mockAuditLog(); + (repository.findOne as jest.Mock).mockResolvedValue(log); - const auditInput = { - actionType: AuditActionType.CLAIM_UPDATED, - entityType: AuditEntityType.CLAIM, - entityId: 'attack-2', - description: 'CF IP spoof attempt', - }; + const result = await service.getAuditLogsByEventId('evt-1'); - (repository.create as jest.Mock).mockReturnValue({ - ...auditInput, - ipAddress: '203.0.113.0', - }); - (repository.save as jest.Mock).mockResolvedValue({ - id: 'audit-attack-2', + expect(repository.findOne).toHaveBeenCalledWith({ + where: { eventId: 'evt-1' }, + relations: ['user'], }); + expect(result).toEqual(log); + }); + }); - await service.log(auditInput); + describe('getStorageStats', () => { + it('should return storage statistics', async () => { + (repository.count as jest.Mock).mockResolvedValue(100); - expect(repository.create).toHaveBeenCalledWith( - expect.objectContaining({ - ipAddress: '203.0.113.0', // Real IP masked, not spoofed CF header - }), - ); + const oldestQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockAuditLog({ createdAt: new Date('2024-01-01') })), + } as any; + + const newestQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockAuditLog({ createdAt: new Date('2024-06-15') })), + } as any; + + (repository.createQueryBuilder as jest.Mock) + .mockReturnValueOnce(oldestQueryBuilder) + .mockReturnValueOnce(newestQueryBuilder); + + const stats = await service.getStorageStats(); + + expect(stats.totalRecords).toBe(100); + expect(stats.oldestRecord).toBeDefined(); + expect(stats.newestRecord).toBeDefined(); }); }); describe('deleteOldLogs', () => { - it('should delete audit logs older than the configured cutoff date', async () => { + it('should delete logs older than cutoff respecting legal hold', async () => { const mockQueryBuilder = { delete: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -306,69 +347,76 @@ describe('AuditTrailService - IP Security and Masking', () => { const deleted = await service.deleteOldLogs(90); - expect(repository.createQueryBuilder).toHaveBeenCalledWith('audit'); - expect(mockQueryBuilder.delete).toHaveBeenCalled(); expect(mockQueryBuilder.where).toHaveBeenCalledWith( - 'audit.createdAt < :cutoff', + 'audit.createdAt < :cutoff AND audit.retentionUntil IS NULL', expect.objectContaining({ cutoff: expect.any(Date) }), ); - expect(mockQueryBuilder.execute).toHaveBeenCalled(); expect(deleted).toBe(4); }); + }); - it('should return zero when no old audit logs are deleted', async () => { - const mockQueryBuilder = { - delete: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - execute: jest.fn().mockResolvedValue({ affected: 0 }), - } as any; + describe('getChangeHistory', () => { + it('should compute changes between before and after states', async () => { + const log = mockAuditLog({ + beforeState: { status: 'open', value: 100 }, + afterState: { status: 'resolved', value: 100 }, + }); - (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + (repository.find as jest.Mock).mockResolvedValue([log]); - const deleted = await service.deleteOldLogs(30); + const history = await service.getChangeHistory(AuditEntityType.CLAIM, 'claim-1'); - expect(deleted).toBe(0); + expect(history).toHaveLength(1); + expect(history[0].changes).toEqual({ + status: { before: 'open', after: 'resolved' }, + }); }); }); - describe('maskIp utility', () => { - it('should handle undefined and empty values', () => { - expect(maskIp(undefined)).toBeUndefined(); - expect(maskIp('')).toBeUndefined(); - }); + describe('getAuditSummary', () => { + it('should return summary grouped by action type', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { actionType: 'CLAIM_CREATED', count: '10' }, + { actionType: 'CLAIM_RESOLVED', count: '5' }, + ]), + } as any; - it('should mask IPv4 addresses by zeroing the last octet', () => { - expect(maskIp('192.168.1.1')).toBe('192.168.1.0'); - expect(maskIp('203.0.113.45')).toBe('203.0.113.0'); - expect(maskIp('8.8.8.8')).toBe('8.8.8.0'); - }); + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); - it('should handle IPv4 addresses with ports', () => { - expect(maskIp('192.168.1.1:8080')).toBe('192.168.1.0:8080'); - }); + const summary = await service.getAuditSummary(AuditEntityType.CLAIM, 7); - it('should mask IPv6 addresses by zeroing the last 64 bits', () => { - expect(maskIp('2001:db8:85a3:8d3:1319:8a2e:370:7334')).toBe( - '2001:db8:85a3:8d3::', - ); - expect(maskIp('2001:db8:85a3::8a2e:370:7334')).toBe('2001:db8:85a3:0::'); + expect(summary).toEqual({ + CLAIM_CREATED: 10, + CLAIM_RESOLVED: 5, + }); }); + }); - it('should handle IPv6 loopback and special values', () => { - expect(maskIp('::1')).toBe('::'); - expect(maskIp('::')).toBe('::'); - }); + describe('IP Security and Masking', () => { + it('should store masked IP from request', async () => { + mockRequest.ip = '203.0.113.45'; + const input = { + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'test-123', + }; - it('should mask IPv4-mapped IPv6 addresses', () => { - expect(maskIp('::ffff:192.168.1.1')).toBe('::ffff:192.168.1.0'); - expect(maskIp('::ffff:203.0.113.45')).toBe('::ffff:203.0.113.0'); - }); + (repository.create as jest.Mock).mockReturnValue(mockAuditLog()); + (repository.save as jest.Mock).mockResolvedValue({ id: 'audit-1' }); + + await service.log(input); - it('should handle bracketed IPv6 and zone indices', () => { - expect(maskIp('[2001:db8:85a3:8d3:1319:8a2e:370:7334]')).toBe( - '2001:db8:85a3:8d3::', + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '203.0.113.0', + }), ); - expect(maskIp('fe80::1%eth0')).toBe('fe80:0:0:0::'); }); }); }); diff --git a/src/audit/services/audit-trail.service.ts b/src/audit/services/audit-trail.service.ts index e90b214..3f1b022 100644 --- a/src/audit/services/audit-trail.service.ts +++ b/src/audit/services/audit-trail.service.ts @@ -1,14 +1,19 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, Between, Like, In } from 'typeorm'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; import { AuditLog, AuditActionType, AuditEntityType, + AuditSeverity, + AuditCategory, } from '../entities/audit-log.entity'; import { maskIp } from '../utils/ip-masking'; +import { AuditQueueService } from './audit-queue.service'; +import { randomUUID } from 'crypto'; +import { AuditPaginatedResponse } from '../interfaces/audit-response.interface'; export interface AuditLogInput { actionType: AuditActionType; @@ -16,74 +21,136 @@ export interface AuditLogInput { entityId: string; userId?: string; walletAddress?: string; + severity?: AuditSeverity; + category?: AuditCategory; + source?: string; + requestId?: string; description?: string; beforeState?: Record; afterState?: Record; metadata?: Record; correlationId?: string; + ipAddress?: string; + userAgent?: string; + retentionUntil?: Date; +} + +export interface AuditQueryResult { + logs: AuditLog[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface AuditQueryFilters { + entityType?: AuditEntityType; + actionType?: AuditActionType; + severity?: AuditSeverity; + category?: AuditCategory; + userId?: string; + source?: string; + requestId?: string; + correlationId?: string; + search?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; } @Injectable() export class AuditTrailService { private readonly logger = new Logger(AuditTrailService.name); + private writeBuffer: AuditLogInput[] = []; + private bufferTimer: NodeJS.Timeout | null = null; constructor( @InjectRepository(AuditLog) private readonly auditLogRepo: Repository, + private readonly auditQueueService: AuditQueueService, @Inject(REQUEST) private readonly request: Request, ) {} - /** - * Log an action to the audit trail - */ async log(input: AuditLogInput): Promise { try { + const eventId = randomUUID(); const auditLog = this.auditLogRepo.create({ + eventId, actionType: input.actionType, entityType: input.entityType, entityId: input.entityId, userId: input.userId, walletAddress: input.walletAddress, + severity: input.severity || AuditSeverity.LOW, + category: input.category || AuditCategory.OPERATIONS, + source: input.source || this.getSource(), + requestId: input.requestId || this.getRequestId(), description: input.description, beforeState: input.beforeState, afterState: input.afterState, metadata: input.metadata, correlationId: input.correlationId || this.getCorrelationId(), - ipAddress: maskIp(this.getClientIp()), - userAgent: this.request?.get('user-agent'), + ipAddress: input.ipAddress || maskIp(this.getClientIp()), + userAgent: input.userAgent || this.request?.get('user-agent'), + retentionUntil: input.retentionUntil || null, }); await this.auditLogRepo.save(auditLog); - this.logger.debug( - `Audit logged: ${input.actionType} on ${input.entityType} ${input.entityId}`, - ); + this.logger.debug(`Audit logged: ${input.actionType} on ${input.entityType} ${input.entityId}`); } catch (error) { this.logger.error(`Failed to log audit: ${error.message}`, error.stack); - // Don't throw - audit logging should not break the application } } - /** - * Get audit logs for a specific entity - */ + async logBatch(inputs: AuditLogInput[]): Promise { + try { + const auditLogs = inputs.map((input) => + this.auditLogRepo.create({ + eventId: randomUUID(), + actionType: input.actionType, + entityType: input.entityType, + entityId: input.entityId, + userId: input.userId, + walletAddress: input.walletAddress, + severity: input.severity || AuditSeverity.LOW, + category: input.category || AuditCategory.OPERATIONS, + source: input.source || this.getSource(), + requestId: input.requestId || this.getRequestId(), + description: input.description, + beforeState: input.beforeState, + afterState: input.afterState, + metadata: input.metadata, + correlationId: input.correlationId || this.getCorrelationId(), + ipAddress: input.ipAddress || maskIp(this.getClientIp()), + userAgent: input.userAgent || this.request?.get('user-agent'), + retentionUntil: input.retentionUntil || null, + }), + ); + + await this.auditLogRepo.save(auditLogs); + this.logger.debug(`Batch audit logged: ${auditLogs.length} records`); + } catch (error) { + this.logger.error(`Failed to batch log audits: ${error.message}`, error.stack); + } + } + + async logAsync(input: AuditLogInput): Promise { + await this.auditQueueService.enqueue(input); + } + async getEntityAuditLogs( entityType: AuditEntityType, entityId: string, ): Promise { return this.auditLogRepo.find({ - where: { - entityType, - entityId, - }, + where: { entityType, entityId }, order: { createdAt: 'DESC' }, relations: ['user'], }); } - /** - * Get audit logs for a specific user - */ async getUserAuditLogs( userId: string, limit = 100, @@ -100,9 +167,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get audit logs for a specific action type - */ async getActionAuditLogs( actionType: AuditActionType, limit = 100, @@ -119,31 +183,61 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get all audit logs for a specific entity type with filters - */ - async getAuditLogs( - entityType?: AuditEntityType, - actionType?: AuditActionType, - userId?: string, - limit = 100, - offset = 0, - ): Promise<{ logs: AuditLog[]; total: number }> { + async query(filters: AuditQueryFilters): Promise { + const page = filters.page || 1; + const limit = filters.limit || 50; + const offset = (page - 1) * limit; + const query = this.auditLogRepo .createQueryBuilder('audit') .leftJoinAndSelect('audit.user', 'user') .orderBy('audit.createdAt', 'DESC'); - if (entityType) { - query.andWhere('audit.entityType = :entityType', { entityType }); + if (filters.entityType) { + query.andWhere('audit.entityType = :entityType', { entityType: filters.entityType }); + } + + if (filters.actionType) { + query.andWhere('audit.actionType = :actionType', { actionType: filters.actionType }); + } + + if (filters.severity) { + query.andWhere('audit.severity = :severity', { severity: filters.severity }); + } + + if (filters.category) { + query.andWhere('audit.category = :category', { category: filters.category }); } - if (actionType) { - query.andWhere('audit.actionType = :actionType', { actionType }); + if (filters.userId) { + query.andWhere('audit.userId = :userId', { userId: filters.userId }); } - if (userId) { - query.andWhere('audit.userId = :userId', { userId }); + if (filters.source) { + query.andWhere('audit.source = :source', { source: filters.source }); + } + + if (filters.requestId) { + query.andWhere('audit.requestId = :requestId', { requestId: filters.requestId }); + } + + if (filters.correlationId) { + query.andWhere('audit.correlationId = :correlationId', { correlationId: filters.correlationId }); + } + + if (filters.startDate) { + query.andWhere('audit.createdAt >= :startDate', { startDate: new Date(filters.startDate) }); + } + + if (filters.endDate) { + query.andWhere('audit.createdAt <= :endDate', { endDate: new Date(filters.endDate) }); + } + + if (filters.search) { + query.andWhere( + '(audit.description LIKE :search OR audit.entityId LIKE :search)', + { search: `%${filters.search}%` }, + ); } const [logs, total] = await query @@ -151,12 +245,11 @@ export class AuditTrailService { .take(limit) .getManyAndCount(); - return { logs, total }; + const totalPages = Math.ceil(total / limit); + + return { logs, total, page, limit, totalPages }; } - /** - * Get audit logs within a date range - */ async getAuditLogsByDateRange( startDate: Date, endDate: Date, @@ -166,10 +259,7 @@ export class AuditTrailService { const [logs, total] = await this.auditLogRepo .createQueryBuilder('audit') .leftJoinAndSelect('audit.user', 'user') - .where('audit.createdAt BETWEEN :startDate AND :endDate', { - startDate, - endDate, - }) + .where('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) .orderBy('audit.createdAt', 'DESC') .skip(offset) .take(limit) @@ -178,9 +268,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get a summary of audit logs grouped by action type - */ async getAuditSummary( entityType?: AuditEntityType, days = 7, @@ -209,9 +296,6 @@ export class AuditTrailService { return summary; } - /** - * Get change history for an entity - */ async getChangeHistory( entityType: AuditEntityType, entityId: string, @@ -229,47 +313,82 @@ export class AuditTrailService { timestamp: log.createdAt, action: log.actionType, userId: log.userId, - changes: this.computeChanges(log.beforeState, log.afterState), + changes: this.computeChanges(log.beforeState || {}, log.afterState || {}), })); } - /** - * Delete old audit logs (retention policy) - */ + async getAuditLogsByCorrelationId(correlationId: string): Promise { + return this.auditLogRepo.find({ + where: { correlationId }, + order: { createdAt: 'ASC' }, + }); + } + + async getAuditLogsByEventId(eventId: string): Promise { + return this.auditLogRepo.findOne({ where: { eventId }, relations: ['user'] }); + } + async deleteOldLogs(daysToKeep: number): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); - // Use raw query due to TypeORM SQLite limitations with date comparisons const query = this.auditLogRepo .createQueryBuilder('audit') .delete() - .where('audit.createdAt < :cutoff', { cutoff: cutoffDate }); + .where('audit.createdAt < :cutoff AND audit.retentionUntil IS NULL', { + cutoff: cutoffDate, + }); const result = await query.execute(); - this.logger.log( - `Purged ${result.affected || 0} audit logs older than ${daysToKeep} days`, - ); + this.logger.log(`Purged ${result.affected || 0} audit logs older than ${daysToKeep} days`); return result.affected || 0; } - private getClientIp(): string | undefined { - if (!this.request) return undefined; + async getStorageStats(): Promise<{ totalRecords: number; oldestRecord: Date | null; newestRecord: Date | null }> { + const totalRecords = await this.auditLogRepo.count(); + const oldest = await this.auditLogRepo + .createQueryBuilder('audit') + .orderBy('audit.createdAt', 'ASC') + .getOne(); + const newest = await this.auditLogRepo + .createQueryBuilder('audit') + .orderBy('audit.createdAt', 'DESC') + .getOne(); + + return { + totalRecords, + oldestRecord: oldest?.createdAt || null, + newestRecord: newest?.createdAt || null, + }; + } - // Use req.ip which respects trust proxy configuration - // Falls back to socket remoteAddress for direct connections + getClientIp(): string | undefined { + if (!this.request) return undefined; return this.request.ip || this.request.socket?.remoteAddress; } + private getSource(): string { + try { + if (this.request) { + const host = this.request.get('host') || 'unknown'; + return `api:${host}`; + } + } catch {} + return 'system'; + } + + private getRequestId(): string | undefined { + if (this.request?.headers['x-request-id']) { + return this.request.headers['x-request-id'] as string; + } + return undefined; + } + private getCorrelationId(): string { if (this.request?.headers['x-correlation-id']) { return this.request.headers['x-correlation-id'] as string; } - return this.generateCorrelationId(); - } - - private generateCorrelationId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } @@ -297,13 +416,4 @@ export class AuditTrailService { return changes; } - - public async getAuditLogsByCorrelationId( - correlationId: string, - ): Promise { - return this.auditLogRepo.find({ - where: { correlationId }, - order: { createdAt: 'ASC' }, - }); - } } diff --git a/src/audit/services/compliance.service.spec.ts b/src/audit/services/compliance.service.spec.ts new file mode 100644 index 0000000..ed90bbd --- /dev/null +++ b/src/audit/services/compliance.service.spec.ts @@ -0,0 +1,219 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ComplianceService } from './compliance.service'; +import { AuditLog, AuditActionType, AuditEntityType, AuditSeverity, AuditCategory } from '../entities/audit-log.entity'; +import { Repository } from 'typeorm'; + +describe('ComplianceService', () => { + let service: ComplianceService; + let repository: jest.Mocked>; + + const mockAuditLog = (overrides: Partial = {}): AuditLog => ({ + id: 'audit-1', + eventId: 'evt-1', + actionType: AuditActionType.CLAIM_CREATED, + entityType: AuditEntityType.CLAIM, + entityId: 'claim-1', + userId: 'user-1', + walletAddress: '0x123', + severity: AuditSeverity.LOW, + category: AuditCategory.OPERATIONS, + source: 'api', + requestId: 'req-1', + description: 'Test log', + beforeState: null, + afterState: null, + metadata: null, + ipAddress: '203.0.113.0', + userAgent: 'test-agent', + correlationId: 'corr-1', + retentionUntil: null, + user: null, + createdAt: new Date('2024-06-15'), + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ComplianceService, + { + provide: getRepositoryToken(AuditLog), + useValue: { + find: jest.fn(), + findAndCount: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ComplianceService); + repository = module.get>( + getRepositoryToken(AuditLog), + ) as jest.Mocked>; + }); + + describe('generateReport', () => { + it('should generate an audit-summary report by default', async () => { + const mockQueryBuilder = { + createQueryBuilder: undefined, + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockAuditLog()]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const report = await service.generateReport({}); + + expect(report.type).toBe('audit-summary'); + expect(report.totalRecords).toBe(1); + expect(report.summary.total).toBe(1); + expect(report.dateRange.start).toBeDefined(); + expect(report.dateRange.end).toBeDefined(); + }); + + it('should generate admin-activity report', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockAuditLog()]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const report = await service.generateReport({ type: 'admin-activity' }); + + expect(report.type).toBe('admin-activity'); + expect(report.title).toContain('Administrator'); + }); + + it('should generate login-history report', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockAuditLog()]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const report = await service.generateReport({ type: 'login-history' }); + + expect(report.type).toBe('login-history'); + }); + + it('should generate security-incidents report', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockAuditLog()]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const report = await service.generateReport({ type: 'security-incidents' }); + + expect(report.type).toBe('security-incidents'); + }); + + it('should filter by date range', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + await service.generateReport({ + startDate: '2024-01-01', + endDate: '2024-12-31', + }); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'audit.createdAt BETWEEN :startDate AND :endDate', + expect.objectContaining({ + startDate: expect.any(Date), + endDate: expect.any(Date), + }), + ); + }); + }); + + describe('exportAuditLogs', () => { + it('should export logs as JSON by default', async () => { + (repository.find as jest.Mock).mockResolvedValue([mockAuditLog()]); + + const result = await service.exportAuditLogs({}); + + expect(result.format).toBe('application/json'); + expect(result.filename).toContain('audit-export'); + expect(Array.isArray(result.data)).toBe(true); + }); + + it('should export logs as CSV when requested', async () => { + (repository.find as jest.Mock).mockResolvedValue([mockAuditLog()]); + + const result = await service.exportAuditLogs({ format: 'csv' }); + + expect(result.format).toBe('text/csv'); + expect(result.filename).toContain('.csv'); + expect(typeof result.data).toBe('string'); + expect(result.data).toContain('eventId'); + expect(result.data).toContain('evt-1'); + }); + }); + + describe('getCategorySummary', () => { + it('should return counts grouped by category', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { category: 'OPERATIONS', count: '20' }, + { category: 'SECURITY', count: '5' }, + ]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const summary = await service.getCategorySummary(30); + + expect(summary).toEqual({ + OPERATIONS: 20, + SECURITY: 5, + }); + }); + }); + + describe('getDailyActivity', () => { + it('should return daily activity counts', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { date: '2024-06-15', count: '10' }, + { date: '2024-06-16', count: '5' }, + ]), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const activity = await service.getDailyActivity(7); + + expect(activity).toHaveLength(2); + expect(activity[0]).toEqual({ date: '2024-06-15', count: 10 }); + }); + }); +}); diff --git a/src/audit/services/compliance.service.ts b/src/audit/services/compliance.service.ts new file mode 100644 index 0000000..a11d5f3 --- /dev/null +++ b/src/audit/services/compliance.service.ts @@ -0,0 +1,242 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + AuditLog, + AuditActionType, + AuditEntityType, + AuditSeverity, + AuditCategory, +} from '../entities/audit-log.entity'; +import { ComplianceReport } from '../interfaces/audit-response.interface'; +import { randomUUID } from 'crypto'; + +export interface ReportOptions { + type?: string; + startDate?: string; + endDate?: string; + userId?: string; + format?: 'json' | 'csv'; +} + +@Injectable() +export class ComplianceService { + private readonly logger = new Logger(ComplianceService.name); + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + async generateReport(options: ReportOptions): Promise { + const type = options.type || 'audit-summary'; + const startDate = options.startDate + ? new Date(options.startDate) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = options.endDate ? new Date(options.endDate) : new Date(); + const format = options.format || 'json'; + + const query = this.auditLogRepo + .createQueryBuilder('audit') + .where('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC'); + + if (options.userId) { + query.andWhere('audit.userId = :userId', { userId: options.userId }); + } + + switch (type) { + case 'admin-activity': + query.andWhere('audit.category = :cat', { cat: AuditCategory.ADMINISTRATIVE }); + break; + case 'moderation-actions': + query.andWhere('audit.category = :cat', { cat: AuditCategory.MODERATION }); + break; + case 'login-history': + query.andWhere('(audit.actionType = :s OR audit.actionType = :f)', { + s: AuditActionType.LOGIN_SUCCESS, + f: AuditActionType.LOGIN_FAILED, + }); + break; + case 'governance-actions': + query.andWhere('audit.category = :cat', { cat: AuditCategory.GOVERNANCE }); + break; + case 'permission-changes': + query.andWhere('(audit.actionType = :pc OR audit.actionType = :ra OR audit.actionType = :rr)', { + pc: AuditActionType.PERMISSION_CHANGED, + ra: AuditActionType.ROLE_ASSIGNED, + rr: AuditActionType.ROLE_REVOKED, + }); + break; + case 'security-incidents': + query.andWhere('audit.severity IN (:...severities)', { + severities: [AuditSeverity.HIGH, AuditSeverity.CRITICAL], + }); + break; + } + + const records = await query.getMany(); + const summary = this.buildSummary(records); + + const report: ComplianceReport = { + id: randomUUID(), + type, + title: this.getReportTitle(type), + generatedAt: new Date().toISOString(), + generatedBy: 'system', + dateRange: { + start: startDate.toISOString(), + end: endDate.toISOString(), + }, + summary, + records: format === 'csv' ? this.toCsvRecords(records) : records, + totalRecords: records.length, + }; + + return report; + } + + async exportAuditLogs(options: ReportOptions): Promise<{ data: any; format: string; filename: string }> { + const startDate = options.startDate + ? new Date(options.startDate) + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const endDate = options.endDate ? new Date(options.endDate) : new Date(); + const format = options.format || 'json'; + + const records = await this.auditLogRepo.find({ + where: { + createdAt: this.buildDateRangeCondition(startDate, endDate) as any, + }, + order: { createdAt: 'DESC' }, + relations: ['user'], + }); + + const filename = `audit-export-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}`; + + if (format === 'csv') { + return { + data: this.toCsvString(records), + format: 'text/csv', + filename: `${filename}.csv`, + }; + } + + return { + data: records, + format: 'application/json', + filename: `${filename}.json`, + }; + } + + private buildDateRangeCondition(start: Date, end: Date) { + return { start, end }; + } + + private buildSummary(records: AuditLog[]): Record { + const summary: Record = { + total: records.length, + }; + + records.forEach((record) => { + const actionKey = `action:${record.actionType}`; + summary[actionKey] = (summary[actionKey] || 0) + 1; + + const severityKey = `severity:${record.severity}`; + summary[severityKey] = (summary[severityKey] || 0) + 1; + }); + + return summary; + } + + private getReportTitle(type: string): string { + const titles: Record = { + 'admin-activity': 'Administrator Activity Report', + 'moderation-actions': 'Moderation Actions Report', + 'login-history': 'Login History Report', + 'governance-actions': 'Governance Actions Report', + 'permission-changes': 'Permission Changes Report', + 'security-incidents': 'Security Incidents Report', + 'audit-summary': 'Audit Summary Report', + }; + return titles[type] || 'Compliance Report'; + } + + private toCsvRecords(records: AuditLog[]): any[] { + return records.map((r) => ({ + eventId: r.eventId, + actionType: r.actionType, + entityType: r.entityType, + entityId: r.entityId, + userId: r.userId, + severity: r.severity, + category: r.category, + description: r.description, + createdAt: r.createdAt?.toISOString(), + ipAddress: r.ipAddress, + source: r.source, + requestId: r.requestId, + })); + } + + private toCsvString(records: AuditLog[]): string { + const headers = ['eventId', 'actionType', 'entityType', 'entityId', 'userId', 'severity', 'category', 'description', 'createdAt', 'ipAddress', 'source', 'requestId']; + const rows = this.toCsvRecords(records); + + const escapeCsv = (val: any): string => { + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const lines = [ + headers.join(','), + ...rows.map((row) => headers.map((h) => escapeCsv(row[h])).join(',')), + ]; + + return lines.join('\n'); + } + + async getCategorySummary(days = 30): Promise> { + const since = new Date(); + since.setDate(since.getDate() - days); + + const query = this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.category', 'category') + .addSelect('COUNT(*)', 'count') + .where('audit.createdAt >= :since', { since }) + .groupBy('audit.category'); + + const results = await query.getRawMany(); + + const summary: Record = {}; + results.forEach((r) => { + summary[r.category] = parseInt(r.count, 10); + }); + + return summary; + } + + async getDailyActivity(days = 30): Promise> { + const since = new Date(); + since.setDate(since.getDate() - days); + + const query = this.auditLogRepo + .createQueryBuilder('audit') + .select("DATE(audit.createdAt) as date") + .addSelect('COUNT(*)', 'count') + .where('audit.createdAt >= :since', { since }) + .groupBy('date') + .orderBy('date', 'ASC'); + + const results = await query.getRawMany(); + + return results.map((r) => ({ + date: r.date, + count: parseInt(r.count, 10), + })); + } +} diff --git a/src/audit/services/index.ts b/src/audit/services/index.ts index dff8065..2de0524 100644 --- a/src/audit/services/index.ts +++ b/src/audit/services/index.ts @@ -1 +1,6 @@ export * from './audit-trail.service'; +export * from './audit-retention.service'; +export * from './audit-queue.service'; +export * from './compliance.service'; +export * from './security-monitoring.service'; +export * from './audit-metrics.service'; diff --git a/src/audit/services/security-monitoring.service.spec.ts b/src/audit/services/security-monitoring.service.spec.ts new file mode 100644 index 0000000..2e41b59 --- /dev/null +++ b/src/audit/services/security-monitoring.service.spec.ts @@ -0,0 +1,169 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SecurityMonitoringService } from './security-monitoring.service'; +import { AuditLog, AuditActionType, AuditEntityType, AuditSeverity, AuditCategory } from '../entities/audit-log.entity'; +import { Repository } from 'typeorm'; + +describe('SecurityMonitoringService', () => { + let service: SecurityMonitoringService; + let repository: jest.Mocked>; + + const mockAuditLog = (overrides: Partial = {}): AuditLog => ({ + id: 'audit-1', + eventId: 'evt-1', + actionType: AuditActionType.LOGIN_FAILED, + entityType: AuditEntityType.USER, + entityId: 'user-1', + userId: 'user-1', + walletAddress: null, + severity: AuditSeverity.MEDIUM, + category: AuditCategory.AUTHENTICATION, + source: 'api', + requestId: 'req-1', + description: 'Failed login', + beforeState: null, + afterState: null, + metadata: null, + ipAddress: '203.0.113.0', + userAgent: 'test-agent', + correlationId: 'corr-1', + retentionUntil: null, + user: null, + createdAt: new Date(), + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SecurityMonitoringService, + { + provide: getRepositoryToken(AuditLog), + useValue: { + count: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SecurityMonitoringService); + repository = module.get>( + getRepositoryToken(AuditLog), + ) as jest.Mocked>; + }); + + describe('checkFailedLogins', () => { + it('should return incident when threshold is exceeded', async () => { + (repository.count as jest.Mock).mockResolvedValue(6); + + const incident = await service.checkFailedLogins('user-1'); + + expect(incident).not.toBeNull(); + expect(incident!.type).toBe('BRUTE_FORCE_LOGIN'); + expect(incident!.severity).toBe('HIGH'); + expect(incident!.actor).toBe('user-1'); + }); + + it('should return null when threshold is not exceeded', async () => { + (repository.count as jest.Mock).mockResolvedValue(2); + + const incident = await service.checkFailedLogins('user-1'); + + expect(incident).toBeNull(); + }); + }); + + describe('checkPermissionEscalation', () => { + it('should return incident when permission changes exceed threshold', async () => { + (repository.count as jest.Mock).mockResolvedValue(5); + + const incident = await service.checkPermissionEscalation('user-1'); + + expect(incident).not.toBeNull(); + expect(incident!.type).toBe('PERMISSION_ESCALATION'); + }); + + it('should return null when permission changes are normal', async () => { + (repository.count as jest.Mock).mockResolvedValue(1); + + const incident = await service.checkPermissionEscalation('user-1'); + + expect(incident).toBeNull(); + }); + }); + + describe('checkSuspiciousApiUsage', () => { + it('should return incident when API calls exceed threshold', async () => { + const incident = await service.checkSuspiciousApiUsage('user-1', 150); + + expect(incident).not.toBeNull(); + expect(incident!.type).toBe('SUSPICIOUS_API_USAGE'); + expect(incident!.severity).toBe('MEDIUM'); + }); + + it('should return null when API calls are within limits', async () => { + const incident = await service.checkSuspiciousApiUsage('user-1', 10); + + expect(incident).toBeNull(); + }); + }); + + describe('getRecentSecurityEvents', () => { + it('should return grouped security events', async () => { + const logs = [ + mockAuditLog({ actionType: AuditActionType.LOGIN_FAILED, userId: 'user-1' }), + mockAuditLog({ actionType: AuditActionType.PERMISSION_CHANGED, userId: 'user-2', severity: AuditSeverity.HIGH }), + ]; + + (repository.find as jest.Mock).mockResolvedValue(logs); + + const events = await service.getRecentSecurityEvents(60); + + expect(events.length).toBeGreaterThan(0); + expect(events[0].type).toBe('SECURITY_EVENT'); + }); + }); + + describe('getFailedLoginReport', () => { + it('should return failed login report grouped by user', async () => { + const logs = [ + mockAuditLog({ userId: 'user-1' }), + mockAuditLog({ userId: 'user-1' }), + mockAuditLog({ userId: 'user-2' }), + ]; + + (repository.find as jest.Mock).mockResolvedValue(logs); + + const report = await service.getFailedLoginReport(7); + + expect(report.total).toBe(3); + expect(report.byUser['user-1']).toBe(2); + expect(report.byUser['user-2']).toBe(1); + }); + }); + + describe('getAdminActivityReport', () => { + it('should return admin activity report', async () => { + const logs = [ + mockAuditLog({ + category: AuditCategory.ADMINISTRATIVE, + actionType: AuditActionType.ADMIN_ACTION, + userId: 'admin-1', + }), + mockAuditLog({ + category: AuditCategory.ADMINISTRATIVE, + actionType: AuditActionType.CONFIGURATION_CHANGED, + userId: 'admin-1', + }), + ]; + + (repository.find as jest.Mock).mockResolvedValue(logs); + + const report = await service.getAdminActivityReport(30); + + expect(report.total).toBe(2); + expect(report.byAdmin['admin-1']).toBe(2); + }); + }); +}); diff --git a/src/audit/services/security-monitoring.service.ts b/src/audit/services/security-monitoring.service.ts new file mode 100644 index 0000000..72b30e0 --- /dev/null +++ b/src/audit/services/security-monitoring.service.ts @@ -0,0 +1,227 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan, In } from 'typeorm'; +import { + AuditLog, + AuditActionType, + AuditSeverity, + AuditCategory, +} from '../entities/audit-log.entity'; +import { SecurityIncident } from '../interfaces/audit-response.interface'; +import { randomUUID } from 'crypto'; + +export interface SecurityThresholds { + maxFailedLogins: number; + maxPermissionChanges: number; + maxApiCallsPerMinute: number; + suspiciousTimeWindowMinutes: number; +} + +const DEFAULT_THRESHOLDS: SecurityThresholds = { + maxFailedLogins: 5, + maxPermissionChanges: 3, + maxApiCallsPerMinute: 100, + suspiciousTimeWindowMinutes: 15, +}; + +@Injectable() +export class SecurityMonitoringService { + private readonly logger = new Logger(SecurityMonitoringService.name); + private readonly thresholds: SecurityThresholds; + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) { + this.thresholds = this.loadThresholds(); + } + + async checkFailedLogins(userId: string): Promise { + const since = new Date(); + since.setMinutes(since.getMinutes() - this.thresholds.suspiciousTimeWindowMinutes); + + const recentFailures = await this.auditLogRepo.count({ + where: { + userId, + actionType: AuditActionType.LOGIN_FAILED, + createdAt: MoreThan(since), + }, + }); + + if (recentFailures >= this.thresholds.maxFailedLogins) { + return { + id: randomUUID(), + type: 'BRUTE_FORCE_LOGIN', + severity: 'HIGH', + description: `User ${userId} had ${recentFailures} failed login attempts in ${this.thresholds.suspiciousTimeWindowMinutes} minutes`, + timestamp: new Date().toISOString(), + actor: userId, + metadata: { failedAttempts: recentFailures, windowMinutes: this.thresholds.suspiciousTimeWindowMinutes }, + resolved: false, + }; + } + + return null; + } + + async checkPermissionEscalation(userId: string): Promise { + const since = new Date(); + since.setMinutes(since.getMinutes() - this.thresholds.suspiciousTimeWindowMinutes); + + const recentChanges = await this.auditLogRepo.count({ + where: { + userId, + actionType: In([ + AuditActionType.PERMISSION_CHANGED, + AuditActionType.ROLE_ASSIGNED, + AuditActionType.ROLE_REVOKED, + ]), + createdAt: MoreThan(since), + }, + }); + + if (recentChanges >= this.thresholds.maxPermissionChanges) { + return { + id: randomUUID(), + type: 'PERMISSION_ESCALATION', + severity: 'HIGH', + description: `User ${userId} had ${recentChanges} permission/role changes in ${this.thresholds.suspiciousTimeWindowMinutes} minutes`, + timestamp: new Date().toISOString(), + actor: userId, + metadata: { changes: recentChanges, windowMinutes: this.thresholds.suspiciousTimeWindowMinutes }, + resolved: false, + }; + } + + return null; + } + + async checkSuspiciousApiUsage(userId: string, requestCount: number): Promise { + if (requestCount > this.thresholds.maxApiCallsPerMinute) { + return { + id: randomUUID(), + type: 'SUSPICIOUS_API_USAGE', + severity: 'MEDIUM', + description: `User ${userId} made ${requestCount} API calls (threshold: ${this.thresholds.maxApiCallsPerMinute})`, + timestamp: new Date().toISOString(), + actor: userId, + metadata: { requestCount, threshold: this.thresholds.maxApiCallsPerMinute }, + resolved: false, + }; + } + + return null; + } + + async getRecentSecurityEvents(minutes = 60): Promise { + const since = new Date(); + since.setMinutes(since.getMinutes() - minutes); + + const criticalLogs = await this.auditLogRepo.find({ + where: [ + { + actionType: AuditActionType.LOGIN_FAILED, + createdAt: MoreThan(since), + }, + { + actionType: AuditActionType.PERMISSION_CHANGED, + createdAt: MoreThan(since), + }, + { + severity: In([AuditSeverity.HIGH, AuditSeverity.CRITICAL]), + createdAt: MoreThan(since), + }, + ], + order: { createdAt: 'DESC' }, + take: 100, + }); + + const grouped = this.groupSecurityEvents(criticalLogs); + return Object.values(grouped); + } + + async getFailedLoginReport(days = 7): Promise<{ total: number; byUser: Record; events: AuditLog[] }> { + const since = new Date(); + since.setDate(since.getDate() - days); + + const events = await this.auditLogRepo.find({ + where: { + actionType: AuditActionType.LOGIN_FAILED, + createdAt: MoreThan(since), + }, + order: { createdAt: 'DESC' }, + }); + + const byUser: Record = {}; + events.forEach((e) => { + const key = e.userId || e.ipAddress || 'unknown'; + byUser[key] = (byUser[key] || 0) + 1; + }); + + return { total: events.length, byUser, events }; + } + + async getAdminActivityReport(days = 30): Promise<{ total: number; byAdmin: Record; events: AuditLog[] }> { + const since = new Date(); + since.setDate(since.getDate() - days); + + const events = await this.auditLogRepo.find({ + where: { + category: AuditCategory.ADMINISTRATIVE, + createdAt: MoreThan(since), + }, + order: { createdAt: 'DESC' }, + }); + + const byAdmin: Record = {}; + events.forEach((e) => { + const key = e.userId || 'unknown'; + byAdmin[key] = (byAdmin[key] || 0) + 1; + }); + + return { total: events.length, byAdmin, events }; + } + + private groupSecurityEvents(logs: AuditLog[]): Record { + const groups: Record = {}; + + logs.forEach((log) => { + const key = log.userId || log.ipAddress || 'unknown'; + + if (!groups[key]) { + groups[key] = { + id: randomUUID(), + type: 'SECURITY_EVENT', + severity: log.severity, + description: `Security events detected for ${key}`, + timestamp: log.createdAt.toISOString(), + actor: log.userId || 'unknown', + ipAddress: log.ipAddress || undefined, + metadata: { eventCount: 0, events: [] }, + resolved: false, + }; + } + + if (!groups[key].metadata) groups[key].metadata = {}; + if (!groups[key].metadata.eventCount) groups[key].metadata.eventCount = 0; + if (!groups[key].metadata.events) groups[key].metadata.events = []; + groups[key].metadata.eventCount++; + groups[key].metadata.events.push({ + actionType: log.actionType, + description: log.description, + timestamp: log.createdAt.toISOString(), + }); + }); + + return groups; + } + + private loadThresholds(): SecurityThresholds { + return { + maxFailedLogins: parseInt(process.env.AUDIT_MAX_FAILED_LOGINS ?? String(DEFAULT_THRESHOLDS.maxFailedLogins), 10), + maxPermissionChanges: parseInt(process.env.AUDIT_MAX_PERMISSION_CHANGES ?? String(DEFAULT_THRESHOLDS.maxPermissionChanges), 10), + maxApiCallsPerMinute: parseInt(process.env.AUDIT_MAX_API_CALLS ?? String(DEFAULT_THRESHOLDS.maxApiCallsPerMinute), 10), + suspiciousTimeWindowMinutes: parseInt(process.env.AUDIT_SUSPICIOUS_WINDOW_MINUTES ?? String(DEFAULT_THRESHOLDS.suspiciousTimeWindowMinutes), 10), + }; + } +}