Saltar a contenido

ADR-020 · User model cross-service · Authentik UUID = canónico, JIT provisioning, cross-DB GRANT

Fecha: 2026-04-29 Source: /srv/projects/cis/cis-plan/DECISIONS.md (do not edit here — re-split desde la fuente)


Contexto

CIS hoy tiene 5 fuentes distintas de identidad de usuarios:

  1. Authentik (auth.innovacionsantiago.cl) — IdP único, UUID es la sub del JWT.
  2. cis_admin.usuarios — schema legacy con campos email, nombre, rut, cargo, etc. Pre-existe a Authentik.
  3. cis_inbox.accounts — link User × provider externo (Gmail OAuth, IMAP, WA).
  4. cis_platform.users — gateway, organizations, créditos.
  5. cis_claudia.chat_threads.owner_user_id — quién es dueño del thread.

El panel /usuarios en cis-admin (live 2026-04-22) ya implementa merge unificado leyendo de las 5 fuentes con cache 60s, vía cross-DB GRANT SELECT (cis_admin lee cis_platform.users y cis_inbox.accounts). El patrón funciona pero no estaba documentado como canon.

ADR-014 (2026-04-22) ya estableció Authentik UUID como sub del JWT pero no formalizó las consecuencias del lado consumidor: cómo cada servicio crea su row local, qué columnas son sensibles, cómo evitar exponer oauth_refresh_token por error.

Alternativas consideradas

  1. (a) Sync periódico Authentik → DBs locales
  2. Pro: simple mental model. Cada servicio tiene una copia completa del directorio.
  3. Contra: lag (un user nuevo no aparece hasta el siguiente sync). Múltiples sources of truth. Riesgo de drift.

  4. (b) JIT provisioning en cada OIDC callbackpropuesta

  5. Pro: zero-config. Un user que nunca toca un servicio no aparece en su DB. El callback OIDC ya tiene el JWT con todas las claims; upsert local es trivial.
  6. Contra: requires que cada servicio implemente el upsert. Mitigado por core/py-common/oidc.py con helper ensure_local_user(jwt).

  7. (c) Single users table compartida

  8. Pro: una sola tabla, cero merge.
  9. Contra: rompe el isolation por servicio. Una migration de schema afecta a todos. Schema legacy cis_admin.usuarios con campos rut, cargo, domicilio no encaja con cis_inbox.accounts (provider, oauth_refresh_token).

Decisión

Authentik User UUID = canónico. Cada servicio tiene su row local (ej. cis_inbox.accounts.user_id UUID FK, cis_admin.usuarios.authentik_uuid UUID UNIQUE, cis_platform.users.id UUID PRIMARY KEY). Esa columna siempre matchea la sub del JWT.

Email lower (LOWER(email)) es el identificador humano legible para queries y matching cross-service donde el UUID no está disponible (ej. invite flow, reportes contables). Nunca es FK; el FK es siempre el UUID.

JIT provisioning (no sync periódico): cada servicio expone su lifespan/middleware OIDC que en el callback hace INSERT ... ON CONFLICT (authentik_uuid) DO UPDATE con los campos relevantes desde el JWT. Si el user nunca toca el servicio, no existe localmente — y eso es deseable (privacy by default).

Cross-DB GRANT pattern (validado en cis-admin 2026-04-22):

-- en cis_platform DB
GRANT SELECT ON users, organizations TO cis_admin;

-- en cis_inbox DB
GRANT SELECT ON accounts TO cis_admin;

cis-admin se conecta con engines independientes (uno por DB) y consolida en memoria. Lectura cross-DB es SELECT-only; ninguna mutación cruza el boundary del servicio.

Exclusion lists para columnas sensibles: en cualquier endpoint que retorne data unificada, hay una lista negra de columnas que jamás se proyectan al cliente:

  • cis_inbox.accounts.oauth_refresh_token
  • cis_inbox.accounts.imap_app_password
  • cis_inbox.accounts.imap_decrypt_key
  • cis_admin.usuarios.password_hash (legacy, en deprecation)
  • cis_platform.users.api_token_hash

El test test_users_unified_no_secrets en cis-admin/tests/ valida que cualquier response del endpoint /api/v1/users/unified jamás contiene ninguna de esas keys en el JSON serialized.

Bridge cis_admin.usuarios ↔ Authentik: la tabla legacy gana la columna authentik_uuid UUID UNIQUE NULL. Es nullable durante transición (usuarios pre-Authentik existentes); P0.4-cierre o flujo F.0 backfilea matching por email lower. La tabla usuario_empresa_acceso (M:N user × empresa) usa usuario_id (FK a usuarios.id local) — el UUID Authentik se llega vía join.

Consecuencias

  • Positivo: una sola fuente de verdad de identidad (Authentik). Cada servicio tiene su row local cuando lo necesita. Cross-service queries posibles vía email lower o UUID. Patrón documentado y reusable para cualquier servicio nuevo.
  • Negativo: requires disciplina de no shortcut "creo el user local sin Authentik primero". Un agente o admin que crea un user manual en cis_admin.usuarios sin pasar por Authentik produce un row huérfano. Mitigación: NOT NULL constraint en authentik_uuid para usuarios creados post-2026-05-01 (cutoff documentado).
  • Riesgo: si Authentik DB se pierde, los UUID se pueden regenerar desde email pero rompen FK. Mitigación: backup pg_dump diario de Authentik DB (output Bloque LH disaster recovery).
  • Migración:
  • cis-platform, cis-inbox, cis-pagos: ya alineados con Authentik UUID. ✅.
  • cis-admin: bridge authentik_uuid ya existe (2026-04-22). Backfill pendiente para usuarios legacy.
  • cis-claudia: chat_threads.owner_user_id migra a UUID si no lo es ya.
  • Compatibilidad: el endpoint /api/v1/users/unified (cis-admin) no cambia su forma. Internamente la query se simplifica (un solo UUID a matchear vez de 5 emails).
  • Dependencia de ADR-016: core/py-common/oidc.py con helper ensure_local_user(jwt, db) es el contrato canónico que cada servicio invoca en su OIDC callback. Sin ese helper, cada servicio reimplementa el upsert con riesgos.
  • Relación con ADR-028: este ADR define el bridge técnico Authentik ↔ DBs locales. ADR-028 documenta el modelo legal Personas/Empresas/Vínculos sobre cis_admin. Son complementarios.