Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/audit/controllers/audit-log.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Query,
Param,
Post,
Patch,
Res,
Headers,
} from '@nestjs/common';
Expand Down Expand Up @@ -381,6 +382,67 @@ export class AuditController {
};
}

@Get('retention')
@ApiOperation({ summary: 'Get audit retention status' })
async getRetentionStatus(
@Headers('x-request-id') requestId?: string,
): Promise<AuditResponse<any>> {
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<AuditResponse<any>> {
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<AuditResponse<any>> {
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<AuditResponse<any>> {
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(
Expand Down
8 changes: 8 additions & 0 deletions src/audit/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
6 changes: 4 additions & 2 deletions src/audit/processors/audit-log.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +16,7 @@ export class AuditLogProcessor extends WorkerHost {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepo: Repository<AuditLog>,
private readonly auditTrailService: AuditTrailService,
) {
super();
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/audit/services/audit-trail.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe('AuditTrailService', () => {
userAgent: 'test-agent',
correlationId: 'corr-1',
retentionUntil: null,
integrityHash: null,
archived: false,
user: null,
createdAt: new Date(),
...overrides,
Expand Down
82 changes: 79 additions & 3 deletions src/audit/services/audit-trail.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -363,6 +366,79 @@ export class AuditTrailService {
};
}

async stampIntegrityHash(id: string): Promise<void> {
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<number> {
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<number> {
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;
Expand Down
2 changes: 2 additions & 0 deletions src/audit/services/compliance.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/audit/services/security-monitoring.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ describe('SecurityMonitoringService', () => {
userAgent: 'test-agent',
correlationId: 'corr-1',
retentionUntil: null,
integrityHash: null,
archived: false,
user: null,
createdAt: new Date(),
...overrides,
Expand Down
51 changes: 51 additions & 0 deletions src/audit/utils/integrity.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
afterState?: Record<string, any>;
metadata?: Record<string, any>;
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;
}
2 changes: 1 addition & 1 deletion src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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;

Expand Down