Aller au contenu

Configuration

Le pas-à-pas a peuplé Vault et posé le ClusterSecretStore. Cette page revient sur ce qui arrive ensuite : la forme que doit prendre chaque ExternalSecret, ce qui distingue un secret Vault d'une variable ConfigMap et d'une variable figée dans une image, et l'inventaire des sources par service. Elle ne répète pas l'installation d'External Secrets Operator ni le descellement de Vault, détaillés à l'étape 6 du Pas-à-pas.

Le chemin d'un secret, de Vault au pod

opérateur ─vault kv patch─▶ Vault KV v2, mount « secrets », chemin prod/<service>
   ─auth kubernetes, rôle eso─▶ ClusterSecretStore vault-store
   ─ExternalSecret (dataFrom: extract)─▶ Secret Kubernetes ─envFrom─▶ pod

Ce dépôt ne porte que le maillon du milieu, l'ExternalSecret. Les deux extrémités, la valeur dans Vault et la variable dans le pod, ne sont jamais écrites en clair dans les manifests.

La chaine d'authentification OIDC et l'invariant d'issuer unique

Un seul issuer doit apparaitre partout : dans KC_HOSTNAME, dans les clés Vault qui portent l'issuer côté portails et APIs, et dans le realm Keycloak. La figure suit ce chemin d'un bout à l'autre ; c'est l'endroit où repérer un maillon qui porterait encore l'ancienne forme du domaine.

La forme obligatoire d'un ExternalSecret

Tout ExternalSecret qui alimente des variables d'environnement doit prendre cette forme :

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: trade-classification-svc-secret
  namespace: api
spec:
  refreshInterval: 1m
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault-store
  target:
    name: trade-classification-svc-secret
    creationPolicy: Owner
    deletionPolicy: Retain
  dataFrom:
    - extract:
        key: prod/classification

dataFrom: extract injecte dans le Secret toutes les clés présentes au chemin Vault, telles quelles. Une liste data: énumérée oblige au contraire à déclarer chaque clé une par une dans le manifest, et l'oubli d'une seule est silencieux : le pod démarre normalement, la variable manque seulement au runtime. Le cas est déjà arrivé sur CONTENT_SERVICE_URL, avec pour symptôme un proxy média en 404. Avec extract, ajouter une clé côté Vault suffit ; il reste à redémarrer les pods pour qu'ils la lisent.

creationPolicy: Owner place l'ExternalSecret en propriétaire du Secret produit. C'est la propriété qui surprend le plus un opérateur qui découvre ce dépôt : elle signifie qu'un Secret posé à la main, par exemple avec kubectl create secret ou kubectl edit, est écrasé dès la synchronisation suivante, silencieusement. Le seul point d'écriture valable est Vault.

deletionPolicy: Retain décide de ce qui arrive au Secret quand une clé disparait côté Vault : il garde sa dernière valeur connue au lieu d'être vidé. Une erreur de manipulation dans Vault devient ainsi une dérive silencieuse plutôt qu'une panne immédiate : le Secret survit à la disparition de sa source, et l'écart ne se voit que dans le statut de l'ExternalSecret.

apps/keycloak/keycloak-manifests.yaml ne déclare pas deletionPolicy : il prend alors le défaut Retain de l'API, le bon comportement, mais sans l'intention écrite.

Écrire dans Vault se fait avec vault kv patch, jamais vault kv put, sur tout chemin partagé par plusieurs clés. put remplace l'intégralité du contenu du chemin : toute clé absente de la commande disparait, donc disparait du Secret à la synchronisation suivante, sans erreur ni alerte. put reste acceptable sur un chemin dédié à une seule clé, comme celui du tunnel Cloudflare.

Un ExternalSecret du dépôt s'écarte de cette forme sans y être obligé, et c'est exactement le défaut qui vient d'être décrit : keycloak-db-external-secret (apps/keycloak/keycloak-manifests.yaml) énumère username et password en data:, pas en extract, pour produire le Secret keycloak-db-secret. Une clé ajoutée par la suite au chemin Vault prod/keycloak-db-secret n'atteindra donc jamais ce Secret tant que le manifest n'est pas modifié pour l'énumérer explicitement.

Un objet déroge légitimement, pour une raison précise et non pour une préférence : le Secret du tunnel Cloudflare, cloudflared-credentials, matérialise un fichier, pas un jeu de variables d'environnement. Un point dans un nom de clé Vault se lit mal en extract, donc cet objet déclare une entrée data: avec property, puis un target.template qui recompose la clé de fichier attendue par le conteneur. core/vpn/externalsecret-wireguard.example.yaml montre la même construction pour un futur Secret WireGuard, mais porte le suffixe .example.yaml : il n'est pas appliqué sur l'instance de référence, ce n'est qu'un modèle. Cette exception ne vaut que pour un secret-fichier à clé unique.

