Gérer les secrets avec OpenBao & External Secrets Operator
Objectifs pédagogiques¶
- Comprendre ce qu'un gestionnaire de secrets apporte par rapport à des secrets chiffrés dans Git
- Maîtriser les concepts d'OpenBao : scellement, moteurs de secrets, méthodes d'authentification, politiques, audit
- Authentifier des workloads Kubernetes et des pipelines GitLab CI sans secret statique
- Synchroniser les secrets dans Kubernetes avec External Secrets Operator
- Isoler strictement les secrets de chaque environnement et le prouver
Pourquoi un gestionnaire de secrets¶
Les limites des secrets chiffrés dans Git¶
Sealed Secrets et SOPS résolvent le stockage, pas tout le cycle de vie
- Rotation : chaque nouvelle valeur impose un commit, une revue et une synchronisation
- Révocation : impossible de couper l'accès d'une identité précise à un secret
- Traçabilité : aucune trace de qui a lu quel secret, ni quand
- Étanchéité : une même clé de déchiffrement couvre souvent plusieurs environnements
- Secrets de la CI : ils restent des variables statiques de longue durée
Ce qu'apporte un coffre centralisé¶
- Contrôle d'accès par identité : un workload ou un humain n'obtient que les chemins autorisés par sa politique
- Audit de chaque requête : qui, quoi, quand, depuis où, y compris les refus
- Rotation sans redéploiement : la valeur change dans le coffre, les consommateurs suivent
- Jetons éphémères : durée de vie courte, renouvellement et révocation centralisés
- Secrets dynamiques : identifiants générés à la demande, propres à chaque consommateur
Le problème du secret zéro¶
Pour lire un secret, il faut s'authentifier, et donc détenir un premier secret
- Un jeton statique stocké en variable CI ne fait que déplacer le problème
- La solution consiste à s'appuyer sur une identité déjà prouvée par la plateforme
- Kubernetes : le jeton du ServiceAccount, validé par l'API Server
- GitLab CI : le jeton OIDC signé par GitLab pour chaque job (
id_tokens) - Le coffre vérifie l'identité, puis délivre un jeton court lié à une politique
OpenBao¶
OpenBao en bref¶
- Fork communautaire de HashiCorp Vault, créé après le passage de Vault sous licence BSL en 2023
- Projet hébergé par la Linux Foundation, sous licence open source MPL 2.0
- API compatible avec Vault : les outils de l'écosystème fonctionnent (External Secrets Operator, Terraform)
- CLI
bao, variables d'environnementBAO_ADDRetBAO_TOKEN - Déploiement sur Kubernetes par le chart Helm officiel
openbao/openbao
Architecture¶
- Stockage : backend chiffré, Raft intégré pour la haute disponibilité
- Barrière cryptographique : tout ce qui est écrit dans le stockage est chiffré
- Moteurs de secrets : KV, base de données, PKI, Transit (chiffrement à la demande)
- Méthodes d'authentification : Kubernetes, JWT/OIDC, AppRole, userpass
- Politiques : règles en refus par défaut qui associent chemins et droits
- Dispositifs d'audit : journalisation de chaque requête et de chaque réponse
Scellement et descellement¶
Au démarrage, une instance est scellée : elle connaît ses données chiffrées mais pas la clé qui les déchiffre
- Descellement Shamir : la clé est découpée en parts, un quorum (ex : 3 sur 5) la reconstitue
- Auto-descellement : la clé est protégée par un service externe (KMS, HSM, autre instance en Transit)
- Les parts sont confiées à des personnes distinctes, jamais stockées dans un dépôt Git
- Une instance scellée ne sert aucun secret : c'est un point de défaillance à surveiller
- Depuis OpenBao 2.7, les KMS cloud passent par des plugins externes
Initialisation¶
export BAO_ADDR=http://127.0.0.1:8200
# 5 parts de clé, 3 nécessaires pour desceller
bao operator init -key-shares=5 -key-threshold=3
# À répéter avec 3 parts différentes
bao operator unseal
bao status # Sealed: false
- La commande affiche les parts et le jeton root une seule fois
- Le jeton root sert à la configuration initiale, puis il est révoqué
- Les opérations courantes passent par des identités et des politiques nominatives
Le moteur KV v2¶
bao secrets enable -path=secret kv-v2
bao kv put secret/securepay/stripe api_key=sk_test_factice
bao kv get secret/securepay/stripe
bao kv get -version=1 secret/securepay/stripe
- Chaque écriture crée une version : retour arrière possible après une erreur
- Suppression réversible (
delete) ou définitive (destroy) - Dans les politiques, les données sont sous
secret/data/...et les métadonnées soussecret/metadata/...
Les politiques¶
- Tout ce qui n'est pas explicitement autorisé est refusé
- Une politique par consommateur et par usage, jamais une politique « fourre-tout »
- Capacités :
create,read,update,delete,list,sudo,deny
Authentification Kubernetes¶
L'API Server vérifie le jeton du ServiceAccount (TokenReview), OpenBao applique le rôle correspondant
bao auth enable kubernetes
bao write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc"
bao write auth/kubernetes/role/securepay-api \
bound_service_account_names=securepay-api \
bound_service_account_namespaces=securepay-prod \
policies=securepay-api-read \
ttl=1h
- Le rôle lie un ServiceAccount précis dans un namespace précis à une politique
- Un pod d'un autre namespace est refusé, même avec un ServiceAccount du même nom
Authentification JWT de GitLab CI¶
bao auth enable jwt
bao write auth/jwt/config \
oidc_discovery_url="https://gitlab.com" bound_issuer="https://gitlab.com"
bao write auth/jwt/role/securepay-ci - <<'EOF'
{
"role_type": "jwt",
"policies": ["securepay-ci"],
"user_claim": "user_email",
"bound_audiences": "https://openbao.securepay.local",
"bound_claims": {
"project_path": "mon-groupe/securepay-api",
"ref_protected": "true"
},
"token_explicit_max_ttl": 300
}
EOF
- Seule la pipeline de ce projet, sur une branche protégée, obtient un jeton
Côté pipeline¶
read-secret:
image:
name: ghcr.io/openbao/openbao:<version>
entrypoint: [""]
id_tokens:
OPENBAO_ID_TOKEN:
aud: https://openbao.securepay.local
variables:
BAO_ADDR: https://openbao.securepay.local
script:
- export BAO_TOKEN="$(bao write -field=token auth/jwt/login role=securepay-ci jwt="$OPENBAO_ID_TOKEN")"
- bao kv get -field=registry_token secret/securepay/ci > /dev/null
- Plus aucun jeton OpenBao stocké dans les variables GitLab
- Le jeton OIDC est signé par GitLab, propre au job, et expire rapidement
- Le runner doit pouvoir joindre OpenBao : runner self-hosted dans un lab local
Le journal d'audit¶
Depuis OpenBao 2.3, les dispositifs d'audit se déclarent dans la configuration du serveur (bao audit enable est refusé par défaut, suite à CVE-2025-6000)
audit "file" "to-stdout" {
description = "Journal d'audit collecté par la stack de logs"
options {
file_path = "stdout"
}
}
- Chaque requête et chaque refus sont journalisés
- Les valeurs sensibles sont remplacées par leur empreinte HMAC
stdoutpermet la collecte par Loki comme n'importe quel journal de pod
Une instance par environnement¶
Pourquoi séparer les instances¶
- Rayon d'impact : une compromission de staging ne donne accès à aucun secret de production
- Clés distinctes : chaque instance a ses propres parts de descellement et son propre jeton root
- Droits distincts : les opérateurs de staging n'ont aucun accès à l'instance de production
- Cycles de vie distincts : une mise à jour d'OpenBao se teste d'abord en staging
- Preuve simple : l'étanchéité se démontre par des tests négatifs entre instances
Cartographie de l'étanchéité¶
| Staging | Production | |
|---|---|---|
| Instance | openbao-staging | openbao-prod |
| Workload | SA securepay-api dans securepay-staging | SA securepay-api dans securepay-prod |
| Rôle Kubernetes | lié à securepay-staging uniquement | lié à securepay-prod uniquement |
| Rôle CI | toute branche protégée | main uniquement |
| Opérateurs | profil développeur + opérateur | profil opérateur uniquement |
Prouver l'étanchéité par des tests négatifs¶
# Jeton du ServiceAccount de staging
TOKEN=$(kubectl -n securepay-staging create token securepay-api)
# Tentative de connexion sur l'instance de production
kubectl -n openbao-prod port-forward svc/openbao-prod 8201:8200 &
BAO_ADDR=http://127.0.0.1:8201 \
bao write auth/kubernetes/login role=securepay-api jwt="$TOKEN"
# Attendu : refus, namespace du ServiceAccount non autorisé
- Le refus est attendu et journalisé dans l'audit de production
- Ces tests sont rejoués en CI : une régression de configuration est détectée immédiatement
External Secrets Operator¶
Principe¶
- L'opérateur lit les secrets dans un fournisseur externe et les écrit dans des Secrets Kubernetes
- SecretStore : où et comment s'authentifier (namespacé)
- ClusterSecretStore : même chose pour tout le cluster
- ExternalSecret : quels secrets synchroniser, vers quel Secret, à quelle fréquence
- OpenBao est pris en charge par le fournisseur
vault, grâce à la compatibilité d'API - L'application ne change pas : elle lit un Secret Kubernetes classique
SecretStore par namespace¶
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: openbao
namespace: securepay-prod
spec:
provider:
vault:
server: http://openbao-prod.openbao-prod.svc:8200
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: securepay-api
serviceAccountRef:
name: securepay-api
- Un
SecretStorepar namespace plutôt qu'unClusterSecretStorepartagé : l'étanchéité est portée par l'identité de chaque namespace
ExternalSecret¶
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: stripe
namespace: securepay-prod
spec:
refreshInterval: 1m
secretStoreRef:
name: openbao
kind: SecretStore
target:
name: stripe
data:
- secretKey: STRIPE_SECRET_KEY
remoteRef:
key: securepay/stripe
property: api_key
Rotation de bout en bout¶
- Nouvelle valeur écrite dans OpenBao (nouvelle version KV)
- External Secrets Operator la récupère au prochain
refreshInterval - Le Secret Kubernetes est mis à jour
- Les variables d'environnement d'un pod ne changent pas à chaud : il faut un redémarrage progressif
-
Un contrôleur comme Reloader déclenche ce redémarrage quand le Secret change
-
Le temps de propagation se mesure : c'est une donnée du runbook de rotation
- Un fichier monté depuis un Secret est mis à jour par le kubelet, avec un délai
Quand le coffre tombe¶
- Le Secret déjà synchronisé reste en place : l'application continue de fonctionner
- La synchronisation échoue et l'
ExternalSecretpasse en erreur - Aucune rotation n'est possible tant que la panne dure
- Une alerte doit surveiller l'état des
ExternalSecretet l'état scellé d'OpenBao - C'est un scénario de chaos à jouer avant qu'il n'arrive en production
Exploitation¶
Exploiter OpenBao en production¶
- Haute disponibilité : 3 nœuds Raft, auto-descellement pour redémarrer sans intervention humaine
- Sauvegardes : instantanés Raft réguliers, chiffrés, testés en restauration
- Supervision : état scellé, latence, erreurs d'authentification, expiration des jetons
- Réseau : NetworkPolicies limitant l'accès aux seuls consommateurs légitimes
- TLS : obligatoire hors lab, certificats gérés par cert-manager
- Procédures : descellement, rotation des parts de clé, révocation d'urgence
Anti-patterns à proscrire¶
- Utiliser le jeton root au quotidien ou le stocker dans un dépôt
- Une politique unique donnant accès à tous les secrets
- Un
ClusterSecretStorepartagé entre staging et production - Des jetons OpenBao statiques dans les variables CI
- Les parts de descellement détenues par une seule personne
- Un coffre jamais testé en panne ni en restauration
À retenir¶
- Un coffre apporte ce que Git ne peut pas : contrôle par identité, audit de chaque lecture, rotation sans redéploiement
- Le secret zéro se résout avec les identités existantes : ServiceAccount Kubernetes, jeton OIDC de GitLab CI
- Une instance par environnement, des rôles liés à un namespace précis, et des tests négatifs comme preuve
- External Secrets Operator découple l'application du coffre, mais la rotation demande un redémarrage
- Depuis OpenBao 2.3, l'audit se déclare dans la configuration du serveur