diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5577aaf..c77f555 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,8 @@ model User { // Primary wallet address — canonical identifier for the user walletAddress String @unique + role String @default("USER") + reputation Int @default(0) // Sybil Resistance diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts index ac7d35c..95bd213 100644 --- a/src/audit/audit.module.ts +++ b/src/audit/audit.module.ts @@ -3,15 +3,34 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; import { AuditLog } from './entities/audit-log.entity'; import { AuditTrailService } from './services/audit-trail.service'; +import { AuditSearchService } from './services/audit-search.service'; +import { AuditComplianceService } from './services/audit-compliance.service'; +import { AuditMetricsService } from './services/audit-metrics.service'; import { AuditRetentionService } from './services/audit-retention.service'; import { AuditController } from './controllers/audit-log.controller'; import { AuditLoggingInterceptor } from './interceptors/audit-logging.interceptor'; +import { RolesGuard } from '../auth/guards/roles.guard'; @Global() @Module({ imports: [TypeOrmModule.forFeature([AuditLog]), ScheduleModule], - providers: [AuditTrailService, AuditLoggingInterceptor, AuditRetentionService], + providers: [ + AuditTrailService, + AuditSearchService, + AuditComplianceService, + AuditMetricsService, + AuditRetentionService, + AuditLoggingInterceptor, + RolesGuard, + ], controllers: [AuditController], - exports: [AuditTrailService, AuditLoggingInterceptor], + exports: [ + AuditTrailService, + AuditSearchService, + AuditComplianceService, + AuditMetricsService, + AuditRetentionService, + AuditLoggingInterceptor, + ], }) export class AuditModule {} diff --git a/src/audit/controllers/audit-log.controller.ts b/src/audit/controllers/audit-log.controller.ts index a7145e4..5f4d2bb 100644 --- a/src/audit/controllers/audit-log.controller.ts +++ b/src/audit/controllers/audit-log.controller.ts @@ -1,42 +1,45 @@ -import { Controller, Get, Query, Param, UseInterceptors } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { + Controller, Get, Post, Patch, Query, Param, Body, + UseGuards, Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { AuditTrailService } from '../services/audit-trail.service'; +import { AuditSearchService } from '../services/audit-search.service'; +import { AuditComplianceService } from '../services/audit-compliance.service'; +import { AuditMetricsService } from '../services/audit-metrics.service'; +import { AuditRetentionService } from '../services/audit-retention.service'; import { AuditLog, AuditActionType, AuditEntityType } from '../entities/audit-log.entity'; +import { SearchAuditDto } from '../dto/search-audit.dto'; +import { GenerateReportDto, ReportType, ReportFormat } from '../dto/compliance-report.dto'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../entities/user.entity'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; @ApiTags('audit') @Controller('audit') export class AuditController { - constructor(private readonly auditTrailService: AuditTrailService) {} + constructor( + private readonly auditTrailService: AuditTrailService, + private readonly auditSearchService: AuditSearchService, + private readonly auditComplianceService: AuditComplianceService, + private readonly auditMetricsService: AuditMetricsService, + private readonly auditRetentionService: AuditRetentionService, + ) {} - /** - * 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; - - return this.auditTrailService.getAuditLogs( - entityType, - actionType, - userId, - parsedLimit, - parsedOffset, - ); + @ApiOperation({ summary: 'Search audit logs with advanced filters' }) + @ApiResponse({ status: 200, description: 'Returns filtered audit logs' }) + async search(@Query() dto: SearchAuditDto): Promise { + const startTime = Date.now(); + const result = await this.auditSearchService.search(dto); + this.auditMetricsService.recordSearchOperation(Date.now() - startTime); + return result; } - /** - * 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, @@ -44,11 +47,8 @@ export class AuditController { return this.auditTrailService.getEntityAuditLogs(entityType, entityId); } - /** - * 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('limit') limit?: string, @@ -56,19 +56,11 @@ export class AuditController { ): 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; - - return this.auditTrailService.getUserAuditLogs( - userId, - parsedLimit, - parsedOffset, - ); + return this.auditTrailService.getUserAuditLogs(userId, parsedLimit, parsedOffset); } - /** - * Get audit logs for a specific action type - * GET /audit/action/CLAIM_CREATED?limit=50 - */ @Get('action/:actionType') + @ApiOperation({ summary: 'Get audit logs by action type' }) async getActionAuditLogs( @Param('actionType') actionType: AuditActionType, @Query('limit') limit?: string, @@ -76,19 +68,11 @@ export class AuditController { ): 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; - - return this.auditTrailService.getActionAuditLogs( - actionType, - parsedLimit, - parsedOffset, - ); + return this.auditTrailService.getActionAuditLogs(actionType, parsedLimit, parsedOffset); } - /** - * 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, @@ -103,11 +87,8 @@ export class AuditController { return this.auditTrailService.getChangeHistory(entityType, entityId); } - /** - * Get audit summary for a specific entity type - * GET /audit/summary?entityType=CLAIM&days=7 - */ @Get('summary') + @ApiOperation({ summary: 'Get audit summary grouped by action type' }) async getAuditSummary( @Query('entityType') entityType?: AuditEntityType, @Query('days') days?: string, @@ -115,4 +96,126 @@ export class AuditController { const parsedDays = days ? Math.max(parseInt(days, 10), 1) : 7; return this.auditTrailService.getAuditSummary(entityType, parsedDays); } + + @Get('correlation/:correlationId') + @ApiOperation({ summary: 'Get audit logs by correlation ID' }) + async getByCorrelationId( + @Param('correlationId') correlationId: string, + ): Promise { + return this.auditSearchService.findByCorrelationId(correlationId); + } + + @Get('request/:requestId') + @ApiOperation({ summary: 'Get audit logs by request ID' }) + async getByRequestId( + @Param('requestId') requestId: string, + ): Promise { + return this.auditSearchService.findByRequestId(requestId); + } + + @Get('failed-access') + @ApiOperation({ summary: 'Get failed access attempts' }) + async getFailedAccess( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ): Promise<{ logs: AuditLog[]; total: number }> { + return this.auditSearchService.findFailedAccessAttempts( + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + limit ? Math.min(parseInt(limit, 10), 500) : 100, + offset ? Math.max(parseInt(offset, 10), 0) : 0, + ); + } + + @Get('security-events') + @ApiOperation({ summary: 'Get security event audit logs' }) + async getSecurityEvents( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ): Promise<{ logs: AuditLog[]; total: number }> { + return this.auditSearchService.findSecurityEvents( + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + limit ? Math.min(parseInt(limit, 10), 500) : 100, + offset ? Math.max(parseInt(offset, 10), 0) : 0, + ); + } + + @Get('integrity/:id') + @ApiOperation({ summary: 'Verify integrity of an audit record' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async verifyIntegrity(@Param('id') id: string): Promise<{ valid: boolean; id: string }> { + const result = await this.auditTrailService.verifyIntegrity(id); + return { valid: result.valid, id }; + } + + @Post('reports') + @ApiOperation({ summary: 'Generate a compliance report' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.MODERATOR, UserRole.ADMIN, UserRole.SUPER_ADMIN) + async generateReport( + @Body() dto: GenerateReportDto, + @CurrentUser() user: any, + ): Promise { + this.auditMetricsService.incrementExportRequests(); + return this.auditComplianceService.generateReport(dto, user?.id || 'system'); + } + + @Get('reports/types') + @ApiOperation({ summary: 'Get available report types' }) + getReportTypes(): { types: string[] } { + return { types: Object.values(ReportType) }; + } + + @Get('metrics') + @ApiOperation({ summary: 'Get audit system metrics' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async getMetrics(): Promise { + return this.auditMetricsService.getMetrics(); + } + + @Get('retention') + @ApiOperation({ summary: 'Get retention policy status' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async getRetentionStatus(): Promise { + const status = await this.auditTrailService.getRetentionStatus(); + const config = this.auditRetentionService.getRetentionConfig(); + return { ...status, config }; + } + + @Post('legal-hold/:entityId') + @ApiOperation({ summary: 'Place legal hold on audit records for an entity' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiBody({ schema: { properties: { reason: { type: 'string' }, initiatedBy: { type: 'string' } } } }) + async placeLegalHold( + @Param('entityId') entityId: string, + @Body('reason') reason: string, + @CurrentUser() user: any, + ): Promise<{ affected: number }> { + const affected = await this.auditTrailService.placeLegalHold( + entityId, + reason || 'Legal hold', + user?.id || 'system', + ); + return { affected }; + } + + @Patch('legal-hold/:entityId/remove') + @ApiOperation({ summary: 'Remove legal hold from audit records' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async removeLegalHold( + @Param('entityId') entityId: string, + ): Promise<{ affected: number }> { + const affected = await this.auditTrailService.removeLegalHold(entityId); + return { affected }; + } } diff --git a/src/audit/dto/compliance-report.dto.ts b/src/audit/dto/compliance-report.dto.ts new file mode 100644 index 0000000..e45ab4d --- /dev/null +++ b/src/audit/dto/compliance-report.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsDateString, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum ReportType { + ADMIN_ACTIVITY = 'ADMIN_ACTIVITY', + MODERATION_ACTIONS = 'MODERATION_ACTIONS', + GOVERNANCE_ACTIONS = 'GOVERNANCE_ACTIONS', + AUTHENTICATION_HISTORY = 'AUTHENTICATION_HISTORY', + FAILED_ACCESS_ATTEMPTS = 'FAILED_ACCESS_ATTEMPTS', + SECURITY_EVENTS = 'SECURITY_EVENTS', + PROTOCOL_OPERATIONS = 'PROTOCOL_OPERATIONS', + USER_ACTIVITY = 'USER_ACTIVITY', +} + +export enum ReportFormat { + JSON = 'JSON', + CSV = 'CSV', +} + +export class GenerateReportDto { + @ApiProperty({ enum: ReportType }) + @IsEnum(ReportType) + reportType: ReportType; + + @ApiPropertyOptional({ enum: ReportFormat, default: ReportFormat.JSON }) + @IsOptional() + @IsEnum(ReportFormat) + format?: ReportFormat; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + days?: number; +} + +export class ReportResponse { + @ApiProperty() + reportType: ReportType; + + @ApiProperty() + generatedAt: string; + + @ApiProperty() + period: { start: string; end: string }; + + @ApiProperty() + generatedBy: string; + + @ApiProperty() + totalEvents: number; + + @ApiProperty() + data: any; + + @ApiProperty({ required: false }) + exportUrl?: string; +} diff --git a/src/audit/dto/retention-policy.dto.ts b/src/audit/dto/retention-policy.dto.ts new file mode 100644 index 0000000..099d4ea --- /dev/null +++ b/src/audit/dto/retention-policy.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsInt, Min, IsEnum, IsString, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; +import { AuditEventType } from '../entities/audit-log.entity'; + +export class RetentionPolicyConfig { + @ApiProperty({ default: 365 }) + @IsInt() + @Min(1) + @Type(() => Number) + defaultRetentionDays: number; + + @ApiPropertyOptional() + @IsOptional() + overrides?: { eventType: AuditEventType; days: number }[]; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + archiveBeforeDelete?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + exportBeforeDelete?: boolean; +} + +export class LegalHoldDto { + @ApiProperty() + @IsString() + entityId: string; + + @ApiProperty() + @IsString() + reason: string; + + @ApiProperty() + @IsString() + initiatedBy: string; +} + +export class RetentionStatusDto { + @ApiProperty() + totalRecords: number; + + @ApiProperty() + archivedRecords: number; + + @ApiProperty() + legalHoldRecords: number; + + @ApiProperty() + pendingCleanup: number; + + @ApiProperty() + estimatedSizeBytes: number; + + @ApiProperty() + retentionEndDate: string; +} diff --git a/src/audit/dto/search-audit.dto.ts b/src/audit/dto/search-audit.dto.ts new file mode 100644 index 0000000..c981c14 --- /dev/null +++ b/src/audit/dto/search-audit.dto.ts @@ -0,0 +1,101 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsEnum, IsDateString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { AuditActionType, AuditEntityType, AuditEventType, AuditSeverity } from '../entities/audit-log.entity'; + +export class SearchAuditDto { + @ApiPropertyOptional({ enum: AuditActionType }) + @IsOptional() + @IsEnum(AuditActionType) + actionType?: AuditActionType; + + @ApiPropertyOptional({ enum: AuditEntityType }) + @IsOptional() + @IsEnum(AuditEntityType) + entityType?: AuditEntityType; + + @ApiPropertyOptional({ enum: AuditEventType }) + @IsOptional() + @IsEnum(AuditEventType) + eventType?: AuditEventType; + + @ApiPropertyOptional({ enum: AuditSeverity }) + @IsOptional() + @IsEnum(AuditSeverity) + severity?: AuditSeverity; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + actorRole?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + entityId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + resourceType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + correlationId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + requestId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + ipAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + walletAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + searchTerm?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(1000) + limit?: number; + + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number; + + @ApiPropertyOptional({ default: 'DESC' }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/src/audit/entities/audit-log.entity.ts b/src/audit/entities/audit-log.entity.ts index 0ce5345..6af5043 100644 --- a/src/audit/entities/audit-log.entity.ts +++ b/src/audit/entities/audit-log.entity.ts @@ -33,6 +33,55 @@ export enum AuditActionType { USER_UPDATED = 'USER_UPDATED', WALLET_UNLINKED = 'WALLET_UNLINKED', VERIFICATION_INITIATED = 'VERIFICATION_INITIATED', + + // Authentication & Authorization + LOGIN_SUCCESS = 'LOGIN_SUCCESS', + LOGIN_FAILED = 'LOGIN_FAILED', + LOGOUT = 'LOGOUT', + CHALLENGE_REQUESTED = 'CHALLENGE_REQUESTED', + TOKEN_REFRESHED = 'TOKEN_REFRESHED', + TOKEN_REVOKED = 'TOKEN_REVOKED', + AUTHORIZATION_FAILED = 'AUTHORIZATION_FAILED', + PERMISSION_DENIED = 'PERMISSION_DENIED', + + // Administrative actions + ADMIN_ACTION = 'ADMIN_ACTION', + USER_BANNED = 'USER_BANNED', + USER_UNBANNED = 'USER_UNBANNED', + USER_ROLE_CHANGED = 'USER_ROLE_CHANGED', + SYSTEM_CONFIG_CHANGED = 'SYSTEM_CONFIG_CHANGED', + FEATURE_FLAG_CHANGED = 'FEATURE_FLAG_CHANGED', + + // Moderator actions + MODERATOR_ACTION = 'MODERATOR_ACTION', + EVIDENCE_HIDDEN = 'EVIDENCE_HIDDEN', + EVIDENCE_RESTORED = 'EVIDENCE_RESTORED', + FLAG_REVIEWED = 'FLAG_REVIEWED', + + // Governance actions + PROPOSAL_CREATED = 'PROPOSAL_CREATED', + PROPOSAL_UPDATED = 'PROPOSAL_UPDATED', + VOTE_CAST = 'VOTE_CAST', + VOTE_CHANGED = 'VOTE_CHANGED', + PROPOSAL_EXECUTED = 'PROPOSAL_EXECUTED', + PROPOSAL_CANCELLED = 'PROPOSAL_CANCELLED', + + // Dispute actions + DISPUTE_CREATED = 'DISPUTE_CREATED', + DISPUTE_UPDATED = 'DISPUTE_UPDATED', + DISPUTE_RESOLVED = 'DISPUTE_RESOLVED', + DISPUTE_ESCALATED = 'DISPUTE_ESCALATED', + + // Staking actions + STAKE_DEPOSITED = 'STAKE_DEPOSITED', + STAKE_WITHDRAWN = 'STAKE_WITHDRAWN', + STAKE_SLASHED = 'STAKE_SLASHED', + + // Security events + SUSPICIOUS_ACTIVITY = 'SUSPICIOUS_ACTIVITY', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + INTEGRITY_VIOLATION = 'INTEGRITY_VIOLATION', + ACCESS_AUDITED = 'ACCESS_AUDITED', } export enum AuditEntityType { @@ -41,6 +90,35 @@ export enum AuditEntityType { REWARD = 'REWARD', USER = 'USER', WALLET = 'WALLET', + DISPUTE = 'DISPUTE', + PROPOSAL = 'PROPOSAL', + VOTE = 'VOTE', + STAKE = 'STAKE', + CONFIG = 'CONFIG', + AUDIT_LOG = 'AUDIT_LOG', + SYSTEM = 'SYSTEM', +} + +export enum AuditEventType { + AUTHENTICATION = 'AUTHENTICATION', + AUTHORIZATION = 'AUTHORIZATION', + API_REQUEST = 'API_REQUEST', + ADMINISTRATIVE = 'ADMINISTRATIVE', + MODERATOR = 'MODERATOR', + GOVERNANCE = 'GOVERNANCE', + VERIFICATION = 'VERIFICATION', + DISPUTE = 'DISPUTE', + REWARD = 'REWARD', + CONFIG_CHANGE = 'CONFIG_CHANGE', + SECURITY = 'SECURITY', + STAKING = 'STAKING', +} + +export enum AuditSeverity { + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR', + CRITICAL = 'CRITICAL', } @Entity('audit_logs') @@ -51,6 +129,12 @@ export enum AuditEntityType { @Index(['entityId']) @Index(['userId', 'createdAt']) @Index(['actionType', 'createdAt']) +@Index(['eventType']) +@Index(['severity']) +@Index(['actorRole']) +@Index(['archived']) +@Index(['retentionUntil']) +@Index(['integrityHash']) export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; @@ -80,6 +164,21 @@ export class AuditLog { @Column({ nullable: true }) walletAddress: string; + @Column({ type: 'varchar', nullable: true }) + actorRole: string; + + @Column({ type: 'varchar', nullable: true }) + eventType: AuditEventType; + + @Column({ type: 'varchar', nullable: true }) + resourceType: string; + + @Column({ + type: 'varchar', + default: AuditSeverity.INFO, + }) + severity: AuditSeverity; + @Column({ type: 'text', nullable: true }) description: string; @@ -98,9 +197,21 @@ export class AuditLog { @Column({ nullable: true }) userAgent: string; - @CreateDateColumn() - createdAt: Date; + @Column({ nullable: true }) + requestId: string; @Column({ nullable: true }) correlationId: string; + + @Column({ type: 'varchar', nullable: true }) + integrityHash: string; + + @Column({ default: false }) + archived: boolean; + + @Column({ type: 'datetime', nullable: true }) + retentionUntil: Date | null; + + @CreateDateColumn() + createdAt: Date; } diff --git a/src/audit/guards/audit-access.guard.ts b/src/audit/guards/audit-access.guard.ts new file mode 100644 index 0000000..ba67262 --- /dev/null +++ b/src/audit/guards/audit-access.guard.ts @@ -0,0 +1,68 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '../../entities/user.entity'; + +export const AUDIT_ACCESS_KEY = 'audit_access'; +export enum AuditAccessLevel { + VIEW = 'VIEW', + EXPORT = 'EXPORT', + MANAGE = 'MANAGE', +} + +@Injectable() +export class AuditAccessGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredLevel = this.reflector.getAllAndOverride( + AUDIT_ACCESS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredLevel) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException('Authentication required'); + } + + const role = user.role; + + switch (requiredLevel) { + case AuditAccessLevel.VIEW: + if (role === UserRole.USER) { + const requestedUserId = request.params.userId || request.query.userId; + if (requestedUserId && requestedUserId !== user.id) { + throw new ForbiddenException('Insufficient permissions to view audit data'); + } + } + return true; + + case AuditAccessLevel.EXPORT: + if (![UserRole.MODERATOR, UserRole.ADMIN, UserRole.SUPER_ADMIN].includes(role)) { + throw new ForbiddenException('Insufficient permissions to export audit data'); + } + return true; + + case AuditAccessLevel.MANAGE: + if (![UserRole.ADMIN, UserRole.SUPER_ADMIN].includes(role)) { + throw new ForbiddenException('Insufficient permissions to manage audit data'); + } + return true; + + default: + return true; + } + } +} + +export function AuditAccess(level: AuditAccessLevel) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata(AUDIT_ACCESS_KEY, level, descriptor.value); + return descriptor; + }; +} diff --git a/src/audit/services/audit-compliance.service.ts b/src/audit/services/audit-compliance.service.ts new file mode 100644 index 0000000..9d0c971 --- /dev/null +++ b/src/audit/services/audit-compliance.service.ts @@ -0,0 +1,359 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog, AuditActionType, AuditEventType, AuditSeverity } from '../entities/audit-log.entity'; +import { ReportType, ReportFormat, GenerateReportDto, ReportResponse } from '../dto/compliance-report.dto'; + +@Injectable() +export class AuditComplianceService { + private readonly logger = new Logger(AuditComplianceService.name); + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + async generateReport(dto: GenerateReportDto, generatedBy: string): Promise { + const endDate = dto.endDate ? new Date(dto.endDate) : new Date(); + let startDate: Date; + + if (dto.startDate) { + startDate = new Date(dto.startDate); + } else if (dto.days) { + startDate = new Date(); + startDate.setDate(startDate.getDate() - dto.days); + } else { + startDate = new Date(); + startDate.setDate(startDate.getDate() - 30); + } + + let data: any; + let totalEvents = 0; + + switch (dto.reportType) { + case ReportType.ADMIN_ACTIVITY: + data = await this.generateAdminActivityReport(startDate, endDate); + break; + case ReportType.MODERATION_ACTIONS: + data = await this.generateModerationReport(startDate, endDate); + break; + case ReportType.GOVERNANCE_ACTIONS: + data = await this.generateGovernanceReport(startDate, endDate); + break; + case ReportType.AUTHENTICATION_HISTORY: + data = await this.generateAuthenticationReport(startDate, endDate); + break; + case ReportType.FAILED_ACCESS_ATTEMPTS: + data = await this.generateFailedAccessReport(startDate, endDate); + break; + case ReportType.SECURITY_EVENTS: + data = await this.generateSecurityReport(startDate, endDate); + break; + case ReportType.PROTOCOL_OPERATIONS: + data = await this.generateProtocolOperationsReport(startDate, endDate); + break; + case ReportType.USER_ACTIVITY: + data = await this.generateUserActivityReport(startDate, endDate); + break; + } + + if (data && data.events) { + totalEvents = data.events.length; + } + + const response: ReportResponse = { + reportType: dto.reportType, + generatedAt: new Date().toISOString(), + period: { + start: startDate.toISOString(), + end: endDate.toISOString(), + }, + generatedBy, + totalEvents, + data, + }; + + if (dto.format === ReportFormat.CSV) { + response.exportUrl = `data:text/csv;base64,${Buffer.from(this.convertToCsv(data)).toString('base64')}`; + } + + return response; + } + + private async generateAdminActivityReport(startDate: Date, endDate: Date) { + const adminActions = [ + AuditActionType.ADMIN_ACTION, + AuditActionType.USER_BANNED, + AuditActionType.USER_UNBANNED, + AuditActionType.USER_ROLE_CHANGED, + AuditActionType.SYSTEM_CONFIG_CHANGED, + AuditActionType.FEATURE_FLAG_CHANGED, + ]; + + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...adminActions)', { adminActions }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byAction = this.groupBy(events, 'actionType'); + const byAdmin = this.groupBy(events, 'userId'); + + return { + events, + summary: { + totalAdminActions: events.length, + uniqueAdmins: new Set(events.map((e) => e.userId).filter(Boolean)).size, + actionsByType: Object.fromEntries( + Object.entries(byAction).map(([key, val]) => [key, (val as any[]).length]), + ), + mostActiveAdmins: Object.fromEntries( + Object.entries(byAdmin) + .map(([key, val]) => [key, (val as any[]).length]) + .sort(([, a], [, b]) => b - a) + .slice(0, 10), + ), + }, + }; + } + + private async generateModerationReport(startDate: Date, endDate: Date) { + const modActions = [ + AuditActionType.MODERATOR_ACTION, + AuditActionType.EVIDENCE_HIDDEN, + AuditActionType.EVIDENCE_RESTORED, + AuditActionType.FLAG_REVIEWED, + AuditActionType.EVIDENCE_FLAGGED, + ]; + + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...modActions)', { modActions }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byAction = this.groupBy(events, 'actionType'); + + return { + events, + summary: { + totalModActions: events.length, + uniqueModerators: new Set(events.map((e) => e.userId).filter(Boolean)).size, + actionsByType: Object.fromEntries( + Object.entries(byAction).map(([key, val]) => [key, (val as any[]).length]), + ), + }, + }; + } + + private async generateGovernanceReport(startDate: Date, endDate: Date) { + const govActions = [ + AuditActionType.PROPOSAL_CREATED, + AuditActionType.PROPOSAL_UPDATED, + AuditActionType.VOTE_CAST, + AuditActionType.VOTE_CHANGED, + AuditActionType.PROPOSAL_EXECUTED, + AuditActionType.PROPOSAL_CANCELLED, + ]; + + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...govActions)', { govActions }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byAction = this.groupBy(events, 'actionType'); + + return { + events, + summary: { + totalGovernanceActions: events.length, + uniqueVoters: new Set(events.map((e) => e.userId).filter(Boolean)).size, + actionsByType: Object.fromEntries( + Object.entries(byAction).map(([key, val]) => [key, (val as any[]).length]), + ), + }, + }; + } + + private async generateAuthenticationReport(startDate: Date, endDate: Date) { + const authActions = [ + AuditActionType.LOGIN_SUCCESS, + AuditActionType.LOGIN_FAILED, + AuditActionType.LOGOUT, + AuditActionType.CHALLENGE_REQUESTED, + AuditActionType.TOKEN_REFRESHED, + AuditActionType.TOKEN_REVOKED, + ]; + + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...authActions)', { authActions }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const successful = events.filter((e) => e.actionType === AuditActionType.LOGIN_SUCCESS).length; + const failed = events.filter((e) => e.actionType === AuditActionType.LOGIN_FAILED).length; + + return { + events, + summary: { + totalAuthEvents: events.length, + successfulLogins: successful, + failedLogins: failed, + successRate: events.length > 0 ? Math.round((successful / events.length) * 100) : 0, + uniqueUsers: new Set(events.map((e) => e.userId).filter(Boolean)).size, + }, + }; + } + + private async generateFailedAccessReport(startDate: Date, endDate: Date) { + const failedActions = [ + AuditActionType.LOGIN_FAILED, + AuditActionType.AUTHORIZATION_FAILED, + AuditActionType.PERMISSION_DENIED, + ]; + + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...failedActions)', { failedActions }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byIp = this.groupBy(events, 'ipAddress'); + const topIPs = Object.entries(byIp) + .map(([ip, evts]) => ({ ip, count: (evts as any[]).length })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + return { + events, + summary: { + totalFailedAttempts: events.length, + uniqueIPs: new Set(events.map((e) => e.ipAddress).filter(Boolean)).size, + uniqueUsers: new Set(events.map((e) => e.userId).filter(Boolean)).size, + topSourceIPs: topIPs, + }, + }; + } + + private async generateSecurityReport(startDate: Date, endDate: Date) { + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.eventType = :eventType', { eventType: AuditEventType.SECURITY }) + .andWhere('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const criticalCount = events.filter((e) => e.severity === AuditSeverity.CRITICAL).length; + const errorCount = events.filter((e) => e.severity === AuditSeverity.ERROR).length; + + return { + events, + summary: { + totalSecurityEvents: events.length, + criticalEvents: criticalCount, + errorEvents: errorCount, + warningEvents: events.filter((e) => e.severity === AuditSeverity.WARNING).length, + bySeverity: { + CRITICAL: criticalCount, + ERROR: errorCount, + WARNING: events.filter((e) => e.severity === AuditSeverity.WARNING).length, + INFO: events.filter((e) => e.severity === AuditSeverity.INFO).length, + }, + }, + }; + } + + private async generateProtocolOperationsReport(startDate: Date, endDate: Date) { + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byEventType = this.groupBy(events, 'eventType'); + + return { + events, + summary: { + totalOperations: events.length, + operationsByType: Object.fromEntries( + Object.entries(byEventType).map(([key, val]) => [key, (val as any[]).length]), + ), + }, + }; + } + + private async generateUserActivityReport(startDate: Date, endDate: Date) { + const events = await this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.createdAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('audit.userId IS NOT NULL') + .orderBy('audit.createdAt', 'DESC') + .getMany(); + + const byUser = this.groupBy(events, 'userId'); + const topUsers = Object.entries(byUser) + .map(([userId, userEvents]) => ({ + userId, + count: (userEvents as any[]).length, + lastAction: (userEvents as any[]).sort( + (a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0], + })) + .sort((a, b) => b.count - a.count) + .slice(0, 50); + + return { + events, + summary: { + totalUserActions: events.length, + uniqueUsers: new Set(events.map((e) => e.userId).filter(Boolean)).size, + mostActiveUsers: topUsers, + }, + }; + } + + private groupBy(array: any[], key: string): Record { + return array.reduce((result, item) => { + const groupKey = item[key] || 'UNKNOWN'; + if (!result[groupKey]) { + result[groupKey] = []; + } + result[groupKey].push(item); + return result; + }, {} as Record); + } + + private convertToCsv(data: any): string { + if (!data || !data.events || !Array.isArray(data.events)) { + return 'No data'; + } + + const headers = ['id', 'actionType', 'entityType', 'entityId', 'userId', 'walletAddress', 'description', 'ipAddress', 'createdAt']; + const rows = data.events.map((event: any) => + headers.map((h) => { + const val = event[h]; + if (val === null || val === undefined) return ''; + if (typeof val === 'object') return JSON.stringify(val); + return String(val).replace(/"/g, '""'); + }).join(','), + ); + + return [headers.join(','), ...rows].join('\n'); + } +} diff --git a/src/audit/services/audit-metrics.service.ts b/src/audit/services/audit-metrics.service.ts new file mode 100644 index 0000000..59f39f2 --- /dev/null +++ b/src/audit/services/audit-metrics.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../entities/audit-log.entity'; + +@Injectable() +export class AuditMetricsService { + private readonly logger = new Logger(AuditMetricsService.name); + + private metrics = { + eventsGenerated: 0, + failedWrites: 0, + searchOperations: 0, + searchLatencyMs: 0, + exportRequests: 0, + integrityChecks: 0, + integrityFailures: 0, + storageUtilizationBytes: 0, + lastCalculatedStorage: Date.now(), + }; + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + incrementEventsGenerated(): void { + this.metrics.eventsGenerated++; + } + + incrementFailedWrites(): void { + this.metrics.failedWrites++; + } + + recordSearchOperation(latencyMs: number): void { + this.metrics.searchOperations++; + this.metrics.searchLatencyMs = latencyMs; + } + + incrementExportRequests(): void { + this.metrics.exportRequests++; + } + + recordIntegrityCheck(passed: boolean): void { + this.metrics.integrityChecks++; + if (!passed) { + this.metrics.integrityFailures++; + } + } + + async getMetrics(): Promise<{ + eventsGenerated: number; + failedWrites: number; + searchOperations: number; + averageSearchLatencyMs: number; + exportRequests: number; + integrityChecks: number; + integrityFailures: number; + integrityHealthPercent: number; + storageUtilizationBytes: number; + totalRecords: number; + recordsByEventType: Record; + recordsBySeverity: Record; + }> { + const totalRecords = await this.auditLogRepo.count(); + const avgLatency = this.metrics.searchOperations > 0 + ? Math.round(this.metrics.searchLatencyMs / this.metrics.searchOperations) + : 0; + const integrityHealth = this.metrics.integrityChecks > 0 + ? Math.round(((this.metrics.integrityChecks - this.metrics.integrityFailures) / this.metrics.integrityChecks) * 100) + : 100; + + const eventTypeCounts = await this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.eventType', 'eventType') + .addSelect('COUNT(*)', 'count') + .groupBy('audit.eventType') + .getRawMany(); + + const severityCounts = await this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.severity', 'severity') + .addSelect('COUNT(*)', 'count') + .groupBy('audit.severity') + .getRawMany(); + + const recordsByEventType: Record = {}; + eventTypeCounts.forEach((r: any) => { + recordsByEventType[r.eventType || 'UNKNOWN'] = parseInt(r.count, 10); + }); + + const recordsBySeverity: Record = {}; + severityCounts.forEach((r: any) => { + recordsBySeverity[r.severity || 'UNKNOWN'] = parseInt(r.count, 10); + }); + + const recordSize = 1024; + const storageUtilizationBytes = totalRecords * recordSize; + + return { + eventsGenerated: this.metrics.eventsGenerated, + failedWrites: this.metrics.failedWrites, + searchOperations: this.metrics.searchOperations, + averageSearchLatencyMs: avgLatency, + exportRequests: this.metrics.exportRequests, + integrityChecks: this.metrics.integrityChecks, + integrityFailures: this.metrics.integrityFailures, + integrityHealthPercent: integrityHealth, + storageUtilizationBytes, + totalRecords, + recordsByEventType, + recordsBySeverity, + }; + } +} diff --git a/src/audit/services/audit-retention.service.ts b/src/audit/services/audit-retention.service.ts index 66355f2..632541b 100644 --- a/src/audit/services/audit-retention.service.ts +++ b/src/audit/services/audit-retention.service.ts @@ -2,17 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { AuditTrailService } from './audit-trail.service'; +import { AuditEventType } from '../entities/audit-log.entity'; @Injectable() export class AuditRetentionService { private readonly logger = new Logger(AuditRetentionService.name); - private readonly daysToKeep: number; + private readonly defaultDaysToKeep: number; + private readonly retentionOverrides: Map = new Map(); constructor( private readonly auditTrailService: AuditTrailService, private readonly configService: ConfigService, ) { - this.daysToKeep = this.resolveRetentionDays(); + this.defaultDaysToKeep = this.resolveRetentionDays(); + this.loadRetentionOverrides(); } @Cron(process.env.AUDIT_LOG_RETENTION_CRON || CronExpression.EVERY_DAY_AT_MIDNIGHT, { @@ -21,19 +24,53 @@ export class AuditRetentionService { }) async purgeOldAuditLogs(): Promise { const deletedCount = await this.auditTrailService.deleteOldLogs( - this.daysToKeep, + this.defaultDaysToKeep, ); this.logger.log( - `Audit retention job removed ${deletedCount} records older than ${this.daysToKeep} days`, + `Audit retention job removed ${deletedCount} records older than ${this.defaultDaysToKeep} days`, ); return deletedCount; } + getRetentionDays(eventType?: AuditEventType): number { + if (eventType && this.retentionOverrides.has(eventType)) { + return this.retentionOverrides.get(eventType)!; + } + return this.defaultDaysToKeep; + } + + getRetentionConfig(): { + defaultDays: number; + overrides: Record; + } { + const overrides: Record = {}; + this.retentionOverrides.forEach((days, eventType) => { + overrides[eventType] = days; + }); + return { defaultDays: this.defaultDaysToKeep, overrides }; + } + private resolveRetentionDays(): number { const rawDays = this.configService.get('AUDIT_LOG_RETENTION_DAYS'); const parsedDays = parseInt(rawDays ?? '', 10); return Number.isNaN(parsedDays) || parsedDays <= 0 ? 365 : parsedDays; } + + private loadRetentionOverrides(): void { + const raw = this.configService.get('AUDIT_LOG_RETENTION_OVERRIDES'); + if (!raw) return; + + try { + const parsed = JSON.parse(raw); + for (const [eventType, days] of Object.entries(parsed)) { + if (Object.values(AuditEventType).includes(eventType as AuditEventType) && typeof days === 'number') { + this.retentionOverrides.set(eventType, days); + } + } + } catch { + this.logger.warn('Failed to parse AUDIT_LOG_RETENTION_OVERRIDES'); + } + } } diff --git a/src/audit/services/audit-search.service.ts b/src/audit/services/audit-search.service.ts new file mode 100644 index 0000000..e1312eb --- /dev/null +++ b/src/audit/services/audit-search.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog, AuditActionType, AuditEntityType, AuditEventType, AuditSeverity } from '../entities/audit-log.entity'; +import { SearchAuditDto } from '../dto/search-audit.dto'; + +export interface SearchResult { + logs: AuditLog[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + query: { + filters: Record; + dateRange?: { start?: string; end?: string }; + searchTerm?: string; + }; +} + +@Injectable() +export class AuditSearchService { + private readonly logger = new Logger(AuditSearchService.name); + + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + async search(dto: SearchAuditDto): Promise { + const query = this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .orderBy('audit.createdAt', dto.sortOrder || 'DESC'); + + if (dto.actionType) { + query.andWhere('audit.actionType = :actionType', { actionType: dto.actionType }); + } + + if (dto.entityType) { + query.andWhere('audit.entityType = :entityType', { entityType: dto.entityType }); + } + + if (dto.eventType) { + query.andWhere('audit.eventType = :eventType', { eventType: dto.eventType }); + } + + if (dto.severity) { + query.andWhere('audit.severity = :severity', { severity: dto.severity }); + } + + if (dto.userId) { + query.andWhere('audit.userId = :userId', { userId: dto.userId }); + } + + if (dto.actorRole) { + query.andWhere('audit.actorRole = :actorRole', { actorRole: dto.actorRole }); + } + + if (dto.entityId) { + query.andWhere('audit.entityId = :entityId', { entityId: dto.entityId }); + } + + if (dto.resourceType) { + query.andWhere('audit.resourceType = :resourceType', { resourceType: dto.resourceType }); + } + + if (dto.correlationId) { + query.andWhere('audit.correlationId = :correlationId', { correlationId: dto.correlationId }); + } + + if (dto.requestId) { + query.andWhere('audit.requestId = :requestId', { requestId: dto.requestId }); + } + + if (dto.ipAddress) { + query.andWhere('audit.ipAddress LIKE :ipAddress', { ipAddress: `%${dto.ipAddress}%` }); + } + + if (dto.walletAddress) { + query.andWhere('audit.walletAddress = :walletAddress', { walletAddress: dto.walletAddress }); + } + + if (dto.searchTerm) { + query.andWhere( + '(audit.description LIKE :searchTerm OR audit.entityId LIKE :searchTerm OR audit.metadata LIKE :searchTerm)', + { searchTerm: `%${dto.searchTerm}%` }, + ); + } + + if (dto.startDate) { + query.andWhere('audit.createdAt >= :startDate', { startDate: new Date(dto.startDate) }); + } + + if (dto.endDate) { + query.andWhere('audit.createdAt <= :endDate', { endDate: new Date(dto.endDate) }); + } + + const limit = dto.limit || 100; + const offset = dto.offset || 0; + + const [logs, total] = await query + .skip(offset) + .take(limit) + .getManyAndCount(); + + return { + logs, + total, + page: Math.floor(offset / limit) + 1, + pageSize: limit, + totalPages: Math.ceil(total / limit), + query: { + filters: { + ...(dto.actionType && { actionType: dto.actionType }), + ...(dto.entityType && { entityType: dto.entityType }), + ...(dto.eventType && { eventType: dto.eventType }), + ...(dto.severity && { severity: dto.severity }), + ...(dto.userId && { userId: dto.userId }), + ...(dto.actorRole && { actorRole: dto.actorRole }), + ...(dto.entityId && { entityId: dto.entityId }), + ...(dto.resourceType && { resourceType: dto.resourceType }), + ...(dto.correlationId && { correlationId: dto.correlationId }), + ...(dto.requestId && { requestId: dto.requestId }), + ...(dto.ipAddress && { ipAddress: dto.ipAddress }), + ...(dto.walletAddress && { walletAddress: dto.walletAddress }), + }, + dateRange: { + ...(dto.startDate && { start: dto.startDate }), + ...(dto.endDate && { end: dto.endDate }), + }, + searchTerm: dto.searchTerm, + }, + }; + } + + async findByCorrelationId(correlationId: string): Promise { + return this.auditLogRepo.find({ + where: { correlationId }, + order: { createdAt: 'ASC' }, + }); + } + + async findByRequestId(requestId: string): Promise { + return this.auditLogRepo.find({ + where: { requestId }, + order: { createdAt: 'ASC' }, + }); + } + + async findFailedAccessAttempts( + startDate?: Date, + endDate?: Date, + limit = 100, + offset = 0, + ): Promise<{ logs: AuditLog[]; total: number }> { + const query = this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.actionType IN (:...types)', { + types: [AuditActionType.LOGIN_FAILED, AuditActionType.AUTHORIZATION_FAILED, AuditActionType.PERMISSION_DENIED], + }) + .orderBy('audit.createdAt', 'DESC'); + + if (startDate) { + query.andWhere('audit.createdAt >= :startDate', { startDate }); + } + if (endDate) { + query.andWhere('audit.createdAt <= :endDate', { endDate }); + } + + const [logs, total] = await query.skip(offset).take(limit).getManyAndCount(); + return { logs, total }; + } + + async findSecurityEvents( + startDate?: Date, + endDate?: Date, + limit = 100, + offset = 0, + ): Promise<{ logs: AuditLog[]; total: number }> { + const query = this.auditLogRepo + .createQueryBuilder('audit') + .leftJoinAndSelect('audit.user', 'user') + .where('audit.eventType = :eventType', { eventType: AuditEventType.SECURITY }) + .orderBy('audit.createdAt', 'DESC'); + + if (startDate) { + query.andWhere('audit.createdAt >= :startDate', { startDate }); + } + if (endDate) { + query.andWhere('audit.createdAt <= :endDate', { endDate }); + } + + const [logs, total] = await query.skip(offset).take(limit).getManyAndCount(); + return { logs, total }; + } +} diff --git a/src/audit/services/audit-trail.service.ts b/src/audit/services/audit-trail.service.ts index e90b214..01e18cb 100644 --- a/src/audit/services/audit-trail.service.ts +++ b/src/audit/services/audit-trail.service.ts @@ -7,8 +7,11 @@ import { AuditLog, AuditActionType, AuditEntityType, + AuditEventType, + AuditSeverity, } from '../entities/audit-log.entity'; import { maskIp } from '../utils/ip-masking'; +import { generateAuditHash, verifyAuditIntegrity } from '../utils/integrity'; export interface AuditLogInput { actionType: AuditActionType; @@ -16,11 +19,16 @@ export interface AuditLogInput { entityId: string; userId?: string; walletAddress?: string; + actorRole?: string; + eventType?: AuditEventType; + resourceType?: string; + severity?: AuditSeverity; description?: string; beforeState?: Record; afterState?: Record; metadata?: Record; correlationId?: string; + requestId?: string; } @Injectable() @@ -34,56 +42,83 @@ export class AuditTrailService { private readonly request: Request, ) {} - /** - * Log an action to the audit trail - */ async log(input: AuditLogInput): Promise { try { + const correlationId = input.correlationId || this.getCorrelationId(); + const requestId = input.requestId || this.getRequestId(); + const ipAddress = maskIp(this.getClientIp()); + const userAgent = this.request?.get('user-agent'); + const auditLog = this.auditLogRepo.create({ actionType: input.actionType, entityType: input.entityType, entityId: input.entityId, userId: input.userId, walletAddress: input.walletAddress, + actorRole: input.actorRole, + eventType: input.eventType, + resourceType: input.resourceType, + severity: input.severity || AuditSeverity.INFO, 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'), + correlationId, + requestId, + ipAddress, + userAgent, + }); + + const saved = await this.auditLogRepo.save(auditLog); + + const hash = generateAuditHash({ + id: saved.id, + actionType: saved.actionType, + entityType: saved.entityType, + entityId: saved.entityId, + userId: saved.userId, + walletAddress: saved.walletAddress, + description: saved.description, + beforeState: saved.beforeState, + afterState: saved.afterState, + metadata: saved.metadata, + ipAddress: saved.ipAddress, + userAgent: saved.userAgent, + correlationId: saved.correlationId, + createdAt: saved.createdAt, }); - await this.auditLogRepo.save(auditLog); + await this.auditLogRepo.update(saved.id, { integrityHash: hash }); + this.logger.debug( - `Audit logged: ${input.actionType} on ${input.entityType} ${input.entityId}`, + `Audit logged: ${input.actionType} on ${input.entityType} ${input.entityId} (hash: ${hash.slice(0, 12)}...)`, ); } 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 verifyIntegrity(id: string): Promise<{ valid: boolean; record: AuditLog | null }> { + const record = await this.auditLogRepo.findOne({ where: { id } }); + if (!record) { + return { valid: false, record: null }; + } + + const valid = verifyAuditIntegrity(record); + return { valid, record }; + } + 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 +135,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get audit logs for a specific action type - */ async getActionAuditLogs( actionType: AuditActionType, limit = 100, @@ -119,9 +151,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get all audit logs for a specific entity type with filters - */ async getAuditLogs( entityType?: AuditEntityType, actionType?: AuditActionType, @@ -154,9 +183,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get audit logs within a date range - */ async getAuditLogsByDateRange( startDate: Date, endDate: Date, @@ -178,9 +204,6 @@ export class AuditTrailService { return { logs, total }; } - /** - * Get a summary of audit logs grouped by action type - */ async getAuditSummary( entityType?: AuditEntityType, days = 7, @@ -202,16 +225,13 @@ export class AuditTrailService { const results = await query.getRawMany(); const summary: Record = {}; - results.forEach((r) => { + results.forEach((r: any) => { summary[r.actionType] = parseInt(r.count, 10); }); return summary; } - /** - * Get change history for an entity - */ async getChangeHistory( entityType: AuditEntityType, entityId: string, @@ -233,18 +253,15 @@ export class AuditTrailService { })); } - /** - * Delete old audit logs (retention policy) - */ 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', { cutoff: cutoffDate }) + .andWhere('(audit.retentionUntil IS NULL OR audit.retentionUntil < :now)', { now: new Date() }); const result = await query.execute(); @@ -254,11 +271,69 @@ export class AuditTrailService { return result.affected || 0; } + async getRetentionStatus(): Promise<{ + totalRecords: number; + archivedRecords: number; + legalHoldRecords: number; + pendingCleanup: number; + }> { + const totalRecords = await this.auditLogRepo.count(); + const archivedRecords = await this.auditLogRepo.count({ where: { archived: true } }); + const legalHoldRecords = await this.auditLogRepo + .createQueryBuilder('audit') + .where('audit.retentionUntil IS NOT NULL') + .getCount(); + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 365); + const pendingCleanup = await this.auditLogRepo + .createQueryBuilder('audit') + .where('audit.createdAt < :cutoff', { cutoff: cutoffDate }) + .andWhere('(audit.retentionUntil IS NULL OR audit.retentionUntil < :now)', { now: new Date() }) + .getCount(); + + return { + totalRecords, + archivedRecords, + legalHoldRecords, + pendingCleanup, + }; + } + + async placeLegalHold(entityId: string, reason: string, initiatedBy: string): Promise { + const result = await this.auditLogRepo + .createQueryBuilder() + .update(AuditLog) + .set({ retentionUntil: () => "'9999-12-31 23:59:59'" }) + .where('entityId = :entityId', { entityId }) + .execute(); + + this.logger.log(`Legal hold placed on ${entityId}: ${reason} (by ${initiatedBy})`); + return result.affected || 0; + } + + async removeLegalHold(entityId: string): Promise { + const result = await this.auditLogRepo + .createQueryBuilder() + .update(AuditLog) + .set({ retentionUntil: null }) + .where('entityId = :entityId', { entityId }) + .andWhere('retentionUntil IS NOT NULL') + .execute(); + + this.logger.log(`Legal hold removed from ${entityId}`); + return result.affected || 0; + } + + async getAuditLogsByCorrelationId(correlationId: string): Promise { + return this.auditLogRepo.find({ + where: { correlationId }, + order: { createdAt: 'ASC' }, + }); + } + private getClientIp(): string | undefined { if (!this.request) return undefined; - - // Use req.ip which respects trust proxy configuration - // Falls back to socket remoteAddress for direct connections return this.request.ip || this.request.socket?.remoteAddress; } @@ -266,10 +341,17 @@ export class AuditTrailService { if (this.request?.headers['x-correlation-id']) { return this.request.headers['x-correlation-id'] as string; } - return this.generateCorrelationId(); + return this.generateId(); } - private generateCorrelationId(): string { + private getRequestId(): string { + if (this.request?.headers['x-request-id']) { + return this.request.headers['x-request-id'] as string; + } + return this.generateId(); + } + + private generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } @@ -297,13 +379,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/index.ts b/src/audit/services/index.ts index dff8065..0defed5 100644 --- a/src/audit/services/index.ts +++ b/src/audit/services/index.ts @@ -1 +1,5 @@ export * from './audit-trail.service'; +export * from './audit-search.service'; +export * from './audit-compliance.service'; +export * from './audit-metrics.service'; +export * from './audit-retention.service'; diff --git a/src/audit/utils/integrity.ts b/src/audit/utils/integrity.ts new file mode 100644 index 0000000..cdf86b2 --- /dev/null +++ b/src/audit/utils/integrity.ts @@ -0,0 +1,51 @@ +import * as crypto from 'crypto'; + +export function generateAuditHash(record: { + id: string; + actionType: string; + entityType: string; + entityId: string; + userId?: string; + walletAddress?: string; + description?: string; + beforeState?: Record; + afterState?: Record; + metadata?: Record; + ipAddress?: string; + userAgent?: string; + correlationId?: string; + createdAt: Date | string; + previousHash?: string; +}): string { + const normalized = { + id: record.id, + actionType: record.actionType, + entityType: record.entityType, + entityId: record.entityId, + userId: record.userId || null, + walletAddress: record.walletAddress || null, + description: record.description || null, + beforeState: record.beforeState || null, + afterState: record.afterState || null, + metadata: record.metadata || null, + ipAddress: record.ipAddress || null, + userAgent: record.userAgent || null, + correlationId: record.correlationId || null, + createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, + previousHash: record.previousHash || null, + }; + + const serialized = JSON.stringify(normalized, Object.keys(normalized).sort()); + return crypto.createHash('sha256').update(serialized).digest('hex'); +} + +export function verifyAuditIntegrity( + record: { integrityHash?: string; [key: string]: any }, +): boolean { + if (!record.integrityHash) { + return false; + } + const { integrityHash: _, ...rest } = record; + const expectedHash = generateAuditHash(rest as any); + return expectedHash === record.integrityHash; +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d772421..d3fee32 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -111,12 +111,16 @@ export class AuthService { // until they link their wallet properly const userId = user?.user?.id || null; - // 8. Generate JWT token + // 8. Determine user role + const role = user?.user?.role || 'USER'; + + // 9. Generate JWT token // Align 'sub' with RFC 7519: prefer stable unique subject (userId) when available const subject = userId ? String(userId) : address.toLowerCase(); const payload = { address: address.toLowerCase(), userId, + role, sub: subject, }; @@ -127,6 +131,7 @@ export class AuthService { user: { id: userId, address: address.toLowerCase(), + role, }, }; } @@ -150,10 +155,14 @@ export class AuthService { }) : null; + const role = wallet?.user?.role || payload.role || 'USER'; + return { address: wallet?.address || address, userId: wallet?.user?.id || userId, + role, user: wallet?.user || null, + sub: payload.sub, }; } diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..cb854d0 --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../entities/user.entity'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..db494e4 --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,35 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '../../entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException('Authentication required'); + } + + const hasRole = requiredRoles.some((role) => user.role === role); + + if (!hasRole) { + throw new ForbiddenException('Insufficient permissions'); + } + + return true; + } +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 2a27256..98de7b1 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -54,7 +54,7 @@ export function configureApp(app: INestApplication) { .addTag('indexer', 'Event indexer management') .addTag('rewards', 'Reward management') .addTag('leaderboard', 'User leaderboard rankings') - .addTag('audit', 'Audit log retrieval') + .addTag('audit', 'Audit logging, search, compliance reporting, and retention management') .addTag('health', 'Health check endpoints') .build(); diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index d5b6ce0..bcc7f16 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -8,6 +8,13 @@ import { Index, } from 'typeorm'; +export enum UserRole { + USER = 'USER', + MODERATOR = 'MODERATOR', + ADMIN = 'ADMIN', + SUPER_ADMIN = 'SUPER_ADMIN', +} + @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') @@ -17,6 +24,12 @@ export class User { @Index() walletAddress: string; + @Column({ + type: 'varchar', + default: UserRole.USER, + }) + role: UserRole; + @Column({ type: 'int', default: 0 }) reputation: number;