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:
parent
419ecaba63
commit
5aa5f2e479
5 changed files with 338 additions and 0 deletions
36
CHANGELOG.md
36
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
|
||||
|
|
|
|||
4
Makefile
4
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
|
||||
|
||||
|
|
|
|||
52
docs/devis-identite.md
Normal file
52
docs/devis-identite.md
Normal 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.
|
||||
138
playbooks/maintenance/devis-identite.yml
Normal file
138
playbooks/maintenance/devis-identite.yml
Normal 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
108
scripts/devis_identite.py
Executable 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:]))
|
||||
Loading…
Reference in a new issue