diff --git a/AGENTS.md b/AGENTS.md index 3e2153a..a8e87de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index f14f13c..e4e16fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-13.md b/docs/audit/preuve-2026-09-13.md index b9b2aed..127b65a 100644 --- a/docs/audit/preuve-2026-09-13.md +++ b/docs/audit/preuve-2026-09-13.md @@ -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 diff --git a/docs/devis-services.md b/docs/devis-services.md index 7286bf4..51d0460 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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. diff --git a/roles/amorcage_acces/tasks/main.yml b/roles/amorcage_acces/tasks/main.yml index bdf055b..6301918 100644 --- a/roles/amorcage_acces/tasks/main.yml +++ b/roles/amorcage_acces/tasks/main.yml @@ -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: diff --git a/roles/resoudre_annuaire/defaults/main.yml b/roles/resoudre_annuaire/defaults/main.yml index cc70457..0afd8c2 100644 --- a/roles/resoudre_annuaire/defaults/main.yml +++ b/roles/resoudre_annuaire/defaults/main.yml @@ -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" diff --git a/roles/resoudre_annuaire/tasks/main.yml b/roles/resoudre_annuaire/tasks/main.yml index a1648ec..0939411 100644 --- a/roles/resoudre_annuaire/tasks/main.yml +++ b/roles/resoudre_annuaire/tasks/main.yml @@ -8,7 +8,7 @@ # resoudre_annuaire_uri — ldaps://id-ldap-01. # 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=,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. diff --git a/roles/serveur_dovecot/tasks/main.yml b/roles/serveur_dovecot/tasks/main.yml index a232121..2916f5d 100644 --- a/roles/serveur_dovecot/tasks/main.yml +++ b/roles/serveur_dovecot/tasks/main.yml @@ -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: diff --git a/roles/serveur_icingaweb2/tasks/main.yml b/roles/serveur_icingaweb2/tasks/main.yml index f55e337..320a5fa 100644 --- a/roles/serveur_icingaweb2/tasks/main.yml +++ b/roles/serveur_icingaweb2/tasks/main.yml @@ -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: diff --git a/roles/serveur_keycloak/tasks/federation-ldap.yml b/roles/serveur_keycloak/tasks/federation-ldap.yml index 12764a7..01d3e70 100644 --- a/roles/serveur_keycloak/tasks/federation-ldap.yml +++ b/roles/serveur_keycloak/tasks/federation-ldap.yml @@ -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 }}"]' \ diff --git a/roles/serveur_keycloak/tasks/main.yml b/roles/serveur_keycloak/tasks/main.yml index 5db0407..42d3b39 100644 --- a/roles/serveur_keycloak/tasks/main.yml +++ b/roles/serveur_keycloak/tasks/main.yml @@ -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 diff --git a/roles/serveur_openldap/defaults/main.yml b/roles/serveur_openldap/defaults/main.yml index 09008b8..7a58602 100644 --- a/roles/serveur_openldap/defaults/main.yml +++ b/roles/serveur_openldap/defaults/main.yml @@ -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). diff --git a/roles/serveur_openldap/tasks/main.yml b/roles/serveur_openldap/tasks/main.yml index 0e2bb92..1503939 100644 --- a/roles/serveur_openldap/tasks/main.yml +++ b/roles/serveur_openldap/tasks/main.yml @@ -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 diff --git a/roles/serveur_openldap/templates/sonde-annuaire.sh.j2 b/roles/serveur_openldap/templates/sonde-annuaire.sh.j2 index 1a08edf..52b4707 100644 --- a/roles/serveur_openldap/templates/sonde-annuaire.sh.j2 +++ b/roles/serveur_openldap/templates/sonde-annuaire.sh.j2 @@ -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 diff --git a/roles/serveur_postfix/tasks/main.yml b/roles/serveur_postfix/tasks/main.yml index 00bb086..d375f20 100644 --- a/roles/serveur_postfix/tasks/main.yml +++ b/roles/serveur_postfix/tasks/main.yml @@ -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 diff --git a/scripts/prouver.py b/scripts/prouver.py index 3193f9b..569317d 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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}, ]