109 lines
4.4 KiB
Python
109 lines
4.4 KiB
Python
|
|
#!/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:]))
|