annuaire : un compte de service par consommateur, et la porte se ferme

Keycloak, Dovecot, Postfix et Icinga Web 2 se liaient TOUS avec cn=admin, le compte
d administration de la base. C est le rootDN : slapd lui fait contourner toutes les
ACL. Un seul secret, quatre services, tous les droits sur l arbre — pour ce qui est,
trois fois sur quatre, une simple lecture.

Et les droits livres par Debian etaient intacts : `to * by * read`. Sur ldap://, sans
s authentifier, une machine du reseau enumerait tous les comptes et toutes les
adresses. Des comptes a droits mesures n auraient rien valu tant que cette ligne
restait : on aurait ferme la porte en laissant la fenetre.

L indice etait deja dans le depot. `validatePasswordPolicy` existe parce que slapd
n applique pas ses controles de qualite au rootDN : la consequence etait compensee,
la cause intacte.

- ou=services, un compte par consommateur, secret propre en voute
- sept regles d acces posees EN ENTIER (state: exact) : l ordre est la regle, et
  inserer c est parier sur ce que le paquet aura mis avant nous
- amorcage_acces garde le compte d administration, NOMME comme l exception : il ne
  consomme pas l annuaire, il le provisionne depuis la socket locale
- la sonde passe de -x a -Y EXTERNAL : elle lisait en anonyme et aurait annonce un
  annuaire VIDE sur un annuaire parfaitement sain
- la rotation du compte d administration devient possible (elle n etait posee qu a
  l installation, par debconf : la voute et slapd divergeaient en silence)

Quatre marches payees en chemin :
1. un cinquieme appelant oublie, dont l echec etait masque par no_log — la garde
   refuse desormais SANS no_log : elle nomme la cle absente, jamais son contenu
2. la federation Keycloak ne reecrivait son bindDn que si l URL ou le mode changeaient
   — nouveau secret, ancien nom, error code 49
3. la rotation placee APRES les taches qui se lient en administrateur
4. ansible-vault et son tube : sortie non bloquante = echec silencieux, la voute
   paraissait tournee et etait identique a l octet

P72 exige que tout role incluant resoudre_annuaire NOMME son compte, et qu aucun sauf
amorcage_acces ne nomme admin. Eprouvee dans les deux sens.

Verifie sur l infrastructure : chaque compte lit ce qu il doit, aucun ne voit les
autres, la lecture anonyme rend 0 entree, et les quatre services repondent (doveadm
user, postmap -q, decouverte OIDC 200, portier SSO 200).

vault_openldap_admin et vault_ldap_bind_postfix renouveles : les deux avaient transite
en clair par une session d exploitation. Les anciennes valeurs rendent Invalid
credentials (49).

