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:
parent
71ed179b0c
commit
1b16e67cdc
5 changed files with 145 additions and 10 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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') }}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue