chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { AuditSource } from '../database/entities';
|
||||
import { buildAuditFilters } from './audit-query';
|
||||
import type { ListAuditQueryDto } from './dto/list-audit-query.dto';
|
||||
|
||||
export interface AuditEventSummary {
|
||||
id: string;
|
||||
occurredAt: Date;
|
||||
actorUserId: string | null;
|
||||
actorUsername: string | null;
|
||||
action: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
requestId: string | null;
|
||||
source: AuditSource;
|
||||
ip: string | null;
|
||||
hasDetails: boolean;
|
||||
}
|
||||
|
||||
export interface AuditEventDetail extends AuditEventSummary {
|
||||
userAgent: string | null;
|
||||
beforeData: Record<string, unknown> | null;
|
||||
afterData: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditQueryService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async list(query: ListAuditQueryDto) {
|
||||
this.validateDateRange(query);
|
||||
const filters = buildAuditFilters(query);
|
||||
const page = query.page;
|
||||
const pageSize = query.pageSize;
|
||||
|
||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||
const [countRow] = (await manager.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM audit_events event ${filters.clause}`,
|
||||
filters.parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
|
||||
const parameters = [
|
||||
...filters.parameters,
|
||||
pageSize,
|
||||
(page - 1) * pageSize,
|
||||
];
|
||||
const limitParameter = parameters.length - 1;
|
||||
const offsetParameter = parameters.length;
|
||||
const rows = (await manager.query(
|
||||
`
|
||||
SELECT
|
||||
event.id,
|
||||
event.occurred_at AS "occurredAt",
|
||||
event.actor_user_id AS "actorUserId",
|
||||
event.actor_username AS "actorUsername",
|
||||
event.action,
|
||||
event.entity_type AS "entityType",
|
||||
event.entity_id AS "entityId",
|
||||
event.request_id AS "requestId",
|
||||
event.source,
|
||||
event.ip,
|
||||
(
|
||||
event.before_data IS NOT NULL
|
||||
OR event.after_data IS NOT NULL
|
||||
OR event.metadata IS NOT NULL
|
||||
) AS "hasDetails"
|
||||
FROM audit_events event
|
||||
${filters.clause}
|
||||
ORDER BY event.occurred_at DESC, event.id DESC
|
||||
LIMIT $${limitParameter} OFFSET $${offsetParameter}
|
||||
`,
|
||||
parameters,
|
||||
)) as AuditEventSummary[];
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AuditEventDetail> {
|
||||
return this.dataSource.transaction((manager) =>
|
||||
this.loadEvent(manager, id),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadEvent(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<AuditEventDetail> {
|
||||
const [row] = (await manager.query(
|
||||
`
|
||||
SELECT
|
||||
event.id,
|
||||
event.occurred_at AS "occurredAt",
|
||||
event.actor_user_id AS "actorUserId",
|
||||
event.actor_username AS "actorUsername",
|
||||
event.action,
|
||||
event.entity_type AS "entityType",
|
||||
event.entity_id AS "entityId",
|
||||
event.request_id AS "requestId",
|
||||
event.source,
|
||||
event.ip,
|
||||
event.user_agent AS "userAgent",
|
||||
event.before_data AS "beforeData",
|
||||
event.after_data AS "afterData",
|
||||
event.metadata,
|
||||
(
|
||||
event.before_data IS NOT NULL
|
||||
OR event.after_data IS NOT NULL
|
||||
OR event.metadata IS NOT NULL
|
||||
) AS "hasDetails"
|
||||
FROM audit_events event
|
||||
WHERE event.id = $1
|
||||
`,
|
||||
[id],
|
||||
)) as AuditEventDetail[];
|
||||
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
code: 'AUDIT_EVENT_NOT_FOUND',
|
||||
message: 'Evento de auditoría no encontrado',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private validateDateRange(query: ListAuditQueryDto): void {
|
||||
if (query.from && query.to && new Date(query.from) > new Date(query.to)) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_DATE_RANGE',
|
||||
message: 'La fecha inicial no puede ser posterior a la fecha final',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ListAuditQueryDto } from './dto/list-audit-query.dto';
|
||||
|
||||
export interface AuditFilters {
|
||||
clause: string;
|
||||
parameters: unknown[];
|
||||
}
|
||||
|
||||
export function buildAuditFilters(query: ListAuditQueryDto): AuditFilters {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
|
||||
const add = (condition: (parameter: string) => string, value: unknown) => {
|
||||
parameters.push(value);
|
||||
conditions.push(condition(`$${parameters.length}`));
|
||||
};
|
||||
|
||||
if (query.actorUserId) {
|
||||
add((parameter) => `event.actor_user_id = ${parameter}`, query.actorUserId);
|
||||
}
|
||||
if (query.actorUsername) {
|
||||
add(
|
||||
(parameter) => `event.actor_username ILIKE ${parameter}`,
|
||||
`%${query.actorUsername}%`,
|
||||
);
|
||||
}
|
||||
if (query.action) {
|
||||
add((parameter) => `event.action = ${parameter}`, query.action);
|
||||
}
|
||||
if (query.source) {
|
||||
add((parameter) => `event.source = ${parameter}`, query.source);
|
||||
}
|
||||
if (query.entityType) {
|
||||
add((parameter) => `event.entity_type = ${parameter}`, query.entityType);
|
||||
}
|
||||
if (query.entityId) {
|
||||
add((parameter) => `event.entity_id = ${parameter}`, query.entityId);
|
||||
}
|
||||
if (query.requestId) {
|
||||
add((parameter) => `event.request_id = ${parameter}`, query.requestId);
|
||||
}
|
||||
if (query.from) {
|
||||
add((parameter) => `event.occurred_at >= ${parameter}`, new Date(query.from));
|
||||
}
|
||||
if (query.to) {
|
||||
add((parameter) => `event.occurred_at <= ${parameter}`, new Date(query.to));
|
||||
}
|
||||
if (query.search) {
|
||||
add(
|
||||
(parameter) => `
|
||||
(
|
||||
event.actor_username ILIKE ${parameter}
|
||||
OR event.action ILIKE ${parameter}
|
||||
OR event.entity_type ILIKE ${parameter}
|
||||
OR event.entity_id ILIKE ${parameter}
|
||||
OR event.request_id ILIKE ${parameter}
|
||||
)
|
||||
`,
|
||||
`%${query.search}%`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
clause: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'password',
|
||||
'currentpassword',
|
||||
'newpassword',
|
||||
'passwordhash',
|
||||
'token',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'refreshtokenhash',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'cookies',
|
||||
'setcookie',
|
||||
'secret',
|
||||
'pepper',
|
||||
'jwt',
|
||||
]);
|
||||
|
||||
function normalizedKey(key: string): string {
|
||||
return key.replaceAll(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function sanitizeAuditData(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeAuditData(entry, seen));
|
||||
}
|
||||
if (typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return '[CIRCULAR]';
|
||||
seen.add(value);
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
sanitized[key] = SENSITIVE_KEYS.has(normalizedKey(key))
|
||||
? '[REDACTED]'
|
||||
: sanitizeAuditData(entry, seen);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { AuditQueryService } from './audit-query.service';
|
||||
import { ListAuditQueryDto } from './dto/list-audit-query.dto';
|
||||
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly audit: AuditQueryService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('audit.read')
|
||||
list(@Query() query: ListAuditQueryDto) {
|
||||
return this.audit.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('audit.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.audit.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditQueryService } from './audit-query.service';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [PhaseADataModule],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService, AuditQueryService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { AuditEventsRepository } from '../core-data/repositories/audit-events.repository';
|
||||
import { AuditEvent, AuditSource } from '../database/entities';
|
||||
import { sanitizeAuditData } from './audit-sanitizer';
|
||||
|
||||
export interface RecordAuditInput {
|
||||
actorUserId?: string | null;
|
||||
actorUsername?: string | null;
|
||||
action: string;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
requestId?: string | null;
|
||||
source?: AuditSource;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
beforeData?: Record<string, unknown> | null;
|
||||
afterData?: Record<string, unknown> | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly events: AuditEventsRepository) {}
|
||||
|
||||
async record(
|
||||
input: RecordAuditInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const eventInput = {
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorUsername: input.actorUsername ?? null,
|
||||
action: input.action,
|
||||
entityType: input.entityType ?? null,
|
||||
entityId: input.entityId ?? null,
|
||||
requestId: input.requestId ?? null,
|
||||
source: input.source ?? AuditSource.WEB,
|
||||
ip: input.ip ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
beforeData: sanitizeAuditData(input.beforeData ?? null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
afterData: sanitizeAuditData(input.afterData ?? null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
metadata: sanitizeAuditData(input.metadata ?? null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
};
|
||||
|
||||
if (manager) {
|
||||
await manager.getRepository(AuditEvent).save(eventInput);
|
||||
return;
|
||||
}
|
||||
await this.events.save(this.events.create(eventInput));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { AuditSource } from '../../database/entities';
|
||||
|
||||
const trim = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value;
|
||||
|
||||
export class ListAuditQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 25;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
actorUserId?: string;
|
||||
|
||||
@Transform(trim)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
actorUsername?: string;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
action?: string;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsEnum(AuditSource)
|
||||
source?: AuditSource;
|
||||
|
||||
@Transform(trim)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
entityType?: string;
|
||||
|
||||
@Transform(trim)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
entityId?: string;
|
||||
|
||||
@Transform(trim)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
requestId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
to?: string;
|
||||
|
||||
@Transform(trim)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user