Un OpenBao par environnement avec External Secrets Operator¶
Objectifs pédagogiques¶
À la fin de ce TP, vous devez être capables de :
- déployer, initialiser et desceller deux instances OpenBao isolées, une par environnement
- configurer le moteur KV v2, des politiques minimales et l'authentification Kubernetes
- synchroniser un secret dans chaque environnement avec External Secrets Operator
- prouver par des tests négatifs qu'une identité de staging n'accède jamais à la production
- faire tourner un secret de production sans redéploiement manuel et mesurer sa propagation
- authentifier la CI GitLab sur OpenBao sans aucun secret statique
- exploiter le journal d'audit et observer le comportement de la plateforme quand le coffre tombe
Prérequis¶
- Un cluster Kubernetes de lab (par exemple le cluster k3d
securepaydu dépôt securepay-platform) aveckubectlethelm - Deux namespaces applicatifs
securepay-stagingetsecurepay-prod, contenant chacun un ServiceAccountsecurepay-apiet le Deployment de l'API - La CLI
baoinstallée sur le poste (binaire des releases GitHub d'OpenBao ou gestionnaire de paquets) - kube-prometheus-stack et, idéalement, Loki pour les ateliers 6 et 7
- Un runner GitLab self-hosted capable de joindre le cluster pour l'atelier 5
Versions testées
Les ateliers 1 à 4, 6 et 7 ont été joués sur un cluster de test avec le chart OpenBao 0.30.6 (OpenBao 2.7.2) et le chart External Secrets Operator 2.14.0 : refus d'un jeton de staging (namespace not authorized), SecretStore illégitime en InvalidProviderConfig, jeton haché en hmac-sha256 dans l'audit, Secret conservé et instance rescellée après la panne.
Lab uniquement
Ce TP désactive TLS sur OpenBao pour rester centré sur les secrets. En dehors d'un lab, TLS est obligatoire, avec des certificats gérés par cert-manager. N'utilisez que des valeurs factices.
Contexte¶
Jusqu'ici, le secret Stripe de SecurePay était chiffré dans Git (Sealed Secrets ou SOPS). La même clé de déchiffrement couvre staging et production, aucune lecture n'est tracée, et chaque rotation impose un commit. Un audit vient de montrer qu'un jeton prévu pour les tests pouvait lire la clé de production.
Vous allez déployer un gestionnaire de secrets distinct par environnement et en apporter la preuve.
flowchart LR
subgraph STG["securepay-staging"]
A1["API (SA securepay-api)"] --> S1["Secret stripe"]
end
subgraph PRD["securepay-prod"]
A2["API (SA securepay-api)"] --> S2["Secret stripe"]
end
ESO["External Secrets Operator"] -->|"SecretStore staging"| B1["openbao-staging"]
ESO -->|"SecretStore prod"| B2["openbao-prod"]
ESO --> S1
ESO --> S2
B1 -.->|"refus"| A2
B2 -.->|"refus"| A1 Atelier 1 : Deux instances OpenBao¶
Travail demandé¶
- Ajoutez le dépôt Helm officiel et épinglez une version vérifiée du chart (0.30.6, soit OpenBao 2.7.2, au moment de la rédaction) :
helm repo add openbao https://openbao.github.io/openbao-helm
helm repo update
helm show values openbao/openbao --version <version> | less
- Rédigez
security/openbao/values.yaml. Depuis OpenBao 2.3, un dispositif d'audit ne peut plus être activé par l'API (bao audit enableest refusé par défaut) : il se déclare dans la configuration du serveur.
injector:
enabled: false # les secrets passent par External Secrets Operator
server:
dataStorage:
size: 1Gi
standalone:
config: |
ui = true
listener "tcp" {
tls_disable = 1
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "pebbledb" {
path = "/openbao/data/pebbledb"
}
audit "file" "to-stdout" {
description = "Journal d'audit collecté par la stack de logs"
options {
file_path = "stdout"
}
}
- Installez une release par environnement, avec un nom de release distinct : le chart crée des ressources de niveau cluster (ClusterRoleBinding) dont le nom dérive de la release.
for env in staging prod; do
helm install openbao-$env openbao/openbao --version <version> \
-n openbao-$env --create-namespace -f security/openbao/values.yaml
done
Le pod openbao-prod-0 reste non prêt : c'est normal, l'instance est scellée.
- Initialisez puis descellez chaque instance. Ouvrez un
port-forwardpar instance :
kubectl -n openbao-staging port-forward svc/openbao-staging 8200:8200 &
kubectl -n openbao-prod port-forward svc/openbao-prod 8201:8200 &
export BAO_ADDR=http://127.0.0.1:8201 # instance de production
bao operator init -key-shares=3 -key-threshold=2 -format=json > ~/init-prod.json
bao operator unseal # à répéter avec 2 parts différentes
bao status # Sealed: false
- Recommencez pour staging sur le port 8200.
Parts de clé et jeton root
Le fichier init-*.json contient les parts de descellement et le jeton root. Il ne doit jamais entrer dans un dépôt Git. Distribuez les parts à des personnes différentes (un gestionnaire de mots de passe d'équipe convient pour le lab), puis supprimez le fichier. Documentez la procédure, pas les valeurs.
Ce que vous devez observer¶
- Deux instances indépendantes, chacune avec ses propres parts de clé et son propre jeton root
bao audit listaffiche le dispositifto-stdout/sans qu'aucune commandeaudit enablen'ait été exécutée
Critère de réussite¶
Les deux instances sont descellées, et aucune donnée d'initialisation ne figure dans le dépôt.
Atelier 2 : KV v2, politiques et authentification Kubernetes¶
Travail demandé¶
Pour chaque instance (le jeton root ne sert qu'à cette configuration initiale) :
- Activez le moteur KV v2 et écrivez le secret de l'environnement :
export BAO_ADDR=http://127.0.0.1:8201 BAO_TOKEN=<jeton root prod>
bao secrets enable -path=secret kv-v2
bao kv put secret/securepay/stripe api_key=sk_test_prod_factice
- Créez une politique en lecture seule, limitée aux secrets de l'API :
# security/openbao/policies/securepay-api-read.hcl
path "secret/data/securepay/*" {
capabilities = ["read"]
}
- Activez l'authentification Kubernetes et liez le rôle au ServiceAccount et au namespace de l'environnement :
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
-
Versionnez ces commandes dans un script idempotent (
security/openbao/configure.sh <env>) : la configuration d'un coffre est du code comme le reste. -
Une fois les deux instances configurées et le profil d'administration nominatif en place (voir la gouvernance des identités), révoquez le jeton root :
bao token revoke -self.
Critère de réussite¶
bao read auth/kubernetes/role/securepay-api montre, sur chaque instance, un rôle lié au seul namespace de son environnement.
Atelier 3 : External Secrets Operator¶
Travail demandé¶
- Installez l'opérateur (le chart installe ses CRD) :
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets --version <version> \
-n external-secrets --create-namespace --set serviceMonitor.enabled=true
- Dans chaque namespace applicatif, créez un
SecretStorequi pointe vers l'instance de son environnement, et unExternalSecret:
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: openbao
namespace: securepay-prod
spec:
provider:
vault: # OpenBao est compatible avec l'API Vault
server: http://openbao-prod.openbao-prod.svc:8200
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: securepay-api
serviceAccountRef:
name: securepay-api
---
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
- Faites consommer le Secret par l'API, puis supprimez l'ancien secret chiffré (Sealed Secrets ou SOPS) du dépôt :
# extrait du Deployment de l'API
env:
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: stripe
key: STRIPE_SECRET_KEY
NetworkPolicies
Si vos namespaces sont en refus par défaut, ouvrez explicitement les flux nécessaires : External Secrets Operator vers chaque OpenBao (port 8200), OpenBao vers l'API Server (validation des jetons) et, pour l'atelier 5, OpenBao vers gitlab.com (clés publiques des jetons OIDC).
Ce que vous devez observer¶
kubectl -n securepay-prod get externalsecret stripeafficheSecretSynced- Le Secret
stripecontient la valeur de production, le Secret de staging celle de staging
Critère de réussite¶
L'API de chaque environnement démarre avec le secret de son propre coffre, et plus aucun secret chiffré ne reste dans le dépôt.
Atelier 4 : Prouver l'étanchéité¶
C'est la preuve que les auditeurs (et le jury) attendent : une affirmation ne suffit pas.
Travail demandé¶
- Écrivez
security-tests/secrets-isolation.sh, qui échoue (code de retour non nul) si une identité de staging parvient à s'authentifier sur la production :
#!/usr/bin/env bash
set -u
fail=0
token=$(kubectl -n securepay-staging create token securepay-api --duration=10m)
if BAO_ADDR=http://127.0.0.1:8201 bao write -field=token \
auth/kubernetes/login role=securepay-api jwt="$token" >/dev/null 2>&1; then
echo "ÉCHEC : un jeton de staging s'authentifie sur OpenBao prod"
fail=1
else
echo "OK : jeton de staging refusé par OpenBao prod"
fi
exit "$fail"
-
Ajoutez le test symétrique (production vers staging) et un test qui vérifie que la politique ne permet pas d'écrire.
-
Second test négatif, côté Kubernetes : créez dans
securepay-stagingunSecretStorequi viseopenbao-prod, avec le mêmeExternalSecret. Relevez le motif d'erreur danskubectl describe externalsecret. -
Rejouez le script dans la CI de
securepay-platform(job planifié), pour qu'une régression de configuration soit détectée.
Ce que vous devez observer¶
- Le login est refusé : le namespace du ServiceAccount n'est pas autorisé par le rôle
- L'
ExternalSecretdu second test reste en erreur, et aucun Secret n'est créé - Chaque refus apparaît dans le journal d'audit de l'instance de production (atelier 6)
Critère de réussite¶
Les tests négatifs passent, sont versionnés et rejoués automatiquement. Leur sortie et l'extrait d'audit correspondant sont rangés dans docs/preuves/.
Atelier 5 : Rotation et CI sans secret statique¶
Partie A : Rotation sans redéploiement manuel¶
- Installez Reloader, qui redémarre progressivement un Deployment quand un Secret qu'il consomme change, et annotez l'API :
helm repo add stakater https://stakater.github.io/stakater-charts
helm install reloader stakater/reloader --version <version> -n reloader --create-namespace
kubectl -n securepay-prod annotate deploy/securepay-api reloader.stakater.com/auto="true"
- Faites tourner le secret et mesurez chaque étape :
date +%T && BAO_ADDR=http://127.0.0.1:8201 bao kv put secret/securepay/stripe api_key=sk_test_prod_rotation_2
kubectl -n securepay-prod get secret stripe -w -o jsonpath='{.metadata.resourceVersion}{"\n"}'
kubectl -n securepay-prod rollout status deploy/securepay-api
- Consignez le temps de propagation (écriture dans le coffre → Secret mis à jour → tous les pods redémarrés) dans le runbook de rotation.
Partie B : CI authentifiée par OIDC¶
- Exposez
openbao-stagingpar l'ingress (openbao-staging.securepay.local), puis configurez l'authentification JWT :
export BAO_ADDR=http://127.0.0.1:8200
bao auth enable jwt
bao write auth/jwt/config oidc_discovery_url="https://gitlab.com" bound_issuer="https://gitlab.com"
bao kv put secret/ci/securepay defectdojo_token=factice
bao policy write securepay-ci - <<'EOF'
path "secret/data/ci/securepay" {
capabilities = ["read"]
}
EOF
bao write auth/jwt/role/securepay-ci - <<'EOF'
{
"role_type": "jwt",
"policies": ["securepay-ci"],
"user_claim": "user_email",
"bound_audiences": "http://openbao-staging.securepay.local",
"bound_claims": {
"project_path": "<groupe>/securepay-api",
"ref_protected": "true"
},
"token_explicit_max_ttl": 300
}
EOF
- Ajoutez le job suivant à la pipeline de
securepay-api:
read-ci-secret:
stage: test
tags: [self-hosted]
image:
name: quay.io/openbao/openbao:<version>
entrypoint: [""]
id_tokens:
OPENBAO_ID_TOKEN:
aud: http://openbao-staging.securepay.local
variables:
BAO_ADDR: http://openbao-staging.securepay.local
script:
- export BAO_TOKEN="$(bao write -field=token auth/jwt/login role=securepay-ci jwt="$OPENBAO_ID_TOKEN")"
- bao kv get -field=defectdojo_token secret/ci/securepay > /dev/null && echo "secret lu"
- Supprimez la variable GitLab statique équivalente, puis lancez le job depuis une branche non protégée : le login doit être refusé.
Critère de réussite¶
La rotation est mesurée et ne demande aucune action manuelle sur le cluster. La CI lit son secret sans aucun jeton OpenBao stocké dans GitLab, et seulement depuis une branche protégée.
Atelier 6 : Exploiter le journal d'audit¶
Travail demandé¶
- Retrouvez dans les journaux du pod de production les tentatives de l'atelier 4 :
- Dans Grafana (Loki), construisez la requête qui isole les refus, puis une alerte sur leur fréquence :
- Repérez les champs remplacés par une empreinte
hmac-sha256:et expliquez pourquoi le journal reste exploitable sans exposer de secret.
Critère de réussite¶
Une tentative d'accès illégitime est retrouvable en moins d'une minute : identité, chemin, horodatage, motif du refus.
Atelier 7 : Quand le coffre tombe¶
Travail demandé¶
-
Formulez l'hypothèse avant d'agir : que deviennent l'API, le Secret Kubernetes et l'
ExternalSecretsiopenbao-proddisparaît ? -
Provoquez la panne sous charge nominale (k6) :
-
Observez l'API, le Secret
stripeet l'état de l'ExternalSecretaprès au moins unrefreshInterval. -
Ajoutez deux alertes :
- alert: OpenBaoNotReady
expr: sum by (namespace) (kube_pod_status_ready{namespace=~"openbao-.*", condition="true"}) == 0
for: 2m
labels: {severity: critical, component: openbao, cause: SecretManagerUnavailable}
annotations:
runbook_url: https://gitlab.com/<groupe>/securepay-platform/-/blob/main/docs/runbooks/openbao-indisponible.md
- alert: ExternalSecretSyncFailed
expr: externalsecret_status_condition{condition="Ready", status="False"} == 1
for: 5m
labels: {severity: warning, component: external-secrets, cause: SecretSyncFailed}
La métrique externalsecret_status_condition est exposée par l'opérateur sur son port metrics (8080) ; vérifiez son nom si vous utilisez une autre version du chart.
- Rétablissez l'instance (
--replicas=1) et constatez qu'elle redémarre scellée : sans auto-descellement, quelqu'un doit intervenir avec les parts de clé. Chronométrez cette intervention.
Ce que vous devez observer¶
- L'API continue de fonctionner avec le dernier secret synchronisé
- L'
ExternalSecretpasse en erreur et aucune rotation n'est possible pendant la panne - Le redémarrage exige un descellement humain, qui allonge le temps de rétablissement
Critère de réussite¶
Le comportement en panne est documenté dans le rapport de résilience et le runbook openbao-indisponible.md, preuves à l'appui.
Questions de synthèse¶
- Qu'apporte un coffre par rapport à des secrets chiffrés dans Git, et que coûte-t-il en exploitation ?
- Pourquoi un
SecretStorepar namespace plutôt qu'unClusterSecretStorepartagé ? - Où vit le secret zéro de votre plateforme, et qui peut desceller la production ?
- Quels sont les avantages et les risques de l'auto-descellement ?
- Pourquoi l'audit d'OpenBao se déclare-t-il désormais dans la configuration du serveur plutôt que par l'API ?