Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"snyk.advanced.autoSelectOrganization": true
}
126 changes: 43 additions & 83 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
Expand Down
25 changes: 25 additions & 0 deletions src/infrastructure/audit/algorithms/export-signing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import * as crypto from "crypto";

@Injectable()
export class ExportSigningService {
private readonly systemKey = process.env.AUDIT_EXPORT_SIGNING_KEY || "";

sign(payload: string): string {
if (!this.systemKey) {
throw new Error("AUDIT_EXPORT_SIGNING_KEY is not configured");
}
return crypto
.createHmac("sha256", this.systemKey)
.update(payload)
.digest("hex");
}

verify(payload: string, signature: string): boolean {
const expected = this.sign(payload);
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex"),
);
}
}
85 changes: 85 additions & 0 deletions src/infrastructure/audit/audit-log.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
Res,
ParseUUIDPipe,
} from "@nestjs/common";
import { Response } from "express";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} from "@nestjs/swagger";
import { AuditLogService } from "./audit-log.service";
import { QueryAuditLogDto, ExportAuditLogDto } from "./dto/query-audit-log.dto";
import {
AuditLogResponseDto,
AuditLogListResponseDto,
} from "./dto/audit-log-response.dto";
import { JwtAuthGuard } from "src/core/auth/jwt.guard";
import { ComplianceOfficerGuard } from "./guards/compliance-officer.guard";

@ApiTags("Audit Logs")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, ComplianceOfficerGuard)
@Controller("audit-logs")
export class AuditLogController {
constructor(private readonly auditLogService: AuditLogService) {}

@Get()
@ApiOperation({
summary: "Search and filter audit logs",
description:
"Full-text search with filters by user, action, IP, and date range. Paginated, 100 records per page max.",
})
@ApiResponse({ status: 200, type: AuditLogListResponseDto })
@ApiResponse({ status: 403, description: "Forbidden" })
async query(@Query() query: QueryAuditLogDto): Promise<AuditLogListResponseDto> {
return this.auditLogService.query(query);
}

@Get(":id")
@ApiOperation({ summary: "Get audit log by ID" })
@ApiParam({ name: "id", type: "string" })
@ApiResponse({ status: 200, type: AuditLogResponseDto })
@ApiResponse({ status: 404, description: "Audit log not found" })
async getById(
@Param("id", ParseUUIDPipe) id: string,
): Promise<AuditLogResponseDto> {
return this.auditLogService.findById(id);
}

@Get("export")
@ApiOperation({
summary: "Bulk export audit logs",
description:
"Exports up to 100,000 records for a date range as signed JSON or CSV.",
})
@ApiResponse({ status: 200, description: "Export with integrity signature" })
async export(
@Query() query: ExportAuditLogDto,
@Res() res: Response,
): Promise<void> {
const { payload, signature } =
query.format === "csv"
? await this.auditLogService.exportToCsv(query)
: await this.auditLogService.exportToJson(query);

const ext = query.format === "csv" ? "csv" : "json";
res.setHeader(
"Content-Type",
query.format === "csv" ? "text/csv" : "application/json",
);
res.setHeader(
"Content-Disposition",
`attachment; filename="audit-logs-${Date.now()}.${ext}"`,
);
res.setHeader("X-Signature", signature);
res.send(payload);
}
}
15 changes: 15 additions & 0 deletions src/infrastructure/audit/audit-log.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { AuditLog } from "./entities/audit-log.entity";
import { AuditLogService } from "./audit-log.service";
import { AuditLogController } from "./audit-log.controller";
import { ExportSigningService } from "./algorithms/export-signing.service";

@Module({
imports: [TypeOrmModule.forFeature([AuditLog]), ScheduleModule.forRoot()],
controllers: [AuditLogController],
providers: [AuditLogService, ExportSigningService],
exports: [AuditLogService],
})
export class AuditLogModule {}
169 changes: 168 additions & 1 deletion src/infrastructure/audit/audit-log.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Cron, CronExpression } from "@nestjs/schedule";
import { AuditLog } from "./entities/audit-log.entity";
import {
QueryAuditLogDto,
ExportAuditLogDto,
} from "./dto/query-audit-log.dto";
import { AuditLogListResponseDto } from "./dto/audit-log-response.dto";
import { ExportSigningService } from "./algorithms/export-signing.service";

const RETENTION_YEARS = 7;
const ARCHIVE_AFTER_YEARS = 1;

