""" Migración one-shot: crea usuarios EFC en Keycloak (realm master) y los vincula al tenant en el Hub via BD directa. Estrategia: - Crear usuario en Keycloak via admin REST API - Insertar registro en hub.user_tenants - Guardar keycloak_user_id en cuser_customuser CÓMO USAR: Dry-run (no toca nada): docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py --dry-run Migración real: docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py CASOS ESPECIALES: - Sin email: se omiten (no se pueden crear en KC sin email único) - Email duplicado: se crea una sola vez en KC; el UUID se asigna a todos los registros EFC con ese email """ import argparse import logging import os import sys import time from collections import defaultdict import django sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") django.setup() import requests # noqa: E402 from django.db import transaction # noqa: E402 from api.cuser.models import CustomUser # noqa: E402 _hub_token_cache: dict = {} def get_hub_admin_token(session: requests.Session) -> str: """Obtiene Bearer token del hubadmin. Acepta token pre-generado via HUB_BEARER_TOKEN.""" preset = os.getenv("HUB_BEARER_TOKEN", "") if preset: return preset r = session.post( f"{HUB_API_URL.rstrip('/')}/api/v1/auth/login", json={"username": HUB_ADMIN_USER, "password": HUB_ADMIN_PASS}, timeout=10, ) r.raise_for_status() return r.json()["access_token"] def register_user_in_hub(session: requests.Session, hub_token: str, keycloak_user_id: str) -> bool: """Llama a POST /hub/user-tenants/add para vincular el usuario al tenant.""" r = session.post( f"{HUB_API_URL.rstrip('/')}/api/v1/hub/user-tenants/add", json={"keycloak_user_id": keycloak_user_id, "tenant_id": HUB_TENANT_ID, "role": "user"}, headers={"Authorization": f"Bearer {hub_token}"}, timeout=10, ) if r.status_code in (200, 201): return True if r.status_code == 409: return True # ya registrado logger.error("Hub user-tenants/add %s: %s", r.status_code, r.text[:200]) return False logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("migrate") # ── Configuración ────────────────────────────────────────────────────────────── KC_URL = os.getenv("KEYCLOAK_URL", "http://hub-keycloak:8080") KC_REALM = "master" KC_ADMIN_USER = os.getenv("KEYCLOAK_ADMIN_USERNAME", "admin") KC_ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") HUB_API_URL = os.getenv("HUB_URL", "http://hub-backend:8000") HUB_ADMIN_USER = os.getenv("HUB_ADMIN_USERNAME", "hubadmin") HUB_ADMIN_PASS = os.getenv("HUB_ADMIN_PASSWORD", "Localhost1234!") HUB_TENANT_ID = int(os.getenv("HUB_TENANT_ID", "1")) TENANT_SLUG = os.getenv("HUB_TENANT_SLUG", "efc") TEMP_PASSWORD = "ChangeMe!Temp2025" BATCH_SIZE = 20 def get_kc_admin_token(session: requests.Session) -> str: """Obtiene token de admin de Keycloak.""" r = session.post( f"{KC_URL}/kcauth/realms/master/protocol/openid-connect/token", data={ "client_id": "admin-cli", "grant_type": "password", "username": KC_ADMIN_USER, "password": KC_ADMIN_PASS, }, timeout=10, ) r.raise_for_status() return r.json()["access_token"] def create_kc_user(session: requests.Session, token: str, user: CustomUser) -> str | None: """Crea usuario en Keycloak. Retorna UUID o None si falla.""" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} body = { "username": user.username, "email": user.email, "firstName": user.first_name or "", "lastName": user.last_name or "", "enabled": True, "credentials": [{"type": "password", "value": TEMP_PASSWORD, "temporary": True}], } r = session.post( f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users", json=body, headers=headers, timeout=15, ) if r.status_code == 201: # El ID viene en el header Location: .../users/ location = r.headers.get("Location", "") kc_id = location.split("/")[-1] return kc_id if kc_id else None if r.status_code == 409: # Ya existe — buscar por email search = session.get( f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users?email={user.email}&exact=true", headers=headers, timeout=10, ) users = search.json() if users: return users[0]["id"] logger.warning("[%s] Conflicto 409 pero no se encontró por email", user.username) return None logger.error("[%s] KC %s: %s", user.username, r.status_code, r.text[:200]) return None def run(dry_run: bool): pending = list( CustomUser.objects.filter(is_active=True, keycloak_user_id__isnull=True) .order_by("date_joined") ) sin_email = [u for u in pending if not (u.email or "").strip()] con_email = [u for u in pending if (u.email or "").strip()] by_email: dict[str, list] = defaultdict(list) for u in con_email: by_email[u.email.lower()].append(u) dup_emails = {e: us for e, us in by_email.items() if len(us) > 1} logger.info("═══════════════════════════════════════════") logger.info("Pendientes : %d", len(pending)) logger.info("Sin email (omiten): %d", len(sin_email)) logger.info("Provisiones KC : %d emails únicos", len(by_email)) logger.info("Emails duplicados : %d grupos", len(dup_emails)) logger.info("Tenant Hub : %s", TENANT_SLUG) logger.info("Keycloak URL : %s", KC_URL) logger.info("Hub API URL : %s", HUB_API_URL) logger.info("Hub Tenant ID : %d", HUB_TENANT_ID) logger.info("═══════════════════════════════════════════") if sin_email: logger.warning("Omitidos sin email: %s", [u.username for u in sin_email]) if dup_emails: for email, users in dup_emails.items(): logger.info("Duplicado %s → %s", email, [u.username for u in users]) if dry_run: logger.info("=== DRY-RUN completado — no se hizo ningún cambio ===") return ok = 0; failed = 0; i = 0 with requests.Session() as session: kc_token = get_kc_admin_token(session) hub_token = get_hub_admin_token(session) for email, users in by_email.items(): i += 1 primary = users[0] # Refrescar tokens cada 50 usuarios if i % 50 == 0: try: kc_token = get_kc_admin_token(session) hub_token = get_hub_admin_token(session) except Exception as exc: logger.error("No se pudo refrescar tokens: %s", exc) sys.exit(1) kc_id = create_kc_user(session, kc_token, primary) if not kc_id: failed += 1 continue # Registrar en Hub via API if not register_user_in_hub(session, hub_token, kc_id): failed += 1 continue # Guardar en EFC DB — solo el usuario principal (UNIQUE constraint) # Los duplicados quedan con keycloak_user_id=NULL; comparten identidad KC with transaction.atomic(): CustomUser.objects.filter(pk=primary.pk).update(keycloak_user_id=kc_id) ok += 1 extra = f" (+{len(users)-1} dups)" if len(users) > 1 else "" logger.info("[%d/%d] %-40s → %s%s", i, len(by_email), email, kc_id[:8] + "...", extra) if i % BATCH_SIZE == 0: time.sleep(0.3) logger.info("═══════════════════════════════════════════") logger.info("Exitosos : %d", ok) logger.info("Fallidos : %d", failed) logger.info("Sin email: %d (omitidos)", len(sin_email)) if failed: logger.warning("Hay fallidos — vuelve a correr el script, es idempotente.") sys.exit(1) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") parser.add_argument("--tenant-slug", default=TENANT_SLUG) args = parser.parse_args() if args.tenant_slug != TENANT_SLUG: TENANT_SLUG = args.tenant_slug run(args.dry_run)