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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:35:46 -05:00
1 parent 4e9d6a757d
commit fe8c080471
26 files changed
+993 -28

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { AuditAdminModule } from './audit/audit-admin.module.js';
import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js';
@@ -8,7 +9,7 @@ import { SapModule } from './sap/sap.module.js';
import { SlModule } from './sl/sl.module.js';
@Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, SlModule],
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule],
controllers: [HealthController],
})
export class AppModule {}
+11
View File
@@ -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 {}
+42
View File
@@ -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;
}
+36
View File
@@ -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 });
}
}
+12
View File
@@ -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 {}
+101
View File
@@ -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();
});
});
+110
View File
@@ -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;
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ export class AuthController {
@Post('logout')
@HttpCode(204)
logout(@Req() req: AuthenticatedRequest): void {
this.auth.logout(req.sessionToken);
async logout(@Req() req: AuthenticatedRequest): Promise<void> {
await this.auth.logout(req.sessionToken, req.user, req.ip ?? 'desconocida');
}
@Get('me')
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuditModule } from '../audit/audit.module.js';
import { SapModule } from '../sap/sap.module.js';
import { AuthController } from './auth.controller.js';
import { AuthGuard } from './auth.guard.js';
@@ -8,7 +9,7 @@ import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
@Module({
imports: [SapModule],
imports: [SapModule, AuditModule],
controllers: [AuthController],
providers: [
AuthService,
+45 -1
View File
@@ -3,6 +3,7 @@ import { vi } from 'vitest';
import type { AppConfigService } from '../config/app-config.service.js';
import type { SapClientService } from '../sap/sap-client.service.js';
import { AuthService, escapeODataString } from './auth.service.js';
import type { AuditService } from '../audit/audit.service.js';
import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
@@ -21,12 +22,14 @@ describe('AuthService', () => {
let get: ReturnType<typeof vi.fn>;
let sessions: SessionStoreService;
let service: AuthService;
let record: ReturnType<typeof vi.fn>;
beforeEach(() => {
rows = [employee()];
get = vi.fn(async () => ({ data: { value: rows } }));
sessions = new SessionStoreService({ get: () => 30 } as unknown as AppConfigService);
service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService());
record = vi.fn(async () => undefined);
service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService(), { record } as unknown as AuditService);
});
it('login correcto devuelve token y usuario', async () => {
@@ -72,4 +75,45 @@ describe('AuthService', () => {
await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(/Demasiados intentos/);
expect(get).not.toHaveBeenCalled();
});
describe('auditoría', () => {
it('un login correcto queda registrado con usuario e ip', async () => {
await service.login('ana@acme.com', 'secreto', '9.9.9.9');
expect(record).toHaveBeenCalledWith({ action: 'login', webUser: 'ana@acme.com', ip: '9.9.9.9' });
});
it('un login fallido queda registrado sin distinguir usuario inexistente de clave errónea, y sin la clave', async () => {
await service.login('ana@acme.com', 'mala-clave', '9.9.9.9').catch(() => undefined);
rows = [];
await service.login('nadie', 'otra-clave', '9.9.9.9').catch(() => undefined);
const calls = record.mock.calls.map((c) => c[0]);
expect(calls).toEqual([
{ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } },
{ action: 'login_failed', webUser: 'nadie', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } },
]);
expect(JSON.stringify(calls)).not.toContain('clave');
expect(JSON.stringify(calls)).not.toContain('secreto');
});
it('un usuario inactivo queda registrado como denegado', async () => {
rows = [employee({ Active: 'tNO' })];
await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined);
expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'usuario inactivo' } });
});
it('el bloqueo por demasiados intentos queda registrado', async () => {
for (let i = 0; i < 5; i++) await service.login('ana@acme.com', 'mala', '9.9.9.9').catch(() => undefined);
record.mockClear();
await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined);
expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'demasiados intentos' } });
});
it('el cierre de sesión queda registrado', async () => {
const { token, user } = await service.login('ana@acme.com', 'secreto', '9.9.9.9');
record.mockClear();
await service.logout(token, user, '9.9.9.9');
expect(sessions.validate(token)).toBeUndefined();
expect(record).toHaveBeenCalledWith({ action: 'logout', webUser: 'ana@acme.com', ip: '9.9.9.9' });
});
});
});
+13 -2
View File
@@ -1,5 +1,6 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { AuditService } from '../audit/audit.service.js';
import { SapClientService } from '../sap/sap-client.service.js';
import type { AuthUser } from './auth.types.js';
import { LoginAttemptsService } from './login-attempts.service.js';
@@ -34,10 +35,16 @@ export class AuthService {
private readonly sap: SapClientService,
private readonly sessions: SessionStoreService,
private readonly attempts: LoginAttemptsService,
private readonly audit: AuditService,
) {}
async login(username: string, password: string, ip: string): Promise<{ token: string; user: AuthUser }> {
this.attempts.assertAllowed(username, ip);
try {
this.attempts.assertAllowed(username, ip);
} catch (e) {
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'denied', detail: { reason: 'demasiados intentos' } });
throw e;
}
const employee = await this.findEmployee(username);
// Se compara siempre (aunque no exista el usuario) para no filtrar su existencia por tiempo.
@@ -47,10 +54,12 @@ export class AuthService {
if (!employee || !passwordOk) {
this.attempts.recordFailure(username, ip);
this.logger.warn(`Login fallido para "${username}"`);
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'error', detail: { reason: 'credenciales' } });
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (employee.Active !== 'tYES') {
this.logger.warn(`Login rechazado: empleado ${employee.EmployeeID} inactivo`);
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'denied', detail: { reason: 'usuario inactivo' } });
throw new UnauthorizedException('Usuario inactivo');
}
@@ -63,11 +72,13 @@ export class AuthService {
};
const token = this.sessions.create(user);
this.logger.log(`Login exitoso: ${user.webUser} (empleado ${user.empId})`);
await this.audit.record({ action: 'login', webUser: user.webUser, ip });
return { token, user };
}
logout(token: string): void {
async logout(token: string, user: AuthUser, ip: string): Promise<void> {
this.sessions.remove(token);
await this.audit.record({ action: 'logout', webUser: user.webUser, ip });
}
private async findEmployee(username: string): Promise<EmployeeRow | undefined> {
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Put, Req, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import { AuditService } from '../audit/audit.service.js';
import type { AuthenticatedRequest } from '../auth/auth.types.js';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
import { AdminGuard } from './admin.guard.js';
@@ -29,7 +30,10 @@ export class UserMenuController {
@Controller('permissions')
@UseGuards(AdminGuard)
export class PermissionsController {
constructor(private readonly permissions: PermissionsService) {}
constructor(
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
) {}
@Get()
list() {
@@ -42,16 +46,30 @@ export class PermissionsController {
}
@Put(':webUser')
set(
async set(
@Req() req: AuthenticatedRequest,
@Param('webUser', new ZodValidationPipe(webUserSchema)) webUser: string,
@Body(new ZodValidationPipe(setPathsSchema)) body: SetPathsDto,
) {
return this.permissions.setPaths(webUser, body.paths);
try {
const result = await this.permissions.setPaths(webUser, body.paths);
await this.audit.record({ action: 'permissions_set', webUser: req.user.webUser, ip: req.ip, resource: webUser, detail: { paths: body.paths } });
return result;
} catch (e) {
await this.audit.record({ action: 'permissions_set', webUser: req.user.webUser, ip: req.ip, resource: webUser, outcome: 'error', detail: { paths: body.paths, error: e instanceof Error ? e.message : String(e) } });
throw e;
}
}
@Delete(':webUser')
@HttpCode(204)
remove(@Param('webUser', new ZodValidationPipe(webUserSchema)) webUser: string) {
return this.permissions.remove(webUser);
async remove(@Req() req: AuthenticatedRequest, @Param('webUser', new ZodValidationPipe(webUserSchema)) webUser: string) {
try {
await this.permissions.remove(webUser);
await this.audit.record({ action: 'permissions_remove', webUser: req.user.webUser, ip: req.ip, resource: webUser });
} catch (e) {
await this.audit.record({ action: 'permissions_remove', webUser: req.user.webUser, ip: req.ip, resource: webUser, outcome: 'error', detail: { error: e instanceof Error ? e.message : String(e) } });
throw e;
}
}
}
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module.js';
import { AdminGuard } from './admin.guard.js';
import { MenuPermission } from './menu-permission.entity.js';
import { PermissionsController, UserMenuController } from './permissions.controller.js';
import { PermissionsService } from './permissions.service.js';
@Module({
imports: [TypeOrmModule.forFeature([MenuPermission])],
imports: [TypeOrmModule.forFeature([MenuPermission]), AuditModule],
controllers: [UserMenuController, PermissionsController],
providers: [PermissionsService, AdminGuard],
exports: [PermissionsService],
+2 -2
View File
@@ -46,7 +46,7 @@ export class SlProxyController {
@Body(new ZodValidationPipe(bodySchema)) body: Body,
@Headers() headers: IncomingHeaders,
) {
return this.proxy.create(req.user, resource, body, pickSlHeaders(headers, 'POST'));
return this.proxy.create(req.user, resource, body, pickSlHeaders(headers, 'POST'), req.ip ?? 'desconocida');
}
@Patch(':resource/:key')
@@ -58,6 +58,6 @@ export class SlProxyController {
@Body(new ZodValidationPipe(bodySchema)) body: Body,
@Headers() headers: IncomingHeaders,
): Promise<void> {
await this.proxy.update(req.user, resource, key, body, pickSlHeaders(headers, 'PATCH'));
await this.proxy.update(req.user, resource, key, body, pickSlHeaders(headers, 'PATCH'), req.ip ?? 'desconocida');
}
}
+83
View File
@@ -0,0 +1,83 @@
import { ForbiddenException } from '@nestjs/common';
import { vi } from 'vitest';
import type { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import type { PermissionsService } from '../permissions/permissions.service.js';
import type { SapClientService } from '../sap/sap-client.service.js';
import { SlProxyService } from './sl-proxy.service.js';
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
describe('SlProxyService: auditoría', () => {
let request: ReturnType<typeof vi.fn>;
let record: ReturnType<typeof vi.fn>;
let paths: string[];
let service: SlProxyService;
beforeEach(() => {
request = vi.fn(async () => ({ data: { DocEntry: 316, DocNum: 12 } }));
record = vi.fn(async () => undefined);
paths = ['*'];
service = new SlProxyService(
{ request } as unknown as SapClientService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
});
it('crear queda registrado con la clave creada y solo los nombres de los campos', async () => {
await service.create(user, 'Orders', { CardCode: 'C1', Comments: 'privado', DocumentLines: [{ ItemCode: 'A' }, { ItemCode: 'B' }] }, {}, '10.0.0.5');
expect(record).toHaveBeenCalledWith({
action: 'sl_create',
webUser: 'ana',
ip: '10.0.0.5',
resource: 'Orders',
key: '316',
detail: { fields: ['CardCode', 'Comments'], collections: { DocumentLines: 2 } },
});
expect(JSON.stringify(record.mock.calls)).not.toContain('privado');
});
it('modificar queda registrado con su clave y los campos tocados', async () => {
request.mockResolvedValueOnce({ data: undefined });
await service.update(user, 'BusinessPartners', 'C100', { Phone1: '123' }, {}, '10.0.0.5');
expect(record).toHaveBeenCalledWith({ action: 'sl_update', webUser: 'ana', ip: '10.0.0.5', resource: 'BusinessPartners', key: 'C100', detail: { fields: ['Phone1'] } });
});
it('si SAP rechaza la operación queda registrada como error, con el mensaje, y el error sigue su camino', async () => {
request.mockRejectedValueOnce(new Error('1250000110 - RUT is missing'));
await expect(service.create(user, 'BusinessPartners', { CardCode: 'C9' }, {}, '10.0.0.5')).rejects.toThrow('RUT is missing');
expect(record).toHaveBeenCalledWith({
action: 'sl_create',
webUser: 'ana',
ip: '10.0.0.5',
resource: 'BusinessPartners',
outcome: 'error',
detail: { fields: ['CardCode'], error: '1250000110 - RUT is missing' },
});
request.mockRejectedValueOnce(new Error('412'));
await expect(service.update(user, 'Orders', '5', { Comments: 'x' }, {}, '10.0.0.5')).rejects.toThrow('412');
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'sl_update', key: '5', outcome: 'error' }));
});
it('un usuario sin permiso de escritura queda registrado como denegado y no llega a SAP', async () => {
paths = ['/ventas/ordenes'];
await expect(service.create(user, 'Invoices', { CardCode: 'C1' }, {}, '10.0.0.5')).rejects.toBeInstanceOf(ForbiddenException);
expect(request).not.toHaveBeenCalled();
expect(record).toHaveBeenCalledWith({ action: 'sl_denied', webUser: 'ana', ip: '10.0.0.5', resource: 'Invoices', outcome: 'denied', detail: { method: 'POST' } });
});
it('las acciones de solo consulta (series de numeración) no se auditan', async () => {
request.mockResolvedValueOnce({ data: { value: [] } });
await service.create(user, 'SeriesService_GetDocumentSeries', { DocumentTypeParams: { Document: '17' } }, {}, '10.0.0.5');
expect(record).not.toHaveBeenCalled();
});
it('las lecturas no se auditan', async () => {
request.mockResolvedValueOnce({ data: { value: [] } });
await service.list(user, 'Orders', {}, {});
request.mockResolvedValueOnce({ data: {} });
await service.get(user, 'Orders', '5', {});
expect(record).not.toHaveBeenCalled();
});
});
+34 -9
View File
@@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { isPathAllowed } from '@basic-erp/sap-types';
import { AuditService, keyOfCreated, summarizeBody } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import { PermissionsService } from '../permissions/permissions.service.js';
import { SapClientService } from '../sap/sap-client.service.js';
@@ -20,6 +21,8 @@ import { SL_RESOURCE_MAP, type SlMethod, type SlResource } from './sl-resources.
type Headers = Record<string, string>;
const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 500);
/**
* Ejecuta contra el Service Layer lo que arma el frontend (parámetros OData y
* cuerpo), pero solo sobre recursos registrados, con permisos de menú y con
@@ -32,6 +35,7 @@ export class SlProxyService {
constructor(
private readonly sap: SapClientService,
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
) {}
async list(user: AuthUser, name: string, query: ListQuery, headers: Headers) {
@@ -58,21 +62,39 @@ export class SlProxyService {
return res.data;
}
async create(user: AuthUser, name: string, body: Record<string, unknown>, headers: Headers) {
const resource = await this.access(user, name, 'POST');
this.logger.log(`AUDIT ${user.webUser} POST ${resource.name}`);
const res = await this.sap.request({ method: 'POST', path: resource.name, body, headers });
return res.data;
async create(user: AuthUser, name: string, body: Record<string, unknown>, headers: Headers, ip: string) {
const resource = await this.access(user, name, 'POST', ip);
// Las acciones de solo consulta (series de numeración) no son un cambio: no se auditan.
const audited = resource.write !== null;
if (audited) this.logger.log(`AUDIT ${user.webUser} POST ${resource.name}`);
try {
const res = await this.sap.request({ method: 'POST', path: resource.name, body, headers });
if (audited) {
await this.audit.record({ action: 'sl_create', webUser: user.webUser, ip, resource: resource.name, key: keyOfCreated(res.data), detail: summarizeBody(body) });
}
return res.data;
} catch (e) {
if (audited) {
await this.audit.record({ action: 'sl_create', webUser: user.webUser, ip, resource: resource.name, outcome: 'error', detail: { ...summarizeBody(body), error: errorMessage(e) } });
}
throw e;
}
}
async update(user: AuthUser, name: string, key: string, body: Record<string, unknown>, headers: Headers) {
const resource = await this.access(user, name, 'PATCH');
async update(user: AuthUser, name: string, key: string, body: Record<string, unknown>, headers: Headers, ip: string) {
const resource = await this.access(user, name, 'PATCH', ip);
const path = entityPath(resource, key);
this.logger.log(`AUDIT ${user.webUser} PATCH ${path}`);
await this.sap.request({ method: 'PATCH', path, body, headers });
try {
await this.sap.request({ method: 'PATCH', path, body, headers });
await this.audit.record({ action: 'sl_update', webUser: user.webUser, ip, resource: resource.name, key, detail: summarizeBody(body) });
} catch (e) {
await this.audit.record({ action: 'sl_update', webUser: user.webUser, ip, resource: resource.name, key, outcome: 'error', detail: { ...summarizeBody(body), error: errorMessage(e) } });
throw e;
}
}
private async access(user: AuthUser, name: string, method: SlMethod): Promise<SlResource> {
private async access(user: AuthUser, name: string, method: SlMethod, ip?: string): Promise<SlResource> {
const resource = SL_RESOURCE_MAP.get(name);
if (!resource) throw new NotFoundException(`Recurso no disponible: ${name}`);
if (!resource.methods.includes(method)) {
@@ -83,6 +105,9 @@ export class SlProxyService {
const patterns = await this.permissions.getPaths(user.webUser);
if (!isPathAllowed(patterns, required)) {
this.logger.warn(`${user.webUser} sin permiso para ${method} ${resource.name}`);
if (method !== 'GET') {
await this.audit.record({ action: 'sl_denied', webUser: user.webUser, ip, resource: resource.name, outcome: 'denied', detail: { method } });
}
throw new ForbiddenException(`No tiene permisos para ${method === 'GET' ? 'consultar' : 'modificar'} ${resource.name}`);
}
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { SapModule } from '../sap/sap.module.js';
import { SlProxyController } from './sl-proxy.controller.js';
import { SlProxyService } from './sl-proxy.service.js';
@Module({
imports: [SapModule, PermissionsModule],
imports: [SapModule, PermissionsModule, AuditModule],
controllers: [SlProxyController],
providers: [SlProxyService],
})