Files
backend/script/migrate_users_to_keycloak.py

252 lines
8.8 KiB
Python

"""
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/<uuid>
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)