Set-OPS-Public/roles/serveur_keycloak/tasks/federation-ldap.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

117 lines
6.7 KiB
YAML

---
# Fédération LDAP via kcadm — idempotent. Crée le realm applicatif + le provider de
# stockage LDAP (OpenLDAP, READ_ONLY). Éprouvé avant codification (testmail se connecte).
- name: Attendre que Keycloak réponde
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 120
when: not ansible_check_mode
- name: Configurer le realm et la fédération LDAP (kcadm, idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -euo pipefail
KC={{ serveur_keycloak_home }}/bin/kcadm.sh
CHANGED=0
"$KC" config credentials --server http://localhost:8080 --realm master \
--user {{ serveur_keycloak_admin_user }} --password "$KC_ADMIN_PW" >/dev/null
if ! "$KC" get realms/{{ serveur_keycloak_realm }} >/dev/null 2>&1; then
"$KC" create realms -s realm={{ serveur_keycloak_realm }} -s enabled=true >/dev/null
CHANGED=1
fi
RID=$("$KC" get realms/{{ serveur_keycloak_realm }} --fields id 2>/dev/null \
| grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
# Le provider est cree S'IL MANQUE, et son URL de connexion est RECONCILIEE
# s'il existe. Creer sans jamais corriger laissait une valeur perimee vivre
# indefiniment : le 2026-08-07, le provider pointait encore
# `ldaps://id-ldap-01...` — le nom d'hote errone corrige le matin meme dans
# `resoudre_annuaire`. La federation etait muette, et la synchronisation des
# groupes echouait sur `UnknownHost`.
#
# C'est de l'INFRASTRUCTURE : elle se reconcilie. Seules les APPARTENANCES
# ne le sont pas (D-67), et elles ne vivent pas ici.
FEDID=$("$KC" get components -r {{ serveur_keycloak_realm }} \
-q type=org.keycloak.storage.UserStorageProvider \
--fields id,name --format csv --noquotes 2>/dev/null \
| awk -F, '$2 == "{{ serveur_keycloak_ldap_nom }}" {print $1}' | head -1)
if [ -n "$FEDID" ]; then
URL_ACTUELLE=$("$KC" get components/"$FEDID" -r {{ serveur_keycloak_realm }} 2>/dev/null \
| grep -o '"connectionUrl" : \[ "[^"]*"' | grep -o 'ldaps\?://[^"]*' | head -1)
# `|| true` en fin de chaine : avec `pipefail`, un grep sans correspondance
# tuerait le script entier. Et l'extraction ne s'ancre pas sur `$` — la ligne
# se termine par un guillemet, pas par la valeur.
# Le mot de passe de LIAISON ne se compare pas : Keycloak le masque (`****`).
# On memorise donc son empreinte, comme pour les comptes de secours. Sans
# cela, faire tourner `vault_openldap_admin` couperait Keycloak de
# l'annuaire — la federation garderait l'ancienne valeur, et plus personne
# ne pourrait se connecter.
EMPREINTE_VOULUE="{{ serveur_keycloak_ldap_bind_password | hash('sha256') }}"
EMPREINTE_POSEE=$(cat {{ serveur_keycloak_marqueur_bind }} 2>/dev/null || true)
if [ "$EMPREINTE_POSEE" != "$EMPREINTE_VOULUE" ]; then
"$KC" update components/"$FEDID" -r {{ serveur_keycloak_realm }} \
-s "config.bindCredential=[\"$LDAP_BIND_PW\"]" >/dev/null
printf '%s\n' "$EMPREINTE_VOULUE" > {{ serveur_keycloak_marqueur_bind }}
chmod 600 {{ serveur_keycloak_marqueur_bind }}
CHANGED=1
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 }}"]' \
-s 'config.usersDn=["{{ serveur_keycloak_ldap_users_dn }}"]' \
-s 'config.bindDn=["{{ serveur_keycloak_ldap_bind_dn }}"]' \
-s 'config.editMode=["{{ serveur_keycloak_ldap_edit_mode }}"]' >/dev/null
CHANGED=1
fi
fi
if [ -z "$FEDID" ]; then
"$KC" create components -r {{ serveur_keycloak_realm }} \
-s name={{ serveur_keycloak_ldap_nom }} -s providerId=ldap \
-s providerType=org.keycloak.storage.UserStorageProvider -s parentId="$RID" \
-s 'config.enabled=["true"]' \
-s 'config.vendor=["other"]' \
-s 'config.editMode=["{{ serveur_keycloak_ldap_edit_mode }}"]' \
-s 'config.importEnabled=["true"]' \
-s 'config.connectionUrl=["{{ serveur_keycloak_ldap_url }}"]' \
-s 'config.usersDn=["{{ serveur_keycloak_ldap_users_dn }}"]' \
-s 'config.bindDn=["{{ serveur_keycloak_ldap_bind_dn }}"]' \
-s "config.bindCredential=[\"$LDAP_BIND_PW\"]" \
-s 'config.authType=["simple"]' \
-s 'config.searchScope=["2"]' \
-s 'config.usernameLDAPAttribute=["{{ serveur_keycloak_ldap_username_attr }}"]' \
-s 'config.rdnLDAPAttribute=["{{ serveur_keycloak_ldap_username_attr }}"]' \
-s 'config.uuidLDAPAttribute=["{{ serveur_keycloak_ldap_uuid_attr }}"]' \
-s 'config.userObjectClasses=["{{ serveur_keycloak_ldap_object_classes }}"]' \
-s 'config.useTruststoreSpi=["always"]' \
-s 'config.connectionPooling=["true"]' >/dev/null
CHANGED=1
fi
[ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK
environment:
KC_ADMIN_PW: "{{ serveur_keycloak_admin_password }}"
LDAP_BIND_PW: "{{ serveur_keycloak_ldap_bind_password }}"
register: serveur_keycloak_fed
changed_when: "'SETOPS_CHANGED' in serveur_keycloak_fed.stdout"
no_log: true
retries: 5
delay: 6
until: serveur_keycloak_fed.rc == 0
when: not ansible_check_mode