dec-0118

Vista generada de dec-0118: Seguridad por diseño, parte 2: control plane TS, identidad y web

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md

Enmienda a: r10, r17, r20, dec-0113

Por qué importa (del frontmatter del ADR):

Threat model y arquitectura de seguridad del control plane TS y de la web: modelo de sesión del navegador (BFF, cookies __Host-, CSRF de doble barrera, rotación/expiración/revocación), policy layer deny-by-default para rutas Elysia 2 y handlers NATS, autorización por recurso contra IDOR, CORS, CSP con nonces + Trusted Types, cabeceras, errores sin leak, mínimo privilegio en Postgres/Valkey/NATS y audit log. Lo consumen identity-svc, todos los servicios Elysia, apps/web y la migración a Elysia 2.

Nodos del roadmap que lo citan en refs: track/identity/invitations, track/identity/cli-auth, track/identity/api-keys, track/identity/quick-connect, track/identity/accounts-households-profiles, track/identity/local-password, track/identity/sec, track/docs

Páginas de la documentación que lo citan: Evidencia y verificación adversarial (implementado), Generadores de la documentación (especificado), Arquitectura (implementado), Modo público y Styx como plataforma (especificado), Comunicaciones entre procesos (implementado), Seguridad del data plane (implementado), Modelo de amenazas (implementado), Sesiones, BFF y autorización (implementado), Planos y servicios (implementado), API para agentes y MCP (especificado), API keys (especificado), Conectar la TV y el CLI (especificado), Hogares y perfiles (especificado), Iniciar sesión (especificado), Invitaciones (implementado), Entrar con el CLI (implementado), Modos de reproducción (implementado), Navegadores (especificado), Desplegar styx en Coolify (especificado), Instalar (especificado), Seguir una reproducción por todos los servicios (especificado), Visión (especificado), Primer arranque (especificado), Primera película en Chrome (especificado)

Texto del ADR

Leído de docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md, el fichero canónico.

dec-0118 — Seguridad por diseño, parte 2: control plane TS, identidad y web

  • Fecha: 2026-09-28
  • Estado: LOCKED. Decisiones de waxin del 2026-09-28 recogidas en las notas de entorno de la tanda 3: _"cookies _Host- Secure HttpOnly SameSite, rotación de sesión, expiración abs/idle, revocación server-side (Valkey), CSRF doble barrera, sin tokens en localStorage, refresh rotativo con reuse detection; mínimo privilegio: rol Postgres por servicio, ACL NATS por servicio …; policy layer central deny-by-default para rutas y handlers NATS (test que falla si falta)", y "PARTE 2 TS/WEB aparte (cookies/sesiones, CORS, CSP+Trusted Types, IDOR, roles, policy layer)".
  • Par: dec-0117 (parte 1, data plane Zig) y dec-0119 (spire, comunicaciones).
  • Enmienda:
    • dec-0113 §4 (PROPOSED): el navegador no recibe access tokens (§2.1). Siguen en pie §1–§3 de dec-0113: formato JWT EdDSA, refresh opaco rotatorio con reuse detection y ventana de gracia, y TTL idle/absoluto. Los clientes nativos siguen como dec-0113.
    • r17/r20 §5: el navegador sólo habla con un origen de control plane, la web (BFF), y los servicios no se exponen al navegador (§2.1, §5).
    • r10: el origen de media (delivery plane) es distinto del origen de control. Sin cookies: capabilities de dec-0117 §4.1.

Contexto: qué hay hoy (verificado en 54e997a)

