Set-OPS-Public/roles/serveur_openldap/tasks/main.yml
Daniel Allaire b4e3520561 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
2026-09-13 13:36:38 -04:00

463 lines
18 KiB
YAML

---
- name: Exiger le mot de passe admin LDAP (Vault)
ansible.builtin.assert:
that:
- serveur_openldap_admin_password | length > 0
fail_msg: "serveur_openldap_admin_password est requis (a fournir via Ansible Vault)."
- name: Installer debconf-utils (requis par le module debconf, fournit debconf-get-selections)
ansible.builtin.apt:
name: debconf-utils
state: present
update_cache: true
cache_valid_time: 3600
- name: Preconfigurer slapd (debconf)
ansible.builtin.debconf:
name: slapd
question: "{{ item.question }}"
vtype: "{{ item.vtype }}"
value: "{{ item.value }}"
loop:
- { question: "slapd/no_configuration", vtype: "boolean", value: "false" }
- { question: "slapd/domain", vtype: "string", value: "{{ serveur_openldap_domaine }}" }
- { question: "shared/organization", vtype: "string", value: "{{ serveur_openldap_organisation }}" }
- { question: "slapd/backend", vtype: "select", value: "MDB" }
- { question: "slapd/purge_database", vtype: "boolean", value: "true" }
- { question: "slapd/move_old_database", vtype: "boolean", value: "true" }
- { question: "slapd/allow_ldap_v2", vtype: "boolean", value: "false" }
loop_control:
label: "{{ item.question }}"
- name: Preconfigurer le mot de passe admin slapd (debconf)
ansible.builtin.debconf:
name: slapd
question: "{{ item }}"
vtype: password
value: "{{ serveur_openldap_admin_password }}"
loop:
- slapd/password1
- slapd/password2
no_log: true
- name: Installer OpenLDAP
ansible.builtin.apt:
name: "{{ serveur_openldap_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Activer et demarrer slapd
when: not ansible_check_mode
ansible.builtin.systemd:
name: "{{ serveur_openldap_service }}"
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 }}"
objectClass: organizationalUnit
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
loop: "{{ serveur_openldap_ou }}"
loop_control:
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
# verrouille apres des echecs. Voir docs/autorisation.md §3.
- name: Ppolicy — Charger le module
community.general.ldap_attrs:
dn: "cn=module{0},cn=config"
attributes:
olcModuleLoad: "ppolicy"
state: present
server_uri: "ldapi:///"
when: serveur_openldap_ppolicy_actif | bool
# Le DN de la base varie (`olcDatabase={1}mdb` en general, mais l'index n'est pas
# garanti) : on le LIT au lieu de le supposer.
- name: Ppolicy — Trouver la base mdb
community.general.ldap_search:
dn: "cn=config"
scope: onelevel
filter: "(olcDatabase={1}mdb)"
attrs:
- dn
server_uri: "ldapi:///"
register: serveur_openldap_base_mdb
changed_when: false
when: serveur_openldap_ppolicy_actif | bool
- name: Ppolicy — Retenir le DN de la base
ansible.builtin.set_fact:
serveur_openldap_mdb_dn: >-
{{ (serveur_openldap_base_mdb.results | first).dn }}
when:
- serveur_openldap_ppolicy_actif | bool
- (serveur_openldap_base_mdb.results | default([]) | length) > 0
- name: Ppolicy — Résoudre la politique (déclaration unique)
ansible.builtin.include_role:
name: resoudre_politique_mdp
when: serveur_openldap_ppolicy_actif | bool
- name: Ppolicy — Creer l unite des politiques
community.general.ldap_entry:
dn: "ou=policies,{{ serveur_openldap_base_dn }}"
objectClass: organizationalUnit
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
no_log: true
when: serveur_openldap_ppolicy_actif | bool
# L'overlay AVANT la politique : `olcPPolicyDefault` reference une entree qui doit
# exister, mais l'overlay doit etre en place pour que le schema pwd* soit connu.
# L'ordre inverse echouerait des deux cotes.
- name: Ppolicy — Activer l overlay sur la base
community.general.ldap_entry:
dn: "olcOverlay=ppolicy,{{ serveur_openldap_mdb_dn }}"
objectClass:
- olcOverlayConfig
- olcPPolicyConfig
attributes:
olcOverlay: "ppolicy"
olcPPolicyDefault: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}"
olcPPolicyHashCleartext: "{{ 'TRUE' if serveur_openldap_ppolicy_hash_cleartext | bool else 'FALSE' }}"
olcPPolicyUseLockout: "{{ 'TRUE' if serveur_openldap_ppolicy_use_lockout | bool else 'FALSE' }}"
server_uri: "ldapi:///"
when:
- serveur_openldap_ppolicy_actif | bool
- serveur_openldap_mdb_dn is defined
- name: Ppolicy — Creer la politique par defaut
community.general.ldap_entry:
dn: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}"
objectClass:
- pwdPolicy
- person
- top
attributes: >-
{{ {'cn': 'default', 'sn': 'default'}
| combine(resoudre_politique_mdp_ldap, serveur_openldap_ppolicy_extra) }}
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
no_log: true
when: serveur_openldap_ppolicy_actif | bool
# `ldap_entry` ne fait que CREER : l'entree restait figee a ce qu'elle valait le
# jour de sa creation, et toute modification ulterieure de la declaration etait
# ignoree en silence. Constate le 2026-08-08 — le depot annoncait
# `pwdMustChange: TRUE`, le serveur portait `FALSE` (corrige a la main apres la
# boucle de changement de mot de passe du 2026-08-07), et une reconstruction
# from-zero aurait ressuscite le defaut. Il faut donc RECONCILIER en plus.
- name: Ppolicy — Reconcilier la politique par defaut
community.general.ldap_attrs:
dn: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}"
attributes: "{{ resoudre_politique_mdp_ldap | combine(serveur_openldap_ppolicy_extra) }}"
state: exact
server_uri: "ldapi:///"
bind_dn: "{{ serveur_openldap_admin_dn }}"
bind_pw: "{{ serveur_openldap_admin_password }}"
no_log: true
when: serveur_openldap_ppolicy_actif | bool
# Meme raison : l'overlay non plus n'etait pose qu'a la creation. Son DN porte un
# index d'ordre (`{0}`, `{1}`…) attribue par slapd — on le LIT, on ne le devine pas.
- name: Ppolicy — Trouver le DN de l overlay
community.general.ldap_search:
dn: "{{ serveur_openldap_mdb_dn }}"
scope: onelevel
filter: "(olcOverlay=*ppolicy)"
attrs:
- dn
server_uri: "ldapi:///"
register: serveur_openldap_overlay_ppolicy
changed_when: false
when:
- serveur_openldap_ppolicy_actif | bool
- serveur_openldap_mdb_dn is defined
- name: Ppolicy — Reconcilier les reglages de l overlay
community.general.ldap_attrs:
dn: "{{ (serveur_openldap_overlay_ppolicy.results | first).dn }}"
attributes:
olcPPolicyDefault: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}"
olcPPolicyHashCleartext: "{{ 'TRUE' if serveur_openldap_ppolicy_hash_cleartext | bool else 'FALSE' }}"
olcPPolicyUseLockout: "{{ 'TRUE' if serveur_openldap_ppolicy_use_lockout | bool else 'FALSE' }}"
state: exact
server_uri: "ldapi:///"
when:
- serveur_openldap_ppolicy_actif | bool
- (serveur_openldap_overlay_ppolicy.results | default([]) | length) > 0
# --- TLS (LDAPS + STARTTLS) via le certificat step_ca depose par client_pki ---
- name: TLS — creer le repertoire pont lisible par openldap
ansible.builtin.file:
path: "{{ serveur_openldap_tls_dir }}"
state: directory
owner: openldap
group: openldap
mode: "0750"
when: serveur_openldap_tls_actif | bool
- name: TLS — deployer le script de synchronisation du certificat
ansible.builtin.template:
src: setops-slapd-cert-sync.sh.j2
dest: /usr/local/sbin/setops-slapd-cert-sync
owner: root
group: root
mode: "0755"
when: serveur_openldap_tls_actif | bool
- name: TLS — deployer les unites de synchronisation (service + path)
ansible.builtin.template:
src: "{{ item.s }}"
dest: "{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: "setops-slapd-cert-sync.service.j2", d: "/etc/systemd/system/setops-slapd-cert-sync.service" }
- { s: "setops-slapd-cert-sync.path.j2", d: "/etc/systemd/system/setops-slapd-cert-sync.path" }
loop_control:
label: "{{ item.d | basename }}"
when: serveur_openldap_tls_actif | bool
- name: TLS — synchroniser le certificat maintenant (si client_pki l'a depose)
ansible.builtin.command: /usr/local/sbin/setops-slapd-cert-sync
changed_when: false
when:
- serveur_openldap_tls_actif | bool
- not ansible_check_mode
- name: TLS — activer la surveillance du certificat (renouvellement)
ansible.builtin.systemd:
name: setops-slapd-cert-sync.path
enabled: true
state: started
daemon_reload: true
when:
- serveur_openldap_tls_actif | bool
- not ansible_check_mode
- name: TLS — verifier que le certificat pont est en place
ansible.builtin.stat:
path: "{{ serveur_openldap_tls_dir }}/cert.pem"
register: serveur_openldap_cert_pont
when: serveur_openldap_tls_actif | bool
- name: TLS — avertir si le certificat n'est pas encore disponible
ansible.builtin.debug:
msg: >-
client_pki n'a pas encore depose de certificat sur ce noeud
({{ serveur_openldap_tls_source_cert }}). TLS slapd NON configure : deploie
l'integration client_pki sur ce noeud, puis relance le deploiement.
when:
- serveur_openldap_tls_actif | bool
- not ansible_check_mode
- not (serveur_openldap_cert_pont.stat.exists | default(false))
- name: TLS — configurer slapd (olcTLS* dans cn=config)
community.general.ldap_attrs:
dn: cn=config
server_uri: ldapi:///
attributes:
olcTLSCACertificateFile: "{{ serveur_openldap_tls_dir }}/ca.pem"
olcTLSCertificateFile: "{{ serveur_openldap_tls_dir }}/cert.pem"
olcTLSCertificateKeyFile: "{{ serveur_openldap_tls_dir }}/key.pem"
state: exact
when:
- serveur_openldap_tls_actif | bool
- not ansible_check_mode
- serveur_openldap_cert_pont.stat.exists | default(false)
notify: Redemarrer slapd
- name: TLS — exposer LDAPS (SLAPD_SERVICES)
ansible.builtin.lineinfile:
path: /etc/default/slapd
regexp: '^SLAPD_SERVICES='
line: 'SLAPD_SERVICES="{{ serveur_openldap_services }}"'
when:
- serveur_openldap_tls_actif | bool
- serveur_openldap_cert_pont.stat.exists | default(false)
notify: Redemarrer slapd
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
- name: Assurer le repertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
- name: Deposer la sonde « annuaire »
ansible.builtin.template:
src: sonde-annuaire.sh.j2
dest: /usr/local/lib/setops/sondes/annuaire.sh
owner: root
group: root
mode: "0750"