make prouver : 71 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-13 13:36:38 -04:00
parent db4223904d
commit b4e3520561
16 changed files with 459 additions and 12 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 71 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 72 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,68 @@
# CHANGELOG — Set-OPS
## 2026-09-13 (2) — L'annuaire cesse de tout ouvrir avec la meme cle
### Ce que la mesure a montre
Les quatre services qui interrogent l'annuaire — Keycloak, Dovecot, Postfix, Icinga Web 2
— s'y liaient **tous avec `cn=admin`**, le compte d'administration de la base. C'est le
rootDN : slapd lui fait CONTOURNER TOUTES LES ACL. Un seul secret, quatre services, tous
les droits sur l'arbre — pour ce qui est, trois fois sur quatre, une simple lecture.
Et les droits livres par Debian etaient intacts : `to * by * read`. Sur `ldap://`, **sans
s'authentifier**, une machine du reseau enumerait tous les comptes et toutes les adresses.
**L'indice etait deja dans le depot.** `validatePasswordPolicy` existe parce que « slapd
n'applique pas ses controles de qualite au rootDN ». La consequence etait compensee, la
cause intacte.
### Ce qui a ete fait
- `ou=services` et **un compte par consommateur**, secret propre en voute.
- **Sept regles d'acces**, posees en entier (`state: exact`) plutot qu'inserees : l'ordre
est la regle, et inserer c'est parier sur ce que le paquet aura mis avant nous. Keycloak
ecrit (mots de passe, creations), les trois autres lisent, les comptes de service ne se
voient pas entre eux, et **la lecture anonyme est fermee**.
- `amorcage_acces` garde le compte d'administration, et c'est **nomme comme l'exception** :
il ne consomme pas l'annuaire, il le PROVISIONNE, depuis la socket locale.
- La sonde passe de `-x` a `-Y EXTERNAL`. Elle lisait en anonyme : elle aurait annonce un
annuaire VIDE — la panne la plus grave — sur un annuaire parfaitement sain.
- **La rotation du compte d'administration est enfin possible.** Son mot de passe n'etait
pose qu'a l'installation, par debconf : le faire tourner en voute ne descendait nulle
part, et les deux divergeaient en silence. Un secret qu'on ne peut pas faire tourner est
un secret qu'on ne fera pas tourner.
### Quatre marches payees en chemin
1. **Un cinquieme appelant.** `amorcage_acces` inclut aussi le role partage. Oublie, il
echouait — et `no_log`, qui protege la valeur, masquait aussi la RAISON. La garde
refuse desormais dans une tache SANS `no_log` : elle nomme la cle absente, jamais son
contenu.
2. **Une garde qui surveillait deux champs sur trois.** La federation Keycloak ne
reecrivait son `bindDn` que si l'URL ou le mode changeaient. Nouveau secret, ancien
nom : `error code 49 - Invalid Credentials`, qui accuse les identifiants sans dire
lequel des deux a bouge.
3. **Une rotation placee trop tard.** Les taches qui se lient en administrateur passaient
AVANT la mise a jour du mot de passe. Un secret qui tourne doit descendre avant que
quoi que ce soit s'en serve.
4. **`ansible-vault` et son tube.** Sortie non bloquante = echec silencieux ; la voute
paraissait tournee et etait identique a l'octet. La garde qui compare l'empreinte du
FICHIER avant et apres l'a rattrape — c'est la lecon deja ecrite dans ce depot, et elle
vient de se repayer.
### La garde
`P72` exige que tout role incluant `resoudre_annuaire` NOMME son compte de service, et
qu'aucun sauf `amorcage_acces` ne nomme `admin`. Eprouvee dans les deux sens.
### Rotation
`vault_openldap_admin` et `vault_ldap_bind_postfix` ont ete renouveles : les deux avaient
transite en clair par une session d'exploitation. Verifie sur l'infrastructure — les
anciennes valeurs rendent `Invalid credentials (49)`, les nouvelles ouvrent, et les quatre
services repondent.
## 2026-09-13 — Le genome ne nait plus chez un locataire
### Ce que la console montrait

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (70 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (71 OK · 0 echec · 1 saute)
## Preuves
@ -30,7 +30,7 @@
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 27 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 29 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 5 instance(s) federee(s), aucun index en collision. |
@ -69,7 +69,7 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (71 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (72 preuves, 68 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `904ece3` (publie le 2026-09-12). |
@ -84,6 +84,7 @@
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
## Couverture des affirmations ✅ du registre

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 71 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 72 preuves (dont une conditionnelle, sautée sans la clé de la voûte). 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.

View file

@ -5,6 +5,15 @@
- name: Résoudre la connexion à l'annuaire
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# L'EXCEPTION, ET ELLE EST NOMMEE (2026-09-13). Les quatre services ont desormais
# chacun leur compte de service, a droits mesures. Ce role-ci n'est pas un service :
# il PROVISIONNE — il cree le compte d'amorcage et impose son changement de mot de
# passe. C'est le geste de l'exploitant, joue depuis la machine de l'annuaire par sa
# socket locale. Il garde donc le compte d'administration, qui est fait pour ca.
resoudre_annuaire_bind_ou: ""
resoudre_annuaire_bind_cn: "admin"
resoudre_annuaire_secret: "vault_openldap_admin"
- name: Exiger le secret d'amorçage et le mot de passe d'annuaire (Vault)
ansible.builtin.assert:

View file

@ -8,6 +8,20 @@ resoudre_annuaire_app: "openldap"
resoudre_annuaire_hote: "id-ldap-01"
resoudre_annuaire_port: 636
resoudre_annuaire_encryption: "ldaps"
# Compte de service (bind) — secret déréférencé depuis la voûte dans le rôle consommateur.
resoudre_annuaire_bind_cn: "admin"
resoudre_annuaire_secret: "vault_openldap_admin"
# COMPTE DE LIAISON — CHAQUE CONSOMMATEUR A LE SIEN (2026-09-13).
#
# Ce defaut valait `admin` : les quatre consommateurs se liaient donc avec le compte
# d'administration de l'annuaire. C'est le rootDN, et slapd lui fait CONTOURNER TOUTES
# LES ACL — y compris ses propres controles de qualite de mot de passe. Un seul secret,
# quatre services, tous les droits sur l'arbre, pour ce qui est trois fois sur quatre une
# simple lecture.
#
# Le rôle etait deja pret : `bind_cn` et `secret` sont des variables. Il ne manquait que
# les comptes, leurs droits, et que chaque consommateur nomme le sien.
#
# LE DEFAUT N'EST PLUS UN COMPTE QUI MARCHE. Un consommateur qui ne nomme pas le sien se
# lierait comme `cn=inconnu,ou=services,...`, qui n'existe pas : l'echec est immediat et
# lisible, au lieu de reussir avec tous les droits.
resoudre_annuaire_bind_ou: "services"
resoudre_annuaire_bind_cn: "inconnu"
resoudre_annuaire_secret: "vault_ldap_bind_inconnu"

View file

@ -8,7 +8,7 @@
# resoudre_annuaire_uri — ldaps://id-ldap-01.<domaine>
# resoudre_annuaire_base_dn — dc=...,dc=...
# resoudre_annuaire_users_dn — ou=people,dc=...
# resoudre_annuaire_bind_dn — cn=admin,dc=...
# resoudre_annuaire_bind_dn — cn=<service>,ou=services,dc=...
# resoudre_annuaire_bind_password — secret déréférencé depuis la voûte (no_log)
- name: Exiger un domaine interne non vide
ansible.builtin.assert:
@ -36,9 +36,28 @@
resoudre_annuaire_port: "{{ resoudre_annuaire_port }}"
resoudre_annuaire_base_dn: "dc={{ domaine_interne.split('.') | join(',dc=') }}"
resoudre_annuaire_users_dn: "ou=people,dc={{ domaine_interne.split('.') | join(',dc=') }}"
resoudre_annuaire_bind_dn: "cn={{ resoudre_annuaire_bind_cn }},dc={{ domaine_interne.split('.') | join(',dc=') }}"
resoudre_annuaire_bind_dn: >-
cn={{ resoudre_annuaire_bind_cn }},{{ ('ou=' ~ resoudre_annuaire_bind_ou ~ ',')
if resoudre_annuaire_bind_ou | length > 0 else '' }}dc={{ domaine_interne.split('.') | join(',dc=') }}
# UNE GARDE CENSUREE NE GARDE PAS (mesure du 2026-09-13). La premiere version
# dereferencait directement : quand le secret manquait, `lookup('vars', ...)` levait — et
# `no_log: true`, qui protege legitimement la VALEUR, masquait aussi la RAISON. Le
# deploiement s'arretait sur « the output has been hidden », ce qui ne designe rien.
#
# On dereference donc avec un repli, puis on refuse dans une tache SANS `no_log` : elle
# ne nomme que la cle absente, jamais son contenu.
- name: Déréférencer le mot de passe de bind (Vault)
ansible.builtin.set_fact:
resoudre_annuaire_bind_password: "{{ lookup('vars', resoudre_annuaire_secret) }}"
resoudre_annuaire_bind_password: "{{ lookup('vars', resoudre_annuaire_secret, default='') }}"
no_log: true
- name: Exiger que le compte de liaison ait un secret
ansible.builtin.assert:
that:
- resoudre_annuaire_bind_password | length > 0
fail_msg: >-
Le compte de liaison « {{ resoudre_annuaire_bind_dn }} » n'a pas de secret :
la cle « {{ resoudre_annuaire_secret }} » est absente ou vide dans la voute.
Un role qui consomme l'annuaire doit nommer SON compte de service — voir
roles/resoudre_annuaire/defaults/main.yml.

View file

@ -2,6 +2,10 @@
- name: Resoudre l'annuaire (role partage)
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13).
resoudre_annuaire_bind_cn: "dovecot"
resoudre_annuaire_secret: "vault_ldap_bind_dovecot"
- name: Adopter la connexion annuaire pour Dovecot
ansible.builtin.set_fact:

View file

@ -8,6 +8,10 @@
- name: Résoudre l'annuaire (rôle partagé)
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13).
resoudre_annuaire_bind_cn: "icingaweb2"
resoudre_annuaire_secret: "vault_ldap_bind_icingaweb2"
- name: Adopter la connexion annuaire pour Icinga Web 2
ansible.builtin.set_fact:

View file

@ -58,7 +58,21 @@
fi
MODE_ACTUEL=$("$KC" get components/"$FEDID" -r {{ serveur_keycloak_realm }} 2>/dev/null \
| sed -n 's/.*"editMode" : \[ "\([A-Z_]*\)".*/\1/p' | head -1 || true)
# LE DN DE LIAISON COMPTE AUTANT QUE L'URL (mesure du 2026-09-13).
#
# Cette condition ne regardait que l'URL et le mode. Le jour ou Keycloak a cesse
# de se lier avec le compte d'administration pour prendre le sien, le mot de passe
# a bien suivi — il se reconcilie sur son empreinte, juste au-dessus — mais le DN
# est reste l'ancien. Nouveau secret, ancien nom :
#
# javax.naming.AuthenticationException: [LDAP: error code 49 - Invalid Credentials]
#
# Le message accuse les identifiants sans dire LEQUEL des deux a bouge. Une garde
# qui surveille deux champs sur trois laisse passer le troisieme en silence.
BINDDN_ACTUEL=$("$KC" get components/"$FEDID" -r {{ serveur_keycloak_realm }} 2>/dev/null \
| sed -n 's/.*"bindDn" : \[ "\([^"]*\)".*/\1/p' | head -1 || true)
if [ "$URL_ACTUELLE" != "{{ serveur_keycloak_ldap_url }}" ] \
|| [ "$BINDDN_ACTUEL" != "{{ serveur_keycloak_ldap_bind_dn }}" ] \
|| [ "$MODE_ACTUEL" != "{{ serveur_keycloak_ldap_edit_mode }}" ]; then
"$KC" update components/"$FEDID" -r {{ serveur_keycloak_realm }} \
-s 'config.connectionUrl=["{{ serveur_keycloak_ldap_url }}"]' \

View file

@ -42,6 +42,10 @@
- name: Resoudre l'annuaire (role partage)
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13).
resoudre_annuaire_bind_cn: "keycloak"
resoudre_annuaire_secret: "vault_ldap_bind_keycloak"
when: serveur_keycloak_ldap_federation | bool
- name: Adopter la connexion annuaire pour Keycloak

