Aller au contenu

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 securepay du dépôt securepay-platform) avec kubectl et helm
  • Deux namespaces applicatifs securepay-staging et securepay-prod, contenant chacun un ServiceAccount securepay-api et le Deployment de l'API
  • La CLI bao installé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é

  1. 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
  1. Rédigez security/openbao/values.yaml. Depuis OpenBao 2.3, un dispositif d'audit ne peut plus être activé par l'API (bao audit enable est 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"
        }
      }
  1. 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.

  1. Initialisez puis descellez chaque instance. Ouvrez un port-forward par 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
  1. 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 list affiche le dispositif to-stdout/ sans qu'aucune commande audit enable n'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) :

  1. 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
  1. 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"]
}
bao policy write securepay-api-read security/openbao/policies/securepay-api-read.hcl
  1. 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
  1. Versionnez ces commandes dans un script idempotent (security/openbao/configure.sh <env>) : la configuration d'un coffre est du code comme le reste.

  2. 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é

  1. 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
  1. Dans chaque namespace applicatif, créez un SecretStore qui pointe vers l'instance de son environnement, et un ExternalSecret :
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
  1. 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 stripe affiche SecretSynced
  • Le Secret stripe contient 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é

  1. É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"
  1. Ajoutez le test symétrique (production vers staging) et un test qui vérifie que la politique ne permet pas d'écrire.

  2. Second test négatif, côté Kubernetes : créez dans securepay-staging un SecretStore qui vise openbao-prod, avec le même ExternalSecret. Relevez le motif d'erreur dans kubectl describe externalsecret.

  3. 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'ExternalSecret du 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

  1. 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"
  1. 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
  1. 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

  1. Exposez openbao-staging par 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
  1. 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"
  1. 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é

  1. Retrouvez dans les journaux du pod de production les tentatives de l'atelier 4 :
kubectl -n openbao-prod logs openbao-prod-0 | grep 'auth/kubernetes/login' | head
  1. Dans Grafana (Loki), construisez la requête qui isole les refus, puis une alerte sur leur fréquence :
{namespace="openbao-prod"} |= "auth/kubernetes/login" |= "error"
  1. 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é

  1. Formulez l'hypothèse avant d'agir : que deviennent l'API, le Secret Kubernetes et l'ExternalSecret si openbao-prod disparaît ?

  2. Provoquez la panne sous charge nominale (k6) :

kubectl -n openbao-prod scale statefulset openbao-prod --replicas=0
  1. Observez l'API, le Secret stripe et l'état de l'ExternalSecret après au moins un refreshInterval.

  2. 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.

  1. 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'ExternalSecret passe 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 SecretStore par namespace plutôt qu'un ClusterSecretStore partagé ?
  • 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 ?