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.
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.
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.
Reglas unique / exists scopeadas al tenant, más cache keys y jobs
tenant-scoped. Nada compartido entre tenants por accidente.
Las tablas centrales (tenants, users, tenant_user) no lo llevan.
Toda tabla de dominio (contracts, contract_events, companies,
positions) sí. 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 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.
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.
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.
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.
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.
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.
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
Recuperación nativa de Laravel Cloud (PITR + snapshots). El tope de la plataforma es de 30 días.
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.
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.
Secretos
Todos los secretos viven en los environment secrets de Laravel Cloud, nunca en el cliente ni en el repositorio.
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
Cloudflare WAF y protección DDoS al frente, más Turnstile como anti-bot en el signup público de AxelNow.
El cifrado en reposo de la PII (DUI, salario, dirección) vía casts de Laravel está anotado, no cerrado. Ver Decisiones técnicas.