View file

@ -20,6 +20,98 @@ serveur_openldap_admin_password: "{{ vault_openldap_admin | default('') }}" # r
serveur_openldap_ou:
- people
- groups
# LES COMPTES DE SERVICE VIVENT A PART (2026-09-13). Melanges aux personnes, ils
# apparaitraient dans chaque recherche d'usager — et un annuaire ou « qui travaille
# ici » retourne des demons ne se lit plus.
- services
# --- QUI SE LIE A L'ANNUAIRE, ET AVEC QUELS DROITS (2026-09-13) ----------------------
#
# AVANT : les quatre consommateurs — Keycloak, Dovecot, Postfix, Icinga Web 2 — se liaient
# tous avec `cn=admin`, le compte d'administration de la base. C'est le rootDN : slapd lui
# fait CONTOURNER TOUTES LES ACL, y compris ses propres controles de qualite de mot de
# passe. Un seul secret, quatre services, tous les droits sur l'arbre entier — pour des
# operations qui sont, pour trois d'entre eux, de simples LECTURES.
#
# L'INDICE ETAIT DEJA DANS LE DEPOT. `serveur_keycloak_ldap_durcissement` porte
# `validatePasswordPolicy: true` avec ce motif : « Keycloak se lie en rootDN, et slapd
# n'applique pas ses controles de qualite au rootDN ». On avait compense la consequence
# sans traiter la cause. Avec un compte ordinaire, l'annuaire redevient juge de sa propre
# politique — le reglage reste, mais il n'est plus la seule barriere.
#
# `ecriture: true` n'est accorde qu'a ce qui DOIT ecrire. Keycloak est le seul : il porte
# les mots de passe a travers (edit_mode WRITABLE) et cree les comptes que le web ouvre
# (syncRegistrations). Les trois autres lisent.
serveur_openldap_comptes_service:
- cn: keycloak
secret: vault_ldap_bind_keycloak
ecriture: true
raison: "Federation OIDC : ecrit les mots de passe a travers et cree les comptes."
- cn: dovecot
secret: vault_ldap_bind_dovecot
ecriture: false
raison: "Qui a droit a sa boite aux lettres."
- cn: postfix
secret: vault_ldap_bind_postfix
ecriture: false
raison: "A qui livrer un courriel."
- cn: icingaweb2
secret: vault_ldap_bind_icingaweb2
ecriture: false
raison: "Qui peut voir la supervision."
serveur_openldap_services_dn: "ou=services,{{ serveur_openldap_base_dn }}"
# Empreinte du mot de passe d'administration APPLIQUE. `slappasswd` sale son condensat :
# comparer ce que slapd detient ne dit rien. On garde donc l'empreinte de la valeur
# voulue, exactement comme la federation Keycloak le fait pour sa liaison.
serveur_openldap_marqueur_admin: "/etc/ldap/.setops-admin-sha256"
serveur_openldap_admin_empreinte: "{{ serveur_openldap_admin_password | hash('sha256') }}"
# L'ANNUAIRE ETAIT LISIBLE PAR N'IMPORTE QUI (mesure du 2026-09-13).
#
# La regle livree par Debian est `to * by * read` : sur `ldap://`, sans s'authentifier,
# une machine du reseau enumerait tous les comptes et toutes les adresses. Des comptes de
# service a droits mesures n'auraient servi a rien tant que cette ligne restait — on
# aurait ferme la porte en laissant la fenetre.
#
# `false` restaure le comportement d'origine, pour un ecosysteme qui aurait une raison
# explicite de publier son annuaire. Ce n'est pas le cas par defaut.
serveur_openldap_lecture_anonyme: false
# L'identite du root LOCAL sur le socket unix. La sonde de cet annuaire l'utilise :
# elle compte les entrees sans reseau et sans secret. Fermer la lecture sans lui accorder
# ce droit rendrait la sonde aveugle — elle annoncerait un annuaire vide, c'est-a-dire la
# panne la plus grave, sur un annuaire parfaitement sain.
serveur_openldap_root_local_dn: "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth"
# Raccourcis lisibles, pour que les regles ci-dessous se lisent comme des phrases.
serveur_openldap_ecrivains: >-
{% for c in serveur_openldap_comptes_service if c.ecriture | default(false) -%}
by dn.exact="cn={{ c.cn }},{{ serveur_openldap_services_dn }}" write {% endfor %}
serveur_openldap_lecteurs: >-
{% for c in serveur_openldap_comptes_service if not (c.ecriture | default(false)) -%}
by dn.exact="cn={{ c.cn }},{{ serveur_openldap_services_dn }}" read {% endfor %}
serveur_openldap_root_local: 'by dn.exact="{{ serveur_openldap_root_local_dn }}" read'
# Ce que voit une identite qui n'est designee par aucune regle precedente.
serveur_openldap_reste: "{{ 'by * read' if serveur_openldap_lecture_anonyme | bool else 'by users read by * none' }}"
# LES DROITS, DANS L'ORDRE OU slapd LES LIT. Voir tasks/main.yml pour ce que chaque
# numero protege — et pourquoi deplacer {0} ferme l'annuaire a tout le monde.
serveur_openldap_acces:
- '{0}to attrs=userPassword by self write by anonymous auth {{ serveur_openldap_ecrivains }} by * none'
- '{1}to attrs=shadowLastChange by self write by * read'
- '{2}to dn.subtree="{{ serveur_openldap_services_dn }}" {{ serveur_openldap_root_local }} by * none'
- >-
{3}to dn.subtree="ou=people,{{ serveur_openldap_base_dn }}"
{{ serveur_openldap_ecrivains }} {{ serveur_openldap_lecteurs }}
by self read {{ serveur_openldap_root_local }} by * none
- >-
{4}to dn.subtree="ou=groups,{{ serveur_openldap_base_dn }}"
{{ serveur_openldap_ecrivains }} {{ serveur_openldap_lecteurs }}
by users read {{ serveur_openldap_root_local }} by * none
- '{5}to dn.base="" by * read'
- '{6}to * {{ serveur_openldap_root_local }} {{ serveur_openldap_reste }}'
# --- TLS (LDAPS + STARTTLS) via le certificat d'hote step_ca ---
# Requiert l'integration client_pki sur le noeud (fournit le cert + le renouvellement).

