seize-infra-doc/scripts/furious-to-zitadel/import-furious-to-zitadel.py
Hedi Kalboussi 1e90a85359 Documentation initiale infra Seize — passation J7
- 6 docs racine (README, HANDOVER, ARCHITECTURE, DECISIONS, INSTALL, OPERATIONS)
- 9 docs thématiques dans docs/
- 10 templates docker-compose dans configs/
- 7 scripts Python/Shell d'import Furious vers Zitadel
- 2 matrices RBAC

Etat infra au 5 juin 2026 :
- 88 users Furious importes dans Zitadel
- 8 roles RBAC crees et attribues
- Intranet filtre les cartes selon role (Approche A)
- 7 outils SSO operationnels
2026-06-05 17:49:43 +00:00

315 lines
10 KiB
Python
Executable File

#!/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()