devis d'identite : comparer le deploye au declare

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 06:55:06 -04:00
parent 419ecaba63
commit 5aa5f2e479
5 changed files with 338 additions and 0 deletions

View file

@ -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

View file

@ -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

52
docs/devis-identite.md Normal file
View file

@ -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.

View file

@ -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"

108
scripts/devis_identite.py Executable file
View file

@ -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:]))