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>
628 lines
17 KiB
Markdown
628 lines
17 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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*
|