@Injectable()
export class AuditLogService {
Expand All @@ -21,6 +34,160 @@ export class AuditLogService {
getLogs(limit = 50) {
return this.logs.slice(-limit);
}

constructor(
@InjectRepository(AuditLog)
private readonly repo: Repository<AuditLog>,
private readonly signingService: ExportSigningService,
) {}

async record(entry: {
userId?: string | null;
action: AuditLog["action"];
resourceType?: string;
resourceId?: string;
ipAddress: string;
userAgent?: string;
details?: string;
metadata?: Record<string, unknown>;
}): Promise<AuditLog> {
const searchText = [
entry.action,
entry.resourceType,
entry.resourceId,
entry.ipAddress,
entry.details,
]
.filter(Boolean)
.join(" ");

const log = this.repo.create({ ...entry, searchText });
return this.repo.save(log);
}

async query(dto: QueryAuditLogDto): Promise<AuditLogListResponseDto> {
const page = dto.page ?? 1;
const limit = dto.limit ?? 100;

const qb = this.repo.createQueryBuilder("log");

if (dto.search) {
qb.andWhere(
`to_tsvector('english', log."searchText") @@ websearch_to_tsquery('english', :search)`,
{ search: dto.search },
);
}
if (dto.userId) qb.andWhere("log.userId = :userId", { userId: dto.userId });
if (dto.action) qb.andWhere("log.action = :action", { action: dto.action });
if (dto.ipAddress)
qb.andWhere("log.ipAddress = :ipAddress", { ipAddress: dto.ipAddress });
if (dto.fromDate)
qb.andWhere("log.createdAt >= :fromDate", { fromDate: dto.fromDate });
if (dto.toDate)
qb.andWhere("log.createdAt <= :toDate", { toDate: dto.toDate });

qb.orderBy("log.createdAt", "DESC")
.skip((page - 1) * limit)
.take(limit);

const [data, total] = await qb.getManyAndCount();

return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

async findById(id: string): Promise<AuditLog> {
const log = await this.repo.findOne({ where: { id } });
if (!log) throw new NotFoundException("Audit log not found");
return log;
}

private async fetchForExport(dto: ExportAuditLogDto): Promise<AuditLog[]> {
return this.repo
.createQueryBuilder("log")
.where("log.createdAt >= :fromDate", { fromDate: dto.fromDate })
.andWhere("log.createdAt <= :toDate", { toDate: dto.toDate })
.orderBy("log.createdAt", "ASC")
.take(dto.limit ?? 10000)
.getMany();
}

async exportToJson(
dto: ExportAuditLogDto,
): Promise<{ payload: string; signature: string }> {
const logs = await this.fetchForExport(dto);
const payload = JSON.stringify(logs);
const signature = this.signingService.sign(payload);
return { payload, signature };
}

async exportToCsv(
dto: ExportAuditLogDto,
): Promise<{ payload: string; signature: string }> {
const logs = await this.fetchForExport(dto);
const header = [
"id",
"userId",
"action",
"resourceType",
"resourceId",
"ipAddress",
"createdAt",
];
const rows = logs.map((log) =>
header
.map((field) => JSON.stringify((log as any)[field] ?? ""))
.join(","),
);
const payload = [header.join(","), ...rows].join("\n");
const signature = this.signingService.sign(payload);
return { payload, signature };
}

// Moves logs older than 1 year to cold storage and marks them archived.
// Cold-storage transfer is delegated to an external sink (S3/Glacier);
// this only flips the archivedAt marker once the transfer succeeds.
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async archiveOldLogs(coldStorageWriter?: (logs: AuditLog[]) => Promise<void>) {
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - ARCHIVE_AFTER_YEARS);

const logs = await this.repo
.createQueryBuilder("log")
.where("log.createdAt < :cutoff", { cutoff })
.andWhere("log.archivedAt IS NULL")
.getMany();

if (logs.length === 0) return;

if (coldStorageWriter) await coldStorageWriter(logs);

await this.repo
.createQueryBuilder()
.update(AuditLog)
.set({ archivedAt: new Date() })
.where("id IN (:...ids)", { ids: logs.map((l) => l.id) })
.execute();
}

// Permanently deletes logs past the 7-year retention period.
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async enforceRetention(): Promise<void> {
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - RETENTION_YEARS);

await this.repo
.createQueryBuilder()
.delete()
.from(AuditLog)
.where("createdAt < :cutoff", { cutoff })
.execute();
}
}


Expand Down
54 changes: 54 additions & 0 deletions src/infrastructure/audit/dto/audit-log-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ApiProperty } from "@nestjs/swagger";
import { AuditLogAction } from "../entities/audit-log.entity";

export class AuditLogResponseDto {
@ApiProperty()
id: string;

@ApiProperty({ nullable: true })
userId: string | null;

@ApiProperty({ enum: AuditLogAction })
action: AuditLogAction;

@ApiProperty({ nullable: true })
resourceType: string | null;

@ApiProperty({ nullable: true })
resourceId: string | null;

@ApiProperty()
ipAddress: string;

@ApiProperty({ nullable: true })
userAgent: string | null;

@ApiProperty({ nullable: true })
details: string | null;

@ApiProperty({ nullable: true })
metadata: Record<string, unknown> | null;

@ApiProperty()
createdAt: Date;

@ApiProperty({ nullable: true })
archivedAt: Date | null;
}

export class AuditLogListResponseDto {
@ApiProperty({ type: [AuditLogResponseDto] })
data: AuditLogResponseDto[];

@ApiProperty()
total: number;

@ApiProperty()
page: number;

@ApiProperty()
limit: number;

@ApiProperty()
totalPages: number;
}
Loading
Loading