From 74a2456f091d9952ed8527cbeca459f6606f8b1d Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 7 Aug 2026 14:32:47 -0400 Subject: [PATCH] =?UTF-8?q?keycloak=20:=20group-ldap-mapper=20=E2=80=94=20?= =?UTF-8?q?le=20role=20s'attache=20au=20GROUPE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 33 +++++ roles/serveur_keycloak/defaults/main.yml | 21 ++++ roles/serveur_keycloak/meta/acces.yml | 25 ++-- .../tasks/federation-ldap.yml | 27 +++- roles/serveur_keycloak/tasks/groupes-ldap.yml | 119 ++++++++++++++++++ roles/serveur_keycloak/tasks/main.yml | 6 + 6 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 roles/serveur_keycloak/tasks/groupes-ldap.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c976e3..8a76612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/roles/serveur_keycloak/defaults/main.yml b/roles/serveur_keycloak/defaults/main.yml index cd5f8ad..a6678d1 100644 --- a/roles/serveur_keycloak/defaults/main.yml +++ b/roles/serveur_keycloak/defaults/main.yml @@ -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: [] diff --git a/roles/serveur_keycloak/meta/acces.yml b/roles/serveur_keycloak/meta/acces.yml index 1e4a503..3362465 100644 --- a/roles/serveur_keycloak/meta/acces.yml +++ b/roles/serveur_keycloak/meta/acces.yml @@ -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. diff --git a/roles/serveur_keycloak/tasks/federation-ldap.yml b/roles/serveur_keycloak/tasks/federation-ldap.yml index 2fe417b..f35360e 100644 --- a/roles/serveur_keycloak/tasks/federation-ldap.yml +++ b/roles/serveur_keycloak/tasks/federation-ldap.yml @@ -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" \ diff --git a/roles/serveur_keycloak/tasks/groupes-ldap.yml b/roles/serveur_keycloak/tasks/groupes-ldap.yml new file mode 100644 index 0000000..85eec1b --- /dev/null +++ b/roles/serveur_keycloak/tasks/groupes-ldap.yml @@ -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 diff --git a/roles/serveur_keycloak/tasks/main.yml b/roles/serveur_keycloak/tasks/main.yml index ebb2670..e9f65e3 100644 --- a/roles/serveur_keycloak/tasks/main.yml +++ b/roles/serveur_keycloak/tasks/main.yml @@ -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