feature/implementacion de hub en EFC
This commit is contained in:
251
script/migrate_users_to_keycloak.py
Normal file
251
script/migrate_users_to_keycloak.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user