252 lines
8.8 KiB
Python
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)
|