From fe8c080471433009b7d3098a5bfce009de4b432d Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 12:35:46 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20auditor=C3=ADa=20(audit=5Flog=20en=20SQ?= =?UTF-8?q?Lite,=20endpoint=20/api/audit=20y=20pantalla=20/admin/auditoria?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registra sesiones, creaciones y modificaciones en SAP (solo nombres de campos), denegados y cambios de permisos. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- apps/api/src/app.module.ts | 3 +- apps/api/src/audit/audit-admin.module.ts | 11 ++ apps/api/src/audit/audit-log.entity.ts | 42 +++++ apps/api/src/audit/audit.controller.ts | 36 ++++ apps/api/src/audit/audit.module.ts | 12 ++ apps/api/src/audit/audit.service.spec.ts | 101 +++++++++++ apps/api/src/audit/audit.service.ts | 110 ++++++++++++ apps/api/src/auth/auth.controller.ts | 4 +- apps/api/src/auth/auth.module.ts | 3 +- apps/api/src/auth/auth.service.spec.ts | 46 ++++- apps/api/src/auth/auth.service.ts | 15 +- .../src/permissions/permissions.controller.ts | 28 ++- .../api/src/permissions/permissions.module.ts | 3 +- apps/api/src/sl/sl-proxy.controller.ts | 4 +- apps/api/src/sl/sl-proxy.service.spec.ts | 83 +++++++++ apps/api/src/sl/sl-proxy.service.ts | 43 ++++- apps/api/src/sl/sl.module.ts | 3 +- apps/web/src/app/app.routes.ts | 8 +- .../web/src/app/features/admin/audit.page.css | 67 +++++++ .../src/app/features/admin/audit.page.html | 56 ++++++ .../src/app/features/admin/audit.page.spec.ts | 164 ++++++++++++++++++ apps/web/src/app/features/admin/audit.page.ts | 139 +++++++++++++++ libs/sap-types/src/audit.ts | 32 ++++ libs/sap-types/src/index.ts | 1 + libs/sap-types/src/menu.ts | 5 +- 26 files changed, 993 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/audit/audit-admin.module.ts create mode 100644 apps/api/src/audit/audit-log.entity.ts create mode 100644 apps/api/src/audit/audit.controller.ts create mode 100644 apps/api/src/audit/audit.module.ts create mode 100644 apps/api/src/audit/audit.service.spec.ts create mode 100644 apps/api/src/audit/audit.service.ts create mode 100644 apps/api/src/sl/sl-proxy.service.spec.ts create mode 100644 apps/web/src/app/features/admin/audit.page.css create mode 100644 apps/web/src/app/features/admin/audit.page.html create mode 100644 apps/web/src/app/features/admin/audit.page.spec.ts create mode 100644 apps/web/src/app/features/admin/audit.page.ts create mode 100644 libs/sap-types/src/audit.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5f34cec..83cf568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Pendiente, en orden: Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885). 2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). -4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs. +4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log. 5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro). 6. Revisar las vulnerabilidades de `npm audit` y definir una política para `U_WEB_PASS` (texto plano, claves cortas) antes de producción. 7. Docker/despliegue (HTTPS tras Fortinet; sesiones web en memoria ⇒ un solo proceso). diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2074b01..6070135 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/audit/audit-admin.module.ts b/apps/api/src/audit/audit-admin.module.ts new file mode 100644 index 0000000..97cf45e --- /dev/null +++ b/apps/api/src/audit/audit-admin.module.ts @@ -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 {} diff --git a/apps/api/src/audit/audit-log.entity.ts b/apps/api/src/audit/audit-log.entity.ts new file mode 100644 index 0000000..9155186 --- /dev/null +++ b/apps/api/src/audit/audit-log.entity.ts @@ -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; +} diff --git a/apps/api/src/audit/audit.controller.ts b/apps/api/src/audit/audit.controller.ts new file mode 100644 index 0000000..49bea43 --- /dev/null +++ b/apps/api/src/audit/audit.controller.ts @@ -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; + +/** 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 }); + } +} diff --git a/apps/api/src/audit/audit.module.ts b/apps/api/src/audit/audit.module.ts new file mode 100644 index 0000000..7580c99 --- /dev/null +++ b/apps/api/src/audit/audit.module.ts @@ -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 {} diff --git a/apps/api/src/audit/audit.service.spec.ts b/apps/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..975a0fd --- /dev/null +++ b/apps/api/src/audit/audit.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts new file mode 100644 index 0000000..44795a1 --- /dev/null +++ b/apps/api/src/audit/audit.service.ts @@ -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; +} + +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): { fields: string[]; collections?: Record } { + const collections: Record = {}; + 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; + 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) {} + + async record(entry: AuditEntry): Promise { + 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 = {}; + 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) : null })), + }; + } + + /** Borra lo anterior a `days` días; devuelve cuántos registros quitó. */ + async purgeOlderThan(days: number): Promise { + 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; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index f191e2f..85f35cf 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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 { + await this.auth.logout(req.sessionToken, req.user, req.ip ?? 'desconocida'); } @Get('me') diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 39a06a3..5b9f7de 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 47c43e2..8e147dc 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -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; let sessions: SessionStoreService; let service: AuthService; + let record: ReturnType; 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' }); + }); + }); }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f4c22b7..81ab4ab 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { this.sessions.remove(token); + await this.audit.record({ action: 'logout', webUser: user.webUser, ip }); } private async findEmployee(username: string): Promise { diff --git a/apps/api/src/permissions/permissions.controller.ts b/apps/api/src/permissions/permissions.controller.ts index 278829b..86803e0 100644 --- a/apps/api/src/permissions/permissions.controller.ts +++ b/apps/api/src/permissions/permissions.controller.ts @@ -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; + } } } diff --git a/apps/api/src/permissions/permissions.module.ts b/apps/api/src/permissions/permissions.module.ts index 6bba02e..81527bf 100644 --- a/apps/api/src/permissions/permissions.module.ts +++ b/apps/api/src/permissions/permissions.module.ts @@ -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], diff --git a/apps/api/src/sl/sl-proxy.controller.ts b/apps/api/src/sl/sl-proxy.controller.ts index 84fd5dd..f9a0daa 100644 --- a/apps/api/src/sl/sl-proxy.controller.ts +++ b/apps/api/src/sl/sl-proxy.controller.ts @@ -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 { - 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'); } } diff --git a/apps/api/src/sl/sl-proxy.service.spec.ts b/apps/api/src/sl/sl-proxy.service.spec.ts new file mode 100644 index 0000000..427b38d --- /dev/null +++ b/apps/api/src/sl/sl-proxy.service.spec.ts @@ -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; + let record: ReturnType; + 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(); + }); +}); diff --git a/apps/api/src/sl/sl-proxy.service.ts b/apps/api/src/sl/sl-proxy.service.ts index 7decf1f..46f61cb 100644 --- a/apps/api/src/sl/sl-proxy.service.ts +++ b/apps/api/src/sl/sl-proxy.service.ts @@ -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; +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, 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, 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, headers: Headers) { - const resource = await this.access(user, name, 'PATCH'); + async update(user: AuthUser, name: string, key: string, body: Record, 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 { + private async access(user: AuthUser, name: string, method: SlMethod, ip?: string): Promise { 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}`); } } diff --git a/apps/api/src/sl/sl.module.ts b/apps/api/src/sl/sl.module.ts index 5bf66fe..08baddd 100644 --- a/apps/api/src/sl/sl.module.ts +++ b/apps/api/src/sl/sl.module.ts @@ -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], }) diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 1fc2147..5e5f034 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -78,10 +78,16 @@ const adminRoutes: Routes = [ data: { path: '/admin/permisos', title: 'Permisos de menú' }, loadComponent: () => import('./features/admin/permissions.page').then((m) => m.PermissionsPage), }, + { + path: 'admin/auditoria', + canActivate: [permissionGuard], + data: { path: '/admin/auditoria', title: 'Auditoría' }, + loadComponent: () => import('./features/admin/audit.page').then((m) => m.AuditPage), + }, ]; /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ -const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, '/admin/permisos']); +const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, '/admin/permisos', '/admin/auditoria']); const pendingRoutes: Routes = MENU.flatMap((m) => m.items) .filter((item) => !builtPaths.has(item.path)) .map( diff --git a/apps/web/src/app/features/admin/audit.page.css b/apps/web/src/app/features/admin/audit.page.css new file mode 100644 index 0000000..a7f8623 --- /dev/null +++ b/apps/web/src/app/features/admin/audit.page.css @@ -0,0 +1,67 @@ +:host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + gap: 8px; +} +.head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +h1 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; +} +.hint { + margin: 0; + color: var(--quiet); +} +.filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + padding: 8px 10px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius-panel); +} +.filters label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: var(--quiet); +} +.filters input, +.filters select { + min-width: 130px; +} +.buttons { + display: flex; + gap: 6px; + margin-left: auto; +} +.error { + padding: 6px 10px; + border: 1px solid var(--err); + background: var(--err-bg); + color: var(--err); + font-size: 12px; +} +.link { + margin-left: 8px; + background: none; + border: none; + color: inherit; + text-decoration: underline; + cursor: pointer; +} +.table { + flex: 1; + min-height: 0; +} diff --git a/apps/web/src/app/features/admin/audit.page.html b/apps/web/src/app/features/admin/audit.page.html new file mode 100644 index 0000000..1130e91 --- /dev/null +++ b/apps/web/src/app/features/admin/audit.page.html @@ -0,0 +1,56 @@ +
+
+

Auditoría

+

Quién inició sesión, creó o modificó qué en SAP, qué se le denegó y qué permisos se cambiaron. Se guardan los nombres de los campos, nunca sus valores.

+
+ +
+ +
+ + + + + + +
+ + +
+
+ +@if (errorMessage()) { + +} + + + + diff --git a/apps/web/src/app/features/admin/audit.page.spec.ts b/apps/web/src/app/features/admin/audit.page.spec.ts new file mode 100644 index 0000000..b61397c --- /dev/null +++ b/apps/web/src/app/features/admin/audit.page.spec.ts @@ -0,0 +1,164 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import type { AuditRow } from '@basic-erp/sap-types'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { AUDIT_ACTIONS, AuditPage, describeDetail, formatTimestamp } from './audit.page'; + +const row = (over: Partial = {}): AuditRow => ({ + id: 1, + ts: '2026-10-16T15:04:05.000Z', + webUser: 'ana', + action: 'sl_update', + resource: 'Orders', + key: '316', + outcome: 'ok', + ip: '10.0.0.5', + detail: { fields: ['Comments'] }, + ...over, +}); + +describe('descripción del detalle', () => { + it('lista los campos tocados y las filas de cada tabla, nunca valores', () => { + expect(describeDetail(row({ detail: { fields: ['CardCode', 'Comments'], collections: { DocumentLines: 3, BPAddresses: 0 } } }))).toBe( + 'Campos: CardCode, Comments · Tablas: DocumentLines (3), BPAddresses (0)', + ); + }); + + it('muestra el motivo de un login fallido y el error de SAP', () => { + expect(describeDetail(row({ detail: { reason: 'credenciales' } }))).toBe('credenciales'); + expect(describeDetail(row({ detail: { fields: ['CardCode'], error: '1250000110 - RUT is missing' } }))).toBe('1250000110 - RUT is missing · Campos: CardCode'); + }); + + it('muestra los permisos asignados y el método denegado', () => { + expect(describeDetail(row({ detail: { paths: ['/ventas/*', '/compras/ordenes'] } }))).toBe('Permisos: /ventas/*, /compras/ordenes'); + expect(describeDetail(row({ detail: { paths: [] } }))).toBe('Permisos: (ninguno)'); + expect(describeDetail(row({ detail: { method: 'POST' } }))).toBe('POST'); + }); + + it('sin detalle, vacío', () => { + expect(describeDetail(row({ detail: null }))).toBe(''); + }); + + it('la fecha se muestra local, y vacía si no hay', () => { + expect(formatTimestamp('2026-10-16T15:04:05.000Z')).toMatch(/\d/); + expect(formatTimestamp(null)).toBe(''); + }); + + it('cada acción del servidor tiene su nombre en español', () => { + for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_denied', 'permissions_set', 'permissions_remove']) { + expect(AUDIT_ACTIONS[a], a).toBeTruthy(); + } + }); +}); + +describe('AuditPage', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const el = () => fixture.nativeElement as HTMLElement; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + const reply = (rows: AuditRow[], total = rows.length) => ctrl.expectOne((r) => r.url === '/api/audit').flush({ total, rows }); + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [AuditPage], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(AuditPage); + await tick(); + }); + + it('pide lo más reciente, 50 por página, sin filtros', async () => { + const req = ctrl.expectOne((r) => r.url === '/api/audit'); + expect(req.request.params.get('limit')).toBe('50'); + expect(req.request.params.get('offset')).toBe('0'); + expect(req.request.params.keys().sort()).toEqual(['limit', 'offset']); + req.flush({ total: 0, rows: [] }); + await tick(); + expect(el().textContent).toContain('Sin registros'); + }); + + it('muestra cada hecho con su acción, resultado y detalle', async () => { + reply([ + row(), + row({ id: 2, action: 'login_failed', webUser: 'x', outcome: 'error', resource: null, key: null, detail: { reason: 'credenciales' } }), + row({ id: 3, action: 'sl_denied', outcome: 'denied', detail: { method: 'POST' } }), + ]); + await tick(); + const rows = [...el().querySelectorAll('tbody tr')].map((r) => r.textContent ?? ''); + expect(rows).toHaveLength(3); + expect(rows[0]).toContain('Modificación'); + expect(rows[0]).toContain('Orders'); + expect(rows[0]).toContain('Campos: Comments'); + expect(rows[1]).toContain('Inicio de sesión fallido'); + expect(rows[2]).toContain('Operación denegada'); + expect([...el().querySelectorAll('tbody .badge')].map((b) => b.textContent?.trim())).toEqual(['Correcto', 'Error', 'Denegado']); + expect(el().querySelector('.pager')?.textContent).toContain('1–3 de 3'); + }); + + it('Filtrar envía solo los filtros escritos y vuelve a la primera página', async () => { + reply([row()], 120); + await tick(); + const input = (label: string) => [...el().querySelectorAll('label')].find((l) => l.textContent?.trim().startsWith(label))!.querySelector('input, select') as HTMLInputElement; + input('Usuario').value = 'ana'; + input('Usuario').dispatchEvent(new Event('input')); + input('Acción').value = 'sl_update'; + input('Acción').dispatchEvent(new Event('change')); + input('Desde').value = '2026-10-01'; + input('Desde').dispatchEvent(new Event('input')); + (el().querySelector('button[type=submit]') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.url === '/api/audit'); + expect(req.request.params.get('webUser')).toBe('ana'); + expect(req.request.params.get('action')).toBe('sl_update'); + expect(req.request.params.get('from')).toBe('2026-10-01'); + expect(req.request.params.has('resource')).toBe(false); + expect(req.request.params.get('offset')).toBe('0'); + req.flush({ total: 1, rows: [row()] }); + }); + + it('pagina: Siguiente pide la página que sigue y Anterior vuelve', async () => { + reply([row()], 120); + await tick(); + expect(el().querySelector('.pager')?.textContent).toContain('1–1 de 120'); + const nextBtn = [...el().querySelectorAll('.pager button')].find((b) => b.textContent?.includes('Siguiente'))!; + expect(nextBtn.disabled).toBe(false); + nextBtn.click(); + await tick(); + const req = ctrl.expectOne((r) => r.url === '/api/audit'); + expect(req.request.params.get('offset')).toBe('50'); + req.flush({ total: 120, rows: [row({ id: 51 })] }); + await tick(); + const prev = [...el().querySelectorAll('.pager button')].find((b) => b.textContent?.includes('Anterior'))!; + expect(prev.disabled).toBe(false); + prev.click(); + await tick(); + expect(ctrl.expectOne((r) => r.url === '/api/audit').request.params.get('offset')).toBe('0'); + }); + + it('Limpiar quita los filtros', async () => { + reply([row()]); + await tick(); + const user = [...el().querySelectorAll('label')].find((l) => l.textContent?.trim().startsWith('Usuario'))!.querySelector('input') as HTMLInputElement; + user.value = 'ana'; + user.dispatchEvent(new Event('input')); + (el().querySelector('button[type=submit]') as HTMLElement).click(); + await tick(); + reply([row()]); + await tick(); + ([...el().querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Limpiar') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.url === '/api/audit'); + expect(req.request.params.has('webUser')).toBe(false); + req.flush({ total: 0, rows: [] }); + }); + + it('un error del servidor se avisa con Reintentar', async () => { + ctrl.expectOne((r) => r.url === '/api/audit').flush({ message: 'Requiere permisos de administrador' }, { status: 403, statusText: 'Forbidden' }); + await tick(); + expect(el().querySelector('.error')?.textContent).toContain('Requiere permisos de administrador'); + }); +}); diff --git a/apps/web/src/app/features/admin/audit.page.ts b/apps/web/src/app/features/admin/audit.page.ts new file mode 100644 index 0000000..ba0542d --- /dev/null +++ b/apps/web/src/app/features/admin/audit.page.ts @@ -0,0 +1,139 @@ +import { httpResource } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import type { AuditListResponse, AuditRow } from '@basic-erp/sap-types'; +import { API_URL, apiErrorMessage } from '../../core/api'; +import { DataTable } from '../../shared/list/data-table'; +import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types'; +import { Pager } from '../../shared/list/pager'; + +/** Qué significa cada acción registrada. */ +export const AUDIT_ACTIONS: Readonly> = { + login: 'Inicio de sesión', + login_failed: 'Inicio de sesión fallido', + logout: 'Cierre de sesión', + sl_create: 'Creación', + sl_update: 'Modificación', + sl_denied: 'Operación denegada', + permissions_set: 'Permisos asignados', + permissions_remove: 'Permisos quitados', +}; + +const OUTCOMES: Readonly> = { + ok: { label: 'Correcto', tone: 'ok' }, + error: { label: 'Error', tone: 'err' }, + denied: { label: 'Denegado', tone: 'warn' }, +}; + +const PAGE = 50; + +/** `2026-10-16T15:04:05.000Z` → fecha y hora locales. */ +export const formatTimestamp = (ts: unknown): string => + typeof ts === 'string' && ts ? new Date(ts).toLocaleString('es-CL', { dateStyle: 'short', timeStyle: 'medium' }) : ''; + +/** Resumen legible del detalle: motivo o error, y los campos tocados (nunca valores). */ +export function describeDetail(row: Pick): string { + const d = row.detail; + if (!d) return ''; + const parts: string[] = []; + if (typeof d['reason'] === 'string') parts.push(d['reason']); + if (typeof d['error'] === 'string') parts.push(d['error']); + if (Array.isArray(d['fields']) && d['fields'].length) parts.push(`Campos: ${(d['fields'] as string[]).join(', ')}`); + const collections = d['collections']; + if (collections && typeof collections === 'object') { + const list = Object.entries(collections as Record).map(([k, n]) => `${k} (${n})`); + if (list.length) parts.push(`Tablas: ${list.join(', ')}`); + } + if (Array.isArray(d['paths'])) parts.push(`Permisos: ${(d['paths'] as string[]).join(', ') || '(ninguno)'}`); + if (typeof d['method'] === 'string') parts.push(d['method']); + return parts.join(' · '); +} + +interface Filters { + webUser: string; + action: string; + resource: string; + outcome: string; + from: string; + to: string; +} +const EMPTY: Filters = { webUser: '', action: '', resource: '', outcome: '', from: '', to: '' }; + +/** + * Registro de auditoría (solo acceso total): quién inició sesión, creó o modificó qué en SAP, qué se le + * denegó y qué permisos se cambiaron. Lo más reciente primero, 50 por página, con filtros. + */ +@Component({ + selector: 'sbo-audit-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DataTable, Pager], + templateUrl: './audit.page.html', + styleUrl: './audit.page.css', +}) +export class AuditPage { + protected readonly actions = Object.entries(AUDIT_ACTIONS).map(([value, label]) => ({ value, label })); + protected readonly outcomes = Object.entries(OUTCOMES).map(([value, o]) => ({ value, label: o.label })); + + /** Lo que se está escribiendo en la barra de filtros. */ + protected readonly draft = signal({ ...EMPTY }); + /** Lo que ya se aplicó (solo cambia con «Filtrar» o «Limpiar»). */ + private readonly applied = signal({ ...EMPTY }); + private readonly offset = signal(0); + + private readonly data = httpResource(() => { + const f = this.applied(); + const params: Record = { limit: PAGE, offset: this.offset() }; + for (const [k, v] of Object.entries(f)) if (v) params[k] = v; + return { url: `${API_URL}/audit`, params }; + }); + + protected readonly rows = computed(() => (this.data.hasValue() ? (this.data.value().rows as unknown as ListRow[]) : [])); + protected readonly total = computed(() => (this.data.hasValue() ? this.data.value().total : 0)); + protected readonly loading = computed(() => this.data.isLoading()); + protected readonly errorMessage = computed(() => (this.data.error() ? apiErrorMessage(this.data.error()) : '')); + + protected readonly columns: readonly ListColumn[] = [ + { key: 'ts', title: 'Fecha y hora', width: '160px', format: (r) => formatTimestamp(r['ts']) }, + { key: 'webUser', title: 'Usuario', width: '150px' }, + { key: 'action', title: 'Acción', width: '190px', format: (r) => AUDIT_ACTIONS[String(r['action'])] ?? String(r['action']) }, + { key: 'resource', title: 'Recurso', width: '170px' }, + { key: 'key', title: 'Clave', width: '110px', mono: true }, + { key: 'outcome', title: 'Resultado', width: '100px', format: (r) => OUTCOMES[String(r['outcome'])]?.label ?? String(r['outcome']), tone: (r) => OUTCOMES[String(r['outcome'])]?.tone }, + { key: 'ip', title: 'IP', width: '130px', mono: true }, + { key: 'detail', title: 'Detalle', format: (r) => describeDetail(r as unknown as AuditRow) }, + ]; + + protected readonly rangeText = computed(() => { + const total = this.total(); + if (total === 0) return '0 registros'; + return `${this.offset() + 1}–${Math.min(this.offset() + this.rows().length, total)} de ${total}`; + }); + protected readonly hasPrevious = computed(() => this.offset() > 0); + protected readonly hasNext = computed(() => this.offset() + PAGE < this.total()); + + protected set(key: keyof Filters, value: string): void { + this.draft.update((d) => ({ ...d, [key]: value })); + } + + protected filter(): void { + this.offset.set(0); + this.applied.set({ ...this.draft() }); + } + + protected clear(): void { + this.draft.set({ ...EMPTY }); + this.offset.set(0); + this.applied.set({ ...EMPTY }); + } + + protected reload(): void { + this.data.reload(); + } + + protected previous(): void { + this.offset.update((o) => Math.max(0, o - PAGE)); + } + + protected next(): void { + this.offset.update((o) => o + PAGE); + } +} diff --git a/libs/sap-types/src/audit.ts b/libs/sap-types/src/audit.ts new file mode 100644 index 0000000..edd58f6 --- /dev/null +++ b/libs/sap-types/src/audit.ts @@ -0,0 +1,32 @@ +/** Hechos que registra la auditoría. */ +export type AuditAction = + | 'login' + | 'login_failed' + | 'logout' + | 'sl_create' + | 'sl_update' + | 'sl_denied' + | 'permissions_set' + | 'permissions_remove'; + +export type AuditOutcome = 'ok' | 'error' | 'denied'; + +/** Una fila del registro de auditoría (`GET /api/audit`). */ +export interface AuditRow { + id: number; + /** Momento (ISO 8601, UTC). */ + ts: string; + webUser: string | null; + action: string; + resource: string | null; + key: string | null; + outcome: string; + ip: string | null; + /** Nombres de los campos tocados, motivo o mensaje de error; nunca valores. */ + detail: Record | null; +} + +export interface AuditListResponse { + total: number; + rows: AuditRow[]; +} diff --git a/libs/sap-types/src/index.ts b/libs/sap-types/src/index.ts index 27ffb98..2732944 100644 --- a/libs/sap-types/src/index.ts +++ b/libs/sap-types/src/index.ts @@ -1,3 +1,4 @@ +export * from './audit.js'; export * from './auth.js'; export * from './permissions.js'; export * from './sl.js'; diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 760bf74..9f89708 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -68,7 +68,10 @@ export const MENU: readonly MenuModule[] = [ id: 'admin', label: 'Administración', // Solo acceso total ("*"): el API de permisos exige ser administrador. - items: [{ path: '/admin/permisos', label: 'Permisos de menú' }], + items: [ + { path: '/admin/permisos', label: 'Permisos de menú' }, + { path: '/admin/auditoria', label: 'Auditoría' }, + ], }, ];