#!/usr/bin/env python3 """Devis d'identité : compare ce que le dépôt DÉCLARE à ce que la machine PORTE. Lit le relevé déposé par `playbooks/maintenance/devis-identite.yml` (aucun accès réseau ici) et affiche les écarts. Sort en code 1 s'il y en a — un devis qui ne sait pas dire non ne sert à rien. Pourquoi cet outil. `scripts/prouver.py` est statique : ses 30 preuves montrent que le dépôt est cohérent AVEC LUI-MÊME. Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce. Les quatre défauts du 2026-08-08 vivaient tous dans cet angle : une politique de mot de passe déclarée des deux côtés et appliquée d'aucun, une entrée LDAP figée à sa création, une fédération qui n'écrivait pas dans l'annuaire, un prénom mappé sur le mauvais attribut. Chacun trouvé en relisant après avoir écrit. Il ne remplace pas `make deployer`, qui réconcilie. Il répond à l'autre question : « ce qui tourne correspond-il à ce qui est déclaré ? » """ from __future__ import annotations import json import sys from pathlib import Path RACINE = Path(__file__).resolve().parent.parent RELEVE = RACINE / "instance" / "devis-identite.json" class Ecart: def __init__(self, sujet: str, attendu, reel, portee: str) -> None: self.sujet, self.attendu, self.reel, self.portee = sujet, attendu, reel, portee def _premier(v): """La config d'un composant Keycloak porte des LISTES d'une seule chaîne.""" if isinstance(v, list): return v[0] if v else "" return v def comparer(releve: dict) -> list[Ecart]: d, r = releve["declare"], releve["reel"] realm, fed, ecarts = r["realm"], r["federation"], [] def verifier(sujet, attendu, reel, portee): if str(attendu) != str(reel): ecarts.append(Ecart(sujet, attendu, reel, portee)) verifier("politique de mot de passe", d["passwordPolicy"], realm.get("passwordPolicy") or "(aucune)", "realm") for cle, attendu in d["brute"].items(): verifier(cle, attendu, realm.get(cle, "(absent)"), "realm") verifier("resetPasswordAllowed", d["resetPasswordAllowed"], realm.get("resetPasswordAllowed", False), "realm") verifier("relais SMTP", d["smtpHost"], (realm.get("smtpServer") or {}).get("host") or "(aucun)", "realm") verifier("editMode", d["editMode"], _premier(fed.get("editMode", "(absent)")), "fédération") for cle, attendu in d["durcissement"].items(): verifier(cle, _premier(attendu), _premier(fed.get(cle, "(absent)")), "fédération") for nom, attendu in d["mappeurs"].items(): conf = r["mappeurs"].get(nom) reel = "(mappeur absent)" if conf is None else _premier(conf.get("ldap.attribute", "(absent)")) verifier(nom, attendu, reel, "mappeur") pp = r["ppolicy"] or {} for cle, attendu in d["ppolicy"].items(): verifier(cle, attendu, pp.get(cle, "(absent)"), "annuaire") # Un compte sans adresse ne peut pas recevoir de lien de réinitialisation : pour lui, # « mot de passe oublié » est un écran qui ne mène nulle part. sans_mail = [c.get("uid") for c in r["comptes"] if not c.get("mail")] if sans_mail: ecarts.append(Ecart("comptes sans adresse", "aucun", ", ".join(str(u) for u in sans_mail), "annuaire")) return ecarts def main(argv: list[str] | None = None) -> int: chemin = Path(argv[0]) if argv else RELEVE if not chemin.exists(): print(f"Relevé absent : {chemin}\n" f"Lancer d'abord : make identite-plan", file=sys.stderr) return 2 releve = json.loads(chemin.read_text(encoding="utf-8")) ecarts = comparer(releve) print(f"Devis d'identité — realm « {releve['realm']} » sur {releve['hote']}\n") if not ecarts: nb = len(releve["reel"]["comptes"]) print(f"CONFORME : le réel correspond au déclaré ({nb} compte(s) dans l'annuaire).") return 0 largeur = max(len(e.sujet) for e in ecarts) portee = None for e in ecarts: if e.portee != portee: portee, _ = e.portee, print(f" [{e.portee}]") print(f" {e.sujet:<{largeur}} déclaré: {e.attendu} réel: {e.reel}") print(f"\nÉCART : {len(ecarts)} divergence(s). " f"`make deployer` réconcilie ; ce devis dit seulement ce qui ne correspond pas.") return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))