Rotation d'un secret

External Secrets Operator met à jour le Secret Kubernetes à chaque refreshInterval, une minute pour la quasi-totalité des objets du dépôt. Il ne met à jour aucun pod déjà démarré : les variables d'environnement sont figées au moment où le conteneur démarre, exactement comme les variables NEXT_PUBLIC_* sont figées au moment où l'image est construite, plus bas sur cette page. Une valeur modifiée dans Vault n'a donc d'effet qu'après :

kubectl rollout restart deployment/<nom> -n <namespace>

Sans ce redémarrage, le Secret Kubernetes porte la nouvelle valeur, mais les pods en cours continuent de servir avec l'ancienne, sans qu'aucune erreur ne le signale.

Inventaire des sources par service

Chaque service applicatif combine jusqu'à trois sources : un ExternalSecret Vault, une ConfigMap de confiance CA montée en fichier, et pour les portails des variables figées à la construction de l'image. Aucune de ces trois sources ne se substitue aux deux autres. Ce dépôt ne versionne nulle part la liste des noms de variables que chaque service lit dans son Secret : le tableau ci-dessous donne l'ExternalSecret et le chemin Vault de chaque service, pas les clés qu'il contient. Pour obtenir ces clés sur une installation existante, sans jamais afficher leur valeur :

kubectl get secret <nom-du-secret> -n <namespace> -o jsonpath='{.data}' | tr ',' '\n' | cut -d'"' -f2

Avant que le Secret n'existe, le même inventaire se lit directement sur le chemin Vault, sans afficher les valeurs :

vault kv get -mount=secrets -format=json prod/<service> | jq -r '.data.data | keys[]'
Service Namespace Secret Vault (ExternalSecret, extract) Notes
hub-portail web hub-portail-secret, chemin prod/hub-portail-secret Variables NEXT_PUBLIC_* figées au build, voir plus bas
portal-portail web portal-portail-secret, chemin prod/portal-portail-secret Variables NEXT_PUBLIC_* figées au build
internal-portail internal internal-portail-secret, chemin prod/internal-portail-secret Variables NEXT_PUBLIC_* figées au build
trade-classification-svc api trade-classification-svc-secret, chemin prod/classification Runtime .NET
trade-declaration-svc api trade-declaration-svc-secret, chemin prod/declaration Runtime .NET
trade-declaration-admin-svc api trade-declaration-admin-svc-secret, chemin prod/declaration-admin Runtime .NET
trade-notification-svc api trade-notification-svc-secret, chemin prod/notification Runtime .NET
trade-registration-svc api trade-registration-svc-secret, chemin prod/registration Runtime .NET
trade-valuation-svc api trade-valuation-svc-secret, chemin prod/valuation Runtime .NET
trade-content-service-svc api trade-content-service-svc-secret, chemin prod/content-service Runtime Node, PORT=3000 fixé dans le manifest ; deux pièges spécifiques, voir plus bas
keycloak auth keycloak-db-external-secret, chemin prod/keycloak-db-secret, mode data (username, password), pas extract, voir plus haut KC_HOSTNAME fixé dans apps/keycloak/keycloak.yaml à la forme URL complète du host auth, substituée par render-config.sh avec ${TV_DOMAIN}
cloudflare-dns-token (cert-manager) cert-manager cloudflare-dns-token, chemin prod/cloudflare-dns Consommé par le ClusterIssuer Let's Encrypt
cloudflared-credentials (cloudflare) cloudflare cloudflared-credentials, chemin prod/cloudflared, mode data + template Secret-fichier, exception à extract documentée plus haut

Une colonne « ConfigMap CA montée » figurait ici jusqu'au 2026-09-10. Elle a disparu avec le CA interne : plus rien n'est à monter pour faire confiance à *.${TV_DOMAIN}.

Les variables NEXT_PUBLIC_*, figées au build

