Skip to content

Keycloak · Ingest de usuarios y logins

Servicio Flask en Cloud Run que lee la API admin de Keycloak (OAuth2 por realm) y carga en BigQuery los usuarios con sus roles y atributos (keycloack_users, endpoint /extract) y, desde SOL-477, los eventos de usuario (LOGIN, LOGIN_ERROR, LOGOUT, IMPERSONATE) de cada realm (keycloack_events, endpoint /extract-events). Volumen bajo.

¿Llegaste hasta acá desde…?

Si ves… Eso sale de… La lógica está en…
Tabla de usuarios de Keycloak en BigQuery solvento-data-prod.keycloack.keycloack_users keycloack-ingest → cloud_run/keycloak_ingest/users.py
Roles / atributos de un usuario (tax_id, carrier_id, company_name, business_role) Columnas de keycloack_users users.py (ATTRIBUTES y transform_user)
A qué realm pertenece un usuario Columna realm de keycloack_users Itera los realms REALM_SUPPLIERS y REALM_PUBLIC_API (keycloak_ingest/config.py)
Logins / sesiones de un usuario solvento-data-prod.keycloack.keycloack_events keycloack-ingest → cloud_run/keycloak_ingest/events.py

Resumen

Campo Valor
Origen API admin de Keycloak (https://auth.solvento.mx/), OAuth2 client_credentials por realm (CLIENT_ID=data-client)
Patrón Pull por HTTP (Flask en Cloud Run) → carga a tabla temporal → MERGE en BigQuery
Destino solvento-data-prod.keycloack.keycloack_users · solvento-data-prod.keycloack.keycloack_events
Realms (prod) public-api (clientes; app carriers-login; llave de empresa carrier_id) y suppliers-portal-prod (proveedores; app suppliers-login; llave tax_id). Un secreto por realm: el client data-client existe por separado en cada uno
Frecuencia Usuarios: diaria 0 2 * * * (job keycloack-ingest-daily, POST /extract, creado a mano) · Logins: diaria 0 3 * * * (job keycloack-events-daily 🔧, POST /extract-events, en terraform)
Criticidad 🟢 Baja
Dueño Martin (data eng)
Deploy Imagen con ./publish_container.sh prod → tag de fecha en terraform-gcp-infrastructure (servicio keycloack-ingest-prod, timeout 1800s). Imagen con keycloack_events: 20261006-194846
Repos keycloack-ingest

Diagrama de linaje

graph LR
    SCH[["⏰ Cloud Scheduler<br/>keycloack-ingest-daily · 0 2 * * *<br/>POST /extract"]]
    KC[["🔐 Keycloak<br/>API admin · realms"]]

    SVC["Cloud Run<br/>keycloack-ingest-prod (Flask)"]
    BQ[("solvento-data-prod<br/>keycloack.keycloack_users<br/><i>MERGE ON id</i>")]

    SCH2[["⏰ Cloud Scheduler<br/>keycloack-events-daily · 0 3 * * *<br/>POST /extract-events"]]
    BQ2[("solvento-data-prod<br/>keycloack.keycloack_events<br/><i>MERGE ON event_id</i>")]

    SCH -->|dispara| SVC
    SCH2 -->|dispara| SVC
    KC -->|"OAuth2 + usuarios + roles"| SVC
    KC -->|"eventos LOGIN, LOGIN_ERROR, LOGOUT, IMPERSONATE (view-events)"| SVC
    SVC -->|"tabla temporal + MERGE por realm"| BQ
    SVC -->|"tabla temporal + MERGE"| BQ2

    classDef src fill:#fde68a,stroke:#b45309,color:#000;
    classDef store fill:#bfdbfe,stroke:#1e40af,color:#000;
    class SCH,SCH2,KC src;
    class BQ,BQ2 store;

Paso a paso (POST /extract)

  1. Disparo: Cloud Scheduler keycloack-ingest-daily (0 2 * * *) hace POST /extract. También se puede llamar a mano; hay GET /health.
  2. Autenticación por realm: en prod el servicio usa REALM_SUPPLIERS=suppliers-portal-prod (+ realm public-api) sobre KEYCLOACK_URL=https://auth.solvento.mx/. Cada realm obtiene su token OAuth2 (grant_type=client_credentials, CLIENT_ID=data-client) con su propio secreto de Secret Manager (CLIENT_SECRET_SUPPLIERS, CLIENT_SECRET_PUBLIC_API). Los tokens duran ~60 s y se renuevan solos.
  3. Extracción: pagina usuarios (50 por página); por cada usuario consulta sus roles de realm y toma el primer valor de los atributos (taxId, carrier_id, company_name, business_role, type, phone, etc.).
  4. Carga: por realm, carga las filas a una tabla temporal keycloack_users__staging_* y hace un MERGE con clave ON T.id = S.id: WHEN MATCHED actualiza todos los campos e ingestion_updated_at; WHEN NOT MATCHED inserta con ingested_at e ingestion_updated_at. Los textos vacíos quedan como ''.
  5. Respuesta: JSON con conteos por realm (users, inserted, updated) y errors. Responde 500 si falló algún realm (el otro se carga igual), así Cloud Scheduler marca la corrida como fallida. Ya no devuelve la lista de usuarios.

Incidente corregido (2026-10-06): hasta la imagen 20260519-105801 el MERGE se armaba concatenando los valores en el SQL, con '' como escape (BigQuery no lo acepta). Un apóstrofe en un nombre o una empresa rompía la página entera: la página first=1400 de suppliers-portal-prod (50 usuarios) fallaba todos los días y el endpoint igual respondía 200. Desde 20261006-185724 los valores no pasan por el texto del SQL.

Nota: la lógica de roles y atributos vive en el propio servicio, no en dbt.

Config prod (terraform): GCP_PROJECT_ID=solvento-data-prod, BIGQUERY_DATASET=keycloack, BIGQUERY_USERS_TABLE=keycloack_users. Los .env del repo (dev.env / prod.env) son solo para el build de la imagen; el runtime y los secretos vienen de Terraform y Secret Manager. Tabla completa de variables en el README del repo.

Paso a paso (POST /extract-events) — eventos de usuario (SOL-477)

  1. Disparo: Cloud Scheduler keycloack-events-daily (0 3 * * * MX, body {"days_back":3}). Carga inicial: {"days_back":10} (todo lo que retiene Keycloak).
  2. Token por realm: mismo data-client y secretos que /extract. El service account necesita el rol view-events (client realm-management) además de view-users / query-users / query-groups. Asignado en prod y staging el 2026-10-02.
  3. Extracción: GET /admin/realms/{realm}/events?type=LOGIN&type=LOGIN_ERROR&type=LOGOUT&type=IMPERSONATE&dateFrom=<hoy-N> paginado (500 por página). Tipos configurables con EVENT_TYPES. Keycloak devuelve por time DESC: si entran eventos mientras se pagina aparecen duplicados, nunca huecos.
  4. Transformación: una fila por evento. Se guarda el nombre del realm (el realmId del evento es el id interno, p. ej. suppliers-prod). details va como JSON con claves permitidas (auth_method, auth_type, grant_type, redirect_uri, response_type, response_mode, remember_me, identity_provider, restart_after_timeout, redirected_to_client, impersonator_realm, impersonator_id). Nunca se guardan ipAddress, details.username (email, o lo tipeado en un LOGIN_ERROR), details.impersonator (usuario de soporte) ni ids de tokens.
  5. Carga: load a una tabla temporal keycloack_events__staging_* → MERGE solo-INSERT por event_id → borra la temporal. Idempotente: repetir días no duplica. La tabla destino se crea sola (CREATE TABLE IF NOT EXISTS) en la primera corrida.

Cómo medir uso (leer antes de armar una métrica): - Solo type = 'LOGIN' y client_id IN ('carriers-login', 'suppliers-login') para uso del portal. client_id igual al nombre del realm (public-api, suppliers-portal-prod) son logins por fuera del portal (probablemente API; confirmar con JSON_VALUE(details, '$.grant_type')). - Sesiones = COUNT(DISTINCT session_id), nunca COUNT(*): el frontend a veces entra en un loop de redirecciones en /keycloak-auth y genera decenas de LOGIN de la misma sesión en segundos (visto: 18 en 18 s). La métrica más robusta es días activos (COUNT(DISTINCT DATE(event_ts, 'America/Mexico_City'))). - Excluir las sesiones que tengan un IMPERSONATE: es soporte entrando como el usuario, no uso del cliente. - LOGIN sin auth_type es válido (login completado después de que venció la pantalla de login). - Keycloak no guarda REFRESH_TOKEN: solo se ven inicios y cierres de sesión, no actividad dentro de la sesión. - De usuario a empresa: JOIN keycloack_users ON id = user_id → carrier_id (public-api) o tax_id (suppliers). redirect_uri dice a qué pantalla iba (?redirect=/loads/list).

-- Actividad por usuario (portal, sin sesiones impersonadas)
SELECT e.realm, e.user_id,
       COUNT(DISTINCT DATE(e.event_ts, 'America/Mexico_City')) AS dias_activos,
       COUNT(DISTINCT e.session_id) AS sesiones,
       MAX(e.event_ts) AS ultimo_login
FROM `solvento-data-prod.keycloack.keycloack_events` e
WHERE e.type = 'LOGIN'
  AND e.client_id IN ('carriers-login', 'suppliers-login')
  AND e.session_id NOT IN (
    SELECT session_id FROM `solvento-data-prod.keycloack.keycloack_events`
    WHERE type = 'IMPERSONATE' AND session_id IS NOT NULL)
GROUP BY 1, 2

Historia: Keycloak solo guarda 10 días. La tabla arranca con la carga inicial (days_back: 10) y desde ahí acumula; no hay forma de traer eventos más viejos. keycloack_events reemplazó a keycloack_login_events (solo LOGIN, sin details) el 2026-10-06: se recargaron los 10 días y la vieja se borra.

Tablas que alimenta

Tabla Contenido Job que la carga
solvento-data-prod.keycloack.keycloack_users Usuarios de Keycloak (id, username, email, phone, user_id, tax_id, company_name, business_role, remote_auth, user_type, supplier_id, carrier_id, roles, realm, created_at, enabled, totp, timestamps de carga) Cloud Run keycloack-ingest (POST /extract, MERGE por id)
solvento-data-prod.keycloack.keycloack_events Eventos LOGIN, LOGIN_ERROR, LOGOUT, IMPERSONATE (event_id, event_ts, realm, type, client_id, user_id, session_id, auth_type, error, details JSON, ingested_at). Partición DATE(event_ts), cluster realm, type, user_id Cloud Run keycloack-ingest (POST /extract-events, MERGE por event_id)

DDL de referencia: sql/ddl.sql en el repo.

Consumo aguas abajo

  • Análisis de usuarios / accesos sobre keycloack_users. No hay modelado dbt conocido sobre esta tabla.
  • Sesiones y días activos por usuario sobre keycloack_events (pedido de Customer Success para priorizar contacto a usuarios sin business_role). Sin modelo dbt todavía.
  • Calidad de frontend: sesiones con muchos LOGIN en pocos segundos = loop de redirecciones en /keycloak-auth.

Si falla 🛠️

Impacto: bajo. Las tablas quedan desactualizadas hasta la próxima corrida. Los logins sí se pueden perder si la caída dura más de 10 días.

Síntoma Dónde mirar Acción
/extract o /extract-events devuelve 400 "Faltan variables de entorno" Variables y secretos del servicio en terraform Corregir en terraform.tfvars (keycloack-ingest-prod); los secretos vienen de Secret Manager
Responde 500 con errors: [{realm, error}] Logs Cloud Run keycloack-ingest-prod (JSON, jsonPayload.event="keycloak_run") El realm que falló no se cargó; el otro sí. Corregir y volver a disparar el endpoint (es idempotente)
Error de auth contra Keycloak KEYCLOACK_URL, CLIENT_ID, secretos por realm Verificar credenciales y URL del realm
/extract-events falla con 403 en /admin/realms/{realm}/events Roles del service account de data-client en ese realm Asignar view-events (client realm-management). Lo administra backend (Fede)
Faltan días de eventos ¿El job keycloack-events-daily estuvo caído más de 10 días? Keycloak solo retiene 10 días: lo anterior no se recupera. Si fueron menos, correr /extract-events con {"days_back":10}

Escalación: SQL / Metabase → Diego · Airbyte → Gero · resto → Martin.

Enlaces

  • 📊 Estado en vivo: dashboard "Data Processes" (Cloud Monitoring)
  • 📦 Servicio: keycloack-ingest — entrada cloud_run/app.py, lógica en cloud_run/keycloak_ingest/

Última actualización: 2026-10-06 (tabla keycloack_events con details y LOGIN_ERROR/LOGOUT/IMPERSONATE; antes: refactor a cloud_run/, fix del MERGE de usuarios) · Si cambia el flujo, los realms o el destino, actualizar esta ficha en el mismo MR.