erp-core/docs/02-fase-core-business/MGN-007-audit/especificaciones/ET-AUDIT-backend.md

30 KiB

ET-AUDIT-BACKEND: Servicios y API REST

Identificacion

Campo Valor
ID ET-AUDIT-BACKEND
Modulo MGN-007 Audit
Version 1.0
Estado En Diseno
Framework NestJS
Autor Requirements-Analyst
Fecha 2025-12-05

Estructura de Archivos

apps/backend/src/modules/audit/
├── audit.module.ts
├── controllers/
│   ├── audit-logs.controller.ts
│   ├── access-logs.controller.ts
│   ├── security-events.controller.ts
│   └── scheduled-reports.controller.ts
├── services/
│   ├── audit-logs.service.ts
│   ├── access-logs.service.ts
│   ├── security-events.service.ts
│   ├── security-alerts.service.ts
│   └── scheduled-reports.service.ts
├── entities/
│   ├── audit-log.entity.ts
│   ├── access-log.entity.ts
│   ├── security-event.entity.ts
│   ├── security-alert.entity.ts
│   ├── scheduled-report.entity.ts
│   └── audit-alert.entity.ts
├── dto/
│   ├── query-audit-logs.dto.ts
│   ├── query-access-logs.dto.ts
│   ├── create-security-event.dto.ts
│   ├── resolve-security-event.dto.ts
│   ├── create-scheduled-report.dto.ts
│   └── export-audit.dto.ts
├── interceptors/
│   ├── audit.interceptor.ts
│   └── access-log.interceptor.ts
├── subscribers/
│   └── entity-audit.subscriber.ts
└── jobs/
    ├── partition-maintenance.job.ts
    └── scheduled-reports.job.ts

Entidades

AuditLog Entity

@Entity('audit_logs', { schema: 'core_audit' })
export class AuditLog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'user_id', type: 'uuid', nullable: true })
  userId: string;

  @Column({ length: 20 })
  action: AuditAction;

  @Column({ name: 'entity_type', length: 100 })
  entityType: string;

  @Column({ name: 'entity_id', type: 'uuid' })
  entityId: string;

  @Column({ name: 'old_values', type: 'jsonb', nullable: true })
  oldValues: Record<string, any>;

  @Column({ name: 'new_values', type: 'jsonb', nullable: true })
  newValues: Record<string, any>;

  @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true })
  changedFields: string[];

  @Column({ name: 'ip_address', type: 'inet', nullable: true })
  ipAddress: string;

  @Column({ name: 'user_agent', type: 'text', nullable: true })
  userAgent: string;

  @Column({ name: 'request_id', type: 'uuid', nullable: true })
  requestId: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

export type AuditAction = 'create' | 'update' | 'delete' | 'restore';

SecurityEvent Entity

@Entity('security_events', { schema: 'core_audit' })
export class SecurityEvent {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid', nullable: true })
  tenantId: string;

  @Column({ name: 'user_id', type: 'uuid', nullable: true })
  userId: string;

  @Column({ name: 'event_type', length: 50 })
  eventType: SecurityEventType;

  @Column({ length: 20 })
  severity: SecuritySeverity;

  @Column({ length: 255 })
  title: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ type: 'jsonb', default: {} })
  metadata: Record<string, any>;

  @Column({ name: 'ip_address', type: 'inet', nullable: true })
  ipAddress: string;

  @Column({ name: 'country_code', length: 2, nullable: true })
  countryCode: string;

  @Column({ name: 'is_resolved', default: false })
  isResolved: boolean;

  @Column({ name: 'resolved_by', type: 'uuid', nullable: true })
  resolvedBy: string;

  @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true })
  resolvedAt: Date;

  @Column({ name: 'resolution_notes', type: 'text', nullable: true })
  resolutionNotes: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @OneToMany(() => SecurityAlert, a => a.securityEvent)
  alerts: SecurityAlert[];
}

export type SecuritySeverity = 'low' | 'medium' | 'high' | 'critical';
export type SecurityEventType =
  | 'login_failed' | 'login_blocked' | 'brute_force_detected'
  | 'password_changed' | 'password_reset_requested'
  | 'session_hijack_attempt' | 'concurrent_session'
  | 'session_from_new_location' | 'session_from_new_device'
  | 'permission_escalation' | 'role_changed' | 'admin_created'
  | 'mass_delete_attempt' | 'data_export' | 'sensitive_data_access'
  | 'api_key_created' | 'webhook_modified' | 'settings_changed';

ScheduledReport Entity

@Entity('scheduled_reports', { schema: 'core_audit' })
export class ScheduledReport {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ length: 255 })
  name: string;

  @Column({ name: 'report_type', length: 50 })
  reportType: AuditReportType;

  @Column({ type: 'jsonb', default: {} })
  filters: ReportFilters;

  @Column({ length: 10, default: 'pdf' })
  format: ExportFormat;

  @Column({ length: 100 })
  schedule: string; // cron expression

  @Column({ type: 'text', array: true })
  recipients: string[];

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @Column({ name: 'last_run_at', type: 'timestamptz', nullable: true })
  lastRunAt: Date;

  @Column({ name: 'next_run_at', type: 'timestamptz', nullable: true })
  nextRunAt: Date;

  @Column({ name: 'created_by', type: 'uuid' })
  createdBy: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

export type AuditReportType = 'activity' | 'security' | 'compliance' | 'change' | 'access';
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';

Servicios

AuditLogsService

