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