#!/usr/bin/env python3 """ import-furious-to-zitadel.py — Import des users Furious Squad vers Zitadel. Modes : --mode dry-run : simulation sans écriture (par défaut) --mode test : crée uniquement les 3 premiers users (Furious IDs 1, 2, 3) --mode prod : crée tous les users actifs restants Génère : - users-credentials.csv : 1 ligne par user avec mdp temporaire - import.log : trace de l'exécution Auteur : Hedi Kalboussi Date : 5 juin 2026 """ import argparse import csv import json import logging import os import random import string import sys import time from pathlib import Path import jwt import requests from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend # ============================================================================ # CONFIGURATION # ============================================================================ SCRIPT_DIR = Path(__file__).parent ENV_FILE = SCRIPT_DIR / ".env" KEY_FILE = SCRIPT_DIR / "zitadelfuriousimportkey.json" RAW_DATA_FILE = SCRIPT_DIR / "furious-users-raw.json" CREDENTIALS_CSV = SCRIPT_DIR / "users-credentials.csv" LOG_FILE = SCRIPT_DIR / "import.log" ZITADEL_DOMAIN = "https://sso.seizeconsulting.com" # IDs Furious pour le mode test (3 premiers) TEST_USER_IDS = [1, 2, 3] # ============================================================================ # LOGGING # ============================================================================ logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout), ], ) log = logging.getLogger(__name__) # ============================================================================ # AUTHENTIFICATION ZITADEL (JWT signé) # ============================================================================ def get_zitadel_token(): """Génère un JWT signé avec la clé du service account et l'échange contre un access token.""" with open(KEY_FILE) as f: key_data = json.load(f) user_id = key_data["userId"] key_id = key_data["keyId"] private_key_pem = key_data["key"] # Charger la clé privée RSA private_key = serialization.load_pem_private_key( private_key_pem.encode(), password=None, backend=default_backend(), ) # Créer le JWT now = int(time.time()) payload = { "iss": user_id, "sub": user_id, "aud": ZITADEL_DOMAIN, "iat": now, "exp": now + 3600, # 1h } headers = {"kid": key_id} assertion = jwt.encode(payload, private_key, algorithm="RS256", headers=headers) # Échange contre access token response = requests.post( f"{ZITADEL_DOMAIN}/oauth/v2/token", data={ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "scope": "openid profile urn:zitadel:iam:org:project:id:zitadel:aud", "assertion": assertion, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) response.raise_for_status() return response.json()["access_token"] # ============================================================================ # FONCTIONS UTILITAIRES # ============================================================================ def generate_password(length=16): """Génère un mdp temporaire respectant la policy Zitadel (14+ chars, complexe).""" chars = string.ascii_letters + string.digits + "@#$%&*" # Assure au moins 1 maj, 1 min, 1 chiffre, 1 symbole pwd = ( random.choice(string.ascii_uppercase) + random.choice(string.ascii_lowercase) + random.choice(string.digits) + random.choice("@#$%&*") + "".join(random.choices(chars, k=length - 4)) ) return "".join(random.sample(pwd, len(pwd))) def determine_username(user): """Username = email complet (simple et unique).""" return user.get("email", "").strip().lower() def build_metadata(user): """Construit le dict de metadata à attacher à chaque user Zitadel.""" return { "furious_id": str(user.get("id", "")), "pseudo": user.get("pseudo", ""), "job_title": user.get("job_title", ""), "bu_name": user.get("bu_name", ""), "manager": user.get("manager_pseudo", ""), "status": user.get("status", ""), "arrival_date": user.get("arrival_date", ""), "entity": user.get("entity_name", ""), } # ============================================================================ # CRÉATION D'UN USER ZITADEL # ============================================================================ def create_user_zitadel(user, token, dry_run=False): """Crée un user dans Zitadel via l'API v2.""" email = user.get("email", "").strip().lower() given_name = user.get("first_name", "").strip() family_name = user.get("last_name", "").strip() if not email or not given_name or not family_name: log.warning(f"Données incomplètes pour user Furious id={user.get('id')}, skip") return None username = determine_username(user) password = generate_password() payload = { "username": username, "profile": { "givenName": given_name, "familyName": family_name, "displayName": f"{given_name} {family_name}", "preferredLanguage": "fr", }, "email": { "email": email, "isVerified": True, }, "password": { "password": password, "changeRequired": True, }, } if dry_run: log.info(f"[DRY-RUN] Would create: {email} ({given_name} {family_name})") return { "userId": "DRY_RUN_ID", "username": username, "password": password, } headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } response = requests.post( f"{ZITADEL_DOMAIN}/v2/users/human", headers=headers, json=payload, ) if response.status_code == 409: log.warning(f"User déjà existant : {email}") return None if not response.ok: log.error(f"Erreur création {email} : {response.status_code} - {response.text}") return None result = response.json() user_id = result.get("userId") log.info(f"✅ Créé : {email} → Zitadel ID {user_id}") # Attacher les metadata metadata = build_metadata(user) for key, value in metadata.items(): if value: attach_metadata(user_id, key, value, token) return { "userId": user_id, "username": username, "password": password, } def attach_metadata(user_id, key, value, token): """Attache une metadata custom à un user.""" import base64 value_b64 = base64.b64encode(str(value).encode()).decode() response = requests.post( f"{ZITADEL_DOMAIN}/management/v1/users/{user_id}/metadata/{key}", headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, json={"value": value_b64}, ) if not response.ok: log.warning(f" Metadata {key} échouée : {response.status_code}") # ============================================================================ # MAIN # ============================================================================ def main(): parser = argparse.ArgumentParser() parser.add_argument( "--mode", choices=["dry-run", "test", "prod"], default="dry-run", help="Mode d'exécution", ) args = parser.parse_args() log.info(f"===== Import Furious → Zitadel (mode: {args.mode}) =====") # 1. Charger les données Furious if not RAW_DATA_FILE.exists(): log.error(f"Fichier {RAW_DATA_FILE} manquant. Lancer read-all-users.sh d'abord.") sys.exit(1) with open(RAW_DATA_FILE) as f: data = json.load(f) all_users = data.get("data", {}).get("User", []) # Filtrer les actifs (departure_date == "0000-00-00") actifs = [u for u in all_users if u.get("departure_date") == "0000-00-00"] log.info(f"Users actifs dans Furious : {len(actifs)} sur {len(all_users)} total") # Filtrer selon le mode if args.mode == "test": users_to_import = [u for u in actifs if u.get("id") in TEST_USER_IDS] else: users_to_import = actifs log.info(f"Users à importer : {len(users_to_import)}") # 2. Obtenir le token Zitadel (si pas dry-run) token = None if args.mode != "dry-run": log.info("Authentification Zitadel...") token = get_zitadel_token() log.info("✅ Token Zitadel OK") # 3. Boucler sur les users results = [] for i, user in enumerate(users_to_import, 1): log.info(f"[{i}/{len(users_to_import)}] {user.get('email')}") result = create_user_zitadel(user, token, dry_run=(args.mode == "dry-run")) if result: results.append({ "email": user.get("email"), "first_name": user.get("first_name"), "last_name": user.get("last_name"), "pseudo": user.get("pseudo"), "furious_id": user.get("id"), "zitadel_user_id": result["userId"], "password": result["password"], }) # Pause anti-rate-limit time.sleep(0.2) # 4. Sauvegarder le CSV if results and args.mode != "dry-run": with open(CREDENTIALS_CSV, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter( f, fieldnames=["email", "first_name", "last_name", "pseudo", "furious_id", "zitadel_user_id", "password"], ) writer.writeheader() writer.writerows(results) os.chmod(CREDENTIALS_CSV, 0o600) log.info(f"✅ CSV sauvegardé : {CREDENTIALS_CSV} ({len(results)} lignes)") log.info(f"===== FIN : {len(results)} users importés =====") if __name__ == "__main__": main()