View file

@ -54,6 +54,85 @@
enabled: true
state: started
# LA ROTATION PASSE AVANT TOUT CE QUI SE LIE EN ADMINISTRATEUR (2026-09-13).
#
# Placee plus bas, elle arrivait trop tard : « Creer les unites organisationnelles » se
# lie deja avec le mot de passe de la VOUTE, tandis que slapd detient encore l'ANCIEN.
# Le deploiement echouait sur la huitieme tache, et `no_log` — qui protege la valeur —
# masquait aussi la cause. Un secret qu'on fait tourner doit descendre AVANT que quoi que
# ce soit s'en serve.
#
# Ces taches-ci n'ont pas ce probleme : elles ecrivent par `ldapi:///` en s'identifiant
# par la socket unix (root local), sans mot de passe. C'est precisement ce qui leur
# permet de reparer la liaison qu'elles changent.
- name: Trouver la base mdb (pour y poser les droits)
community.general.ldap_search:
dn: "cn=config"
scope: onelevel
filter: "(olcDatabase={1}mdb)"
attrs:
- dn
server_uri: "ldapi:///"
register: serveur_openldap_base_droits
changed_when: false
- name: Retenir le DN de la base pour les droits
ansible.builtin.set_fact:
serveur_openldap_droits_dn: "{{ (serveur_openldap_base_droits.results | first).dn }}"
when: (serveur_openldap_base_droits.results | default([]) | length) > 0
# --- ROTATION DU COMPTE D'ADMINISTRATION (2026-09-13) -------------------------------
#
# CE MOT DE PASSE N'ETAIT POSE QU'A L'INSTALLATION, par debconf. Le faire tourner en
# voute ne descendait donc nulle part : slapd gardait l'ancien, la voute annoncait le
# neuf, et les deux divergeaient EN SILENCE. Le premier a s'en apercevoir aurait ete
# `amorcage_acces`, au pire moment — quand on cree un acces.
#
# Un secret qu'on ne peut pas faire tourner est un secret qu'on ne fera pas tourner.
#
# `slappasswd` SALE le condensat : deux appels sur la meme valeur rendent deux chaines
# differentes, et comparer l'existant ne dit donc rien. On memorise l'empreinte de la
# valeur VOULUE, comme la federation Keycloak le fait pour sa liaison — meme probleme,
# meme remede, et c'est le signe qu'il s'agit d'un patron, pas d'un bricolage.
- name: Lire l'empreinte du mot de passe d'administration en place
ansible.builtin.slurp:
src: "{{ serveur_openldap_marqueur_admin }}"
register: serveur_openldap_marqueur_lu
failed_when: false
changed_when: false
- name: Hacher le mot de passe d'administration voulu
ansible.builtin.command:
argv: ["slappasswd", "-s", "{{ serveur_openldap_admin_password }}"]
register: serveur_openldap_admin_hash
changed_when: false
no_log: true
when: serveur_openldap_admin_empreinte != (serveur_openldap_marqueur_lu.content | default('') | b64decode | trim)
- name: Aligner le mot de passe d'administration de l'annuaire sur la voute
community.general.ldap_attrs:
dn: "{{ serveur_openldap_droits_dn }}"
attributes:
olcRootPW: "{{ serveur_openldap_admin_hash.stdout | trim }}"
state: exact
server_uri: "ldapi:///"
no_log: true
when:
- serveur_openldap_droits_dn | default('') | length > 0
- serveur_openldap_admin_hash is not skipped
# LE MARQUEUR S'ECRIT APRES, JAMAIS AVANT. Ecrit d'abord, une pose ratee laisserait le
# depot convaincu d'avoir fait tourner un secret qui n'a pas bouge — et la prochaine
# execution ne retenterait rien.
- name: Retenir l'empreinte du mot de passe d'administration pose
ansible.builtin.copy:
content: "{{ serveur_openldap_admin_empreinte }}\n"
dest: "{{ serveur_openldap_marqueur_admin }}"
owner: root
group: root
mode: "0600"
when: serveur_openldap_admin_hash is not skipped
- name: Creer les unites organisationnelles de base
community.general.ldap_entry:
dn: "ou={{ item }},{{ serveur_openldap_base_dn }}"
@ -66,6 +145,81 @@
label: "ou={{ item }}"
no_log: true
# --- COMPTES DE SERVICE (2026-09-13) ------------------------------------------------
#
# Quatre services interrogeaient l'annuaire avec son compte d'administration. Voir
# `defaults/main.yml` pour le raisonnement ; ici on cree les comptes et on pose les droits.
- name: Exiger un secret pour chaque compte de service
ansible.builtin.assert:
that:
- lookup('vars', item.secret, default='') | length > 0
fail_msg: >-
Le compte de service « {{ item.cn }} » n'a pas de secret : {{ item.secret }} est
absent ou vide dans la voute. Un compte sans mot de passe ne se lie pas, et le
service qui en depend echouerait a la premiere requete.
loop: "{{ serveur_openldap_comptes_service }}"
loop_control:
label: "{{ item.cn }}"
no_log: true
- name: Creer les comptes de service de l'annuaire
community.general.ldap_entry:
dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}"
objectClass:
- simpleSecurityObject
- organizationalRole
attributes:
description: "{{ item.raison }}"
userPassword: "{{ lookup('vars', item.secret) }}"
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
loop: "{{ serveur_openldap_comptes_service }}"
loop_control:
label: "{{ item.cn }}"
no_log: true
# `ldap_entry` ne fait que CREER : un secret tourne en voute ne descendrait jamais dans
# l'annuaire, et la rotation paraitrait faite. `ldap_passwd` compare avant d'ecrire, donc
# il est idempotent ET il suit la voute. La lecon est deja celle de la politique de mots
# de passe, quinze lignes plus bas.
- name: Aligner le mot de passe de chaque compte de service sur la voute
community.general.ldap_passwd:
dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}"
passwd: "{{ lookup('vars', item.secret) }}"
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
loop: "{{ serveur_openldap_comptes_service }}"
loop_control:
label: "{{ item.cn }}"
no_log: true
# L'ORDRE DES REGLES EST LA REGLE. slapd applique la PREMIERE dont la cible correspond et
# s'arrete : une regle generale placee trop haut rend muettes toutes celles qui suivent.
# On pose donc la liste ENTIERE (`state: exact`) plutot que d'inserer — inserer, ici,
# c'est parier sur ce que le paquet Debian aura mis avant nous.
#
# {0} LES MOTS DE PASSE, D'ABORD. Cette regle porte sur un ATTRIBUT : elle vaut pour
# toutes les entrees, comptes de service compris — c'est elle qui leur permet de se
# lier (`by anonymous auth`). La retirer ou la deplacer plus bas ferme l'annuaire a
# tout le monde, y compris a l'exploitant.
# {2} LES COMPTES DE SERVICE NE SE VOIENT PAS ENTRE EUX. Compromettre Postfix ne doit pas
# livrer le compte de Keycloak. Le rootDN, lui, contourne les ACL : l'exploitant garde
# la main.
# {3} LES PERSONNES. Keycloak ecrit (mots de passe, creations) ; les trois autres lisent ;
# chacun se lit soi-meme.
# {5} LE RACINE DE L'ANNUAIRE reste lisible : sans lui, aucun client ne peut meme
# decouvrir les contextes servis, et les messages parlent alors de « base introuvable ».
- name: Poser les droits d'acces de l'annuaire
community.general.ldap_attrs:
dn: "{{ serveur_openldap_droits_dn }}"
attributes:
olcAccess: "{{ serveur_openldap_acces }}"
state: exact
server_uri: "ldapi:///"
when: serveur_openldap_droits_dn | default('') | length > 0
# --- Politique de mot de passe (overlay ppolicy) ------------------------------
# Sans cet overlay, `pwdReset` n'existe pas dans le schema : `amorcage_acces` ne peut
# pas forcer le changement du jeton d'amorcage, et rien ne contraint la qualite ni ne

