# ET-ADM-003: Audit Logging y Change Tracking **ID:** ET-ADM-003 **Módulo:** MAI-013 **Relacionado con:** RF-ADM-004 --- ## 📋 Base de Datos ### Tabla: audit_logs ```sql CREATE TABLE audit_logging.audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), timestamp TIMESTAMPTZ DEFAULT NOW(), -- Usuario user_id UUID NOT NULL, user_name VARCHAR(200), user_email VARCHAR(255), user_role VARCHAR(50), -- Contexto constructora_id UUID NOT NULL, project_id UUID, -- Acción action VARCHAR(50) NOT NULL, module VARCHAR(50) NOT NULL, entity_type VARCHAR(50), entity_id UUID, -- Cambios changes JSONB, -- Contexto técnico ip_address INET, user_agent TEXT, session_id UUID, -- Metadata severity VARCHAR(20), success BOOLEAN DEFAULT TRUE, error_message TEXT, -- Retención retention_days INT DEFAULT 90, expires_at DATE ); CREATE INDEX idx_audit_timestamp ON audit_logging.audit_logs(timestamp DESC); CREATE INDEX idx_audit_user ON audit_logging.audit_logs(user_id); CREATE INDEX idx_audit_action ON audit_logging.audit_logs(action); CREATE INDEX idx_audit_module ON audit_logging.audit_logs(module); CREATE INDEX idx_audit_entity ON audit_logging.audit_logs(entity_type, entity_id); CREATE INDEX idx_audit_severity ON audit_logging.audit_logs(severity); CREATE INDEX idx_audit_changes ON audit_logging.audit_logs USING GIN (changes); ``` --- ## 🔧 Backend ### audit-log.entity.ts ```typescript @Entity({ schema: 'audit_logging', name: 'audit_logs' }) export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'timestamptz', default: () => 'NOW()' }) timestamp: Date; @Column({ name: 'user_id' }) userId: string; @Column({ name: 'user_name' }) userName: string; @Column({ length: 50 }) action: string; @Column({ length: 50 }) module: string; @Column({ name: 'entity_type', nullable: true }) entityType?: string; @Column({ name: 'entity_id', nullable: true }) entityId?: string; @Column({ type: 'jsonb', nullable: true }) changes?: any; @Column({ name: 'ip_address', type: 'inet', nullable: true }) ipAddress?: string; @Column({ length: 20 }) severity: string; @Column({ default: true }) success: boolean; } ``` ### audit.service.ts ```typescript @Injectable() export class AuditService { constructor( @InjectRepository(AuditLog) private auditRepo: Repository, ) {} async log(dto: CreateAuditLogDto): Promise { const retentionDays = this.getRetentionDays(dto.severity); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + retentionDays); const log = this.auditRepo.create({ ...dto, retentionDays, expiresAt }); await this.auditRepo.save(log); // Enviar alertas si es crítico if (dto.severity === 'critical' && !dto.success) { await this.sendSecurityAlert(log); } } private getRetentionDays(severity: string): number { const retention = { low: 90, medium: 365, high: 1825, // 5 años critical: 3650 // 10 años }; return retention[severity] || 90; } async findAll(filters: AuditLogFilters): Promise> { const qb = this.auditRepo.createQueryBuilder('a'); if (filters.startDate) { qb.andWhere('a.timestamp >= :start', { start: filters.startDate }); } if (filters.endDate) { qb.andWhere('a.timestamp <= :end', { end: filters.endDate }); } if (filters.userId) { qb.andWhere('a.user_id = :userId', { userId: filters.userId }); } if (filters.action) { qb.andWhere('a.action = :action', { action: filters.action }); } if (filters.module) { qb.andWhere('a.module = :module', { module: filters.module }); } if (filters.severity) { qb.andWhere('a.severity = :severity', { severity: filters.severity }); } qb.orderBy('a.timestamp', 'DESC') .skip((filters.page - 1) * filters.limit) .take(filters.limit); const [items, total] = await qb.getManyAndCount(); return { items, total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(total / filters.limit) }; } } ``` ### audit.interceptor.ts (Logging automático) ```typescript @Injectable() export class AuditInterceptor implements NestInterceptor { constructor(private auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const user = request.user; const startTime = Date.now(); return next.handle().pipe( tap(async (data) => { const duration = Date.now() - startTime; // Solo auditar operaciones críticas if (this.shouldAudit(request)) { await this.auditService.log({ userId: user.id, userName: user.fullName, userEmail: user.email, userRole: user.role, constructoraId: user.constructoraId, action: this.getAction(request.method), module: this.getModule(request.url), entityType: this.getEntityType(request.url), entityId: data?.id, ipAddress: request.ip, userAgent: request.get('user-agent'), sessionId: user.sessionId, severity: this.getSeverity(request), success: true, duration }); } }), catchError(async (error) => { await this.auditService.log({ userId: user?.id, action: this.getAction(request.method), module: this.getModule(request.url), success: false, errorMessage: error.message, severity: 'high' }); throw error; }) ); } private shouldAudit(request: any): boolean { const criticalActions = ['POST', 'PATCH', 'DELETE']; return criticalActions.includes(request.method); } } ``` --- ## 🎨 Frontend ### AuditLogViewer.tsx ```typescript export const AuditLogViewer: React.FC = () => { const [logs, setLogs] = useState([]); const [filters, setFilters] = useState({ startDate: subDays(new Date(), 7), endDate: new Date(), action: '', module: '', severity: '' }); useEffect(() => { fetchLogs(); }, [filters]); const fetchLogs = async () => { const response = await api.get('/admin/audit-logs', { params: filters }); setLogs(response.data.items); }; return (

Bitácora de Auditoría

{/* Filtros */}
setFilters({ ...filters, startDate: start, endDate: end })} />
{/* Timeline */}
{logs.map(log => (
{format(new Date(log.timestamp), 'MMM dd, HH:mm:ss')}
{log.severity} {log.userName} ({log.userRole})
{log.action} en{' '} {log.module} {log.entityType && ` - ${log.entityType}`}
{log.changes && (
{log.changes.map((change, i) => (
{change.field}:{' '} {change.oldValue} →{' '} {change.newValue}
))}
)}
{log.ipAddress}
))}
); }; ``` --- **Generado:** 2025-11-20 **Estado:** ✅ Completo