Les portails Next.js (hub-portail, portal-portail, internal-portail) lisent leurs variables NEXT_PUBLIC_* au moment où l'image est construite, jamais au démarrage du conteneur. C'est le cas par exemple de NEXT_PUBLIC_CONTENT_SERVICE_URL, dont la valeur canonique est écrite dans apps/api/content-service/content-ingress.yaml : https://content.${TV_DOMAIN}. Ce host ne fait pourtant pas partie des trois enregistrements DNS publics autorisés (hub, portal, auth, voir l'étape 13 du Pas-à-pas), alors que les portails qui l'appellent sont eux-mêmes publics : ce dépôt ne dit nulle part comment un navigateur situé hors du réseau interne doit atteindre ce host. Confirmer ce point avant de fixer la variable et de lancer le build, plutôt que de supposer qu'un enregistrement public supplémentaire est la réponse. Une valeur fausse ou périmée n'est pas rattrapable en changeant une variable d'environnement du Deployment, ni en redéployant : la seule correction possible est de reconstruire l'image avec la bonne valeur, puis de redéployer cette nouvelle image. Un changement de domaine (${TV_DOMAIN}) qui ne s'accompagne pas de ce rebuild laisse les portails appeler l'ancien domaine, sans qu'aucune erreur Kubernetes ne le signale : les pods démarrent, la sonde de disponibilité passe, seules les requêtes émises depuis le navigateur échouent.

Ce point rejoint les clés Vault AUTH_KEYCLOAK_ISSUER, NEXTAUTH_URL et NEXTAUTH_URL_INTERNAL déjà signalées en Paramètres : elles changent toutes ensemble avec KC_HOSTNAME, faute de quoi tous les jetons sont rejetés avec l'erreur IDX10205.

Les deux pièges du content-service

Le content-service (Payload) porte deux variables à ne jamais définir, chacune pour une raison distincte, toutes deux déjà rencontrées en conditions réelles.

SERVER_URL (et PAYLOAD_PUBLIC_SERVER_URL) doit rester absente du secret Vault prod/content-service. Quand elle est définie, Payload renvoie des URLs de média absolues, que le navigateur appelle alors directement sur le content-service, sans passer par le proxy /api/media des portails. Cet appel direct est anonyme, sans clé API ni cookie, et l'accès aux médias exige une autorisation : la lecture échoue en 403, de façon intermittente selon le poste qui affiche la page. Absente, Payload émet des URLs relatives, que le navigateur résout depuis l'origine du portail, donc à travers le proxy qui ajoute la clé API côté serveur.

PAYLOAD_AUTO_SEED ne doit pas être définie. Sa présence relance un seed complet des taux de change à chaque démarrage de pod. Ce seed entrait en conflit avec les taux déjà actifs et les marquait expirés alors que leur période de validité restait ouverte, un incident déjà survenu : la section des taux de change devenait vide sur les deux portails à la fois. Absente, le comportement par défaut est sûr. Si le cas se reproduit malgré tout, seules les lignes expirées sans intervention humaine (expired_by_id vide) doivent être réactivées ; une expiration décidée par un administrateur ne se réactive jamais automatiquement.

Confiance CA : plus rien à régler

Il n'y a plus de variable de confiance CA à poser. *.${TV_DOMAIN} est servi par un certificat Let's Encrypt, dont la chaîne est déjà dans le magasin par défaut de chaque image applicative.

Cela n'a pas toujours été le cas, et l'histoire vaut d'être connue avant d'en réintroduire une : jusqu'au 2026-09-10, un CA interne était distribué par trust-manager, avec deux variables différentes pour deux runtimes, SSL_CERT_FILE côté .NET et OpenSSL, NODE_EXTRA_CA_CERTS côté Node.js. Node ne lit ni l'une ni le magasin OpenSSL, ce qui rendait les deux non interchangeables, et les confondre ne produisait aucun effet visible avant une connexion TLS qui échouait.

Elles étaient devenues redondantes dès le passage à Let's Encrypt et ont survécu plusieurs semaines à cet état, « par précaution ». Une confiance dont personne ne se sert ne se remarque pas : c'est ce qui rend ce genre de reste durable.

ATTENTION: si un composant revenait un jour à un certificat interne, la vérification à faire avant de poser SSL_CERT_FILE est que l'image possède bien un magasin par défaut. Mesuré ici : les images .NET en portent 150, contre 151 dans le bundle monté. Pointer SSL_CERT_FILE sur un fichier qui ne contiendrait que le CA interne ferait perdre d'un coup toute confiance publique, sans erreur au démarrage.

Ce que ce paramétrage ne couvre pas

Le nom exact des clés attendues par chaque service, au-delà des variables déjà citées sur cette page et de la commande donnée plus haut pour les retrouver, vit dans le code applicatif, hors du périmètre de ce dépôt. Confirmer ces noms auprès de l'éditeur avant d'écrire dans Vault : une clé absente reproduit le même symptôme que CONTENT_SERVICE_URL plus haut, un service qui démarre normalement et échoue seulement à l'usage.