HechoDónde
playback-svc monta cors({ origin: true }). @elysiajs/cors 1.4.2 trae credentials = true por defecto (dist/index.mjs:22, cabecera en :98), así que cualquier origen hace peticiones con credenciales y lee la respuestaapps/playback-svc/src/transport/http/app.ts:33
POST /sessions de playback-svc no autentica y usa un actor devapps/playback-svc/src/transport/http/routes/sessions.ts, core/handlers/PlaybackHandler.ts:403 (ticket track/identity#01)
identity-svc: cookies styx_rt y styx_oidc_state sin prefijo __Host-, Secure sólo si la URL pública es https y Path=/authapps/identity-svc/src/transport/http/cookies.ts:15-17,44-62, compose.ts:112
/auth/refresh y /auth/logout aceptan la cookie sin comprobar Origin/Sec-Fetch-Site ni token CSRFroutes/auth.ts:139-175
/auth/refresh devuelve el access token en el cuerpo al navegadorroutes/auth.ts:146-150
Sin rol por servicio: todos los servicios usan el superusuario styx de Postgres; Valkey y NATS arrancan sin autenticacióndeploy/docker-compose.infra.yml:10-12,23-26,36-43, deploy/docker-compose.apps.yml:16,51,101
Ningún servicio declara policy por ruta ni por subjectgrep -rn "x-styx-policy" apps packages → 0
La web: SSR con TanStack Start y server functions que llaman a los servicios desde el servidor. No usa storage para credenciales (el único localStorage es estado de UI) y no hay CSPapps/web/src/server/eden.ts:35, apps/web/src/stores/ui-flow.ts:130-140, apps/web/src/routes/__root.tsx:50

Qué se decide

1. Activos y fronteras

  • Activos: credenciales de sesión (cookie de navegador, refresh de nativos, access JWT), clave de firma de identity y de playback (SCT), datos por usuario (progreso, perfiles, bibliotecas privadas), configuración de fuentes con secretos (ctx.secrets, r48), audit log.
  • Fronteras: W1 navegador ↔ web BFF; W2 cliente nativo ↔ gateway de API; W3 BFF/gateway ↔ servicios (HTTP interno y spire/NATS); W4 servicio ↔ Postgres/Valkey/NATS; W5 contenido remoto (metadatos, imágenes, subtítulos, plugins) → DOM.

STRIDE resumido. La columna de la derecha cita la sección que lo cierra:

FronteraAmenazas principalesCierre
W1robo de sesión por XSS, CSRF, fijación de sesión, clickjacking§2, §6
W2robo/replay de refresh tokendec-0113 §2 (rotación y reuse detection) + §2.4
W3petición sin policy, IDOR, confused deputy entre servicios§3, §4, dec-0119
W4credencial compartida que da acceso a todo§7
W5XSS almacenado vía metadatos/subtítulos/plugins, tracking vía imágenes remotas§6.3
todasfuga de detalles internos en errores, repudio§8, §9

2. Identidad y sesiones

2.1 El navegador no tiene tokens portadores (BFF)

  • El único origen de control plane que ve el navegador es la web (TanStack Start). Las server functions y un reverse proxy de /auth/* hacia identity-svc son la única superficie. Los servicios (catalog, playback, sources…) no se exponen al navegador.
  • El navegador no guarda ningún token: nada en localStorage, sessionStorage, IndexedDB, memoria JS ni cookies legibles por JS. Su única credencial es la cookie de sesión HttpOnly.
  • El BFF resuelve la cookie contra identity-svc (qry.identity.browserSession vía spire, revocation-aware) y obtiene un access JWT de vida corta que nunca sale del servidor. Con ese JWT llama a los servicios en nombre del usuario. Así un XSS no puede exfiltrar credenciales: como mucho actúa mientras la página esté abierta, y CSP + Trusted Types (§6) lo hacen difícil.
  • El delivery plane (origen de media, r10) no usa cookies: el BFF entrega al navegador una SCT (dec-0117 §4.1) por sesión de reproducción. Una SCT es una capability acotada y no una credencial de usuario.

2.2 Cookies

CookieAtributosContenido
__Host-styx_sessHttpOnly; Secure; SameSite=Strict; Path=/, sin Domaincredencial opaca <sessionId>.<secreto 256 bit>, en Valkey sólo SHA-256(secreto) (mismo esquema que el refresh de dec-0113 §2)
__Host-styx_loginHttpOnly; Secure; SameSite=Lax; Path=/, vida del login pendientebinding del state OIDC (el callback llega como navegación cross-site, así que Strict no sirve)
  • __Host-styx_login (refinado por TS4-P2-01, AppSec pasada 4): la cookie lleva el login pendiente entero (state, nonce, codeVerifier, returnTo, exp) sellado con AES-256-GCM, clave derivada por HKDF de la clave CSRF. GET /auth/oidc/start es anónimo y no escribe nada en el servidor: un estado compartido con tope que esa ruta llenara era un recurso que unas decenas de clientes agotaban para toda la instalación. El uso único lo da una marca identity:login:used:<state> (SET NX PX) que sólo escribe el callback, con el sello ya abierto y el state comparado.

  • Secure siempre. En dev se usa https con tls internal, igual que el OP de dev (ticket identity#01 D7), y no se admite «http en local».

  • __Host- exige Path=/: la restricción por ruta que hoy da Path=/auth pasa al servidor (sólo /auth/* y el middleware del BFF leen la cookie).

2.3 Rotación, expiración y revocación

  • Rotación: al hacer login (credencial nueva, así que no hay fijación de sesión), al cambiar de privilegios (rol, perfil adulto/infantil) y periódicamente, cada 15 min de uso. La rotación periódica reutiliza el script Lua atómico y la ventana de gracia de dec-0113 §2. Presentar una credencial ya rotada fuera de la ventana revoca la sesión entera y emite un evento de audit de severidad alta (§9).
  • Expiración: idle 30 días deslizante y absoluta 90 días (dec-0113 §3; reversible por config). Para operaciones sensibles (añadir fuentes con secretos, gestionar usuarios, borrar bibliotecas) se exige un login reciente (auth_time ≤ 15 min), si no, re-autenticación.
  • Revocación server-side en Valkey: por sesión, por actor (logout global) y por admin. Existe un índice identity:actor:<id>:sessions y un tope de sesiones concurrentes por actor (reversible). Un JWT de servicio ya emitido sigue verificándose offline hasta su exp (≤ 5 min para el BFF). Las mutaciones comprueban la sesión revocation-aware.
  • Logout: borra en Valkey y responde Clear-Site-Data: "cookies", "storage".

2.4 Clientes nativos (Apple, CLI)

Siguen dec-0113 §1–§3: refresh opaco rotatorio en el almacén seguro de la plataforma (Keychain), transportado en el cuerpo. El access token vive en memoria del proceso. No hay cookies, así que no hay CSRF. Rate-limit y reuse detection son iguales que en el navegador.

2.5 CSRF: doble barrera

Toda petición que cambia estado y lleva la cookie de sesión tiene que pasar dos barreras independientes:

  1. Contexto de navegador: SameSite=Strict en la cookie y comprobación en servidor de Sec-Fetch-Site: same-origin (si falta, Origin debe coincidir exactamente con el origen de la web). Un navegador sin ninguna de las dos cabeceras se rechaza en rutas mutantes.
  2. Token sincronizador: cabecera X-Styx-CSRF con HMAC(clave CSRF del servidor, sessionId), que el SSR entrega a la página. No sirve sin la cookie, y una petición cross-site no puede leerlo ni fabricarlo.

Las server functions GET de TanStack Start no cambian estado (lo comprueba el test de policy de §3, que falla si una ruta GET declara efectos). Test: petición mutante con cookie válida y sin token, con token de otra sesión, con Sec-Fetch-Site: cross-site o con Origin ajeno → 403.

3. Policy layer deny-by-default

  • Rutas Elysia 2: un único macro policy (patrón de ADOPT.md de la migración a Elysia 2) que cada ruta declara y que queda en detail['x-styx-policy']. Valores: public (lista explícita: health, JWKS, /auth/oidc/*), authenticated, role:<rol> y resource:<tipo>:<acción> (§4). Si la ruta no declara policy, deniega (401/403) por construcción. No existe la opción «sin policy = pasa».
  • Test que falla si falta policy: en cada servicio, un test recorre app.routes y falla si una ruta no trae x-styx-policy, o si una ruta public no está en la allowlist versionada del servicio. Lo mismo para las server functions de la web (el wrapper authedServerFn es la única forma de crearlas, y un guard de lint prohíbe createServerFn directo).
  • Handlers NATS: se registran sólo mediante spire (dec-0119 §4). En TS, un handler sin policy hace que el servicio no arranque, y hay un test del registro. En Zig, no compila.
  • Un solo motor: HTTP y NATS evalúan con el mismo can(principal, acción, recurso) de un paquete de dominio (@styx/authz, nombre reversible). Nada de lógica de autorización suelta en handlers.

4. Roles y autorización por recurso (IDOR)

  • Roles: owner (instalación), admin, member y restricted (perfil infantil o invitado). Los permisos son acciones sobre tipos de recurso. Un rol es un conjunto de permisos, no un if en un handler.
  • Por recurso: todo id que llega del cliente (workId, assetId, libraryId, profileId, sessionId, sourceId) se resuelve antes del handler a un recurso con dueño/biblioteca y se autoriza. El handler recibe un tipo marcado Authorized<R, A>, que sólo el policy layer puede construir: un handler que tome un id crudo y consulte la base no compila contra su port. Así se cierra el IDOR por construcción, no por revisión.
  • Alcances (TS4-P2-02, AppSec pasada 4): own (sólo lo del actor), shared (lo suyo y lo compartido sin dueño) y any. own no cubre un recurso sin dueño: compartir es explícito. restricted sólo tiene own (sus sesiones) y no lee la biblioteca hasta que un owner le conceda bibliotecas; member reproduce con shared.
  • Listados: las consultas filtran por las bibliotecas visibles del principal dentro del repositorio (predicado obligatorio en el port). Postgres RLS por actor queda como segunda capa en las tablas por usuario (progreso, perfiles).
  • Test: por cada ruta resource:*, un caso con un recurso de otro actor → 404 (no 403, para no confirmar que existe).

5. CORS

  • Los servicios no montan CORS: el navegador no les habla. Se retira cors({ origin: true }) de playback-svc. Test: preflight desde https://evil.example → sin Access-Control-Allow-*.
  • El origen de media (delivery plane) permite CORS sólo para la allowlist de orígenes de la web, credentials: false (usa SCT, no cookies), métodos GET/OPTIONS y cabeceras mínimas (Range).
  • Prohibido origin: true, origin: '*' con credenciales y reflejar Origin sin allowlist. Lo vigila un guard (§10).

6. CSP, Trusted Types, SRI y cabeceras

6.1 CSP con nonces (respuestas HTML del SSR)

default-src 'none';
script-src 'nonce-{N}' 'strict-dynamic';
style-src 'self' 'nonce-{N}';
img-src 'self' blob: data:;
media-src 'self' blob: {origen-media};
connect-src 'self' {origen-media};
font-src 'self';
worker-src 'self' blob:;
manifest-src 'self';
object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none';
require-trusted-types-for 'script'; trusted-types styx-html styx-url;
upgrade-insecure-requests
  • Nonce nuevo por respuesta (128 bits), inyectado por el SSR en cada <script> y <style>. El <style dangerouslySetInnerHTML> de __root.tsx:50 lleva nonce.
  • connect-src cubre WebTransport hacia el origen de media. Si un navegador objetivo no lo aplica a WebTransport, se documenta y la SCT sigue siendo la barrera.
  • Primero Content-Security-Policy-Report-Only con endpoint de reporte, y a enforcement en el mismo milestone cuando el e2e salga limpio.

6.2 Trusted Types, SRI y dependencias

  • Dos policies nombradas: styx-html (sólo DOMPurify con config restrictiva) y styx-url (allowlist de esquemas https:, relativos y blob: de MSE; rechaza javascript: y data: salvo imágenes). Nada más puede crear sinks.
  • Sin CDNs de terceros en runtime: fuentes y scripts autoalojados. Si alguna vez hay un recurso externo, lleva SRI (integrity + crossorigin) y lo vigila el guard.

6.3 Contenido remoto → DOM (W5)

  • Metadatos remotos, datos de plugins y subtítulos se renderizan como texto (React escapa). Los subtítulos van por TextTrack/VTTCue, nunca por innerHTML. Si hace falta HTML (sinopsis enriquecida), pasa por styx-html.
  • href/src que vengan de datos pasan por styx-url.
  • Las imágenes remotas (pósters, fanart) se sirven a través de un proxy de imágenes del BFF: sin tracking del proveedor, sin mixed content y con validación de tipo y tamaño.

6.4 Cabeceras (web y servicios)

Strict-Transport-Security: max-age=63072000; includeSubDomains (con preload cuando haya dominio propio), Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin (media: cross-origin sólo en el origen de media), Referrer-Policy: no-referrer, Permissions-Policy mínima (sin cámara, micro, geolocalización ni pago; fullscreen=(self), picture-in-picture=(self)), X-Content-Type-Options: nosniff, Cache-Control: no-store en toda respuesta autenticada. COEP queda diferido con SharedArrayBuffer (r55).

7. Mínimo privilegio en datos y bus

  • Postgres: un rol por servicio (identity_svc, catalog_svc, …) con NOSUPERUSER NOCREATEDB NOCREATEROLE, dueño sólo de su schema, REVOKE ALL ON SCHEMA public FROM PUBLIC. Un rol *_migrator separado aplica migraciones y el runtime no puede hacer DDL. El superusuario sólo existe para el bootstrap del cluster. Las contraseñas van en secretos de fichero, no en URLs versionadas en compose.
  • Valkey: ACL por servicio (user identity on >… ~identity:* &identity:* +@read +@write +eval -@admin -@dangerous), usuario default desactivado.
  • NATS: identidades y ACL por servicio. El detalle está en dec-0119 §3.1.
  • Secretos: claves de firma y credenciales en ficheros montados de sólo lectura (*_FILE), no en variables de entorno, que se ven en docker inspect y en /proc/*/environ. Las claves de firma (identity JWT, playback SCT) se publican con kid y rotan con solape (actual + siguiente en JWKS).

8. Errores sin fuga

  • Formato único: application/problem+json (RFC 9457) de Elysia 2.
  • En producción la respuesta no lleva found/valor recibido, stack, mensajes de librerías (JSON.parse, validador) ni eco de la entrada. Lleva type, title, status, el código de dominio y instance = id de la request/traza. El detalle va al log del servidor con el trace id.
  • Un 422 de validación no revela el schema interno más allá del nombre del campo. .error(ValidationError) global barato (hallazgo de ADOPT.md: el 422 de Elysia 2 cuesta 3× CPU y es un vector de DoS).
  • Test: en modo prod, un body inválido, un JSON roto y un error interno forzado → sin found, sin stack y sin el valor enviado.

9. Audit log de seguridad

  • Eventos evt.security.<evento> vía spire (firmados, dec-0119) para: login ok/fallido, logout, rotación, reuse detectado, revocación (sesión/actor/admin), denegación de policy, ruta sin policy al arrancar, CSRF rechazado, rate-limit disparado, emisión y rechazo de SCT, cambios de rol, alta y cambio de fuentes con secretos, re-autenticación.
  • Campos: ts, event, actor (id), session (id), source (servicio), ip (resuelta según proxies de confianza), outcome, reason, traceId. Sin tokens ni secretos.
  • Persistencia append-only en un schema audit con rol de sólo INSERT. Además se exporta como log OTel con atributos security.*.
  • Alertas: reuse detectado (una sola ocurrencia), ráfagas de denegaciones o de SCT inválidas por IP/actor, y un servicio que arranca con rutas sin policy (no debería poder, así que es alerta de integridad).
  • Enmienda 2026-10-01 (TS10-P2-01): "por IP" es por unidad de cliente, la misma del rate-limit (clientUnitKey de @styx/observability/client-unit: IPv4 tal cual, IPv6 por su /64). Agregación y ráfaga usan esa clave; la IP entera queda como dato de la primera fila. Cada servicio+evento abre como mucho N agregaciones por ventana; por encima se pliega en una sin identificadores de cliente y salta la alerta denial_flood. Las denegaciones no ocupan la reserva de escrituras en vuelo de los eventos que no son denegación.

10. Guards (que el diseño no se degrade)

  • Lint o test: prohibido cors( con origin: true, '*' o una función que devuelva true (ninguna llamada cors( fuera del origen de media); prohibidos localStorage/sessionStorage e IndexedDB con claves que contengan token|auth|session|jwt; prohibido dangerouslySetInnerHTML fuera de la allowlist con nonce; prohibido createServerFn directo (sólo authedServerFn).
  • El test de policy de §3 en cada servicio y en la web.
  • Cada guard tiene su mutante rojo demostrado (misma regla que dec-0117 §6).

Qué fija el lock y qué queda reversible

  • Fija el lock: navegador sin tokens portadores (BFF); cookies __Host- con los atributos de §2.2; CSRF de doble barrera; rotación, reuse detection y revocación server-side; policy deny-by-default con test que falla; Authorized<R, A> contra IDOR; servicios sin CORS; CSP con nonces + Trusted Types; problem+json sin fugas en prod; rol/ACL por servicio en Postgres, Valkey y NATS; audit log.
  • Reversible sin ADR: nombres de cookies tras el prefijo, TTLs, cadencia de rotación, lista de roles concreta, nombre del paquete de authz, directivas CSP concretas (siempre que haya nonces y Trusted Types), cabeceras de detalle.

Lo que este ADR NO decide

  • Comunicaciones servicio↔servicio y Bun↔daemon: dec-0119.
  • Sandbox de plugins TS (r48 trust levels): el AppSec final y el SDK de plugins. Este ADR sólo fija que la salida de un plugin es contenido no confiable (§6.3).
  • Multi-tenant entre instalaciones distintas: Styx es una instalación personal/familiar, y los roles de §4 cubren su interior.

Plan

Tickets en docs/track/identity/plans/security-part2-web-identity.plan.md y gaps de identity-svc como tickets docs/track/identity/tickets/02.md–09.md.