diff --git a/apps/openbao-init/templates/clustersecretstore.yaml b/apps/openbao-init/templates/clustersecretstore.yaml new file mode 100644 index 0000000..f0bab06 --- /dev/null +++ b/apps/openbao-init/templates/clustersecretstore.yaml @@ -0,0 +1,33 @@ +{{- if .Values.clusterSecretStore.enabled }} +# Point d'entrée unique d'ESO vers OpenBao, pour tout le cluster. +# +# Vit ici (et non dans apps/secrets-sync/) parce que c'est du câblage OpenBao +# partagé : n'importe quelle app peut le référencer. Le mettre dans le chart +# secrets-sync créerait une dépendance artificielle de tout le cluster envers +# le cas d'usage GitHub. +# +# Le provider s'appelle `vault` : OpenBao est un fork de Vault et parle la même +# API, ESO n'a pas de provider dédié. +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: {{ .Values.clusterSecretStore.name }} + annotations: + # Wave 2 : après le serveur OpenBao (wave 0) et le job de restore (wave 1). + argocd.argoproj.io/sync-wave: "2" +spec: + provider: + vault: + server: {{ .Values.baoAddr | quote }} + path: {{ .Values.clusterSecretStore.mountPath | quote }} + version: v2 + auth: + kubernetes: + mountPath: kubernetes + # Rôle créé manuellement dans OpenBao — voir openbao-claude.md. + # Lié au SA external-secrets/external-secrets via la policy eso-read. + role: {{ .Values.clusterSecretStore.role | quote }} + serviceAccountRef: + name: external-secrets + namespace: external-secrets +{{- end }} diff --git a/apps/openbao-init/values.yaml b/apps/openbao-init/values.yaml index 146ef2d..711c93f 100644 --- a/apps/openbao-init/values.yaml +++ b/apps/openbao-init/values.yaml @@ -25,3 +25,13 @@ s3: bucket: "" uri: "" extraFlag: "" + +# ClusterSecretStore : le pont ESO → OpenBao, partagé par tout le cluster. +# Suppose que ces objets existent côté OpenBao (créés à la main, cf. +# openbao-claude.md) : moteur kv-v2 monté sur `kv/`, auth kubernetes activée, +# policy `eso-read` et rôle `external-secrets`. +clusterSecretStore: + enabled: true + name: openbao + mountPath: kv + role: external-secrets diff --git a/apps/secrets-sync/Chart.yaml b/apps/secrets-sync/Chart.yaml new file mode 100644 index 0000000..a6ae96f --- /dev/null +++ b/apps/secrets-sync/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: secrets-sync +description: Push secrets from OpenBao to GitHub Actions secrets via ESO +type: application +version: 0.1.0 diff --git a/apps/secrets-sync/templates/externalsecret-github-app.yaml b/apps/secrets-sync/templates/externalsecret-github-app.yaml new file mode 100644 index 0000000..99b4ce7 --- /dev/null +++ b/apps/secrets-sync/templates/externalsecret-github-app.yaml @@ -0,0 +1,25 @@ +# Fait entrer la clé privée de la GitHub App dans le cluster. +# +# C'est le maillon qui évite d'avoir la clé dans git : les SecretStores GitHub +# ont besoin d'un Secret k8s, ESO le fabrique depuis OpenBao. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: github-app-private-key + namespace: {{ .Release.Namespace }} + annotations: + # Wave 0 : la clé doit exister avant les SecretStores qui la référencent. + argocd.argoproj.io/sync-wave: "0" +spec: + refreshInterval: {{ .Values.source.refreshInterval | quote }} + secretStoreRef: + name: {{ .Values.clusterSecretStoreName }} + kind: ClusterSecretStore + target: + name: {{ .Values.github.privateKey.secretName }} + creationPolicy: Owner + data: + - secretKey: {{ .Values.github.privateKey.openbaoKey }} + remoteRef: + key: {{ .Values.github.privateKey.openbaoPath }} + property: {{ .Values.github.privateKey.openbaoKey }} diff --git a/apps/secrets-sync/templates/externalsecrets-targets.yaml b/apps/secrets-sync/templates/externalsecrets-targets.yaml new file mode 100644 index 0000000..3eb802a --- /dev/null +++ b/apps/secrets-sync/templates/externalsecrets-targets.yaml @@ -0,0 +1,24 @@ +# Un ExternalSecret par cible : matérialise le secret OpenBao dédié à cette +# cible dans son propre Secret k8s. Isolation volontaire — voir le commentaire +# sur `source` dans values.yaml. +{{- range .Values.targets }} +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: secrets-sync-{{ .name }} + namespace: {{ $.Release.Namespace }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + refreshInterval: {{ $.Values.source.refreshInterval | quote }} + secretStoreRef: + name: {{ $.Values.clusterSecretStoreName }} + kind: ClusterSecretStore + target: + name: secrets-sync-{{ .name }} + creationPolicy: Owner + dataFrom: + - extract: + key: {{ $.Values.source.openbaoPathPrefix }}/{{ .name }} +{{- end }} diff --git a/apps/secrets-sync/templates/pushsecrets.yaml b/apps/secrets-sync/templates/pushsecrets.yaml new file mode 100644 index 0000000..a1ec631 --- /dev/null +++ b/apps/secrets-sync/templates/pushsecrets.yaml @@ -0,0 +1,34 @@ +# Un PushSecret par cible : pousse les clés choisies du Secret k8s intermédiaire +# vers GitHub, à travers le SecretStore correspondant. +{{- range .Values.targets }} +--- +apiVersion: external-secrets.io/v1alpha1 +kind: PushSecret +metadata: + name: github-{{ .name }} + namespace: {{ $.Release.Namespace }} + annotations: + # Wave 2 : après le SecretStore et le Secret source. + argocd.argoproj.io/sync-wave: "2" +spec: + refreshInterval: {{ $.Values.pushRefreshInterval | quote }} + secretStoreRefs: + - name: github-{{ .name }} + kind: SecretStore + selector: + secret: + name: secrets-sync-{{ .name }} + # `None` : supprimer le PushSecret laisse le secret en place côté GitHub. + # Volontaire — un `helm uninstall` ne doit pas casser des workflows en prod. + deletionPolicy: None + # `Replace` : OpenBao fait autorité, une modification manuelle côté GitHub est + # écrasée au refresh suivant. + updatePolicy: Replace + data: + {{- range .secrets }} + - match: + secretKey: {{ . }} + remoteRef: + remoteKey: {{ . }} + {{- end }} +{{- end }} diff --git a/apps/secrets-sync/templates/secretstores.yaml b/apps/secrets-sync/templates/secretstores.yaml new file mode 100644 index 0000000..40e9aaf --- /dev/null +++ b/apps/secrets-sync/templates/secretstores.yaml @@ -0,0 +1,41 @@ +# Un SecretStore GitHub par cible. +# +# Pourquoi un par cible plutôt qu'un seul : dans le provider GitHub d'ESO, la +# portée du push (organisation / repo / environnement) est portée par le +# SecretStore via `repository` et `environment`. Le PushSecret ne choisit que +# les clés, pas la destination. Deux portées = deux stores, même si le repo +# visé est le même. +{{- range .Values.targets }} +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: github-{{ .name }} + namespace: {{ $.Release.Namespace }} + annotations: + # Wave 1 : après l'ExternalSecret qui fabrique le Secret de la clé privée. + argocd.argoproj.io/sync-wave: "1" +spec: + provider: + github: + # Entiers, pas des chaînes : la CRD les type en int64. + # Le `| int64` n'est pas cosmétique : sans lui Helm rend ces valeurs en + # notation scientifique (4.346625e+06) et la CRD rejette le manifeste. + appID: {{ $.Values.github.appID | int64 }} + installationID: {{ $.Values.github.installationID | int64 }} + organization: {{ $.Values.github.organization }} + {{- if .repository }} + repository: {{ .repository }} + {{- else }} + # Pas de `repository` → secret d'organisation. + orgSecretVisibility: {{ $.Values.orgSecretVisibility }} + {{- end }} + {{- if .environment }} + # L'environnement doit exister côté GitHub : ESO ne le crée pas. + environment: {{ .environment }} + {{- end }} + auth: + privateKey: + name: {{ $.Values.github.privateKey.secretName }} + key: {{ $.Values.github.privateKey.openbaoKey }} +{{- end }} diff --git a/apps/secrets-sync/values-local.yaml b/apps/secrets-sync/values-local.yaml new file mode 100644 index 0000000..3287432 --- /dev/null +++ b/apps/secrets-sync/values-local.yaml @@ -0,0 +1,9 @@ +# Cluster local (minikube). +# +# C'est ici que la V0 est validée : le cluster local pousse réellement vers la +# vraie organisation GitHub. Les cibles de la baseline (values.yaml) suffisent, +# rien à surcharger pour l'instant. +# +# Attention si tu actives un jour secrets-sync sur scaleway en parallèle : les +# deux clusters pousseraient les mêmes clés vers les mêmes secrets GitHub, en +# se réécrivant mutuellement toutes les heures. Voir values-scaleway.yaml. diff --git a/apps/secrets-sync/values-scaleway.yaml b/apps/secrets-sync/values-scaleway.yaml new file mode 100644 index 0000000..147b0e9 --- /dev/null +++ b/apps/secrets-sync/values-scaleway.yaml @@ -0,0 +1,11 @@ +# Cluster scaleway (staging). +# +# Volontairement désactivé en V0 : GitHub est une ressource *globale*, pas une +# ressource par cluster. Si local et scaleway synchronisaient les mêmes cibles, +# ils écraseraient tour à tour les mêmes secrets d'organisation — sans dégât +# (les valeurs sont identiques) mais avec deux sources d'écriture concurrentes +# et un historique d'audit illisible côté GitHub. +# +# Pour activer : décider *quel* cluster fait autorité sur la synchro GitHub, +# et ne laisser des `targets` non vides que sur celui-là. +targets: [] diff --git a/apps/secrets-sync/values.yaml b/apps/secrets-sync/values.yaml new file mode 100644 index 0000000..d50696f --- /dev/null +++ b/apps/secrets-sync/values.yaml @@ -0,0 +1,81 @@ +# Synchronisation OpenBao → GitHub Actions secrets. +# +# Sens du flux : OpenBao est la source de vérité. ESO lit les valeurs, les +# matérialise dans un Secret k8s intermédiaire, puis les pousse vers GitHub. +# Le provider GitHub d'ESO est write-only : rien ne revient de GitHub. +# +# Aucune valeur de secret ici — uniquement des identifiants publics et des +# chemins. Les valeurs vivent dans OpenBao. + +# ClusterSecretStore déclaré par le chart openbao-init. +clusterSecretStoreName: openbao + +github: + # appID / installationID sont typés int64 dans la CRD : surtout pas de + # guillemets, sinon le webhook ESO rejette le SecretStore. + appID: 4346625 + installationID: 147805215 + organization: IntegratedDynamic + + # Clé privée de la GitHub App. Lue dans OpenBao, jamais dans git. + privateKey: + # Chemin relatif au mount kv/ (le provider vault préfixe tout seul). + openbaoPath: apps/secrets-sync/github/eso-github-app-private-key + openbaoKey: privateKey.pem + # Secret k8s intermédiaire que les SecretStores référencent. + secretName: github-app-private-key + +# Préfixe des secrets OpenBao source — un par cible, sous /. +# Volontairement PAS un secret unique partagé : deux cibles sans rapport (un +# secret d'org, un secret d'un repo précis) ne doivent pas partager un +# historique de versions Vault ni une policy d'accès. Chaque cible a son propre +# secret, donc sa propre policy possible et son propre historique de rotation. +# +# Le segment `github` isole ce qui est spécifique à ce provider : tout ce qui +# est propre à la synchro (peu importe la destination) vit sous +# apps/secrets-sync/, ce qui est propre à GitHub vit sous +# apps/secrets-sync/github/ — de la place pour un autre provider plus tard +# (ex. apps/secrets-sync/slack/) sans collision de noms. +source: + openbaoPathPrefix: apps/secrets-sync/github + refreshInterval: 1h + +# Cibles de publication. Une entrée = un secret OpenBao (à +# /) + un ExternalSecret + un SecretStore + un +# PushSecret, tous dédiés à cette cible. +# +# Le scope (org / repo / environnement) se règle sur le SecretStore, pas sur le +# PushSecret : c'est pour ça qu'il faut une cible distincte par portée, même +# quand deux cibles visent le même repo. +# +# Champs : +# name identifiant k8s de la cible (kebab-case) ET nom du secret +# OpenBao sous le préfixe ci-dessus +# repository omis → secret d'organisation +# environment optionnel, exige `repository` et que l'env existe déjà côté GitHub +# secrets clés à pousser, telles qu'elles apparaissent dans le secret +# OpenBao de la cible ET dans GitHub (mapping 1:1, volontairement +# simple) +targets: + # - name: global + # secrets: + # - TEST_ORGA + + # - name: infrastructure + # repository: infrastructure + # secrets: + # - TEST_REPO + + - name: infrastructure-scaleway + repository: infrastructure + environment: scaleway + secrets: + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + +# Visibilité des secrets d'organisation créés : "all" ou "private". +orgSecretVisibility: private + +# Fréquence de repush. ESO ne peut pas relire GitHub (write-only) : il repousse +# à l'aveugle à chaque intervalle, ce qui corrige une dérive manuelle côté GitHub. +pushRefreshInterval: 1h diff --git a/clusters/local/templates/secrets-sync.yaml b/clusters/local/templates/secrets-sync.yaml new file mode 100644 index 0000000..217eb56 --- /dev/null +++ b/clusters/local/templates/secrets-sync.yaml @@ -0,0 +1,26 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: secrets-sync + namespace: argocd +spec: + project: default + + source: + repoURL: {{ .Values.repoURL }} + targetRevision: {{ .Values.revision }} + path: apps/secrets-sync + helm: + valueFiles: + - values-local.yaml + + destination: + server: https://kubernetes.default.svc + namespace: secrets-sync + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/clusters/scaleway/templates/secrets-sync.yaml b/clusters/scaleway/templates/secrets-sync.yaml new file mode 100644 index 0000000..887c8c3 --- /dev/null +++ b/clusters/scaleway/templates/secrets-sync.yaml @@ -0,0 +1,29 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: secrets-sync + namespace: argocd +spec: + project: default + + source: + repoURL: {{ .Values.repoURL }} + targetRevision: {{ .Values.revision }} + path: apps/secrets-sync + helm: + # values-scaleway.yaml met `targets: []` : l'Application existe et se + # synchronise, mais ne pousse rien vers GitHub. Voir le fichier pour le + # pourquoi (GitHub est global, un seul cluster doit faire autorité). + valueFiles: + - values-scaleway.yaml + + destination: + server: https://kubernetes.default.svc + namespace: secrets-sync + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true