# Implementacion de Audit Logs **Version:** 1.0.0 **Tiempo estimado:** 1 sprint **Prerequisitos:** NestJS, TypeORM, PostgreSQL --- ## Paso 1: Schema de Base de Datos ```sql -- database/ddl/audit-schema.sql CREATE SCHEMA IF NOT EXISTS audit; -- Logs de cambios en entidades CREATE TABLE audit.entity_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, entity_name VARCHAR(100) NOT NULL, entity_id VARCHAR(100) NOT NULL, action VARCHAR(20) NOT NULL, old_values JSONB, new_values JSONB, changed_fields TEXT[], user_id UUID, user_email VARCHAR(255), ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT valid_action CHECK (action IN ('CREATE', 'UPDATE', 'DELETE', 'RESTORE')) ); -- Logs de acceso a recursos CREATE TABLE audit.access_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, resource_type VARCHAR(100) NOT NULL, resource_id VARCHAR(100) NOT NULL, action VARCHAR(20) NOT NULL, user_id UUID NOT NULL, user_email VARCHAR(255), access_reason TEXT, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT valid_access_action CHECK (action IN ('VIEW', 'DOWNLOAD', 'PRINT', 'EXPORT')) ); -- Logs de acciones de usuario CREATE TABLE audit.action_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID, action VARCHAR(100) NOT NULL, user_id UUID, user_email VARCHAR(255), metadata JSONB DEFAULT '{}', status VARCHAR(20) DEFAULT 'success', error_message TEXT, ip_address INET, user_agent TEXT, duration_ms INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Indices para busqueda CREATE INDEX idx_entity_logs_tenant ON audit.entity_logs(tenant_id); CREATE INDEX idx_entity_logs_entity ON audit.entity_logs(entity_name, entity_id); CREATE INDEX idx_entity_logs_user ON audit.entity_logs(user_id); CREATE INDEX idx_entity_logs_created ON audit.entity_logs(created_at DESC); CREATE INDEX idx_entity_logs_action ON audit.entity_logs(action); CREATE INDEX idx_access_logs_tenant ON audit.access_logs(tenant_id); CREATE INDEX idx_access_logs_resource ON audit.access_logs(resource_type, resource_id); CREATE INDEX idx_access_logs_user ON audit.access_logs(user_id); CREATE INDEX idx_access_logs_created ON audit.access_logs(created_at DESC); CREATE INDEX idx_action_logs_tenant ON audit.action_logs(tenant_id); CREATE INDEX idx_action_logs_user ON audit.action_logs(user_id); CREATE INDEX idx_action_logs_action ON audit.action_logs(action); CREATE INDEX idx_action_logs_created ON audit.action_logs(created_at DESC); -- Funcion para busqueda full-text CREATE INDEX idx_entity_logs_search ON audit.entity_logs USING gin(to_tsvector('spanish', coalesce(entity_name, '') || ' ' || coalesce(user_email, ''))); -- RLS Policies ALTER TABLE audit.entity_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE audit.access_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE audit.action_logs ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_entity ON audit.entity_logs FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); CREATE POLICY tenant_isolation_access ON audit.access_logs FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); CREATE POLICY tenant_isolation_action ON audit.action_logs FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); ``` --- ## Paso 2: Entidades TypeORM ### 2.1 Entity Log ```typescript // backend/src/modules/audit/entities/entity-log.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; @Entity({ schema: 'audit', name: 'entity_logs' }) @Index(['tenantId', 'entityName', 'entityId']) @Index(['createdAt']) export class EntityLog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id' }) tenantId: string; @Column({ name: 'entity_name' }) entityName: string; @Column({ name: 'entity_id' }) entityId: string; @Column() action: 'CREATE' | 'UPDATE' | 'DELETE' | 'RESTORE'; @Column({ type: 'jsonb', nullable: true, name: 'old_values' }) oldValues: Record; @Column({ type: 'jsonb', nullable: true, name: 'new_values' }) newValues: Record; @Column({ type: 'text', array: true, nullable: true, name: 'changed_fields' }) changedFields: string[]; @Column({ nullable: true, name: 'user_id' }) userId: string; @Column({ nullable: true, name: 'user_email' }) userEmail: string; @Column({ type: 'inet', nullable: true, name: 'ip_address' }) ipAddress: string; @Column({ nullable: true, name: 'user_agent' }) userAgent: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` ### 2.2 Access Log ```typescript // backend/src/modules/audit/entities/access-log.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; @Entity({ schema: 'audit', name: 'access_logs' }) export class AccessLog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id' }) tenantId: string; @Column({ name: 'resource_type' }) resourceType: string; @Column({ name: 'resource_id' }) resourceId: string; @Column() action: 'VIEW' | 'DOWNLOAD' | 'PRINT' | 'EXPORT'; @Column({ name: 'user_id' }) userId: string; @Column({ nullable: true, name: 'user_email' }) userEmail: string; @Column({ nullable: true, name: 'access_reason' }) accessReason: string; @Column({ type: 'inet', nullable: true, name: 'ip_address' }) ipAddress: string; @Column({ nullable: true, name: 'user_agent' }) userAgent: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` --- ## Paso 3: Subscriber para Entity Audit ```typescript // backend/src/modules/audit/subscribers/entity-audit.subscriber.ts import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent, RemoveEvent, } from 'typeorm'; import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { EntityLog } from '../entities/entity-log.entity'; import { ClsService } from 'nestjs-cls'; @Injectable() @EventSubscriber() export class EntityAuditSubscriber implements EntitySubscriberInterface { constructor( @InjectDataSource() private dataSource: DataSource, private cls: ClsService, ) { dataSource.subscribers.push(this); } // Solo auditar entidades marcadas con @Audited listenTo() { return undefined; // Escucha todas, filtra por metadata } async afterInsert(event: InsertEvent) { if (!this.shouldAudit(event.metadata)) return; await this.createLog(event, 'CREATE', null, event.entity); } async afterUpdate(event: UpdateEvent) { if (!this.shouldAudit(event.metadata)) return; const changedFields = this.getChangedFields(event.databaseEntity, event.entity); if (changedFields.length === 0) return; await this.createLog(event, 'UPDATE', event.databaseEntity, event.entity, changedFields); } async afterRemove(event: RemoveEvent) { if (!this.shouldAudit(event.metadata)) return; await this.createLog(event, 'DELETE', event.databaseEntity, null); } private shouldAudit(metadata: any): boolean { return metadata.tableMetadataArgs?.audited === true; } private async createLog( event: any, action: string, oldEntity: any, newEntity: any, changedFields?: string[], ) { const user = this.cls.get('user'); const request = this.cls.get('request'); const log = new EntityLog(); log.tenantId = this.cls.get('tenantId'); log.entityName = event.metadata.tableName; log.entityId = (newEntity || oldEntity)?.id; log.action = action as any; log.oldValues = oldEntity ? this.sanitize(oldEntity) : null; log.newValues = newEntity ? this.sanitize(newEntity) : null; log.changedFields = changedFields || []; log.userId = user?.id; log.userEmail = user?.email; log.ipAddress = request?.ip; log.userAgent = request?.headers?.['user-agent']; await this.dataSource.getRepository(EntityLog).save(log); } private getChangedFields(oldEntity: any, newEntity: any): string[] { if (!oldEntity || !newEntity) return []; const changed: string[] = []; for (const key of Object.keys(newEntity)) { if (JSON.stringify(oldEntity[key]) !== JSON.stringify(newEntity[key])) { changed.push(key); } } return changed; } private sanitize(entity: any): Record { const sanitized = { ...entity }; // Remover campos sensibles delete sanitized.password; delete sanitized.passwordHash; delete sanitized.refreshToken; return sanitized; } } ``` --- ## Paso 4: Decoradores ### 4.1 @Audited ```typescript // backend/src/decorators/audited.decorator.ts import { SetMetadata } from '@nestjs/common'; export const AUDITED_KEY = 'audited'; export const Audited = () => SetMetadata(AUDITED_KEY, true); ``` Uso en entidades: ```typescript @Entity() @Audited() export class User { // ... } ``` ### 4.2 @TrackAccess ```typescript // backend/src/decorators/track-access.decorator.ts import { SetMetadata } from '@nestjs/common'; export const TRACK_ACCESS_KEY = 'track_access'; export interface TrackAccessOptions { resourceType: string; action: 'VIEW' | 'DOWNLOAD' | 'PRINT' | 'EXPORT'; requireReason?: boolean; } export const TrackAccess = (options: TrackAccessOptions) => SetMetadata(TRACK_ACCESS_KEY, options); ``` --- ## Paso 5: Interceptor de Acceso ```typescript // backend/src/interceptors/access-audit.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Reflector } from '@nestjs/core'; import { AuditService } from '../modules/audit/audit.service'; import { TRACK_ACCESS_KEY, TrackAccessOptions } from '../decorators/track-access.decorator'; @Injectable() export class AccessAuditInterceptor implements NestInterceptor { constructor( private reflector: Reflector, private auditService: AuditService, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const options = this.reflector.get( TRACK_ACCESS_KEY, context.getHandler(), ); if (!options) return next.handle(); const request = context.switchToHttp().getRequest(); return next.handle().pipe( tap(async (response) => { const resourceId = request.params.id || response?.id; await this.auditService.logAccess({ resourceType: options.resourceType, resourceId, action: options.action, accessReason: request.body?.accessReason || request.query?.reason, }); }), ); } } ``` --- ## Paso 6: Servicio de Auditoria ```typescript // backend/src/modules/audit/audit.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between, Like } from 'typeorm'; import { EntityLog } from './entities/entity-log.entity'; import { AccessLog } from './entities/access-log.entity'; import { ActionLog } from './entities/action-log.entity'; import { ClsService } from 'nestjs-cls'; @Injectable() export class AuditService { constructor( @InjectRepository(EntityLog) private entityLogRepo: Repository, @InjectRepository(AccessLog) private accessLogRepo: Repository, @InjectRepository(ActionLog) private actionLogRepo: Repository, private cls: ClsService, ) {} async logAccess(params: { resourceType: string; resourceId: string; action: 'VIEW' | 'DOWNLOAD' | 'PRINT' | 'EXPORT'; accessReason?: string; }) { const user = this.cls.get('user'); const request = this.cls.get('request'); const log = this.accessLogRepo.create({ tenantId: this.cls.get('tenantId'), resourceType: params.resourceType, resourceId: params.resourceId, action: params.action, userId: user.id, userEmail: user.email, accessReason: params.accessReason, ipAddress: request?.ip, userAgent: request?.headers?.['user-agent'], }); return this.accessLogRepo.save(log); } async logAction(params: { action: string; metadata?: Record; status?: 'success' | 'error'; errorMessage?: string; durationMs?: number; }) { const user = this.cls.get('user'); const request = this.cls.get('request'); const log = this.actionLogRepo.create({ tenantId: this.cls.get('tenantId'), action: params.action, userId: user?.id, userEmail: user?.email, metadata: params.metadata || {}, status: params.status || 'success', errorMessage: params.errorMessage, durationMs: params.durationMs, ipAddress: request?.ip, userAgent: request?.headers?.['user-agent'], }); return this.actionLogRepo.save(log); } async getEntityLogs(filters: { entityName?: string; entityId?: string; userId?: string; action?: string; startDate?: Date; endDate?: Date; page?: number; limit?: number; }) { const query = this.entityLogRepo.createQueryBuilder('log'); if (filters.entityName) { query.andWhere('log.entityName = :entityName', { entityName: filters.entityName }); } if (filters.entityId) { query.andWhere('log.entityId = :entityId', { entityId: filters.entityId }); } if (filters.userId) { query.andWhere('log.userId = :userId', { userId: filters.userId }); } if (filters.action) { query.andWhere('log.action = :action', { action: filters.action }); } if (filters.startDate && filters.endDate) { query.andWhere('log.createdAt BETWEEN :start AND :end', { start: filters.startDate, end: filters.endDate, }); } query.orderBy('log.createdAt', 'DESC'); const page = filters.page || 1; const limit = filters.limit || 50; query.skip((page - 1) * limit).take(limit); const [data, total] = await query.getManyAndCount(); return { data, total, page, limit }; } async exportLogs(filters: any, format: 'csv' | 'json') { const logs = await this.getEntityLogs({ ...filters, limit: 10000 }); if (format === 'csv') { return this.toCSV(logs.data); } return logs.data; } private toCSV(data: EntityLog[]): string { const headers = ['id', 'entityName', 'entityId', 'action', 'userId', 'userEmail', 'createdAt']; const rows = data.map(log => headers.map(h => log[h]).join(',')); return [headers.join(','), ...rows].join('\n'); } } ``` --- ## Paso 7: Uso en Controllers ```typescript // Ejemplo: Acceso a expediente medico @Controller('medical-records') export class MedicalRecordController { @Get(':id') @TrackAccess({ resourceType: 'medical_record', action: 'VIEW', requireReason: true }) async getRecord(@Param('id') id: string, @Query('reason') reason: string) { // El interceptor registra automaticamente el acceso return this.service.findOne(id); } @Get(':id/download') @TrackAccess({ resourceType: 'medical_record', action: 'DOWNLOAD' }) async downloadRecord(@Param('id') id: string) { return this.service.downloadPdf(id); } } ``` --- ## Paso 8: Retencion de Logs ```typescript // backend/src/modules/audit/retention.service.ts import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { EntityLog } from './entities/entity-log.entity'; @Injectable() export class RetentionService { private retentionDays = { entity_logs: 365, // 1 año access_logs: 730, // 2 años (compliance) action_logs: 90, // 3 meses }; @Cron('0 2 * * *') // 2 AM diario async cleanupOldLogs() { for (const [table, days] of Object.entries(this.retentionDays)) { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); // Archivar antes de borrar (opcional) await this.archiveToS3(table, cutoffDate); // Borrar logs antiguos await this.deleteOldLogs(table, cutoffDate); } } private async archiveToS3(table: string, before: Date) { // Implementar si se necesita archivo } private async deleteOldLogs(table: string, before: Date) { // Ejecutar delete por batches para no bloquear } } ``` --- ## Checklist de Implementacion - [ ] Schema de BD creado - [ ] Entidades TypeORM creadas - [ ] EntityAuditSubscriber configurado - [ ] AccessAuditInterceptor funcionando - [ ] Decoradores @Audited y @TrackAccess - [ ] AuditService implementado - [ ] Endpoints de consulta de logs - [ ] Exportacion CSV/JSON - [ ] Politica de retencion - [ ] Tests unitarios --- *Catalogo de Funcionalidades - SIMCO v3.4*