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 ## 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 ### « 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** 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 | | Ce que tu veux faire | URL | Compte |
|---|---|---| |---|---|---|
| **ton compte, tes accès** | `https://auth.<domaine>/realms/<realm>/account/` | `sysadmin` + jeton d'amorçage | | **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 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 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. 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 **Administrer ton realm passe désormais par le groupe** : `realm-admin` (rôle du client
groupe (`meta/acces.yml` de `serveur_keycloak` le signale, `porte_par: aucun`). `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 ### 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 # Constate le 2026-08-07 : READ_ONLY etait code en dur, et la premiere reprise du
# sysadmin butait dessus. # sysadmin butait dessus.
serveur_keycloak_ldap_edit_mode: "WRITABLE" 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: acces:
- groupe: sysadmin - groupe: sysadmin
accorde: realm-admin accorde: realm-admin
# /!\ Non cable : la console d'administration de Keycloak reste accessible au # Role de CLIENT (`realm-management`), attache au GROUPE. Portee : ce realm
# seul compte local `admin` (voute). C'est l'acces de secours de D-40 — mais il # seulement, jamais `master` — le compte de secours reste hors de portee du
# devrait AUSSI etre atteignable par le groupe, sans quoi administrer le realm # groupe, ce qui est le sens meme d'un acces de secours (D-40).
# oblige a passer par le compte de secours. porte_par: role-client
porte_par: aucun
raison: >- 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 echo "SETOPS_ERREUR: groupe '{{ lien.groupe }}' absent du realm (synchronise ?)" >&2
exit 4 exit 4
fi fi
{% for role in lien.roles %} {% for role in lien.roles | default([]) %}
if ! "$KC" get "groups/$GID/role-mappings/realm" -r {{ serveur_keycloak_realm }} \ if ! "$KC" get "groups/$GID/role-mappings/realm" -r {{ serveur_keycloak_realm }} \
--fields name --format csv --noquotes 2>/dev/null | grep -q '^{{ role }}$'; then --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 "$KC" add-roles -r {{ serveur_keycloak_realm }} --gid "$GID" --rolename {{ role }} >/dev/null
CHANGED=1 CHANGED=1
fi fi
{% endfor %} {% 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 %} {% endfor %}
[ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK [ "$CHANGED" = 1 ] && echo SETOPS_CHANGED || echo SETOPS_OK
environment: environment:
@ -157,3 +180,68 @@
when: when:
- serveur_keycloak_groupes_mapper_clients | length > 0 - serveur_keycloak_groupes_mapper_clients | length > 0
- not ansible_check_mode - 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