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:
- Authentik (
auth.innovacionsantiago.cl) — IdP único, UUID es lasubdel JWT. cis_admin.usuarios— schema legacy con camposemail,nombre,rut,cargo, etc. Pre-existe a Authentik.cis_inbox.accounts— link User × provider externo (Gmail OAuth, IMAP, WA).cis_platform.users— gateway, organizations, créditos.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
- (a) Sync periódico Authentik → DBs locales
- Pro: simple mental model. Cada servicio tiene una copia completa del directorio.
-
Contra: lag (un user nuevo no aparece hasta el siguiente sync). Múltiples sources of truth. Riesgo de drift.
-
(b) JIT provisioning en cada OIDC callback ← propuesta
- 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.
-
Contra: requires que cada servicio implemente el upsert. Mitigado por
core/py-common/oidc.pycon helperensure_local_user(jwt). -
(c) Single users table compartida
- Pro: una sola tabla, cero merge.
- Contra: rompe el isolation por servicio. Una migration de schema afecta a todos. Schema legacy
cis_admin.usuarioscon camposrut,cargo,domiciliono encaja concis_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_tokencis_inbox.accounts.imap_app_passwordcis_inbox.accounts.imap_decrypt_keycis_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.usuariossin pasar por Authentik produce un row huérfano. Mitigación: NOT NULL constraint enauthentik_uuidpara 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_uuidya existe (2026-04-22). Backfill pendiente para usuarios legacy. - cis-claudia:
chat_threads.owner_user_idmigra 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.pycon helperensure_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.