From 5aa5f2e47933396d95e866d417c1556d72788a6e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 8 Aug 2026 06:55:06 -0400 Subject: [PATCH] devis d'identite : comparer le deploye au declare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constat de l'exploitant : « ca fait beaucoup de trucs incoherents qu'on debusque ensemble ». Il y a une raison mesurable — les 30 preuves de prouver.py sont STATIQUES (0 appel reseau, 0 ssh, 0 ansible). Elles montrent que le depot est coherent avec lui-meme ; aucune ne demande au systeme deploye s'il ressemble a ce que le depot annonce. Les quatre defauts du jour vivaient tous la. La classe statique est presque epuisee : recensement des motifs « cree mais ne reconcilie jamais » -> amorcage_acces (delibere, D-67), serveur_openldap (corrige le matin), et un seul reste reel (rbac-oidc.yml). Une preuve statique de plus aurait rapporte une ligne. Le patron devis/applicateur (D-23/D-24) existait deja pour les quatre pare-feu, jamais pour les services. make identite-plan l'y porte : - playbooks/maintenance/devis-identite.yml RELEVE le declare et le reel - scripts/devis_identite.py COMPARE (le raisonnement n'a rien a faire en Jinja ; le depot a deja cette forme pour les devis reseau) - le declare n'est jamais recopie : defauts du role + resolveurs. Un devis qui redeclare ce qu'il verifie ne verifie rien. Verifie dans les deux sens : CONFORME sur le systeme reel ; sur un releve ou les quatre defauts du jour sont rejoues plus deux regressions, 6 divergences listees et code de sortie 1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 36 ++++++ Makefile | 4 + docs/devis-identite.md | 52 +++++++++ playbooks/maintenance/devis-identite.yml | 138 +++++++++++++++++++++++ scripts/devis_identite.py | 108 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 docs/devis-identite.md create mode 100644 playbooks/maintenance/devis-identite.yml create mode 100755 scripts/devis_identite.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e9c9b..d6dbde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — Un devis pour l'identité : rien ne comparait le déployé au déclaré + +Constat de l'exploitant après trois séries de corrections : « ça fait beaucoup de trucs +incohérents qu'on débusque ensemble ». Exact, et il y a une raison mesurable. + +**Les 30 preuves sont statiques.** `scripts/prouver.py` ne fait aucun appel réseau, aucun +SSH, aucun `ansible`. Elles établissent 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 — et c'est +exactement là que vivaient les quatre défauts de la journée. + +**La classe statique, elle, est presque épuisée.** Recensement des motifs « crée mais ne +réconcilie jamais » : `amorcage_acces` (délibéré, D-67), `serveur_openldap` (corrigé le +matin), et un seul reste réel — `rbac-oidc.yml`, qui crée trois objets sans jamais les +mettre à jour. Une preuve statique de plus aurait rapporté une ligne. Le trou est ailleurs. + +**Le dépôt avait déjà la réponse sans l'avoir appliquée aux services.** Le patron +devis/applicateur (D-23/D-24) existe pour les quatre pare-feu : `make frontiere-plan` lit +la frontière réelle et montre l'écart. Rien d'équivalent pour l'identité. + +`make identite-plan` comble ça. Le playbook **relève** le déclaré et le réel et les dépose +en JSON ; `scripts/devis_identite.py` **compare**. La séparation n'est pas cosmétique : j'ai +écrit deux fois de suite une expression Jinja de comparaison illisible avant d'admettre que +le raisonnement n'a rien à faire là — et le dépôt a déjà cette forme pour les devis réseau. + +Le déclaré n'est jamais recopié dans le devis : il charge les défauts du rôle et appelle +`resoudre_politique_mdp` et `resoudre_annuaire`. Un devis qui redéclare ce qu'il vérifie ne +vérifie rien. + +**Vérifié dans les deux sens**, ce qui est le minimum pour un instrument : sur le système +réel, `CONFORME`. Sur une copie du relevé où les quatre défauts du jour sont rejoués, plus +deux régressions plausibles (SMTP disparu, compte sans adresse) — six divergences listées, +code de sortie 1. Un vérificateur qui ne sait dire que « conforme » ne vaut rien. + +Couvre l'identité seule. Les autres services attendent le même traitement ; le patron est +là pour être repris. + ## 2026-08-08 — La politique de mot de passe existait des deux côtés et ne s'appliquait d'aucun Question de l'exploitant : « l'intégration Keycloak/LDAP est incomplète, non ? » Elle diff --git a/Makefile b/Makefile index 37660d8..eb3c75c 100644 --- a/Makefile +++ b/Makefile @@ -418,6 +418,10 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S | tail -1 | tr -d ' \r' .PHONY: frontiere-plan frontiere-appliquer +identite-plan: ansible-runtime ## Ecart entre l'identite deployee et ce que le plan derive (aucune ecriture) + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-identite.yml >/dev/null + @python3 scripts/devis_identite.py + frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture) python3 scripts/appliquer_opnsense.py diff --git a/docs/devis-identite.md b/docs/devis-identite.md new file mode 100644 index 0000000..fd7880f --- /dev/null +++ b/docs/devis-identite.md @@ -0,0 +1,52 @@ +# Le devis d'identité : ce qui tourne correspond-il à ce qui est déclaré ? + +> **Instrument ajouté le 2026-08-08**, après une série de défauts qu'aucun test n'avait +> signalés. Lecture seule — il ne modifie rien. + +``` +make identite-plan +``` + +## Le trou qu'il comble + +`scripts/prouver.py` porte 30 preuves. Elles sont toutes **statiques** : elles lisent le +dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est +cohérent **avec lui-même** — que les handlers existent, que les intrants ont un +propriétaire, que rien n'est codé en dur. + +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 : + +| Défaut | Le dépôt disait | La machine portait | +|---|---|---| +| politique de mot de passe | `length(12)…` | *aucune* — `abcd` accepté | +| entrée `ppolicy` figée | `pwdMustChange: TRUE` | `FALSE` | +| fédération en écriture | comptes écrits dans LDAP | `syncRegistrations` absent | +| prénom | `givenName` | `cn` (le nom complet) | + +Chacun a été trouvé en relisant après avoir écrit. Aucun n'a été signalé. + +## Ce qu'il vérifie + +Le playbook `playbooks/maintenance/devis-identite.yml` **relève** deux dictionnaires — le +déclaré et le réel — et les dépose en JSON. `scripts/devis_identite.py` **compare**. La +séparation est délibérée : la comparaison en Jinja est illisible, et le dépôt a déjà cette +forme pour les devis réseau (Python raisonne, Ansible relève). + +- **realm** — politique de mot de passe, anti-force-brute, « mot de passe oublié », relais SMTP +- **fédération** — mode d'écriture et les six clés de durcissement +- **mappeurs** — chaque attribut LDAP projeté +- **annuaire** — l'entrée `pwdPolicy`, attribut par attribut +- **comptes** — ceux qui n'ont pas d'adresse, pour qui « mot de passe oublié » ne mène nulle part + +Le déclaré n'est jamais recopié ici : le playbook charge les défauts du rôle et appelle les +résolveurs. **Un devis qui redéclare ce qu'il vérifie ne vérifie rien.** + +## Ce qu'il ne fait pas + +Il ne corrige pas — c'est `make deployer` qui réconcilie. Il répond à l'autre question, et +sort en code 1 s'il y a un écart. + +Il ne couvre que l'identité. Les autres services attendent le même traitement ; le patron +est là pour être repris. diff --git a/playbooks/maintenance/devis-identite.yml b/playbooks/maintenance/devis-identite.yml new file mode 100644 index 0000000..b2063e2 --- /dev/null +++ b/playbooks/maintenance/devis-identite.yml @@ -0,0 +1,138 @@ +--- +# Devis d'identite — RELEVE seul. Ne modifie rien (D-23 : un devis se lit d'abord). +# +# Le playbook collecte deux dictionnaires — ce que le depot DECLARE, ce que la machine +# PORTE — et les depose en JSON sur le controleur. La comparaison est faite par +# `scripts/devis_identite.py` : elle n'a rien a faire en Jinja, et le depot a deja +# cette forme (devis_opnsense, devis_sdn : Python raisonne, Ansible releve). +# +# Pourquoi ce devis existe. Les 30 preuves de `prouver.py` sont STATIQUES : elles +# montrent que le depot est coherent avec lui-meme. Aucune ne demande au systeme +# deploye s'il ressemble a ce que le depot annonce. Les quatre defauts du 2026-08-08 +# etaient tous de ce second type — chacun trouve en relisant apres avoir ecrit, +# aucun signale par un test. +# +# make identite-plan + +- name: Devis d'identité — relever le déclaré et le réel + hosts: serveur_keycloak + become: true + gather_facts: false + + vars: + kc: "http://localhost:8080" + devis_identite_sortie: "{{ playbook_dir }}/../../instance/devis-identite.json" + + tasks: + # Les valeurs DECLAREES vivent dans les defauts du role. On les charge plutot que + # de les recopier ici : un devis qui redeclare ce qu'il verifie ne verifie rien. + - name: Charger les valeurs déclarées par le rôle Keycloak + ansible.builtin.include_vars: + dir: "{{ playbook_dir }}/../../roles/serveur_keycloak/defaults" + + - name: Résoudre la politique de mot de passe (déclaration unique) + ansible.builtin.include_role: + name: resoudre_politique_mdp + + # Meme raison : les DN de l'annuaire ne se recopient pas, ils se resolvent. + - name: Résoudre la connexion à l'annuaire + ansible.builtin.include_role: + name: resoudre_annuaire + + - name: Charger le registre des applications + ansible.builtin.include_vars: + file: "{{ setops_plan_dir }}/applications.yml" + + - name: Obtenir un jeton d'administration + ansible.builtin.uri: + url: "{{ kc }}/realms/master/protocol/openid-connect/token" + method: POST + body_format: form-urlencoded + body: + grant_type: password + client_id: admin-cli + username: "{{ serveur_keycloak_admin_user }}" + password: "{{ serveur_keycloak_admin_password }}" + register: devis_jeton + no_log: true + + - name: Lire le realm + ansible.builtin.uri: + url: "{{ kc }}/admin/realms/{{ serveur_keycloak_realm }}" + headers: + Authorization: "Bearer {{ devis_jeton.json.access_token }}" + register: devis_realm + no_log: true + + - name: Lire la fédération LDAP + ansible.builtin.uri: + url: "{{ kc }}/admin/realms/{{ serveur_keycloak_realm }}/components?type=org.keycloak.storage.UserStorageProvider" + headers: + Authorization: "Bearer {{ devis_jeton.json.access_token }}" + register: devis_fed + no_log: true + + - name: Lire les mappeurs de la fédération + ansible.builtin.uri: + url: >- + {{ kc }}/admin/realms/{{ serveur_keycloak_realm }}/components?parent={{ + devis_fed.json[0].id }}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper + headers: + Authorization: "Bearer {{ devis_jeton.json.access_token }}" + register: devis_mappeurs + no_log: true + + - name: Lire la politique de mot de passe de l'annuaire + community.general.ldap_search: + dn: "ou=policies,{{ resoudre_annuaire_base_dn }}" + scope: onelevel + filter: "(objectClass=pwdPolicy)" + server_uri: "ldapi:///" + register: devis_ppolicy + changed_when: false + + # Un compte sans adresse ne peut pas recevoir de lien de reinitialisation : pour + # lui, « mot de passe oublie » est un ecran qui ne mene nulle part, et le seul + # recours redevient l'exploitant manipulant le mot de passe d'autrui. + - name: Lire les comptes de l'annuaire + community.general.ldap_search: + dn: "{{ resoudre_annuaire_users_dn }}" + scope: onelevel + filter: "(objectClass=inetOrgPerson)" + attrs: ["uid", "mail", "givenName"] + server_uri: "ldapi:///" + register: devis_comptes + changed_when: false + + - name: Déposer le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ devis_identite_sortie }}" + mode: "0600" + content: "{{ { + 'hote': inventory_hostname, + 'realm': serveur_keycloak_realm, + 'declare': { + 'passwordPolicy': resoudre_politique_mdp_keycloak, + 'brute': resoudre_politique_mdp_brute, + 'ppolicy': resoudre_politique_mdp_ldap, + 'durcissement': serveur_keycloak_ldap_durcissement, + 'mappeurs': serveur_keycloak_ldap_attributs, + 'editMode': serveur_keycloak_ldap_edit_mode, + 'resetPasswordAllowed': serveur_keycloak_reset_mot_de_passe | bool, + 'smtpHost': applications[serveur_keycloak_smtp_app].hote ~ '.' ~ domaine_interne, + }, + 'reel': { + 'realm': devis_realm.json, + 'federation': devis_fed.json[0].config, + 'mappeurs': dict(devis_mappeurs.json | map(attribute='name') | list + | zip(devis_mappeurs.json | map(attribute='config') | list)), + 'ppolicy': (devis_ppolicy.results | first) | default({}), + 'comptes': devis_comptes.results, + }, + } | to_nice_json }}" + delegate_to: localhost + become: false + + - name: Indiquer le relevé + ansible.builtin.debug: + msg: "Relevé déposé : {{ devis_identite_sortie }} — comparaison par scripts/devis_identite.py" diff --git a/scripts/devis_identite.py b/scripts/devis_identite.py new file mode 100755 index 0000000..5b83831 --- /dev/null +++ b/scripts/devis_identite.py @@ -0,0 +1,108 @@ +#!/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:]))