Aller au contenu

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'environnement BAO_ADDR et BAO_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 sous secret/metadata/...

Les politiques

# securepay-api-read.hcl
path "secret/data/securepay/*" {
  capabilities = ["read"]
}
bao policy write securepay-api-read securepay-api-read.hcl
  • 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
  • stdout permet 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 SecretStore par namespace plutôt qu'un ClusterSecretStore partagé : 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

  1. Nouvelle valeur écrite dans OpenBao (nouvelle version KV)
  2. External Secrets Operator la récupère au prochain refreshInterval
  3. Le Secret Kubernetes est mis à jour
  4. Les variables d'environnement d'un pod ne changent pas à chaud : il faut un redémarrage progressif
  5. Un contrôleur comme Reloader déclenche ce redémarrage quand le Secret change

  6. Le temps de propagation se mesure : c'est une donnée du runbook de rotation

  7. 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'ExternalSecret passe en erreur
  • Aucune rotation n'est possible tant que la panne dure
  • Une alerte doit surveiller l'état des ExternalSecret et 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 ClusterSecretStore partagé 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

Pour aller plus loin