keycloak : group-ldap-mapper — le role s'attache au GROUPE

La chaine est complete : LDAP cn=sysadmin -> groupe de realm -> role
grafana-admin -> Admin dans Grafana. Ajouter quelqu'un au groupe dans l'annuaire
lui ouvre Grafana sans deploiement et sans que personne ne soit nomme (D-66).
Rejoue : changed=0.

Defaut de fond trouve en chemin : la federation LDAP n'etait JAMAIS reconciliee.
`federation-ldap.yml` creait le provider s'il manquait puis ne le corrigeait
plus — il pointait encore `ldaps://id-ldap-01...`, le nom errone corrige le matin
meme dans `resoudre_annuaire`. La synchronisation echouait sur `UnknownHost`.

C'est le revers de D-67 au mauvais endroit : l'INFRASTRUCTURE se reconcilie,
seules les appartenances ne le sont pas. URL, usersDn et bindDn sont desormais
corriges a chaque passage.

Et j'avais masque l'echec avec `|| true` : le mapper existait, le realm restait
vide, rien ne disait pourquoi. Retire — l'erreur remonte avec son message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-07 14:32:47 -04:00
parent 6f2acafdb6
commit 74a2456f09
6 changed files with 216 additions and 15 deletions

View file

@ -2,6 +2,39 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### La chaîne d'habilitation est complète
`group-ldap-mapper` construit, et le rôle est **attaché au groupe** — pas à une personne.
```
LDAP cn=sysadmin,ou=groups member: uid=sysadmin
Keycloak groupe `sysadmin` projeté, membre `sysadmin`
rôle de realm `grafana-admin` attaché AU GROUPE
Grafana claim roles → oidc_role_path → Admin
```
Ajouter quelqu'un à `cn=sysadmin` dans l'annuaire lui ouvre Grafana en Admin : **sans toucher
au dépôt, sans déploiement, sans nommer personne**. C'est D-66 réalisé, et c'est ce qui rend
la reprise par le sysadmin effective. Rejoué : `changed=0`.
`serveur_keycloak_role_assignments` reste vide, et son commentaire dit maintenant que c'est
définitif.
**Un défaut de fond découvert en chemin : la fédération n'était jamais réconciliée.**
`federation-ldap.yml` créait le provider s'il manquait, puis ne le corrigeait plus jamais. Le
provider pointait donc encore `ldaps://id-ldap-01.chezlepro.internal` — le nom d'hôte erroné
corrigé le matin même dans `resoudre_annuaire`. La fédération était muette, et la
synchronisation des groupes échouait sur un laconique `UnknownHost`.
C'est le revers de D-67 appliqué au mauvais endroit : **l'infrastructure se réconcilie**,
seules les appartenances ne le sont pas. L'URL, le DN des utilisateurs et le DN de liaison
sont désormais corrigés à chaque passage.
**Et j'avais masqué l'échec.** La synchronisation portait `|| true` : le mapper existait, le
realm restait vide, et rien ne disait pourquoi. Ça m'a coûté la moitié du diagnostic. Le `||
true` est retiré, l'erreur remonte avec son message.
### Les cinq `meta/acces.yml` — et ce qu'ils rendent visible
Chaque rôle web déclare désormais le **groupe** qu'il reconnaît et ce qu'il lui accorde. Un

View file

@ -68,3 +68,24 @@ serveur_keycloak_role_assignments: []
# chiffre + verifie le cert serveur contre le root_ca step-ca (l'hote doit etre dans le SAN).
serveur_keycloak_db_sslmode: ""
serveur_keycloak_db_sslrootcert: "/etc/step/certs/root_ca.crt"
# --- Projection des groupes LDAP (D-65) ---------------------------------------
# LDAP porte les habilitations ; Keycloak les traduit en groupes de realm, puis en
# roles que les services lisent dans le jeton. Sans ce mapper, la chaine s'arrete
# avant le premier service et l'assignation se fait utilisateur par utilisateur —
# ce que D-66 interdit.
serveur_keycloak_groupes_ldap: true
serveur_keycloak_groupes_mapper_nom: "groupes-ldap"
# DERIVE de l'annuaire (resoudre_annuaire), jamais ecrit.
serveur_keycloak_groupes_dn: "ou=groups,{{ resoudre_annuaire_base_dn | default('') }}"
# Le schema REEL de l'annuaire Set-OPS : `serveur_openldap` cree `ou=groups`, et
# `amorcage_acces` y pose des `groupOfNames` dont les membres sont des DN.
serveur_keycloak_groupes_object_class: "groupOfNames"
serveur_keycloak_groupes_membership_attr: "member"
# Quel groupe LDAP porte quel role de realm. C'est ce qui REMPLACE l'assignation
# nominative : ajouter quelqu'un au groupe suffit, aucun deploiement n'est requis.
# Les roles doivent exister (`serveur_keycloak_realm_roles`).
serveur_keycloak_groupes_roles: []

View file

@ -5,26 +5,25 @@
#
# `projette` decrit la traduction attendue ; `porte_par` dit ce qui la realise.
acces_projection:
# /!\ LA PROJECTION N'EXISTE PAS. `serveur_keycloak_role_assignments` assigne
# un role a un UTILISATEUR, nommement — ce que D-66 interdit. Le commentaire du
# role l'admettait deja : « En prod, preferer l'assignation via groupe
# d'annuaire ; ici, explicite pour la preuve. »
#
# Sans mapper `group-ldap-mapper` + politique role<-groupe, la chaine s'arrete
# ici : les groupes LDAP n'atteignent jamais les services. Constate le
# 2026-08-07, au moment d'ecrire les meta/acces.yml des cinq roles web.
# `group-ldap-mapper` importe les groupes de l'annuaire dans le realm, et le
# ROLE EST ATTACHE AU GROUPE — pas a une personne (D-66). Ajouter quelqu'un a
# `cn=sysadmin,ou=groups` lui ouvre Grafana en Admin, sans deploiement.
# `serveur_keycloak_groupes_roles` declare la correspondance.
- groupe: sysadmin
projette: [grafana-admin]
porte_par: aucun
porte_par: group-ldap-mapper
raison: >-
Le groupe d'administration doit devenir le role de realm que Grafana lit
dans le claim `roles`.
Le groupe d'administration devient le role de realm que Grafana lit dans le
claim `roles`.
# Ce que Keycloak accorde SUR LUI-MEME (sa propre console d'administration).
acces:
- groupe: sysadmin
accorde: realm-admin
# /!\ Non cable : la console d'administration de Keycloak reste accessible au
# seul compte local `admin` (voute). C'est l'acces de secours de D-40 — mais il
# devrait AUSSI etre atteignable par le groupe, sans quoi administrer le realm
# oblige a passer par le compte de secours.
porte_par: aucun
raison: >-
Administration du realm : clients, mappers, politiques. Aujourd'hui seul le
compte local `admin` (voute) l'obtient.
Administration du realm : clients, mappers, politiques.

View file

@ -23,8 +23,31 @@
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)
if ! "$KC" get components -r {{ serveur_keycloak_realm }} -q name={{ serveur_keycloak_ldap_nom }} 2>/dev/null \
| grep -q '"{{ serveur_keycloak_ldap_nom }}"'; then
# 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)
if [ "$URL_ACTUELLE" != "{{ serveur_keycloak_ldap_url }}" ]; 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 }}"]' >/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" \

View file

@ -0,0 +1,119 @@
---
# PROJECTION DES GROUPES LDAP (D-65). LDAP porte les habilitations ; Keycloak les
# traduit en groupes de realm, puis en roles que les services lisent dans le jeton.
#
# Sans ce mapper, la chaine s'arrete avant le premier service : les groupes LDAP
# n'atteignent jamais Grafana, Forgejo ni Nextcloud, et l'assignation se fait
# utilisateur par utilisateur — ce que D-66 interdit. Constate le 2026-08-07.
- name: Projeter les groupes LDAP dans le realm (group-ldap-mapper)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -euo pipefail
KC={{ serveur_keycloak_home }}/bin/kcadm.sh
"$KC" config credentials --server http://localhost:8080 --realm master \
--user {{ serveur_keycloak_admin_user }} --password "$KC_ADMIN_PW" >/dev/null
CHANGED=0
# Le provider de federation, retrouve par son NOM : son identifiant est
# genere a la creation, on ne peut pas le supposer.
FID=$("$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 [ -z "$FID" ]; then
echo "SETOPS_ERREUR: provider LDAP '{{ serveur_keycloak_ldap_nom }}' introuvable" >&2
exit 3
fi
EXISTE=$("$KC" get components -r {{ serveur_keycloak_realm }} \
-q parent="$FID" -q type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper \
--fields name --format csv --noquotes 2>/dev/null \
| grep -c '^{{ serveur_keycloak_groupes_mapper_nom }}$' || true)
if [ "$EXISTE" = 0 ]; then
"$KC" create components -r {{ serveur_keycloak_realm }} \
-s name={{ serveur_keycloak_groupes_mapper_nom }} \
-s providerId=group-ldap-mapper \
-s providerType=org.keycloak.storage.ldap.mappers.LDAPStorageMapper \
-s parentId="$FID" \
-s 'config."groups.dn"=["{{ serveur_keycloak_groupes_dn }}"]' \
-s 'config."group.name.ldap.attribute"=["cn"]' \
-s 'config."group.object.classes"=["{{ serveur_keycloak_groupes_object_class }}"]' \
-s 'config."membership.ldap.attribute"=["{{ serveur_keycloak_groupes_membership_attr }}"]' \
-s 'config."membership.attribute.type"=["DN"]' \
-s 'config."membership.user.ldap.attribute"=["{{ serveur_keycloak_ldap_username_attr }}"]' \
-s 'config."mode"=["READ_ONLY"]' \
-s 'config."user.roles.retrieve.strategy"=["LOAD_GROUPS_BY_MEMBER_ATTRIBUTE"]' \
-s 'config."preserve.group.inheritance"=["false"]' \
-s 'config."drop.non.existing.groups.during.sync"=["false"]' >/dev/null
CHANGED=1
fi
# La synchronisation IMPORTE les groupes : sans elle, le mapper existe mais
# le realm reste vide jusqu'a la premiere connexion d'un membre.
MID=$("$KC" get components -r {{ serveur_keycloak_realm }} -q parent="$FID" \
--fields id,name --format csv --noquotes 2>/dev/null \
| awk -F, '$2 == "{{ serveur_keycloak_groupes_mapper_nom }}" {print $1}' | head -1)
# PAS de `|| true` ici : un echec de synchronisation doit se voir. Masque, il
# m'a coute la moitie du diagnostic — le mapper existait, le realm restait
# vide, et rien ne disait pourquoi (2026-08-07).
if [ -n "$MID" ]; then
if ! "$KC" create "user-storage/$FID/mappers/$MID/sync?direction=fedToKeycloak" \
-r {{ serveur_keycloak_realm }} 2>/tmp/setops-sync.err >/dev/null; then
echo "SETOPS_ERREUR: synchronisation des groupes echouee : $(cat /tmp/setops-sync.err)" >&2
rm -f /tmp/setops-sync.err
exit 5
fi
rm -f /tmp/setops-sync.err
fi
[ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK
environment:
KC_ADMIN_PW: "{{ serveur_keycloak_admin_password }}"
register: serveur_keycloak_grp
changed_when: "'SETOPS_CHANGED' in serveur_keycloak_grp.stdout"
no_log: true
retries: 5
delay: 6
until: serveur_keycloak_grp.rc == 0
when: not ansible_check_mode
# LE GROUPE PORTE LE ROLE, et c'est ce qui remplace l'assignation nominative.
# Un membre du groupe LDAP herite du role de realm sans que personne ne le nomme :
# ajouter quelqu'un a `cn=sysadmin` dans LDAP suffit (D-66).
- name: Attacher les rôles de realm aux groupes projetés
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -euo pipefail
KC={{ serveur_keycloak_home }}/bin/kcadm.sh
"$KC" config credentials --server http://localhost:8080 --realm master \
--user {{ serveur_keycloak_admin_user }} --password "$KC_ADMIN_PW" >/dev/null
CHANGED=0
{% for lien in serveur_keycloak_groupes_roles %}
GID=$("$KC" get groups -r {{ serveur_keycloak_realm }} \
--fields id,name --format csv --noquotes 2>/dev/null \
| awk -F, '$2 == "{{ lien.groupe }}" {print $1}' | head -1)
if [ -z "$GID" ]; then
echo "SETOPS_ERREUR: groupe '{{ lien.groupe }}' absent du realm (synchronise ?)" >&2
exit 4
fi
{% for role in lien.roles %}
if ! "$KC" get "groups/$GID/role-mappings/realm" -r {{ serveur_keycloak_realm }} \
--fields name --format csv --noquotes 2>/dev/null | grep -q '^{{ role }}$'; then
"$KC" add-roles -r {{ serveur_keycloak_realm }} --gid "$GID" --rolename {{ role }} >/dev/null
CHANGED=1
fi
{% endfor %}
{% endfor %}
[ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK
environment:
KC_ADMIN_PW: "{{ serveur_keycloak_admin_password }}"
register: serveur_keycloak_grp_roles
changed_when: "'SETOPS_CHANGED' in serveur_keycloak_grp_roles.stdout"
no_log: true
when:
- serveur_keycloak_groupes_roles | length > 0
- not ansible_check_mode

View file

@ -163,6 +163,12 @@
ansible.builtin.include_tasks: federation-ldap.yml
when: serveur_keycloak_ldap_federation | bool
- name: Projeter les groupes LDAP (mapper + roles de groupe)
ansible.builtin.include_tasks: groupes-ldap.yml
when:
- serveur_keycloak_ldap_federation | bool
- serveur_keycloak_groupes_ldap | bool
- name: Enregistrer les clients OIDC applicatifs
ansible.builtin.include_tasks: clients-oidc.yml
when: serveur_keycloak_clients | length > 0