@Injectable()
export class AuditLogsService {
  constructor(
    @InjectRepository(AuditLog)
    private readonly repo: Repository<AuditLog>,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async findAll(
    tenantId: string,
    query: QueryAuditLogsDto
  ): Promise<PaginatedResult<AuditLog>> {
    const qb = this.repo.createQueryBuilder('log')
      .where('log.tenant_id = :tenantId', { tenantId });

    if (query.entityType) {
      qb.andWhere('log.entity_type = :entityType', { entityType: query.entityType });
    }

    if (query.entityId) {
      qb.andWhere('log.entity_id = :entityId', { entityId: query.entityId });
    }

    if (query.userId) {
      qb.andWhere('log.user_id = :userId', { userId: query.userId });
    }

    if (query.action) {
      qb.andWhere('log.action = :action', { action: query.action });
    }

    if (query.dateFrom) {
      qb.andWhere('log.created_at >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('log.created_at <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('log.created_at', 'DESC');

    return paginate(qb, query);
  }

  async getEntityHistory(
    tenantId: string,
    entityType: string,
    entityId: string
  ): Promise<AuditLog[]> {
    return this.repo.find({
      where: { tenantId, entityType, entityId },
      order: { createdAt: 'DESC' },
    });
  }

  async log(data: CreateAuditLogDto): Promise<AuditLog> {
    const log = this.repo.create(data);
    return this.repo.save(log);
  }

  async getStatsByAction(
    tenantId: string,
    dateFrom: Date,
    dateTo: Date
  ): Promise<ActionStats[]> {
    const result = await this.repo
      .createQueryBuilder('log')
      .select('log.action', 'action')
      .addSelect('COUNT(*)', 'count')
      .where('log.tenant_id = :tenantId', { tenantId })
      .andWhere('log.created_at BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo })
      .groupBy('log.action')
      .getRawMany();

    return result;
  }

  async getStatsByEntity(
    tenantId: string,
    dateFrom: Date,
    dateTo: Date
  ): Promise<EntityStats[]> {
    const result = await this.repo
      .createQueryBuilder('log')
      .select('log.entity_type', 'entityType')
      .addSelect('COUNT(*)', 'count')
      .where('log.tenant_id = :tenantId', { tenantId })
      .andWhere('log.created_at BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo })
      .groupBy('log.entity_type')
      .orderBy('COUNT(*)', 'DESC')
      .limit(10)
      .getRawMany();

    return result;
  }
}

SecurityEventsService

@Injectable()
export class SecurityEventsService {
  constructor(
    @InjectRepository(SecurityEvent)
    private readonly repo: Repository<SecurityEvent>,
    private readonly alertsService: SecurityAlertsService,
    private readonly notificationsService: NotificationsService,
  ) {}

  async findAll(
    tenantId: string | null,
    query: QuerySecurityEventsDto
  ): Promise<PaginatedResult<SecurityEvent>> {
    const qb = this.repo.createQueryBuilder('event');

    if (tenantId) {
      qb.where('event.tenant_id = :tenantId', { tenantId });
    }

    if (query.severity) {
      qb.andWhere('event.severity = :severity', { severity: query.severity });
    }

    if (query.eventType) {
      qb.andWhere('event.event_type = :eventType', { eventType: query.eventType });
    }

    if (query.isResolved !== undefined) {
      qb.andWhere('event.is_resolved = :isResolved', { isResolved: query.isResolved });
    }

    if (query.dateFrom) {
      qb.andWhere('event.created_at >= :dateFrom', { dateFrom: query.dateFrom });
    }

    qb.orderBy('event.created_at', 'DESC');

    return paginate(qb, query);
  }

  async create(dto: CreateSecurityEventDto): Promise<SecurityEvent> {
    const event = this.repo.create(dto);
    const saved = await this.repo.save(event);

    // Auto-alert for high/critical severity
    if (['high', 'critical'].includes(dto.severity)) {
      await this.triggerAlerts(saved);
    }

    return saved;
  }

  async resolve(
    id: string,
    userId: string,
    dto: ResolveSecurityEventDto
  ): Promise<SecurityEvent> {
    const event = await this.repo.findOneOrFail({ where: { id } });

    event.isResolved = true;
    event.resolvedBy = userId;
    event.resolvedAt = new Date();
    event.resolutionNotes = dto.notes;

    return this.repo.save(event);
  }

  async getUnresolvedCount(tenantId?: string): Promise<SeverityCounts> {
    const qb = this.repo.createQueryBuilder('event')
      .select('event.severity', 'severity')
      .addSelect('COUNT(*)', 'count')
      .where('event.is_resolved = false');

    if (tenantId) {
      qb.andWhere('event.tenant_id = :tenantId', { tenantId });
    }

    qb.groupBy('event.severity');

    const results = await qb.getRawMany();

    return {
      low: parseInt(results.find(r => r.severity === 'low')?.count || '0'),
      medium: parseInt(results.find(r => r.severity === 'medium')?.count || '0'),
      high: parseInt(results.find(r => r.severity === 'high')?.count || '0'),
      critical: parseInt(results.find(r => r.severity === 'critical')?.count || '0'),
    };
  }

  private async triggerAlerts(event: SecurityEvent): Promise<void> {
    // Get admin users to notify
    const admins = await this.getSecurityAdmins(event.tenantId);

    const alert = await this.alertsService.create({
      securityEventId: event.id,
      sentTo: admins.map(a => a.id),
      channels: ['in_app', 'email'],
    });

    // Send notifications
    for (const admin of admins) {
      await this.notificationsService.send({
        userId: admin.id,
        type: 'security_alert',
        title: `Security Alert: ${event.title}`,
        body: event.description,
        data: { eventId: event.id, severity: event.severity },
        channels: ['in_app', 'email'],
      });
    }
  }
}

AccessLogsService

@Injectable()
export class AccessLogsService {
  constructor(
    @InjectRepository(AccessLog)
    private readonly repo: Repository<AccessLog>,
  ) {}

  async findAll(
    tenantId: string | null,
    query: QueryAccessLogsDto
  ): Promise<PaginatedResult<AccessLog>> {
    const qb = this.repo.createQueryBuilder('log');

    if (tenantId) {
      qb.where('log.tenant_id = :tenantId', { tenantId });
    }

    if (query.userId) {
      qb.andWhere('log.user_id = :userId', { userId: query.userId });
    }

    if (query.path) {
      qb.andWhere('log.path LIKE :path', { path: `%${query.path}%` });
    }

    if (query.method) {
      qb.andWhere('log.method = :method', { method: query.method });
    }

    if (query.statusCode) {
      qb.andWhere('log.status_code = :statusCode', { statusCode: query.statusCode });
    }

    if (query.minResponseTime) {
      qb.andWhere('log.response_time_ms >= :minResponseTime', {
        minResponseTime: query.minResponseTime
      });
    }

    if (query.dateFrom) {
      qb.andWhere('log.created_at >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('log.created_at <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('log.created_at', 'DESC');

    return paginate(qb, query);
  }

  async log(data: CreateAccessLogDto): Promise<void> {
    // Insert async - no wait for performance
    this.repo.insert(data).catch(err => {
      console.error('Failed to log access:', err);
    });
  }

  async getAggregatedStats(
    tenantId: string,
    dateFrom: Date,
    dateTo: Date,
    groupBy: 'hour' | 'day'
  ): Promise<AccessStats[]> {
    const dateFormat = groupBy === 'hour'
      ? "to_char(log.created_at, 'YYYY-MM-DD HH24:00')"
      : "to_char(log.created_at, 'YYYY-MM-DD')";

    const result = await this.repo
      .createQueryBuilder('log')
      .select(dateFormat, 'period')
      .addSelect('COUNT(*)', 'totalRequests')
      .addSelect('AVG(log.response_time_ms)', 'avgResponseTime')
      .addSelect('COUNT(*) FILTER (WHERE log.status_code >= 400)', 'errorCount')
      .where('log.tenant_id = :tenantId', { tenantId })
      .andWhere('log.created_at BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo })
      .groupBy('period')
      .orderBy('period', 'ASC')
      .getRawMany();

    return result;
  }

  async getTopEndpoints(
    tenantId: string,
    dateFrom: Date,
    limit: number = 10
  ): Promise<EndpointStats[]> {
    return this.repo
      .createQueryBuilder('log')
      .select('log.method', 'method')
      .addSelect('log.path', 'path')
      .addSelect('COUNT(*)', 'count')
      .addSelect('AVG(log.response_time_ms)', 'avgResponseTime')
      .where('log.tenant_id = :tenantId', { tenantId })
      .andWhere('log.created_at >= :dateFrom', { dateFrom })
      .groupBy('log.method, log.path')
      .orderBy('COUNT(*)', 'DESC')
      .limit(limit)
      .getRawMany();
  }
}

ScheduledReportsService

@Injectable()
export class ScheduledReportsService {
  constructor(
    @InjectRepository(ScheduledReport)
    private readonly repo: Repository<ScheduledReport>,
    private readonly auditLogsService: AuditLogsService,
    private readonly accessLogsService: AccessLogsService,
    private readonly securityEventsService: SecurityEventsService,
    private readonly emailService: EmailService,
    private readonly exportService: ExportService,
  ) {}

  async findAll(tenantId: string): Promise<ScheduledReport[]> {
    return this.repo.find({
      where: { tenantId },
      order: { createdAt: 'DESC' },
    });
  }

  async create(
    tenantId: string,
    userId: string,
    dto: CreateScheduledReportDto
  ): Promise<ScheduledReport> {
    const report = this.repo.create({
      ...dto,
      tenantId,
      createdBy: userId,
      nextRunAt: this.calculateNextRun(dto.schedule),
    });

    return this.repo.save(report);
  }

  async update(
    id: string,
    dto: UpdateScheduledReportDto
  ): Promise<ScheduledReport> {
    const report = await this.repo.findOneOrFail({ where: { id } });

    Object.assign(report, dto);
    if (dto.schedule) {
      report.nextRunAt = this.calculateNextRun(dto.schedule);
    }

    return this.repo.save(report);
  }

  async delete(id: string): Promise<void> {
    await this.repo.delete(id);
  }

  async runReport(id: string): Promise<Buffer> {
    const report = await this.repo.findOneOrFail({ where: { id } });

    const data = await this.fetchReportData(report);
    return this.exportService.export(data, report.format, report.reportType);
  }

  @Cron('0 * * * *') // Every hour
  async processScheduledReports(): Promise<void> {
    const dueReports = await this.repo.find({
      where: {
        isActive: true,
        nextRunAt: LessThanOrEqual(new Date()),
      },
    });

    for (const report of dueReports) {
      try {
        const data = await this.fetchReportData(report);
        const file = await this.exportService.export(data, report.format, report.reportType);

        await this.emailService.sendWithAttachment({
          to: report.recipients,
          subject: `Audit Report: ${report.name}`,
          body: `Attached is your scheduled ${report.reportType} report.`,
          attachment: {
            filename: `${report.name}_${format(new Date(), 'yyyy-MM-dd')}.${report.format}`,
            content: file,
          },
        });

        report.lastRunAt = new Date();
        report.nextRunAt = this.calculateNextRun(report.schedule);
        await this.repo.save(report);
      } catch (error) {
        console.error(`Failed to run report ${report.id}:`, error);
      }
    }
  }

  private async fetchReportData(report: ScheduledReport): Promise<any[]> {
    const { dateFrom, dateTo } = this.getDateRange(report.filters);

    switch (report.reportType) {
      case 'activity':
      case 'change':
        return this.auditLogsService.findAll(report.tenantId, {
          dateFrom,
          dateTo,
          ...report.filters,
          limit: 10000,
        }).then(r => r.items);

      case 'access':
        return this.accessLogsService.findAll(report.tenantId, {
          dateFrom,
          dateTo,
          ...report.filters,
          limit: 10000,
        }).then(r => r.items);

      case 'security':
        return this.securityEventsService.findAll(report.tenantId, {
          dateFrom,
          dateTo,
          ...report.filters,
          limit: 10000,
        }).then(r => r.items);

      default:
        return [];
    }
  }

  private calculateNextRun(cronExpression: string): Date {
    const interval = parseExpression(cronExpression);
    return interval.next().toDate();
  }
}

Interceptores

AuditInterceptor

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(
    private readonly auditService: AuditLogsService,
    private readonly cls: ClsService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const handler = context.getHandler();
    const auditConfig = Reflect.getMetadata('audit', handler);

    if (!auditConfig) {
      return next.handle();
    }

    return next.handle().pipe(
      tap(async (response) => {
        try {
          await this.auditService.log({
            tenantId: request.tenantId,
            userId: request.user?.id,
            action: auditConfig.action,
            entityType: auditConfig.entityType,
            entityId: response?.id || request.params?.id,
            oldValues: this.cls.get('oldValues'),
            newValues: response,
            ipAddress: request.ip,
            userAgent: request.headers['user-agent'],
            requestId: request.id,
          });
        } catch (error) {
          console.error('Audit log failed:', error);
        }
      }),
    );
  }
}

// Decorator
export const Audit = (config: { action: AuditAction; entityType: string }) =>
  SetMetadata('audit', config);

// Usage
@Post()
@Audit({ action: 'create', entityType: 'contact' })
async create(@Body() dto: CreateContactDto) { ... }

AccessLogInterceptor

@Injectable()
export class AccessLogInterceptor implements NestInterceptor {
  constructor(private readonly accessLogsService: AccessLogsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const startTime = Date.now();

    return next.handle().pipe(
      finalize(() => {
        const response = context.switchToHttp().getResponse();

        this.accessLogsService.log({
          tenantId: request.tenantId,
          userId: request.user?.id,
          sessionId: request.sessionId,
          requestId: request.id,
          method: request.method,
          path: request.route?.path || request.path,
          queryParams: request.query,
          statusCode: response.statusCode,
          responseTimeMs: Date.now() - startTime,
          ipAddress: request.ip,
          userAgent: request.headers['user-agent'],
          referer: request.headers['referer'],
        });
      }),
    );
  }
}

TypeORM Subscriber

EntityAuditSubscriber

@EventSubscriber()
export class EntityAuditSubscriber implements EntitySubscriberInterface {
  private readonly auditableEntities = [
    'Contact', 'User', 'Role', 'Tenant', 'Setting',
  ];

  constructor(
    dataSource: DataSource,
    private readonly auditService: AuditLogsService,
    private readonly cls: ClsService,
  ) {
    dataSource.subscribers.push(this);
  }

  listenTo() {
    return Object.class; // Listen to all entities
  }

  beforeUpdate(event: UpdateEvent<any>): void {
    if (this.shouldAudit(event.metadata.name)) {
      // Store old values for comparison
      this.cls.set('oldValues', event.databaseEntity);
    }
  }

  afterInsert(event: InsertEvent<any>): Promise<void> {
    return this.logChange(event, 'create');
  }

  afterUpdate(event: UpdateEvent<any>): Promise<void> {
    return this.logChange(event, 'update');
  }

  afterRemove(event: RemoveEvent<any>): Promise<void> {
    return this.logChange(event, 'delete');
  }

  afterSoftRemove(event: SoftRemoveEvent<any>): Promise<void> {
    return this.logChange(event, 'delete');
  }

  afterRecover(event: RecoverEvent<any>): Promise<void> {
    return this.logChange(event, 'restore');
  }

  private shouldAudit(entityName: string): boolean {
    return this.auditableEntities.includes(entityName);
  }

  private async logChange(
    event: any,
    action: AuditAction
  ): Promise<void> {
    if (!this.shouldAudit(event.metadata.name)) {
      return;
    }

    const entity = event.entity || event.databaseEntity;
    if (!entity) return;

    try {
      await this.auditService.log({
        tenantId: this.cls.get('tenantId'),
        userId: this.cls.get('userId'),
        action,
        entityType: event.metadata.tableName,
        entityId: entity.id,
        oldValues: action === 'update' ? this.cls.get('oldValues') : null,
        newValues: action === 'delete' ? null : entity,
        ipAddress: this.cls.get('ipAddress'),
        requestId: this.cls.get('requestId'),
      });
    } catch (error) {
      console.error('Entity audit failed:', error);
    }
  }
}

Controladores

AuditLogsController

@ApiTags('Audit Logs')
@Controller('audit/logs')
@UseGuards(JwtAuthGuard, RbacGuard)
export class AuditLogsController {
  constructor(private readonly service: AuditLogsService) {}

  @Get()
  @Permissions('audit.logs.read')
  async findAll(
    @TenantId() tenantId: string,
    @Query() query: QueryAuditLogsDto
  ) {
    return this.service.findAll(tenantId, query);
  }

  @Get('entity/:entityType/:entityId')
  @Permissions('audit.logs.read')
  async getEntityHistory(
    @TenantId() tenantId: string,
    @Param('entityType') entityType: string,
    @Param('entityId') entityId: string
  ) {
    return this.service.getEntityHistory(tenantId, entityType, entityId);
  }

  @Get('stats/actions')
  @Permissions('audit.logs.read')
  async getStatsByAction(
    @TenantId() tenantId: string,
    @Query('dateFrom') dateFrom: Date,
    @Query('dateTo') dateTo: Date
  ) {
    return this.service.getStatsByAction(tenantId, dateFrom, dateTo);
  }

  @Get('stats/entities')
  @Permissions('audit.logs.read')
  async getStatsByEntity(
    @TenantId() tenantId: string,
    @Query('dateFrom') dateFrom: Date,
    @Query('dateTo') dateTo: Date
  ) {
    return this.service.getStatsByEntity(tenantId, dateFrom, dateTo);
  }

  @Post('export')
  @Permissions('audit.logs.export')
  async export(
    @TenantId() tenantId: string,
    @Body() dto: ExportAuditDto
  ) {
    const data = await this.service.findAll(tenantId, dto);
    return this.service.export(data.items, dto.format);
  }
}

SecurityEventsController

@ApiTags('Security Events')
@Controller('audit/security')
@UseGuards(JwtAuthGuard, RbacGuard)
export class SecurityEventsController {
  constructor(private readonly service: SecurityEventsService) {}

  @Get()
  @Permissions('audit.security.read')
  async findAll(
    @TenantId() tenantId: string,
    @Query() query: QuerySecurityEventsDto
  ) {
    return this.service.findAll(tenantId, query);
  }

  @Get('counts')
  @Permissions('audit.security.read')
  async getUnresolvedCounts(@TenantId() tenantId: string) {
    return this.service.getUnresolvedCount(tenantId);
  }

  @Patch(':id/resolve')
  @Permissions('audit.security.manage')
  async resolve(
    @Param('id') id: string,
    @CurrentUser() user: User,
    @Body() dto: ResolveSecurityEventDto
  ) {
    return this.service.resolve(id, user.id, dto);
  }
}

// Admin controller for global events
@ApiTags('Security Events Admin')
@Controller('admin/audit/security')
@UseGuards(JwtAuthGuard, RbacGuard)
@Permissions('admin.security.manage')
export class SecurityEventsAdminController {
  constructor(private readonly service: SecurityEventsService) {}

  @Get()
  async findAll(@Query() query: QuerySecurityEventsDto) {
    return this.service.findAll(null, query);
  }

  @Get('counts')
  async getGlobalCounts() {
    return this.service.getUnresolvedCount();
  }
}

ScheduledReportsController

@ApiTags('Audit Reports')
@Controller('audit/reports')
@UseGuards(JwtAuthGuard, RbacGuard)
export class ScheduledReportsController {
  constructor(private readonly service: ScheduledReportsService) {}

  @Get()
  @Permissions('audit.reports.read')
  async findAll(@TenantId() tenantId: string) {
    return this.service.findAll(tenantId);
  }

  @Post()
  @Permissions('audit.reports.manage')
  async create(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: CreateScheduledReportDto
  ) {
    return this.service.create(tenantId, user.id, dto);
  }

  @Patch(':id')
  @Permissions('audit.reports.manage')
  async update(
    @Param('id') id: string,
    @Body() dto: UpdateScheduledReportDto
  ) {
    return this.service.update(id, dto);
  }

  @Delete(':id')
  @Permissions('audit.reports.manage')
  async delete(@Param('id') id: string) {
    return this.service.delete(id);
  }

  @Post(':id/run')
  @Permissions('audit.reports.manage')
  async runNow(@Param('id') id: string, @Res() res: Response) {
    const buffer = await this.service.runReport(id);
    res.setHeader('Content-Type', 'application/octet-stream');
    res.send(buffer);
  }
}

DTOs

Query DTOs

export class QueryAuditLogsDto extends PaginationDto {
  @IsOptional()
  @IsString()
  entityType?: string;

  @IsOptional()
  @IsUUID()
  entityId?: string;

  @IsOptional()
  @IsUUID()
  userId?: string;

  @IsOptional()
  @IsIn(['create', 'update', 'delete', 'restore'])
  action?: AuditAction;

  @IsOptional()
  @IsDateString()
  dateFrom?: string;

  @IsOptional()
  @IsDateString()
  dateTo?: string;
}

export class QuerySecurityEventsDto extends PaginationDto {
  @IsOptional()
  @IsIn(['low', 'medium', 'high', 'critical'])
  severity?: SecuritySeverity;

  @IsOptional()
  @IsString()
  eventType?: string;

  @IsOptional()
  @IsBoolean()
  @Transform(({ value }) => value === 'true')
  isResolved?: boolean;

  @IsOptional()
  @IsDateString()
  dateFrom?: string;
}

Create/Update DTOs

export class CreateScheduledReportDto {
  @IsString()
  @Length(1, 255)
  name: string;

  @IsIn(['activity', 'security', 'compliance', 'change', 'access'])
  reportType: AuditReportType;

  @IsObject()
  filters: Record<string, any>;

  @IsIn(['csv', 'xlsx', 'pdf', 'json'])
  @IsOptional()
  format?: ExportFormat = 'pdf';

  @IsString()
  schedule: string; // cron expression

  @IsArray()
  @IsEmail({}, { each: true })
  recipients: string[];
}

export class ResolveSecurityEventDto {
  @IsString()
  @IsOptional()
  notes?: string;
}

API Endpoints Summary

Method Path Permission Description
GET /audit/logs audit.logs.read List audit logs
GET /audit/logs/entity/:type/:id audit.logs.read Get entity history
GET /audit/logs/stats/actions audit.logs.read Stats by action
GET /audit/logs/stats/entities audit.logs.read Stats by entity type
POST /audit/logs/export audit.logs.export Export logs
GET /audit/access audit.access.read List access logs
GET /audit/access/stats audit.access.read Access statistics
GET /audit/access/endpoints audit.access.read Top endpoints
GET /audit/security audit.security.read List security events
GET /audit/security/counts audit.security.read Unresolved counts
PATCH /audit/security/:id/resolve audit.security.manage Resolve event
GET /audit/reports audit.reports.read List scheduled reports
POST /audit/reports audit.reports.manage Create report
PATCH /audit/reports/:id audit.reports.manage Update report
DELETE /audit/reports/:id audit.reports.manage Delete report
POST /audit/reports/:id/run audit.reports.manage Run report now

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial