- 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
315 lines
10 KiB
Python
Executable File
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()
|