⌘J
En esta página 7

Engineering / Seguridad y privacidad

Seguridad y privacidad

Cómo está diseñado proteger la PII laboral real (aislamiento multi-tenant en tres capas, guardrails del LLM, serialización de PII, backups y secretos).

Parcialmente implementado: la capa de tenancy + RLS ya es código (F2 mergeada, jul 2026 — ver Multi-tenancy); el resto (LLM, firma, PII en reposo) sigue siendo diseño. Axel maneja PII laboral real (salario, DUI, dirección), así que la seguridad es requisito de fundación, no un extra post-MVP.

Esta página resume el modelo de seguridad y privacidad de Axel end-to-end: cómo se aísla cada tenant, cómo se acota al LLM, cómo cruza la PII la frontera entre backend y cliente, y cómo se resguardan datos y secretos.

Aislamiento multi-tenant

El aislamiento cross-tenant es el riesgo número uno del producto. El modelo es shared schema + columna tenant_id + Postgres RLS como backstop (decisión 0.8). Ver Multi-tenancy y aislamiento para el detalle completo; aquí va el resumen de las tres capas de defensa.

Capa 1: global scope de Eloquent

Un trait BelongsToTenant inyecta el tenant_id del contexto en toda query de dominio. Protege el path del ORM: la ruta feliz de la aplicación.

Capa 2: Postgres RLS (backstop)

FORCE ROW LEVEL SECURITY a nivel base cubre lo que salta el ORM: un DB::table() crudo, la query tool del LLM, rutas centrales o jobs. Fail-closed: contexto ausente devuelve 0 filas, no un cruce.

Capa 3: scoping transversal

Reglas unique / exists scopeadas al tenant, más cache keys y jobs tenant-scoped. Nada compartido entre tenants por accidente.

Qué lleva tenant_id y qué no

Las tablas centrales (tenants, users, tenant_user) no lo llevan. Toda tabla de dominio (contracts, contract_events, companies, positions) . La tabla clauses (derecho SV común) es global.

La resolución es por sesión (current_tenant_id, seteado en F1): el tenant_id resuelto alimenta el global scope y ejecuta SET LOCAL app.current_tenant, la variable que lee la policy de RLS en cada transacción.

Dominio único, tenant por sesión (decisión 0.10, cerrada 16-jul). Se descartó el subdominio por tenant: contradecía la elección de UUIDv7 (no revelar clientes). Ver Decisiones → 0.10. El aislamiento por RLS no depende de esto: se apoya en tenant_id, resuélvase por sesión o por subdominio.

Por qué columna y no schema

La columna falla vacío: si el contexto no está seteado, tenant_id IS NULL devuelve 0 filas. Schema-per-tenant bajo connection pooling falla cruzado: un search_path residual expone datos de otro tenant (leak activo, no vacío). Además, ningún régimen de compliance relevante (GDPR Art. 32, SOC 2) exige separación por schema; lo que exigen es aislamiento efectivo, que la columna con RLS provee.

El gotcha crítico del RLS. FORCE ROW LEVEL SECURITY es silenciosamente inerte si Laravel conecta como owner de la tabla. Un test que solo consulta vía Eloquent pasa en verde sin ejercitar RLS en absoluto. Por eso el gate de aislamiento (TenantIsolationTest) corre bajo un rol no-owner, con casos que saltan el scope a propósito, y no solo local sino contra Laravel Cloud real con el pooler transaccional activo.

El riesgo residual honesto: con shared schema el aislamiento es responsabilidad del código. Un query sin scope filtra. El vector dominante de fallo es un bug de aplicación, no la arquitectura, y por eso la defensa se estructura en capas y con un gate P0 dedicado.

Guardrails del LLM

El principio rector es “intención = LLM, verdad = motor determinista”. El LLM interpreta lo que el usuario quiere; nunca genera SQL crudo, ni modifica datos, ni escribe el documento legal.

Sin path directo LLM a la DB

En la consulta NL de cartera (lo más riesgoso), el LLM propone un query plan que pasa por un Eloquent query builder con whitelist de tablas y columnas y scope de tenant forzado. No hay camino directo del modelo a la base.

Citas por ID, no texto libre

Las citas legales salen solo de la tabla Citations (por ID, nunca texto generado). Es la barrera anti-alucinación: el modelo no puede inventar una referencia normativa.

Auditoría de cada interacción

ai_interactions (input, output, tokens, latencia por tenant y usuario) y ai_guardrail_logs (acción, si fue bloqueada, motivo). El gate incluye un test de penetración: no SQL injection, no cross-tenant.

Sin failover automático de modelo

Anti-feature deliberado: no se hace swap automático entre modelos, porque un cambio de modelo podría cambiar la interpretación legal. Ante falla, degrada a un formulario manual.

Serialización de PII

La frontera entre el backend (Laravel) y el cliente (React vía Inertia) es donde la PII puede filtrarse por descuido. La regla es dura: nunca se serializa un model de Eloquent crudo como prop.

Whitelist explícita por DTO

Todo lo que llega al cliente pasa por un objeto spatie/laravel-data con whitelist explícita. Un campo sensible (salario, DUI) solo llega a React si el DTO lo incluye a propósito.

Limpieza del history del navegador

La history-encryption de Inertia v3 limpia la PII del historial del navegador al hacer logout, para que no quede cacheada en el cliente.

Backups y retención

DR de corto plazo

Recuperación nativa de Laravel Cloud (PITR + snapshots). El tope de la plataforma es de 30 días.

Archivo de largo plazo

Un job propio (Artisan Command mensual) hace pg_dump hacia R2 (cuenta propia de Cloudflare) con rotación, para lo que excede la ventana de la plataforma.

Respaldo de PDFs firmados

Los PDF firmados se respaldan a R2 antes del delete del Optimizer, de modo que el documento con valor probatorio nunca dependa de una sola copia.

La retención legal está sin cerrar. Research técnico preliminar (no opinión legal) apunta a un piso probable de 10 años, ligado al Código Tributario (Art. 147, comprobantes de retención de ISR sobre salarios). No es un plazo oficial: falta la validación del área legal (pendiente en el backlog jurídico). Hasta que se cierre, no afirmar ninguna cifra de retención como definitiva.

Secretos

Secretos fuera del cliente y del repo

Todos los secretos viven en los environment secrets de Laravel Cloud, nunca en el cliente ni en el repositorio.

Optimizer sin puertos públicos

El VPS del Optimizer de UANATACA es accesible solo vía Cloudflare Tunnel (cero puertos públicos). Solo se le transmiten hashes: ningún dato sensible sale de la infraestructura de Axel.

Edge

WAF, DDoS y anti-bot

Cloudflare WAF y protección DDoS al frente, más Turnstile como anti-bot en el signup público de AxelNow.

Cifrado en reposo de PII

abierto El cifrado en reposo de la PII (DUI, salario, dirección) vía casts de Laravel está anotado, no cerrado. Ver Decisiones técnicas.