Secrets et identité : External Secrets, Workload Identity
Chez Ferrand Médical, la clé d'API Gemini était copiée dans trois Secrets de trois namespaces, créée un vendredi par une personne partie depuis, jamais changée. Personne ne savait dire qui pouvait la lire : tous ceux qui ont get secrets sur l'un des trois namespaces, plus quiconque a eu accès au poste où elle a été collée. Il y a trois niveaux de réponse, et le meilleur supprime la clé.
Le mécanisme
flowchart TB
subgraph N1["Niveau 1 : Secret Kubernetes créé à la main (lab kind)"]
K1["make secret : kubectl create secret"] --> S1["Secret inference-gemini"] --> P1["Pod : GEMINI_API_KEY"]
end
subgraph N2["Niveau 2 : External Secrets Operator + Secret Manager"]
SM["Secret Manager : lab-k8s-gitops-gemini-api-key (versions, audit)"] -->|"ESO, identité Workload Identity, toutes les heures"| S2["Secret inference-gemini"] --> P2["Pod : GEMINI_API_KEY"]
end
subgraph N3["Niveau 3 : Workload Identity, aucune clé"]
P3["Pod, ServiceAccount assistant/inference"] -->|"jeton du serveur de métadonnées GKE"| STS["Security Token Service"]
STS -->|"jeton d'accès court (1 h)"| VX["Vertex AI : roles/aiplatform.user"]
end
- Niveau 1 : acceptable dans un cluster jetable. Le Secret n'est pas dans Git (aucune trace dans
gitops-repo/, vérifié parmake e2e), mais sa valeur vit dans etcd et sur le poste qui l'a créée, sans rotation. - Niveau 2 : quand une clé est inévitable (fournisseur tiers, API Gemini hors Vertex). La source est Secret Manager, versionnée et auditée ; External Secrets Operator (ESO) recrée le Secret Kubernetes et le rafraîchit. La rotation se fait à un seul endroit.
- Niveau 3 : pour appeler Google Cloud, il n'y a pas de clé du tout. Le ServiceAccount Kubernetes du pod devient un principal IAM ; Vertex AI reçoit un jeton d'accès de courte durée. Rien à voler, rien à faire tourner.
La mise en œuvre
Niveau 3 : ce que voit le code
Le code ne change pas entre la clé et Workload Identity. Le client google-genai lit les identifiants par défaut (ADC) quand vertexai=True :
if s.use_vertex:
self.client = genai.Client(vertexai=True, project=s.project, location=s.location)
else:
self.client = genai.Client(api_key=s.api_key)Sur GKE, les ADC sont fournis par le serveur de métadonnées du nœud. Ailleurs, par gcloud auth application-default login. C'est ce qui rend le mode GKE testable sans cluster : make e2e-vertex démarre l'application en mode Vertex AI, retire explicitement toute clé de l'environnement et fait un appel réel.
e2e-vertex: ## Test réel du mode GKE : Vertex AI par identifiants ADC (ce que fournit Workload Identity)
@env -u GEMINI_API_KEY -u GOOGLE_API_KEY GOOGLE_GENAI_USE_VERTEXAI=true GOOGLE_CLOUD_PROJECT=$(PROJECT) \
GOOGLE_CLOUD_LOCATION=$(REGION) MODEL=gemini-2.5-flash $(PY) scripts/e2e_vertex.pyCôté IAM, une seule liaison, déclarée en Terraform (main.tf) : le ServiceAccount Kubernetes, désigné par son namespace et son nom, reçoit roles/aiplatform.user. Workload Identity Federation for GKE permet cette liaison directe, sans compte de service Google intermédiaire ni annotation.
app_principal = "principal://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.project_id}.svc.id.goog/subject/ns/${var.app_namespace}/sa/${var.app_ksa}"
resource "google_project_iam_member" "app_vertex" {
project = var.project_id
role = "roles/aiplatform.user"
member = local.app_principal
}Côté chart, l'environnement gke n'a pas de Secret et ouvre le chemin vers le serveur de métadonnées dans la NetworkPolicy (environments/gke/values.yaml) :
existingSecret: ""
networkPolicy:
extraEgress:
- to: [{ ipBlock: { cidr: 169.254.169.252/32 } }] # serveur de métadonnées GKE : jetons Workload Identity
ports: [{ port: 988, protocol: TCP }]
- to: [{ ipBlock: { cidr: 169.254.169.254/32 } }]
ports: [{ port: 80, protocol: TCP }]Workload Identity n'existe pas sur kind : il n'y a ni serveur de métadonnées GKE ni pool d'identités. Monter dans le cluster local une clé de compte de service ou vos propres identifiants recréerait exactement le problème que ce niveau supprime. Le lab garde donc le mode simulé dans le cluster et fait l'appel réel hors cluster.
Niveau 2 : External Secrets, si une clé reste nécessaire
secretstore-gcp.yaml déclare la source et le Secret à produire :
kind: ExternalSecret
spec:
refreshInterval: 1h
secretStoreRef: { kind: ClusterSecretStore, name: gcp-secret-manager }
target: { name: inference-gemini, creationPolicy: Owner }
data:
- secretKey: GEMINI_API_KEY
remoteRef: { key: lab-k8s-gitops-gemini-api-key, version: latest }L'opérateur s'authentifie lui aussi par Workload Identity : son ServiceAccount (external-secrets/external-secrets) reçoit roles/secretmanager.secretAccessor sur ce secret seulement, par la même syntaxe principal://. Ce fichier n'est pas appliqué dans le lab : ESO sur kind exigerait une clé de compte de service pour lire Secret Manager, c'est-à-dire un secret pour protéger un secret.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
403 Permission denied on resource ... aiplatform.endpoints.predict sur GKE | le principal IAM désigne assistant/inference mais le pod tourne avec un autre ServiceAccount (nom de release différent, namespace renommé) | releaseName fixe (leçon 1), test de rendu sur serviceAccountName |
Failed to retrieve ... metadata ou délai dépassé au premier appel | NetworkPolicy en refus par défaut sans sortie vers 169.254.169.252:988 | extraEgress de l'environnement gke |
Appels Vertex AI facturés au mauvais projet, avertissement without a quota project | ADC d'utilisateur sans projet de quota (poste du développeur) | gcloud auth application-default set-quota-project ; sur GKE, le projet du pool est utilisé |
| Clé tournée dans Secret Manager, ancienne clé encore utilisée | variables d'environnement figées au démarrage | redémarrage piloté après rotation |
ESO en erreur SecretSyncedError | rôle accordé au projet au lieu du secret, ou nom de secret faux | liaison IAM sur la ressource secret, kubectl describe externalsecret |
La règle de décision
- Appel à une API Google Cloud depuis GKE : Workload Identity, jamais de clé. C'est le cas de Vertex AI.
- Clé inévitable : Secret Manager comme source unique, ESO pour la livrer, rotation documentée avec redémarrage.
- Secret créé par
kubectl: seulement dans un cluster jetable, et jamais dans le dépôt GitOps (même chiffré, sauf outil dédié comme Sealed Secrets ou SOPS, que ce projet n'utilise pas). - À surveiller : les journaux d'audit d'accès aux secrets, et l'âge de chaque version de clé encore active.
Pratique
Un développeur propose, pour tester Workload Identity sur kind, de créer une clé JSON de compte de service et de la monter dans le pod. Que répondez-vous ?
Workload Identity délivre des jetons d'une heure liés au pod ; une clé JSON vaut jusqu'à sa révocation, et beaucoup d'organisations interdisent leur création. Le code étant identique (ADC), make e2e-vertex prouve la partie applicative ; seule la liaison IAM reste à vérifier sur GKE. Le base64 n'est pas un chiffrement.
Après la rotation de la clé dans Secret Manager, ESO a mis à jour le Secret inference-gemini à 10 h 02. À 10 h 30, l'équipe révoque l'ancienne clé ; à 10 h 31, tous les appels échouent en 403. Pourquoi ?
Une variable d'environnement issue d'un Secret est figée à la création du conteneur. Le Secret était à jour, les processus non. Ordre correct : nouvelle version, synchronisation, redémarrage progressif (qui passe par le canary), vérification, puis révocation.
Exercice
Écrivez la commande gcloud qui donne à l'opérateur ESO (ServiceAccount external-secrets du namespace external-secrets) le droit de lire uniquement le secret lab-k8s-gitops-gemini-api-key, sans compte de service Google.
Voir la solution
PROJECT=votre-projet
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
gcloud secrets add-iam-policy-binding lab-k8s-gitops-gemini-api-key --project $PROJECT \
--role roles/secretmanager.secretAccessor \
--member "principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/$PROJECT.svc.id.goog/subject/ns/external-secrets/sa/external-secrets"La liaison est posée sur la ressource secret, pas sur le projet : l'opérateur ne lit aucun autre secret. En Terraform, c'est une ressource google_secret_manager_secret_iam_member avec le même member. Le ClusterSecretStore n'a alors besoin d'aucune référence à une clé : le fournisseur gcpsm utilise l'identité du pod de l'opérateur.
Résultat obtenu
Exécution du 2026-10-16, hors cluster, identifiants ADC, aucune clé dans l'environnement (identifiant de projet masqué) :
$ make e2e-vertex PROJECT=VOTRE-PROJET
readiness : {'ready': True, 'problems': []}
UserWarning: Your application has authenticated using end user credentials from Google Cloud SDK without a quota project. ...
Vertex AI (europe-west1) modèle=gemini-2.5-flash latence=3493 ms
Pour un problème de badge ne fonctionnant plus, veuillez contacter le service de sécurité ou la gestion des bâtiments. Ils sont en charge de l'activation et de la gestion des droits d'accès.
e2e-vertex : OKL'avertissement de projet de quota est celui de la ligne 3 du tableau : sur un poste de développeur, les ADC d'utilisateur n'ont pas de projet de facturation des quotas. Sur GKE, il n'apparaît pas.
Dans le cluster kind, aucune valeur secrète :
$ kubectl -n assistant get secret
No resources found in assistant namespace.
$ make e2e
[OK] aucun Secret dans le dépôt Git
[OK] réponse via l'Ingress : HTTP 200
backend=fake modèle=fake version=0.2.0 ...Je sais faire
- Choisir entre Secret manuel, External Secrets et Workload Identity selon le cas
- Lier un ServiceAccount Kubernetes à un rôle IAM par un principal
principal:// - Ouvrir la NetworkPolicy vers le serveur de métadonnées GKE
- Tester le mode Vertex AI sans cluster et sans clé avec
make e2e-vertex - Ordonner une rotation de clé sans coupure