diff --git a/src/audit/controllers/audit-log.controller.ts b/src/audit/controllers/audit-log.controller.ts index fed698a..0f1ee56 100644 --- a/src/audit/controllers/audit-log.controller.ts +++ b/src/audit/controllers/audit-log.controller.ts @@ -4,6 +4,7 @@ import { Query, Param, Post, + Patch, Res, Headers, } from '@nestjs/common'; @@ -381,6 +382,67 @@ export class AuditController { }; } + @Get('retention') + @ApiOperation({ summary: 'Get audit retention status' }) + async getRetentionStatus( + @Headers('x-request-id') requestId?: string, + ): Promise> { + const status = await this.auditTrailService.getRetentionStatus(); + return { + success: true, + data: status, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Post('legal-hold/:entityType/:entityId') + @ApiOperation({ summary: 'Place a legal hold on all audit logs for an entity' }) + async placeLegalHold( + @Param('entityType') entityType: AuditEntityType, + @Param('entityId') entityId: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const affected = await this.auditTrailService.placeLegalHold(entityType, entityId); + return { + success: true, + data: { entityType, entityId, affected }, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Patch('legal-hold/:entityType/:entityId/remove') + @ApiOperation({ summary: 'Remove legal hold from audit logs for an entity' }) + async removeLegalHold( + @Param('entityType') entityType: AuditEntityType, + @Param('entityId') entityId: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const affected = await this.auditTrailService.removeLegalHold(entityType, entityId); + return { + success: true, + data: { entityType, entityId, affected }, + timestamp: new Date().toISOString(), + requestId, + }; + } + + @Get('integrity/:id') + @ApiOperation({ summary: 'Verify the integrity hash of an audit log' }) + async verifyIntegrity( + @Param('id') id: string, + @Headers('x-request-id') requestId?: string, + ): Promise> { + const result = await this.auditTrailService.verifyIntegrity(id); + return { + success: true, + data: result, + timestamp: new Date().toISOString(), + requestId, + }; + } + @Get('metrics') @ApiOperation({ summary: 'Get audit system metrics' }) async getMetrics( diff --git a/src/audit/entities/audit-log.entity.ts b/src/audit/entities/audit-log.entity.ts index 6f22eed..ff0c470 100644 --- a/src/audit/entities/audit-log.entity.ts +++ b/src/audit/entities/audit-log.entity.ts @@ -143,6 +143,8 @@ export enum AuditCategory { @Index(['actionType', 'createdAt']) @Index(['category', 'createdAt']) @Index(['severity', 'createdAt']) +@Index(['archived']) +@Index(['integrityHash']) export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; @@ -213,4 +215,10 @@ export class AuditLog { @Column({ type: 'datetime', nullable: true }) retentionUntil: Date | null; + + @Column({ type: 'varchar', nullable: true }) + integrityHash: string | null; + + @Column({ default: false }) + archived: boolean; } diff --git a/src/audit/processors/audit-log.processor.ts b/src/audit/processors/audit-log.processor.ts index bb867d7..74ff266 100644 --- a/src/audit/processors/audit-log.processor.ts +++ b/src/audit/processors/audit-log.processor.ts @@ -4,7 +4,7 @@ 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 { AuditLogInput, AuditTrailService } from '../services/audit-trail.service'; import { maskIp } from '../utils/ip-masking'; import { AUDIT_QUEUE_NAME } from '../services/audit-queue.service'; import { randomUUID } from 'crypto'; @@ -16,6 +16,7 @@ export class AuditLogProcessor extends WorkerHost { constructor( @InjectRepository(AuditLog) private readonly auditLogRepo: Repository, + private readonly auditTrailService: AuditTrailService, ) { super(); } @@ -43,7 +44,8 @@ export class AuditLogProcessor extends WorkerHost { userAgent: input.userAgent, }); - await this.auditLogRepo.save(auditLog); + const saved = await this.auditLogRepo.save(auditLog); + await this.auditTrailService.stampIntegrityHash(saved.id); } catch (error) { this.logger.error(`Failed to process audit job ${job.id}: ${error.message}`); throw error; diff --git a/src/audit/services/audit-trail.service.spec.ts b/src/audit/services/audit-trail.service.spec.ts index 64ee59b..03ea89e 100644 --- a/src/audit/services/audit-trail.service.spec.ts +++ b/src/audit/services/audit-trail.service.spec.ts @@ -41,6 +41,8 @@ describe('AuditTrailService', () => { userAgent: 'test-agent', correlationId: 'corr-1', retentionUntil: null, + integrityHash: null, + archived: false, user: null, createdAt: new Date(), ...overrides, diff --git a/src/audit/services/audit-trail.service.ts b/src/audit/services/audit-trail.service.ts index 3f1b022..ccbee00 100644 --- a/src/audit/services/audit-trail.service.ts +++ b/src/audit/services/audit-trail.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, Like, In } from 'typeorm'; +import { Repository, Between, Like, In, Not, IsNull } from 'typeorm'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; import { @@ -14,6 +14,7 @@ import { maskIp } from '../utils/ip-masking'; import { AuditQueueService } from './audit-queue.service'; import { randomUUID } from 'crypto'; import { AuditPaginatedResponse } from '../interfaces/audit-response.interface'; +import { generateAuditHash, verifyAuditIntegrity } from '../utils/integrity'; export interface AuditLogInput { actionType: AuditActionType; @@ -97,7 +98,8 @@ export class AuditTrailService { retentionUntil: input.retentionUntil || null, }); - await this.auditLogRepo.save(auditLog); + const saved = await this.auditLogRepo.save(auditLog); + await this.stampIntegrityHash(saved.id); 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); @@ -129,7 +131,8 @@ export class AuditTrailService { }), ); - await this.auditLogRepo.save(auditLogs); + const saved = await this.auditLogRepo.save(auditLogs); + await Promise.all(saved.map((record) => this.stampIntegrityHash(record.id))); this.logger.debug(`Batch audit logged: ${auditLogs.length} records`); } catch (error) { this.logger.error(`Failed to batch log audits: ${error.message}`, error.stack); @@ -363,6 +366,79 @@ export class AuditTrailService { }; } + async stampIntegrityHash(id: string): Promise { + try { + const record = await this.auditLogRepo.findOne({ where: { id } }); + if (!record) return; + const { integrityHash: _existing, ...hashable } = record; + record.integrityHash = generateAuditHash(hashable as any); + await this.auditLogRepo.update(id, { integrityHash: record.integrityHash }); + } catch (error) { + this.logger.error(`Failed to stamp integrity hash: ${error.message}`, error.stack); + } + } + + async verifyIntegrity(id: string): Promise<{ valid: boolean; id: string; integrityHash?: string; reason?: string }> { + const record = await this.auditLogRepo.findOne({ where: { id } }); + if (!record) { + return { valid: false, id, reason: 'not_found' }; + } + if (!record.integrityHash) { + return { valid: false, id, reason: 'hash_missing' }; + } + const { integrityHash, ...hashable } = record; + const valid = verifyAuditIntegrity({ ...hashable, integrityHash }); + return valid + ? { valid: true, id, integrityHash: record.integrityHash } + : { valid: false, id, reason: 'hash_mismatch' }; + } + + async placeLegalHold(entityType: AuditEntityType, entityId: string): Promise { + const retentionUntil = new Date(); + retentionUntil.setFullYear(retentionUntil.getFullYear() + 100); + const result = await this.auditLogRepo + .createQueryBuilder() + .update(AuditLog) + .set({ retentionUntil }) + .where('entityType = :entityType AND entityId = :entityId', { entityType, entityId }) + .execute(); + this.logger.log(`Legal hold placed on ${entityType} ${entityId}`); + return result.affected || 0; + } + + async removeLegalHold(entityType: AuditEntityType, entityId: string, retentionDays = 365): Promise { + const retentionUntil = new Date(); + retentionUntil.setDate(retentionUntil.getDate() + retentionDays); + const result = await this.auditLogRepo + .createQueryBuilder() + .update(AuditLog) + .set({ retentionUntil }) + .where('entityType = :entityType AND entityId = :entityId', { entityType, entityId }) + .execute(); + this.logger.log(`Legal hold removed on ${entityType} ${entityId}`); + return result.affected || 0; + } + + async getRetentionStatus(): Promise<{ + totalRecords: number; + archivedRecords: number; + recordsWithRetention: number; + pendingPurge: number; + }> { + const totalRecords = await this.auditLogRepo.count(); + const archivedRecords = await this.auditLogRepo.count({ where: { archived: true } }); + const recordsWithRetention = await this.auditLogRepo.count({ + where: { retentionUntil: Not(IsNull()) }, + }); + const pendingPurge = await this.auditLogRepo + .createQueryBuilder('audit') + .where('audit.createdAt < :cutoff AND audit.retentionUntil IS NULL', { + cutoff: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + }) + .getCount(); + return { totalRecords, archivedRecords, recordsWithRetention, pendingPurge }; + } + getClientIp(): string | undefined { if (!this.request) return undefined; return this.request.ip || this.request.socket?.remoteAddress; diff --git a/src/audit/services/compliance.service.spec.ts b/src/audit/services/compliance.service.spec.ts index ed90bbd..cb17b1e 100644 --- a/src/audit/services/compliance.service.spec.ts +++ b/src/audit/services/compliance.service.spec.ts @@ -28,6 +28,8 @@ describe('ComplianceService', () => { userAgent: 'test-agent', correlationId: 'corr-1', retentionUntil: null, + integrityHash: null, + archived: false, user: null, createdAt: new Date('2024-06-15'), ...overrides, diff --git a/src/audit/services/security-monitoring.service.spec.ts b/src/audit/services/security-monitoring.service.spec.ts index 2e41b59..9d17556 100644 --- a/src/audit/services/security-monitoring.service.spec.ts +++ b/src/audit/services/security-monitoring.service.spec.ts @@ -28,6 +28,8 @@ describe('SecurityMonitoringService', () => { userAgent: 'test-agent', correlationId: 'corr-1', retentionUntil: null, + integrityHash: null, + archived: false, user: null, createdAt: new Date(), ...overrides, 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/bootstrap.ts b/src/bootstrap.ts index 295f214..000cddb 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') .addTag('ai-assistant', 'AI assistant conversations, knowledge-base retrieval, and usage analytics') .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;