keycloak : realm-admin par appartenance, et la boucle de mot de passe

`realm-admin` (role du client `realm-management`) est attache au GROUPE sysadmin.
Administrer le realm ne passe plus par le compte local. Portee : CE realm, jamais
`master` — le compte de secours reste hors d'atteinte du groupe, et c'est le sens
meme d'un acces de secours (D-40).

Les roles de CLIENT sont un espace de noms distinct : la declaration gagne
`roles_client`. L'API attend l'UUID du client, pas son clientId — interroger par
le nom rendait une erreur, la verification echouait toujours et la tache se
declarait `changed` a chaque passage. Deux passages consecutifs a changed=0.

La boucle de changement de mot de passe : `pwdMustChange: TRUE` signifie « quand
un ADMINISTRATEUR pose un mot de passe, l'utilisateur doit le changer ». Keycloak
ecrit en tant qu'administrateur — chaque changement relaye etait vu comme une
reinitialisation. Incompatible par construction avec un IdP qui relaie.

La contrainte est deplacee la ou l'utilisateur la voit : pwdMustChange FALSE cote
annuaire, action `UPDATE_PASSWORD` posee par Keycloak. J'avais eprouve pwdReset
au niveau LDAP, ou il marche, sans parcourir le chemin complet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-07 19:47:10 -04:00
parent 71ed179b0c
commit 1b16e67cdc
5 changed files with 145 additions and 10 deletions

View file

@ -2,6 +2,44 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### Administrer le realm par appartenance — le dernier `porte_par: aucun`
`realm-admin` (rôle du client `realm-management`) est attaché au **groupe** `sysadmin`.
Administrer le realm ne passe plus par le compte local : il suffit d'appartenir au groupe dans
l'annuaire.
**Portée : ce realm seulement, jamais `master`.** Le compte `admin` reste hors d'atteinte du
groupe, et c'est délibéré — un accès de secours qui dépendrait des habilitations qu'il doit
pouvoir réparer n'en serait pas un (D-40).
La console à utiliser est celle du realm — `/admin/<realm>/console/` — pas la racine `/admin/`,
qui est celle de `master`. Le runbook nomme désormais les **trois** portes et dit ce que
chacune gouverne.
**Les rôles de client sont un espace de noms distinct** des rôles de realm ; la déclaration
gagne un champ `roles_client`. Et l'API attend l'**UUID** du client, pas son `clientId` :
interroger par le nom rendait une erreur, la vérification échouait toujours, et la tâche se
déclarait `changed` à chaque passage alors que le rôle était déjà posé. Corrigé — deux passages
consécutifs à `changed=0`.
### La boucle de changement de mot de passe
Après `editMode=WRITABLE`, le changement partait mais **rebouclait sans fin**. Cause :
`pwdMustChange: TRUE` signifie « quand un *administrateur* pose un mot de passe, l'utilisateur
doit le changer ». Or Keycloak écrit en tant qu'administrateur (`cn=admin`) — chaque changement
relayé était donc vu comme une réinitialisation, et OpenLDAP reposait `pwdReset` aussitôt.
**C'est incompatible par construction avec un IdP qui relaie le changement.** La contrainte a
été déplacée là où l'utilisateur la voit : `pwdMustChange: FALSE` côté annuaire, et Keycloak
pose l'action requise `UPDATE_PASSWORD` tant que `pwdReset` est vrai — un écran qui explique,
au lieu d'un refus muet au niveau du protocole.
Je ne l'avais pas vu parce que j'avais éprouvé `pwdReset` **au niveau LDAP**, où il fonctionne
parfaitement, sans jamais parcourir le chemin complet à travers Keycloak. L'opérateur l'a dit
avant moi : « ce n'est pas du tout explicite » — c'était le symptôme de deux mécanismes qui ne
se parlent pas.
### « Federated storage is not writable » — la fédération était en lecture seule
Le changement de mot de passe imposé échouait : `editMode=READ_ONLY` était **codé en dur**

View file

@ -122,14 +122,20 @@ est exact et parfaitement trompeur — le mot de passe est bon, c'est la porte q
| Ce que tu veux faire | URL | Compte |
|---|---|---|
| **ton compte, tes accès** | `https://auth.<domaine>/realms/<realm>/account/` | `sysadmin` + jeton d'amorçage |
| administrer Keycloak lui-même | `https://auth.<domaine>/admin/` | `admin` + `vault_keycloak_admin` |
| **administrer ton realm** | `https://auth.<domaine>/admin/<realm>/console/` | `sysadmin` — par le groupe |
| Keycloak lui-même (secours) | `https://auth.<domaine>/admin/` | `admin` + `vault_keycloak_admin` |
La première ligne est celle de la reprise. C'est là que le changement de mot de passe te sera
imposé, et c'est cette connexion qui **importe ton groupe dans le realm** — sans elle, les
habilitations sur Grafana, Forgejo et Nextcloud restent câblées mais jamais exercées.
La seconde reste le compte local de secours : administrer le realm ne passe pas encore par le
groupe (`meta/acces.yml` de `serveur_keycloak` le signale, `porte_par: aucun`).
**Administrer ton realm passe désormais par le groupe** : `realm-admin` (rôle du client
`realm-management`) est attaché à `sysadmin`. La console est celle du realm —
`/admin/<realm>/console/` — pas la racine `/admin/`, qui est celle de `master`.
La dernière ligne reste le **compte de secours** (D-40). Sa portée est `master`, hors d'atteinte
du groupe : c'est voulu. Un accès de secours qui dépendrait des habilitations qu'il doit pouvoir
réparer ne serait pas un accès de secours.
### 6.3 Faire confiance à l'AC interne

