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