View file

@ -15,7 +15,14 @@ systemctl is-active --quiet slapd || { echo "L'annuaire (slapd) n'est pas actif.
# ON COMPTE LES ENTREES, ON NE SE CONTENTE PAS D'UNE REPONSE. Un annuaire vide repond
# `success` a tout — c'est exactement le mensonge des sauvegardes vides : vert, et sans
# contenu. Sans ce comptage, la sonde dirait « tout va bien » sur un annuaire efface.
n=$(ldapsearch -x -H ldapi:/// -b "${BASE}" -s sub '(objectClass=*)' dn 2>/dev/null | grep -c '^dn:')
# `-Y EXTERNAL`, PAS `-x` (2026-09-13). La sonde lisait en ANONYME. Ca marchait tant que
# l'annuaire etait ouvert a tous — c'est-a-dire tant qu'il avait le defaut qu'on vient de
# corriger. Une fois la lecture anonyme fermee, un `-x` ne compte plus rien et la sonde
# annonce un annuaire VIDE : la panne la plus grave, sur un annuaire parfaitement sain.
#
# `EXTERNAL` sur le socket unix identifie le root local, a qui les droits accordent la
# lecture. Aucun secret, aucun reseau — ce que doit rester une sonde.
n=$(ldapsearch -Y EXTERNAL -H ldapi:/// -b "${BASE}" -s sub '(objectClass=*)' dn 2>/dev/null | grep -c '^dn:')
[[ "${n}" =~ ^[0-9]+$ ]] || { echo "L'annuaire ne repond pas sous ${BASE}."; exit 2; }
if (( n < MINIMUM )); then

View file

@ -2,6 +2,10 @@
- name: Resoudre l'annuaire (role partage)
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13).
resoudre_annuaire_bind_cn: "postfix"
resoudre_annuaire_secret: "vault_ldap_bind_postfix"
when: serveur_postfix_mode != "relais"
- name: Adopter la connexion annuaire pour Postfix

View file

@ -3312,6 +3312,62 @@ def preuve_pool_du_site_nest_pas_celui_du_tenant() -> tuple[bool, str]:
return True, "`site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant."
def preuve_annuaire_sans_compte_maitre() -> tuple[bool, str]:
"""Aucun service ne se lie-t-il a l'annuaire avec son compte d'administration ?
MESURE DU 2026-09-13 : les quatre consommateurs — Keycloak, Dovecot, Postfix,
Icinga Web 2 — s'y liaient tous avec `cn=admin`. C'est le rootDN : slapd lui fait
CONTOURNER TOUTES LES ACL, y compris ses propres controles de qualite de mot de passe.
Un seul secret, quatre services, tous les droits sur l'arbre — pour ce qui est, trois
fois sur quatre, une simple lecture.
LE DEPOT PORTAIT DEJA L'INDICE. `validatePasswordPolicy` existe parce que « slapd
n'applique pas ses controles de qualite au rootDN » : la consequence etait compensee,
la cause intacte.
CE QUI EST EXIGE ICI : tout role qui inclut `resoudre_annuaire` NOMME son compte de
service. Une seule exception, et elle est nommee — `amorcage_acces` ne consomme pas
l'annuaire, il le PROVISIONNE : il cree le compte d'amorcage depuis la socket locale
de la machine qui porte l'annuaire. C'est le geste de l'exploitant.
Sans cette garde, un role neuf heriterait du defaut du role partage et se lierait
silencieusement avec ce que ce defaut designe.
"""
import re as _re
roles_dir = RACINE / "roles"
incluent: dict[str, str] = {}
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
td = roles_dir / role / "tasks"
if not td.is_dir():
continue
for tf in sorted(td.glob("*.yml")):
texte = tf.read_text(encoding="utf-8")
if _re.search(r"name:\s*resoudre_annuaire\b", texte):
incluent[role] = texte
if not incluent:
return True, "Aucun role n'inclut `resoudre_annuaire` : rien a garder."
muets, maitres = [], []
for role, texte in sorted(incluent.items()):
cn = _re.search(r"resoudre_annuaire_bind_cn:\s*[\"']?([\w-]+)", texte)
if cn is None:
muets.append(role)
elif cn.group(1) == "admin" and role != "amorcage_acces":
maitres.append(role)
if maitres:
return False, ("Se lie(nt) a l'annuaire avec son compte d'administration — tous les "
"droits sur l'arbre pour une lecture : " + ", ".join(maitres))
if muets:
return False, ("N'a/ont pas nomme leur compte de service et heriteront du defaut du "
"role partage : " + ", ".join(muets))
return True, (f"{len(incluent)} role(s) consultent l'annuaire, chacun avec SON compte de "
"service ; seul `amorcage_acces` garde celui d'administration, et il "
"provisionne au lieu de consommer.")
def preuve_depot_binaires_complet() -> tuple[bool, str]:
"""Le depot du site tient-il TOUT ce que les roles vont chercher sur Internet ?
@ -3580,6 +3636,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_depot_binaires_complet},
{"id": "P71", "titre": "Pool du site : le genome ne nait pas chez un tenant",
"refs": [], "func": preuve_pool_du_site_nest_pas_celui_du_tenant},
{"id": "P72", "titre": "Annuaire : aucun service ne se lie avec le compte du maitre",
"refs": [], "func": preuve_annuaire_sans_compte_maitre},
]