View file

@ -112,3 +112,7 @@ serveur_keycloak_groupes_mapper_clients: []
# Constate le 2026-08-07 : READ_ONLY etait code en dur, et la premiere reprise du
# sysadmin butait dessus.
serveur_keycloak_ldap_edit_mode: "WRITABLE"
# Compte d'amorcage dont le changement de mot de passe doit etre EXPLICITE dans
# Keycloak. Vide = aucun alignement. Voir roles/amorcage_acces (D-67).
serveur_keycloak_amorcage_uid: "{{ amorcage_acces_uid | default('sysadmin') }}"

View file

@ -20,10 +20,9 @@ acces_projection:
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
# Role de CLIENT (`realm-management`), attache au GROUPE. Portee : ce realm
# seulement, jamais `master` — le compte de secours reste hors de portee du
# groupe, ce qui est le sens meme d'un acces de secours (D-40).
porte_par: role-client
raison: >-
Administration du realm : clients, mappers, politiques.
Administration du realm : clients, mappers, politiques, utilisateurs.

View file

@ -100,13 +100,36 @@
echo "SETOPS_ERREUR: groupe '{{ lien.groupe }}' absent du realm (synchronise ?)" >&2
exit 4
fi
{% for role in lien.roles %}
{% for role in lien.roles | default([]) %}
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 %}
{# Roles de CLIENT — `realm-admin` du client `realm-management` en est un. Ils
vivent dans un espace de noms distinct des roles de realm : `add-roles` les
distingue par `--cclientid`. Sans ca, administrer le realm resterait le
privilege du seul compte local de secours. #}
{% for cl in lien.roles_client | default([]) %}
{% for role in cl.roles %}
# L'API attend l'UUID du client, PAS son clientId : interroger par le nom rend
# une erreur, la verification echoue toujours, et la tache se declare `changed`
# a chaque passage alors que le role est deja la. Constate le 2026-08-07.
CCID=$("$KC" get clients -r {{ serveur_keycloak_realm }} -q clientId={{ cl.client }} \
--fields id --format csv --noquotes 2>/dev/null | head -1 || true)
if [ -z "$CCID" ]; then
echo "SETOPS_ERREUR: client '{{ cl.client }}' introuvable dans le realm" >&2; exit 6
fi
if ! "$KC" get "groups/$GID/role-mappings/clients/$CCID" \
-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" \
--cclientid {{ cl.client }} --rolename {{ role }} >/dev/null
CHANGED=1
fi
{% endfor %}
{% endfor %}
{% endfor %}
[ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK
environment:
@ -157,3 +180,68 @@
when:
- serveur_keycloak_groupes_mapper_clients | length > 0
- not ansible_check_mode
# --- Rendre le changement de mot de passe EXPLICITE ---------------------------
# OpenLDAP marque `pwdReset` et refuse les operations au niveau du PROTOCOLE ;
# Keycloak a son propre systeme d'actions requises et NE LIT PAS `pwdReset`. Les
# deux mecanismes ne se parlent pas : l'utilisateur entre avec son jeton, atterrit
# dans la console de compte, et rien ne lui dit pourquoi ni quoi faire. Constate le
# 2026-08-07 par l'operateur : « ce n'est pas du tout explicite ».
#
# On aligne donc Keycloak sur l'annuaire : tant que `pwdReset` est vrai, l'action
# `UPDATE_PASSWORD` est posee et Keycloak affiche un ecran qui l'explique. Elle
# disparait d'elle-meme quand le mot de passe change — l'annuaire retire `pwdReset`,
# et la condition cesse d'etre vraie.
- name: Lire `pwdReset` sur le compte d'amorçage (délégué à l'annuaire)
ansible.builtin.command:
argv:
- ldapsearch
- "-x"
- "-H"
- "ldapi:///"
- "-D"
- "{{ resoudre_annuaire_bind_dn }}"
- "-w"
- "{{ resoudre_annuaire_bind_password }}"
- "-b"
- "uid={{ serveur_keycloak_amorcage_uid }},{{ resoudre_annuaire_users_dn }}"
- "+"
delegate_to: "{{ groups['serveur_openldap'][0] }}"
register: serveur_keycloak_pwdreset
changed_when: false
failed_when: false
no_log: true
when:
- serveur_keycloak_amorcage_uid | length > 0
- (groups['serveur_openldap'] | default([])) | length > 0
- not ansible_check_mode
- name: Exiger le changement de mot de passe dans Keycloak aussi
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
UID_KC=$("$KC" get users -r {{ serveur_keycloak_realm }} \
-q username={{ serveur_keycloak_amorcage_uid }} \
--fields id --format csv --noquotes 2>/dev/null | head -1 || true)
if [ -z "$UID_KC" ]; then echo "SETOPS_OK (pas encore importe)"; exit 0; fi
if "$KC" get users/"$UID_KC" -r {{ serveur_keycloak_realm }} 2>/dev/null \
| grep -q 'UPDATE_PASSWORD'; then
echo SETOPS_OK
else
"$KC" update users/"$UID_KC" -r {{ serveur_keycloak_realm }} \
-s 'requiredActions=["UPDATE_PASSWORD"]' >/dev/null
echo SETOPS_CHANGED
fi
environment:
KC_ADMIN_PW: "{{ serveur_keycloak_admin_password }}"
register: serveur_keycloak_req_action
changed_when: "'SETOPS_CHANGED' in serveur_keycloak_req_action.stdout"
no_log: true
when:
- serveur_keycloak_amorcage_uid | length > 0
- "'pwdReset: TRUE' in (serveur_keycloak_pwdreset.stdout | default(''))"
- not ansible_check_mode