feat: auditoría (audit_log en SQLite, endpoint /api/audit y pantalla /admin/auditoria)
Registra sesiones, creaciones y modificaciones en SAP (solo nombres de campos), denegados y cambios de permisos. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e9d6a757d
commit
fe8c080471
26 files changed
+993
-28
No files matched your search
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PermissionsModule } from '../permissions/permissions.module.js';
|
||||
import { AuditController } from './audit.controller.js';
|
||||
import { AuditModule } from './audit.module.js';
|
||||
|
||||
/** Consulta de la auditoría: aparte de `AuditModule` para que `PermissionsModule` pueda registrar sin ciclos. */
|
||||
@Module({
|
||||
imports: [AuditModule, PermissionsModule],
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditAdminModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/** Un hecho que queda registrado: quién hizo qué, sobre qué, desde dónde y con qué resultado. */
|
||||
@Entity('audit_log')
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/** Momento (ISO 8601, UTC). */
|
||||
@Index()
|
||||
@Column({ type: 'varchar' })
|
||||
ts: string;
|
||||
|
||||
/** Usuario web; en un intento de login fallido, el usuario que se intentó. */
|
||||
@Index()
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
webUser: string | null;
|
||||
|
||||
/** Ver `AuditAction`. */
|
||||
@Index()
|
||||
@Column({ type: 'varchar' })
|
||||
action: string;
|
||||
|
||||
/** Recurso del Service Layer (`Orders`) o, en cambios de permisos, el usuario afectado. */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
resource: string | null;
|
||||
|
||||
/** Clave del registro (DocEntry, CardCode…). */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
key: string | null;
|
||||
|
||||
/** `ok`, `error` (SAP o el sistema lo rechazó) o `denied` (sin permiso). */
|
||||
@Column({ type: 'varchar', default: 'ok' })
|
||||
outcome: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ip: string | null;
|
||||
|
||||
/** JSON con el detalle (nombres de los campos enviados, mensaje de error…). Nunca valores ni claves. */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
detail: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
|
||||
import { AdminGuard } from '../permissions/admin.guard.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/, 'Fecha inválida (AAAA-MM-DD)');
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
webUser: z.string().trim().max(100).optional(),
|
||||
action: z.string().trim().max(40).optional(),
|
||||
resource: z.string().trim().max(100).optional(),
|
||||
outcome: z.enum(['ok', 'error', 'denied']).optional(),
|
||||
from: isoDate.optional(),
|
||||
to: isoDate.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
type AuditQuery = z.infer<typeof querySchema>;
|
||||
|
||||
/** Fin de un día (`2026-10-16` → `2026-10-16T23:59:59.999Z`) para que «hasta» incluya todo ese día. */
|
||||
const endOfDay = (d: string): string => (d.length === 10 ? `${d}T23:59:59.999Z` : d);
|
||||
|
||||
/** Consulta del registro de auditoría (solo administradores). */
|
||||
@Controller('audit')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AuditController {
|
||||
constructor(private readonly audit: AuditService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodValidationPipe(querySchema)) q: AuditQuery) {
|
||||
return this.audit.list({ ...q, to: q.to ? endOfDay(q.to) : undefined });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditLog } from './audit-log.entity.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
/** Tabla y servicio de auditoría; los demás módulos solo inyectan `AuditService`. */
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuditLog } from './audit-log.entity.js';
|
||||
import { AuditService, keyOfCreated, summarizeBody } from './audit.service.js';
|
||||
|
||||
describe('AuditService (SQLite en memoria)', () => {
|
||||
let ds: DataSource;
|
||||
let service: AuditService;
|
||||
|
||||
beforeEach(async () => {
|
||||
ds = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [AuditLog], synchronize: true });
|
||||
await ds.initialize();
|
||||
service = new AuditService(ds.getRepository(AuditLog));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ds.destroy();
|
||||
});
|
||||
|
||||
it('registra un hecho con fecha, usuario, recurso, clave, resultado, ip y detalle', async () => {
|
||||
await service.record({ action: 'sl_create', webUser: 'ana', ip: '10.0.0.5', resource: 'Orders', key: 316, detail: { fields: ['CardCode'] } });
|
||||
const { total, rows } = await service.list({});
|
||||
expect(total).toBe(1);
|
||||
expect(rows[0]).toMatchObject({ action: 'sl_create', webUser: 'ana', ip: '10.0.0.5', resource: 'Orders', key: '316', outcome: 'ok', detail: { fields: ['CardCode'] } });
|
||||
expect(new Date(rows[0].ts).toISOString()).toBe(rows[0].ts);
|
||||
});
|
||||
|
||||
it('lista lo más reciente primero y pagina', async () => {
|
||||
for (let i = 1; i <= 5; i++) await service.record({ action: 'login', webUser: `u${i}` });
|
||||
const page = await service.list({ limit: 2, offset: 1 });
|
||||
expect(page.total).toBe(5);
|
||||
expect(page.rows.map((r) => r.webUser)).toEqual(['u4', 'u3']);
|
||||
});
|
||||
|
||||
it('filtra por usuario (parte del nombre), acción, recurso y resultado', async () => {
|
||||
await service.record({ action: 'login', webUser: 'ana' });
|
||||
await service.record({ action: 'sl_update', webUser: 'ana', resource: 'Orders', key: 1 });
|
||||
await service.record({ action: 'sl_update', webUser: 'luis', resource: 'Orders', key: 2, outcome: 'error' });
|
||||
await service.record({ action: 'sl_update', webUser: 'luis', resource: 'Invoices', key: 3 });
|
||||
expect((await service.list({ webUser: 'an' })).total).toBe(2);
|
||||
expect((await service.list({ action: 'sl_update' })).total).toBe(3);
|
||||
expect((await service.list({ resource: 'Orders' })).total).toBe(2);
|
||||
expect((await service.list({ outcome: 'error' })).rows.map((r) => r.key)).toEqual(['2']);
|
||||
expect((await service.list({ webUser: 'luis', action: 'sl_update', resource: 'Orders' })).total).toBe(1);
|
||||
});
|
||||
|
||||
it('filtra por rango de fechas', async () => {
|
||||
await ds.getRepository(AuditLog).insert([
|
||||
{ ts: '2026-10-01T10:00:00.000Z', action: 'login', outcome: 'ok' },
|
||||
{ ts: '2026-10-10T10:00:00.000Z', action: 'login', outcome: 'ok' },
|
||||
{ ts: '2026-10-20T10:00:00.000Z', action: 'login', outcome: 'ok' },
|
||||
]);
|
||||
expect((await service.list({ from: '2026-10-05T00:00:00.000Z' })).total).toBe(2);
|
||||
expect((await service.list({ to: '2026-10-15T00:00:00.000Z' })).total).toBe(2);
|
||||
expect((await service.list({ from: '2026-10-05T00:00:00.000Z', to: '2026-10-15T00:00:00.000Z' })).total).toBe(1);
|
||||
});
|
||||
|
||||
it('el límite máximo es 200', async () => {
|
||||
await service.record({ action: 'login' });
|
||||
expect((await service.list({ limit: 9999 })).rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nunca lanza: si no puede escribir solo lo avisa', async () => {
|
||||
await ds.destroy();
|
||||
await expect(service.record({ action: 'login', webUser: 'ana' })).resolves.toBeUndefined();
|
||||
await ds.initialize();
|
||||
});
|
||||
|
||||
it('el detalle se recorta para no llenar la base', async () => {
|
||||
await service.record({ action: 'sl_update', detail: { fields: Array.from({ length: 5000 }, (_, i) => `Campo${i}`) } });
|
||||
const row = await ds.getRepository(AuditLog).findOneByOrFail({ id: 1 });
|
||||
expect(row.detail!.length).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('purga lo anterior a N días y conserva lo reciente', async () => {
|
||||
await ds.getRepository(AuditLog).insert({ ts: '2020-01-01T00:00:00.000Z', action: 'login', outcome: 'ok' });
|
||||
await service.record({ action: 'login' });
|
||||
expect(await service.purgeOlderThan(30)).toBe(1);
|
||||
expect((await service.list({})).total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qué se guarda de lo enviado a SAP', () => {
|
||||
it('solo los nombres de los campos y cuántas filas lleva cada colección, nunca los valores', () => {
|
||||
const summary = summarizeBody({ CardCode: 'C1', Comments: 'dato privado', DocumentLines: [{ ItemCode: 'A' }, { ItemCode: 'B' }], BPAddresses: [] });
|
||||
expect(summary).toEqual({ fields: ['CardCode', 'Comments'], collections: { DocumentLines: 2, BPAddresses: 0 } });
|
||||
expect(JSON.stringify(summary)).not.toContain('dato privado');
|
||||
expect(JSON.stringify(summary)).not.toContain('"A"');
|
||||
});
|
||||
|
||||
it('sin colecciones, solo los campos', () => {
|
||||
expect(summarizeBody({ Phone1: '1' })).toEqual({ fields: ['Phone1'] });
|
||||
});
|
||||
|
||||
it('la clave de lo creado sale de lo que devuelve SAP', () => {
|
||||
expect(keyOfCreated({ DocEntry: 77, DocNum: 12 })).toBe('77');
|
||||
expect(keyOfCreated({ CardCode: 'C9' })).toBe('C9');
|
||||
expect(keyOfCreated({ ItemCode: 'A 1' })).toBe('A 1');
|
||||
expect(keyOfCreated({})).toBeNull();
|
||||
expect(keyOfCreated(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type { AuditAction, AuditRow } from '@basic-erp/sap-types';
|
||||
import { Between, type FindOptionsWhere, Like, MoreThanOrEqual, LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { AuditLog } from './audit-log.entity.js';
|
||||
|
||||
export interface AuditEntry {
|
||||
action: AuditAction;
|
||||
webUser?: string | null;
|
||||
ip?: string | null;
|
||||
resource?: string | null;
|
||||
key?: string | number | null;
|
||||
outcome?: 'ok' | 'error' | 'denied';
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
webUser?: string;
|
||||
action?: string;
|
||||
resource?: string;
|
||||
outcome?: string;
|
||||
/** ISO desde / hasta (inclusive). */
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
const MAX_DETAIL = 2000;
|
||||
|
||||
/**
|
||||
* De un cuerpo enviado a SAP, solo los nombres de los campos (y cuántas filas lleva cada colección):
|
||||
* el registro dice qué se tocó, nunca los valores (pueden ser datos de clientes o contraseñas).
|
||||
*/
|
||||
export function summarizeBody(body: Record<string, unknown>): { fields: string[]; collections?: Record<string, number> } {
|
||||
const collections: Record<string, number> = {};
|
||||
const fields: string[] = [];
|
||||
for (const [name, value] of Object.entries(body)) {
|
||||
if (Array.isArray(value)) collections[name] = value.length;
|
||||
else fields.push(name);
|
||||
}
|
||||
return Object.keys(collections).length ? { fields, collections } : { fields };
|
||||
}
|
||||
|
||||
/** Clave del registro creado, según lo que devuelve SAP. */
|
||||
export function keyOfCreated(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
for (const k of ['DocEntry', 'CardCode', 'ItemCode', 'Code', 'AbsEntry']) {
|
||||
if (d[k] !== undefined && d[k] !== null) return String(d[k]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registro de auditoría en SQLite. `record` nunca lanza: si no se puede escribir se avisa en el log, pero la
|
||||
* operación del usuario ya ocurrió (o ya falló) y no debe cambiar por un problema del registro.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(@InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
try {
|
||||
const detail = entry.detail ? JSON.stringify(entry.detail).slice(0, MAX_DETAIL) : null;
|
||||
await this.repo.insert({
|
||||
ts: new Date().toISOString(),
|
||||
webUser: entry.webUser ?? null,
|
||||
action: entry.action,
|
||||
resource: entry.resource ?? null,
|
||||
key: entry.key === undefined || entry.key === null ? null : String(entry.key),
|
||||
outcome: entry.outcome ?? 'ok',
|
||||
ip: entry.ip ?? null,
|
||||
detail,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(`No se pudo registrar la auditoría (${entry.action}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(filters: AuditFilters): Promise<{ total: number; rows: AuditRow[] }> {
|
||||
const where: FindOptionsWhere<AuditLog> = {};
|
||||
if (filters.webUser) where.webUser = Like(`%${filters.webUser}%`);
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.resource) where.resource = filters.resource;
|
||||
if (filters.outcome) where.outcome = filters.outcome;
|
||||
if (filters.from && filters.to) where.ts = Between(filters.from, filters.to);
|
||||
else if (filters.from) where.ts = MoreThanOrEqual(filters.from);
|
||||
else if (filters.to) where.ts = LessThanOrEqual(filters.to);
|
||||
const [rows, total] = await this.repo.findAndCount({
|
||||
where,
|
||||
order: { id: 'DESC' },
|
||||
take: Math.min(filters.limit ?? 50, 200),
|
||||
skip: filters.offset ?? 0,
|
||||
});
|
||||
return {
|
||||
total,
|
||||
rows: rows.map((r) => ({ ...r, detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : null })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Borra lo anterior a `days` días; devuelve cuántos registros quitó. */
|
||||
async purgeOlderThan(days: number): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
const res = await this.repo.createQueryBuilder().delete().where('ts < :cutoff', { cutoff }).execute();
|
||||
return res.affected ?? 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user