Ressources
Piloter la configuration Keycloak en GitOps : adoption, dérive et suppression de champs
Piloter Keycloak en GitOps : ce que l'Operator, l'API d'administration et un contrôleur possèdent vraiment. Adoption, dérive, secrets et suppression.
Par Corentin Mas, publié le · 27 min de lecture
Base d'expérience : Conception et maintien d'un opérateur Kubernetes open source pilotant la configuration d'un fournisseur d'identité par son API d'administration, sans exposer d'environnement client.
Relecture factuelle : Corentin Mas, le
Deux situations reviennent lorsque Keycloak est piloté depuis Git, que la configuration passe par le Keycloak Operator, par Terraform ou par keycloak-config-cli. Un contrôleur Kubernetes réconcilie une ressource qui décrit un client OpenID Connect (OIDC), et un réglage posé dans la console d'administration disparaît. Ailleurs, la ligne qui fixait l'URL racine d'un client est retirée du manifeste : elle disparaît du dépôt, la valeur reste dans le serveur. L'origine est commune. Une ressource déclarative posée sur une API d'administration plus ancienne qu'elle ne possède pas l'objet distant. Elle possède au mieux un ensemble de champs, et cet ensemble n'est écrit nulle part tant que personne ne l'écrit. La décision structurante n'est donc pas de savoir comment appeler l'API d'administration Keycloak : c'est de savoir quels champs le manifeste revendique et ce qu'il advient des autres.
Cette lecture est figée sur la branche Keycloak 26.7 et sur les ressources personnalisées de l'opérateur en k8s.keycloak.org/v2beta1 et v2alpha1. Les guides officiels Keycloak et Kubernetes cités, ainsi que les sources du serveur au tag 26.7.0, ont été vérifiés le . Avant de transposer une conclusion, il faut comparer cette référence à l'image du serveur, au chart, aux CustomResourceDefinitions (CRD) et à la version d'opérateur réellement installés. Plusieurs comportements décrits ici relèvent du code du serveur, pas de la référence d'interface REST (Representational State Transfer). Ils peuvent changer d'une version majeure à l'autre sans note de version visible.
Décider ce qu'une ressource déclare et ce qu'elle ignore
Kubernetes possède déjà un modèle de propriété de champ, et il vaut la peine de le regarder avant d'en inventer un autre. Le Server-Side Apply enregistre dans metadata.managedFields quel gestionnaire a posé quelle valeur, refuse par un conflit la modification d'un champ possédé par un autre, et transfère la propriété quand une valeur change effectivement. La documentation officielle est surtout explicite sur le retrait : si un champ disparaît d'un manifeste appliqué, l'API cherche un autre propriétaire ; faute d'en trouver un, le champ est supprimé de l'objet vivant ou remis à sa valeur par défaut. Le retrait est donc exprimable, parce que le serveur sait ce que ce gestionnaire possédait avant.
L'API d'administration v1 de Keycloak n'offre aucun équivalent. Elle ne connaît pas de gestionnaire de champ et ne distingue pas une valeur non gérée d'une valeur qu'on vient de retirer. L'API d'administration des clients v2, livrée en 26.7 derrière la fonctionnalité expérimentale client-admin-api:v2, ne tient pas davantage de registre de propriété ; elle sépare seulement remplacement et modification partielle, comme le détaille le troisième chapitre. Dans les deux cas, la comptabilité de propriété vit dans le contrôleur et dans le statut de la ressource Kubernetes, pas dans le serveur d'identité.
Jusqu'à récemment, l'opérateur officiel tranchait cette difficulté par la réduction maximale du périmètre. Le KeycloakRealmImport accepte dans spec.realm une RealmRepresentation complète, mais sa documentation pose trois limites : un royaume existant du même nom n'est pas écrasé ; la ressource ne prend en charge que la création, sans mise à jour ni suppression ; les modifications faites directement dans Keycloak ne sont pas resynchronisées vers elle. La même page recommande de supprimer la ressource une fois l'import terminé, pour nettoyer le Job et le Pod associés.
Depuis 26.7, l'opérateur livre aussi, en k8s.keycloak.org/v2alpha1, les ressources KeycloakOIDCClient et KeycloakSAMLClient. Elles réconcilient en continu, comparent une empreinte du désiré conservée dans status.hash au lieu de relire le distant, publient l'identifiant interne du client dans status et suppriment le client distant avec la ressource. Elles exigent la fonctionnalité serveur expérimentale client-admin-api:v2, donc une décision d'image, et leur version alpha annonce un contrat non figé. Comme elles comparent l'empreinte du désiré et non l'état distant, une modification faite à la console n'est pas détectée : elle n'est recouverte qu'au prochain changement de la ressource. Et si la ressource Keycloak est supprimée avant elles, ou si la fonctionnalité est désactivée, le client distant reste en place.
Un contrôleur qui veut davantage doit classer chaque champ de la représentation distante dans trois familles. Les champs déclarés sont ceux que le manifeste revendique : réécrits à chaque réconciliation, leur retrait doit produire une action. Les champs observés sont lus et publiés dans status pour le diagnostic, jamais écrits. Les champs non gérés sont abandonnés à la console, à un autre outil ou au défaut du serveur ; ce choix doit être visible dans la CRD plutôt que déduit d'un silence. Une représentation Keycloak expose beaucoup plus de champs qu'un manifeste n'en déclare : laisser la frontière implicite revient à la redécouvrir lors d'un incident.
Keycloak Operator, Terraform ou keycloak-config-cli : qui possède quoi
Les trois outils que l'on rencontre pour piloter Keycloak depuis Git tranchent tous cette question de propriété, mais pas au même endroit. Les comparer sur la même grille évite de choisir un outil pour sa syntaxe, puis de découvrir sa politique de retrait en production.
| Question | Keycloak Operator officiel | Terraform, fournisseur keycloak/keycloak | keycloak-config-cli |
|---|---|---|---|
| Mémoire de propriété | Aucune pour KeycloakRealmImport, limité à la création. Clients v2alpha1 : empreinte du désiré dans status.hash. | L'état Terraform : seules les ressources qu'il contient sont gérées. | Un état distant stocké en attributs du royaume, import.remote-state.enabled, actif par défaut. |
| Objet préexistant | Un royaume existant n'est jamais écrasé par l'import. | terraform import avec l'identifiant interne du client, pas son clientId ; l'argument import = true adopte un client intégré sans jamais le supprimer. | Mis à jour s'il est déclaré dans le fichier ; jamais supprimé tant que l'état distant est actif. |
| Réglage modifié à la console | Non détecté ; recouvert au prochain changement de la ressource. | Visible au plan. Réinitialisé à l'application pour un champ doté d'une valeur par défaut dans le schéma ; conservé pour un champ calculé que la configuration ne déclare pas. | Recouvert à l'exécution suivante si le champ est déclaré ; conservé s'il est omis, seuls les champs non nuls du fichier étant appliqués à un client existant. |
| Retrait d'un élément | Sans objet pour l'import ; un client v2alpha1 est supprimé avec sa ressource. | Retirer la ressource du code supprime l'objet distant, sauf import = true. | import.managed.<type>=full supprime l'élément absent d'un type présent dans le fichier, créé par l'outil si l'état distant est actif ; no-delete le préserve. Défaut full, sauf client-scope et scope-mapping. |
Aucun des trois ne dispense de la grille en trois familles. Terraform et keycloak-config-cli déclarent implicitement tout champ qu'ils renvoient ; l'opérateur officiel ne relit pas l'état distant. Le choix se fait donc sur la politique de retrait et de suppression acceptable pour le royaume visé. Versions lues le : le fournisseur Terraform keycloak/keycloak, repris par le projet Keycloak en décembre 2024, est testé contre Keycloak 26.0 à 26.7 ; keycloak-config-cli 6.5.1 est construit contre Keycloak 26.5, et sa compatibilité avec un serveur 26.7 est à vérifier avant usage.
L'opérateur open source que nous publions sous licence Apache 2.0, ctn-solutions/keycloak-operator, applique cette grille : il enregistre les champs appliqués au passage précédent pour traduire un retrait, expose une politique d'adoption par ressource (CreateOnly par défaut, Adopt, FailIfExists), pose un finalizer sur chaque ressource et laisse un royaume en place à la suppression, sauf deletionPolicy: Delete explicite. Sa matrice d'intégration couvre Keycloak 26.0 à 26.3 : elle ne vaut pas validation sur la branche 26.7 lue ici.
Adopter un objet préexistant sans l'écraser
Le cas normal n'est pas le champ vierge. Le royaume, le client ou le fournisseur d'identité existe déjà, créé à la main, par un KeycloakRealmImport antérieur ou par un outil que personne n'a désinstallé. La première réconciliation doit donc trancher avant d'écrire : ce contrôleur a-t-il le droit de prendre la main sur un objet qu'il n'a pas créé ? L'API d'administration ne répond pas à sa place, puisqu'une représentation lue ne porte aucune trace de son auteur. Seuls les événements d'administration, s'ils sont activés, permettent de la reconstituer.
Keycloak dispose déjà d'un vocabulaire d'adoption, et mieux vaut s'y aligner qu'en forger un. L'import partiel de la console propose trois traitements lorsqu'une ressource importée existe déjà : abandonner l'import, ignorer les doublons sans interrompre le traitement, ou remplacer l'existant par ce qui est importé. La commande kc.sh import expose le même arbitrage par l'option --override, qui vaut true tant qu'on ne la contredit pas : l'écrasement est le comportement implicite. L'import au démarrage par --import-realm prend la position inverse et ignore l'opération si le royaume existe, pour ne pas recréer des royaumes ni perdre de l'état entre deux redémarrages.
Ce dernier point est une contrainte d'exploitation, pas une préférence. La documentation exige l'arrêt de tous les nœuds avant un kc.sh import avec écrasement : la commande ne rejoint pas le cluster de caches, et un royaume écrasé laisse des caches incohérents qui exposent des informations périmées. Elle recommande plutôt de supprimer par l'API d'administration les royaumes à écraser. Une adoption implémentée par réimport n'est donc pas une opération à chaud.
Pour un contrôleur qui écrit par l'API d'administration, la politique d'adoption mérite un champ obligatoire de la CRD, sans valeur par défaut confortable. Trois positions suffisent : refuser et passer en erreur tant qu'un humain n'a pas tranché ; adopter en ne revendiquant que les champs déclarés ; ne créer que si l'objet est absent. L'adoption doit écrire dans status, avant la première écriture, la représentation observée à la prise en main. Sans cet enregistrement, la première réconciliation est indiscernable d'une modification légitime, et personne ne peut reconstituer ce qui a été recouvert.
L'autorisation du compte de service relève de la même discipline. Un jeton porteur de droits d'administration larges rend la politique d'adoption purement déclarative, puisque rien n'empêche techniquement l'écrasement. Keycloak 26.7 expose des permissions d'administration à granularité fine dont la version 1 est marquée dépréciée, la version 2 étant activée par défaut : c'est la version réellement activée sur le serveur visé qui détermine la granularité opposable au compte de service. La traçabilité existe côté serveur, à condition d'être allumée. Les événements d'administration enregistrent alors les actions effectuées via l'API, et l'option Include representation conserve les documents JSON envoyés. La console passe par la même API : c'est le compte authentifié dans l'événement, donc un compte de service dédié au contrôleur, qui distingue une écriture automatique d'une modification faite à la main. L'option de troncature --spi-events-store-jpa-max-field-length, encore citée par le guide d'administration, a été retirée en 23.0.0 avec --spi-events-store-jpa-max-detail-length et reste sans effet sur la branche 26.7 : la représentation est conservée entière, mais le serveur en retire d'abord les valeurs sensibles.
Remplacement complet ou modification partielle : le coût du retrait d'un champ
La question se pose champ par champ, et l'API d'administration ne répond pas uniformément. L'opération PUT /admin/realms/<royaume>/clients/<uuid> est intitulée « Update the client », mais la branche 26.7 n'y effectue pas un remplacement de la représentation. La mise à jour applique les propriétés une par une, en faisant primer la valeur reçue, puis la valeur déjà stockée dans le modèle. Le traitement de l'URL racine donne la forme générale de la règle : la valeur n'est posée que si elle est non nulle. Un champ scalaire absent du corps n'est donc pas effacé, il est conservé.
Une exception coûteuse accompagne cette règle. Si la fonctionnalité d'autorisation est active, la mise à jour relit authorizationServicesEnabled : dès que le corps ne porte pas explicitement la valeur vraie, le serveur désactive les services d'autorisation du client, ce qui supprime son serveur de ressources avec ses ressources, politiques et permissions. Ce champ doit donc être déclaré dès que le client en dépend, sous peine de voir un passage anodin détruire une configuration entière.
La table des attributs étendus, exposée dans attributes, suit la logique de conservation : le serveur écrit les entrées présentes et ne supprime pas les autres. Un attribut posé une fois par la console survit à des réconciliations qui ne le mentionnent jamais. Les listes se comportent autrement. Si protocolMappers figure dans le corps, le serveur met à jour ou crée les mappeurs listés, puis supprime ceux qui n'y figurent pas. Une liste redirectUris reçue remplace de même l'ensemble enregistré, après filtrage des entrées vides.
Une exception inverse guette celui qui croit tout piloter par ce seul appel. En 26.7, defaultClientScopes et optionalClientScopes envoyés dans le corps d'un PUT sont ignorés : la réconciliation d'ensemble des portées n'a lieu qu'à la création du client et à l'enregistrement dynamique. Sur un client existant, seules les sous-ressources default-client-scopes/<id> et optional-client-scopes/<id> ajoutent ou retirent une portée. Un contrôleur qui compte sur la sémantique d'ensemble ne modifiera jamais les portées et croira pourtant les piloter.
La conséquence pratique est nette. Retirer une ligne scalaire du manifeste ne retire rien côté serveur : il faut envoyer la valeur neutre que le champ accepte, chaîne vide ou défaut documenté, et vérifier que le serveur ne la filtre pas comme il filtre les URL de redirection vides. À l'inverse, envoyer une liste par prudence, même identique à l'existant, la fait passer sous la propriété du manifeste et détruit ce qu'un autre outil y avait ajouté. Une seule requête mélange deux sémantiques : un champ omis reste non géré, une liste envoyée fait autorité sur son contenu entier.
C'est l'ambiguïté que les mécanismes Kubernetes résolvent. Le JSON Merge Patch de la RFC 7386, exposé par l'API Kubernetes sous application/merge-patch+json, distingue l'absence d'un membre, sans effet, et un membre positionné à null, qui le retire. Le Server-Side Apply y parvient autrement, par la mémoire des champs possédés. L'API d'administration v1 n'implémente ni l'un ni l'autre. L'API des clients v2 corrige l'asymétrie à son échelle : sous /admin/api/<royaume>/clients/v2/<clientId>, elle expose un PATCH en application/merge-patch+json et un PUT de création ou remplacement. Cette voie reste expérimentale, et c'est v1 que pilotent aujourd'hui la plupart des contrôleurs en production.
Contre l'API v1, le contrôleur doit donc conserver lui-même, dans status, la liste des champs que la dernière réconciliation réussie a écrits. Au passage suivant, un champ présent dans cette liste et absent du manifeste est un retrait à traduire en valeur explicite ; un champ absent des deux n'a jamais été géré et doit rester intact.
Un contrôleur peut malgré tout viser le remplacement complet, c'est-à-dire renvoyer à chaque passage une représentation entière construite depuis le manifeste. Ce choix revendique la totalité de l'objet : tout réglage posé à la console, tout attribut ajouté par un autre outil, tout défaut serveur non recopié est recouvert au premier passage. Il ne se défend que sur un royaume dont aucune autre partie prenante n'écrit, et il ne dispense pas de nommer les valeurs neutres, puisque l'API v1 ne déduit jamais une suppression d'une absence. Le tableau oppose les deux stratégies champ par champ.
| Situation | Stratégie de remplacement complet | Modification partielle, champs déclarés seulement |
|---|---|---|
| Champ scalaire jamais mentionné par le manifeste | Le contrôleur envoie sa propre valeur par défaut : un réglage légitime posé hors bande est recouvert au premier passage. | Omis du corps, donc conservé par le serveur en 26.7 ; sa valeur reste inconnue du contrôleur tant qu'elle n'est pas classée parmi les champs observés. |
| Champ scalaire retiré du manifeste après avoir été géré | Impossible aussi : contre l'API v1, le serveur ne remplace jamais l'ensemble de la représentation, et un champ absent du corps reste à sa valeur stockée. Même un outil exhaustif doit envoyer la valeur neutre. | Impossible sans mémoire : un corps sans le champ est indiscernable d'un champ non géré. Exige la liste des champs écrits au passage précédent, puis une valeur neutre explicite. |
Liste envoyée dans le corps, par exemple protocolMappers | Fait autorité : les éléments absents de la liste sont supprimés par le serveur. | Fait autorité de la même manière dès qu'elle est présente. Une liste ne peut donc pas être « partiellement gérée ». |
Portées de client, defaultClientScopes et optionalClientScopes | Sans effet par cette voie : sur un client existant, la mise à jour ignore ces deux listes en 26.7. | Sans effet également. L'ajout et le retrait passent par les sous-ressources default-client-scopes/<id> et optional-client-scopes/<id>. |
| Attribut étendu posé par la console | Non supprimé pour autant : le serveur écrit les entrées reçues et ne retire pas les autres. | Conservé. Son retrait suppose une écriture explicite de la valeur vide, ou un appel dédié selon la version. |
| Valeur masquée à la relecture : secret d'un fournisseur d'identité, mot de passe SMTP, propriété de composant | Réécrit à chaque passage, puisque la valeur relue revient masquée et ne peut pas être comparée (voir « Faire circuler les secrets dans les deux sens »). | Écrit seulement si le manifeste le déclare. Une référence de coffre, rendue en clair par le serveur, reste comparable en lecture ; une valeur littérale non. |
Faire circuler les secrets dans les deux sens
Un même objet met en jeu deux flux de secrets opposés, et les confondre produit des boucles de réécriture ou des fuites. Le premier descend du serveur vers le cluster : le secret d'un client OIDC confidentiel est produit par Keycloak, POST /admin/realms/<royaume>/clients/<uuid>/client-secret en génère un nouveau et GET sur le même chemin renvoie le secret courant dans une CredentialRepresentation. Le contrôleur publie cette valeur dans un Secret Kubernetes que la charge consommatrice monte. Ce champ ne doit pas figurer dans spec : sinon chaque réconciliation impose la valeur du dépôt et annule toute rotation faite côté serveur.
Le second flux remonte du cluster vers le serveur. Le secret de liaison d'un fournisseur d'identité OIDC, le mot de passe du serveur de messagerie sortante (SMTP, Simple Mail Transfer Protocol) d'un royaume et les identifiants de connexion d'une fédération d'annuaire LDAP (Lightweight Directory Access Protocol) sont consommés par Keycloak et proviennent d'ailleurs. Déclarés par le manifeste, donc écrits par le contrôleur, ils doivent venir d'un Secret Kubernetes plutôt que du dépôt Git.
La relecture n'est pas symétrique, et c'est le point décisif pour la détection de dérive. Dans Keycloak 26.7, l'utilitaire StripSecretsUtils remplace les valeurs sensibles de certaines représentations rendues par la constante "**********" : le clientSecret d'un fournisseur d'identité, le password et l'authTokenClientSecret du serveur SMTP d'un royaume, toute propriété de configuration d'un composant déclarée secrète par son fournisseur, les secrets contenus dans un export de royaume et ceux des représentations persistées avec les événements d'administration. Le secret d'un client fait exception : GET /admin/realms/<royaume>/clients/<uuid> le renvoie en clair, comme GET sur client-secret.
Cette asymétrie commande deux conduites opposées. Là où le masquage s'applique, une valeur littérale relue est toujours égale à elle-même et jamais comparable à la valeur désirée : la réconciliation ne peut que réécrire à l'aveugle. Là où il ne s'applique pas, la lecture est comparable, mais chaque relecture fait transiter le secret par le contrôleur, ses journaux et le terminal appelant. Le masquage ménage une exception explicite : une valeur qui correspond au motif ${vault.<clé>} est renvoyée telle quelle.
Deux flux de secrets opposés, et ce que la relecture permet de comparer
Le secret d'un client confidentiel descend du serveur vers un Secret Kubernetes ; les secrets consommés par Keycloak remontent d'un Secret Kubernetes vers le serveur. À la relecture, seuls le secret client et une référence de coffre restent comparables.
Schéma défilable horizontalement ; sa version textuelle complète suit.
Lire le schéma sous forme textuelle
- Flux descendant. Keycloak génère le secret d'un client OIDC confidentiel ; le contrôleur le lit par
GET .../client-secretet le publie dans un Secret Kubernetes monté par l'application. Ce champ reste hors despec, sinon chaque passage annule la rotation faite côté serveur. - Flux montant. Le secret de liaison d'un fournisseur d'identité, le mot de passe SMTP et les identifiants LDAP viennent d'un Secret Kubernetes, jamais du dépôt Git ; le contrôleur écrit la valeur ou une référence de coffre.
- Relecture du secret client. Il revient en clair : la comparaison est possible, mais le secret transite par le contrôleur et ses journaux.
- Relecture d'un littéral masqué. Il revient sous forme d'astérisques : aucune comparaison n'est possible et la réconciliation réécrit à l'aveugle.
- Relecture d'une référence de coffre. Une valeur de la forme
${vault.<clé>}revient telle quelle : la réconciliation peut conclure « conforme » sans rien écrire.
Stocker une référence de coffre au lieu d'un littéral change la nature du problème : la relecture redevient comparable, et une réconciliation peut conclure « conforme » sans écrire. Keycloak documente deux implémentations de son interface de fournisseur de service (SPI, Service Provider Interface) de coffre, un coffre fichier et un coffre fondé sur un magasin de clés Java. Elles s'activent par l'option de construction --vault=file ou --vault=keystore : c'est une décision d'image, pas un réglage à chaud. Le coffre fichier vise les secrets Kubernetes montés dans le conteneur. --vault-dir en fixe la racine de lecture. Ses intégrations documentées sont exactement les trois entrées du flux montant.
La convention de nommage produit des références silencieusement non résolues quand elle est ignorée. L'expression ne porte que le nom du secret, ${vault.<secret>} : le résolveur de clés compose lui-même le nom de fichier attendu à partir du royaume et de ce nom. Les tirets bas internes au nom de secret sont doublés ; avec le résolveur par défaut REALM_UNDERSCORE_KEY, ceux du nom de royaume le sont aussi, les deux parties étant séparées par un tiret bas simple.
Un dernier effet de bord précède toute comparaison. Lors du masquage de la configuration d'un composant, le serveur retire aussi de la représentation rendue les clés auxquelles ne correspond aucune propriété déclarée par le fournisseur. Une clé inconnue n'apparaît donc pas comme un écart : elle n'apparaît pas du tout. Déduire « aucune dérive » de l'absence d'un champ dans la lecture est une erreur de conclusion.
Le relevé qui suit ne modifie rien. Il réunit ce qu'il faut tenir ensemble avant toute conclusion : les versions de CRD réellement servies, les gestionnaires de champs côté Kubernetes, et ce que le serveur accepte de relire. Toutes les commandes sont des références synthétiques paramétrées, jamais une configuration prête à déployer ni une sortie client.
Commandes d'observation, en lecture seule
export KC_URL='https://<HOTE_KEYCLOAK>'
export REALM='<ROYAUME>'
export CLIENT_ID='<IDENTIFIANT_FONCTIONNEL>'
read -rs TOKEN # jeton d'administration obtenu hors bande : ne jamais l'inscrire
# dans un manifeste, un journal ni l'historique du shell
# CRD et versions servies, côté cluster
kubectl get crd keycloaks.k8s.keycloak.org keycloakrealmimports.k8s.keycloak.org \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .spec.versions[*]} {.name}{" served="}{.served}{" storage="}{.storage}{"\n"}{end}{end}'
# Image du serveur réellement déployée
kubectl get keycloaks.k8s.keycloak.org \
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{.spec.image}{"\n"}{end}'
# Champs gérés et gestionnaires, côté Kubernetes
kubectl get <RESSOURCE> <NOM> --show-managed-fields -o yaml
# Résoudre l'identifiant interne depuis l'identifiant fonctionnel
CLIENT_UUID=$(curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC_URL/admin/realms/$REALM/clients?clientId=$CLIENT_ID" | jq -r '.[].id')
# Représentation relue : le secret du client revient en clair, ne pas le projeter
curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC_URL/admin/realms/$REALM/clients/$CLIENT_UUID" \
| jq '{id, clientId, rootUrl, redirectUris, defaultClientScopes}'
# Là où le masquage s'applique : secret de liaison d'un fournisseur d'identité
curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC_URL/admin/realms/$REALM/identity-provider/instances/<ALIAS>" \
| jq '.config.clientSecret'
# Littéral relu -> "**********" comparaison impossible
# Référence de coffre -> "${vault.<SECRET>}" comparableLa dernière commande est le test décisif : si clientSecret revient masqué, aucune comparaison n'est possible et la réconciliation de ce champ réécrit à l'aveugle. Les deux premières relèvent du premier chapitre : versions de ressources servies, puis propriétaire de chaque champ côté Kubernetes.
Corriger la dérive, puis décider ce que la suppression emporte
La dérive n'a de sens que relativement aux champs déclarés. Un écart sur un champ observé est une information de diagnostic ; un écart sur un champ non géré n'est pas une dérive, c'est le fonctionnement prévu. Une ressource qui alerte sur un champ qu'elle a explicitement abandonné pousse l'équipe à couper l'alerte, puis, quelques semaines plus tard, le contrôle lui-même.
Trois politiques méritent d'être offertes par la CRD, une seule active à la fois : corriger l'écart en réécrivant les champs déclarés ; le signaler par une condition de statut sans rien écrire ; ne rien faire, cas utile pendant une reprise ou une bascule. La cadence est un choix de charge : chaque passage produit du trafic sur l'API d'administration, et chaque écriture produit un événement d'administration persisté si la journalisation est activée. Une réconciliation qui réécrit systématiquement les secrets littéraux transforme un contrôle périodique en flux d'écritures continu.
La suppression pose une question distincte, tranchée avant la mise en service. Sans finalizer, la ressource Kubernetes disparaît et l'objet distant survit, sans propriétaire ni trace de son origine. Le mécanisme documenté est précis : sur un objet portant des entrées dans metadata.finalizers, une demande de suppression pose metadata.deletionTimestamp, renvoie un code 202 et empêche le retrait tant que la liste n'est pas vide. Le contrôleur exécute alors son nettoyage, puis retire sa clé. Deux contraintes encadrent ce mécanisme : un nom de finalizer personnalisé doit être qualifié par un domaine, et une fois deletionTimestamp posé, il reste possible de retirer des finalizers mais plus d'en ajouter. Un contrôleur qui poserait le sien trop tard ne le poserait jamais.
Le contenu du nettoyage est un choix explicite. Supprimer l'objet distant convient à un environnement éphémère ; le laisser en place, en journalisant l'abandon, convient à un royaume de production où l'effacement d'un client coupe des services. Les ressources KeycloakOIDCClient et KeycloakSAMLClient tranchent dans le premier sens. Ce choix doit figurer dans spec, être lisible dans status et rester invariant pendant la suppression. Le Secret publié par le contrôleur peut porter une ownerReference vers la ressource et disparaître avec elle, mais les références de propriété entre espaces de noms sont interdites par conception : un propriétaire doit résider dans le même espace que son dépendant, faute de quoi la référence est traitée comme absente et l'objet dépendant devient éligible au ramasse-miettes.
Reste l'asymétrie des identifiants, qui rend une suppression plus définitive qu'elle n'en a l'air. La documentation des paramètres de chemin est explicite dans les deux sens : pour un client, le segment d'URL est « l'identifiant du client, pas le client-id » ; pour un royaume, c'est « le nom du royaume, pas son identifiant ». L'identifiant fonctionnel se recherche par GET /admin/realms/<royaume>/clients?clientId=<valeur> : c'est la seule clé stable qu'un manifeste peut porter. L'identifiant interne est produit par le serveur à la création, sauf si la représentation envoyée le porte. Recréer un client supprimé par son seul identifiant fonctionnel produit donc un nouvel identifiant interne, et ce qui référençait l'ancien ailleurs ne suit pas. Pour un royaume, c'est le nom qui sert de clé d'API : le renommer déplace l'objet pour tous les appelants.
Une réconciliation, de la résolution de l'identifiant à la décision de suppression
Le contrôleur résout l'identifiant fonctionnel en identifiant interne, applique sa politique d'adoption, compare seulement les champs déclarés et lisibles, puis traite le retrait d'un champ à partir de la liste des champs écrits au passage précédent.
Schéma défilable horizontalement ; sa version textuelle complète suit.
Lire le schéma sous forme textuelle
- Résoudre l'identité. L'identifiant fonctionnel du manifeste est traduit en identifiant interne par une recherche, par exemple
clients?clientId=. L'identifiant interne est produit par le serveur à la création, sauf si la représentation envoyée le porte explicitement. - Appliquer la politique d'adoption. Objet absent : créer. Objet présent : refuser, adopter ou ne rien faire, selon le champ déclaré dans
spec. L'adoption enregistre d'abord la représentation observée dansstatus. - Délimiter les champs. Séparer déclarés, observés et non gérés. Seuls les champs déclarés sont écrits, et seuls eux peuvent constituer une dérive.
- Lire, en tenant compte des angles morts. Un secret de fournisseur d'identité, un mot de passe SMTP ou une propriété de composant reviennent masqués, sauf référence de coffre ; une clé de configuration inconnue du fournisseur est retirée de la représentation rendue. Ces deux cas sont des angles morts de lecture, pas des preuves de conformité.
- Comparer et décider. Écart sur un champ déclaré : corriger, signaler ou ignorer. Écart sur un champ observé : publier en statut. Champ non géré : ne rien conclure.
- Traiter le retrait. Un champ écrit au passage précédent et absent du manifeste est un retrait : envoyer la valeur neutre explicite, car une valeur absente du corps est conservée par le serveur.
- Écrire, puis enregistrer. Conserver dans
statusla liste des champs effectivement écrits et l'horodatage : c'est la seule mémoire de propriété disponible au passage suivant. Les portées de client passent par leurs sous-ressources, jamais par le corps de la mise à jour. - Clore la suppression. Le finalizer qualifié posé dès la création permet, à la pose de
deletionTimestamp, de supprimer l'objet distant ou de l'abandonner explicitement, puis de retirer la clé. Aucun finalizer ne peut être ajouté après ce point.
Sources officielles et limites de lecture
Les comportements décrits ont été vérifiés le contre les sources ci-dessous. Le traitement champ par champ des représentations et le masquage des secrets ne figurent pas dans la référence REST : ils sont vérifiables dans le code du serveur, lié ici au tag 26.7.0 pour éviter qu'une évolution de la branche principale ne change silencieusement la référence. Le guide d'administration et le code divergent sur un point cité plus haut, la troncature des représentations d'événements : c'est le code de la version installée qui fait foi.
- Automatiser un import de royaume avec l'opérateur Keycloak, limites du
KeycloakRealmImport. - Importer et exporter des royaumes Keycloak,
--override,--import-realm, import partiel et arrêt des nœuds. - Utiliser un coffre avec Keycloak, implémentations, intégrations et convention de nommage.
- Guide d'administration du serveur Keycloak, journalisation des événements d'administration et permissions à granularité fine.
- Ressource d'administration d'un client, mise à jour et secret, Keycloak 26.7.0.
- Traitement des représentations à la création et à la mise à jour, Keycloak 26.7.0.
- Masquage des valeurs sensibles dans les représentations rendues, Keycloak 26.7.0.
- API d'administration des clients v2,
PATCHJSON Merge Patch etPUT, Keycloak 26.7.0. - Contrôleur des ressources
KeycloakOIDCClientetKeycloakSAMLClient, Keycloak 26.7.0. - keycloak-config-cli : objets gérés,
import.managedet état distant. - Fournisseur Terraform
keycloak/keycloak, ressourcekeycloak_openid_client, import et argumentimport. - Schéma et mise à jour d'un client dans le fournisseur Terraform, valeurs par défaut et champs calculés.
- Reprise du fournisseur Terraform par le projet Keycloak, décembre 2024.
- Server-Side Apply et retrait d'un champ appliqué.
- Concepts de l'API Kubernetes, types de PATCH et RFC 7386.
- Finalizers Kubernetes,
deletionTimestampet règles de nommage. - Ramasse-miettes Kubernetes et références de propriété.
Cette lecture n'établit pas qu'une politique d'adoption ou de suppression convient à un royaume donné : elle expose des mécanismes et leurs asymétries. Elle ne décrit pas le comportement de mise à jour au niveau du royaume lui-même, confié par le serveur à RepresentationToModel.updateRealm, qui suit ses propres règles champ par champ. Elle ne dit rien du comportement en charge d'une réconciliation à grande échelle, ni du coût imposé à l'API d'administration quand le nombre de ressources croît. Si l'image, l'opérateur ou la CRD installés diffèrent de la branche 26.7, ce sont les sources de cette version qui font foi, et la comparaison doit être refaite avant toute conclusion. Pour cadrer ces choix sur une plateforme existante, voir notre intervention pour construire une plateforme Kubernetes gouvernée.