workspace-v1/shared/catalog/audit-logs/IMPLEMENTATION.md
rckrdmrd cb4c0681d3 feat(workspace): Add new projects and update architecture
New projects created:
- michangarrito (marketplace mobile)
- template-saas (SaaS template)
- clinica-dental (dental ERP)
- clinica-veterinaria (veterinary ERP)

Architecture updates:
- Move catalog from core/ to shared/
- Add MCP servers structure and templates
- Add git management scripts
- Update SUBREPOSITORIOS.md with 15 new repos
- Update .gitignore for new projects

Repository infrastructure:
- 4 main repositories
- 11 subrepositorios
- Gitea remotes configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:43:28 -06:00

17 KiB

Implementacion de Audit Logs

Version: 1.0.0 Tiempo estimado: 1 sprint Prerequisitos: NestJS, TypeORM, PostgreSQL


Paso 1: Schema de Base de Datos

-- 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

// 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<string, any>;

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

  @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

// 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

// 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<any>) {
    if (!this.shouldAudit(event.metadata)) return;

    await this.createLog(event, 'CREATE', null, event.entity);
  }

  async afterUpdate(event: UpdateEvent<any>) {
    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<any>) {
    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<string, any> {
    const sanitized = { ...entity };
    // Remover campos sensibles
    delete sanitized.password;
    delete sanitized.passwordHash;
    delete sanitized.refreshToken;
    return sanitized;
  }
}

Paso 4: Decoradores

4.1 @Audited

// 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:

@Entity()
@Audited()
export class User {
  // ...
}

4.2 @TrackAccess

// 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

// 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<any> {
    const options = this.reflector.get<TrackAccessOptions>(
      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

// 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<EntityLog>,
    @InjectRepository(AccessLog)
    private accessLogRepo: Repository<AccessLog>,
    @InjectRepository(ActionLog)
    private actionLogRepo: Repository<ActionLog>,
    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<string, any>;
    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

// 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

// 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