Vista generada de dec-0118: Seguridad por diseño, parte 2: control plane TS, identidad y web
docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.mdVista generada desde
docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | LOCKED |
| Fecha | 2026-09-28 |
| Fichero | docs/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)
Leído de docs/decisions/dec-0118-seguridad-parte2-ts-web-identidad.md, el fichero canónico.
dec-0117 (parte 1, data plane Zig) y dec-0119 (spire, comunicaciones).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.54e997a)| Hecho | Dó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 respuesta | apps/playback-svc/src/transport/http/app.ts:33 |
POST /sessions de playback-svc no autentica y usa un actor dev | apps/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=/auth | apps/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 CSRF | routes/auth.ts:139-175 |
/auth/refresh devuelve el access token en el cuerpo al navegador | routes/auth.ts:146-150 |
Sin rol por servicio: todos los servicios usan el superusuario styx de Postgres; Valkey y NATS arrancan sin autenticación | deploy/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 subject | grep -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 CSP | apps/web/src/server/eden.ts:35, apps/web/src/stores/ui-flow.ts:130-140, apps/web/src/routes/__root.tsx:50 |
ctx.secrets, r48), audit log.STRIDE resumido. La columna de la derecha cita la sección que lo cierra:
| Frontera | Amenazas principales | Cierre |
|---|---|---|
| W1 | robo de sesión por XSS, CSRF, fijación de sesión, clickjacking | §2, §6 |
| W2 | robo/replay de refresh token | dec-0113 §2 (rotación y reuse detection) + §2.4 |
| W3 | petición sin policy, IDOR, confused deputy entre servicios | §3, §4, dec-0119 |
| W4 | credencial compartida que da acceso a todo | §7 |
| W5 | XSS almacenado vía metadatos/subtítulos/plugins, tracking vía imágenes remotas | §6.3 |
| todas | fuga de detalles internos en errores, repudio | §8, §9 |
/auth/* hacia identity-svc son la única superficie. Los
servicios (catalog, playback, sources…) no se exponen al navegador.localStorage, sessionStorage, IndexedDB,
memoria JS ni cookies legibles por JS. Su única credencial es la cookie de sesión HttpOnly.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.| Cookie | Atributos | Contenido |
|---|---|---|
__Host-styx_sess | HttpOnly; Secure; SameSite=Strict; Path=/, sin Domain | credencial opaca <sessionId>.<secreto 256 bit>, en Valkey sólo SHA-256(secreto) (mismo esquema que el refresh de dec-0113 §2) |
__Host-styx_login | HttpOnly; Secure; SameSite=Lax; Path=/, vida del login pendiente | binding 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).
auth_time ≤ 15 min), si no, re-autenticación.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.Clear-Site-Data: "cookies", "storage".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.
Toda petición que cambia estado y lleva la cookie de sesión tiene que pasar dos barreras independientes:
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.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.
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».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).can(principal, acción, recurso) de un
paquete de dominio (@styx/authz, nombre reversible). Nada de lógica de autorización suelta
en handlers.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.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.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.resource:*, un caso con un recurso de otro actor → 404 (no 403, para
no confirmar que existe).cors({ origin: true }) de
playback-svc. Test: preflight desde https://evil.example → sin Access-Control-Allow-*.credentials: false (usa SCT, no cookies), métodos GET/OPTIONS y cabeceras mínimas
(Range).origin: true, origin: '*' con credenciales y reflejar Origin sin allowlist.
Lo vigila un guard (§10).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<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.Content-Security-Policy-Report-Only con endpoint de reporte, y a enforcement en el
mismo milestone cuando el e2e salga limpio.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.integrity + crossorigin) y lo vigila el guard.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.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).
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.user identity on >… ~identity:* &identity:* +@read +@write +eval -@admin -@dangerous), usuario default desactivado.*_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).application/problem+json (RFC 9457) de Elysia 2.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..error(ValidationError) global barato (hallazgo de ADOPT.md: el 422 de Elysia 2 cuesta 3×
CPU y es un vector de DoS).found,
sin stack y sin el valor enviado.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.ts, event, actor (id), session (id), source (servicio), ip (resuelta según
proxies de confianza), outcome, reason, traceId. Sin tokens ni secretos.audit con rol de sólo INSERT. Además se exporta como
log OTel con atributos security.*.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.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).__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.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.