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:
parent
db4223904d
commit
b4e3520561
16 changed files with 459 additions and 12 deletions
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
63
CHANGELOG.md
63
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}"]' \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue