From 4ca4a05370e08e86eecb99534d8acea84959fdc7 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 09:12:09 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(minio)=20=E2=80=94=20un=20compte=20de?= =?UTF-8?q?=20service=20par=20app=20consommatrice,=20born=C3=A9=20=C3=A0?= =?UTF-8?q?=20son=20bucket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « On peut adopter ce pattern à toutes les apps qui pourraient avoir besoin de MinIO, et ainsi modifier les app roles / app policy générique. » (fondateur, 26/07) Bonne nouvelle : les modules centraux n'ont PAS eu à changer. `kv_read_paths` existe déjà dans `app_policy` et fait exactement ça — l'ERP s'en sert pour lire les creds GCS de Longhorn. Le motif générique était donc à moitié construit ; il manquait la moitié MinIO. CE QUI EST AJOUTÉ : `minio/iac/consumers.tf` provisionne, pour chaque app déclarée dans `var.consumers`, une politique MinIO bornée à SON bucket (GetObject/PutObject/DeleteObject sur les objets, ListBucket sur le bucket seul — ni les autres buckets, ni l'administration), un compte de service, et écrit ses clés dans `kvv2/minio/`. POURQUOI LES CLÉS VIVENT CHEZ MINIO ET PAS CHEZ L'APP : seul ce pipeline possède les identifiants ROOT. Si chaque app créait son propre compte de service, il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. Ici il ne sort jamais d'ici, et l'app ne reçoit qu'une clé qui ne peut rien lire d'autre que son bucket. Un compte de service qui fuite ne donne accès qu'aux objets qu'il gérait déjà. Ajouter une app = trois lignes déclaratives, documentées au README : son bucket dans values.yaml, son entrée dans `var.consumers`, son `kv_read_paths` au tfvars central. Aucun module à toucher. Le provider parle à `s3.arcodange.fr` — l'endpoint PUBLIC, parce que le runner CI n'est pas dans le LAN et que `.lab` ne s'y résout pas. C'est l'exposition HTTPS mergée ce matin qui rend ce plan applicable. ⚠ Erreur attrapée par le validateur, et corrigée : je relisais `kvv2/minio/config` alors que `local.config` de main.tf porte déjà les identifiants root — une lecture qui aurait créé une dépendance circulaire avec l'écriture faite par ce même plan. Vérifié : `tofu fmt` propre, `tofu validate` réussi (avec TERRAFORM_VAULT_AUTH_JWT posé — sans lui, le provider Vault échoue déjà sur main, ce n'est pas mon fait). Je n'ai lancé AUCUN apply : c'est le workflow qui le fera, et la revue t'appartient. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- hashicorp-vault/iac/terraform.tfvars | 10 +++- minio/README.md | 38 +++++++++++++ minio/iac/consumers.tf | 81 ++++++++++++++++++++++++++++ minio/iac/providers.tf | 18 +++++++ minio/iac/variables.tf | 16 ++++++ 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 minio/iac/consumers.tf create mode 100644 minio/iac/variables.tf diff --git a/hashicorp-vault/iac/terraform.tfvars b/hashicorp-vault/iac/terraform.tfvars index b0921e7..14cdff4 100644 --- a/hashicorp-vault/iac/terraform.tfvars +++ b/hashicorp-vault/iac/terraform.tfvars @@ -24,5 +24,13 @@ applications = [ service_account_namespaces = ["tools"] }, { name = "prospection" }, - { name = "kadans" }, + { + name = "kadans" + # Le pod lit les clés de SON compte de service MinIO, écrites par le pipeline + # `minio` dans son propre espace (minio/iac/consumers.tf). Le mécanisme est + # celui qui existait déjà pour l'ERP et les creds GCS de Longhorn — aucun + # module central n'a eu à changer. Le root de MinIO, lui, ne sort jamais du + # pipeline minio : l'app ne reçoit qu'une clé bornée à son bucket. + kv_read_paths = ["kvv2/data/minio/kadans"] + }, ] diff --git a/minio/README.md b/minio/README.md index a84d3cd..2e8276d 100644 --- a/minio/README.md +++ b/minio/README.md @@ -92,3 +92,41 @@ du fondateur (707 vidéos, ~2 ans), **la durée moyenne est de 53 secondes** et passe très largement. Si la limite se confirme, le plafond de 200 Mio annoncé côté API mérite d'être ramené sous celle du tunnel — mieux vaut refuser tôt, avec une phrase claire, qu'échouer au milieu d'un téléversement. + + +## Donner à une app l'accès au stockage + +Le motif tient en **trois pièces**, et il est générique — ajouter une app ne +demande aucune modification des modules Vault centraux. + +1. **Le bucket** — dans `values.yaml` de ce chart (bloc `buckets`), privé. +2. **Le compte de service** — une entrée dans `var.consumers` de `iac/consumers.tf` : + ```hcl + { app = "mon-app", bucket = "mon-app-fichiers" } + ``` + Le plan crée une politique MinIO **bornée à ce bucket**, un compte de service, + et écrit ses clés dans `kvv2/minio/mon-app`. +3. **La lecture** — dans `hashicorp-vault/iac/terraform.tfvars`, l'app déclare : + ```hcl + { name = "mon-app", kv_read_paths = ["kvv2/data/minio/mon-app"] } + ``` + `kv_read_paths` **existait déjà** (l'ERP s'en sert pour les creds GCS de + Longhorn) : rien à changer dans `app_policy` ni `app_roles`. + +Puis, côté app : une `VaultStaticSecret` sur `kvv2/minio/` et l'injection +des variables dans le Deployment. + +### Pourquoi les clés vivent ICI et pas chez l'app + +Seul ce pipeline possède les identifiants **root** de MinIO. Si chaque app créait +son propre compte de service, il faudrait donner ce root à chaque rôle CI — +c'est-à-dire à tout le monde. Ici il ne sort jamais, et l'app ne reçoit qu'une +clé qui **ne peut rien lire d'autre que son bucket** : ni les autres buckets, ni +l'administration. Un compte de service qui fuite ne donne accès qu'aux objets +qu'il gérait déjà. + +### Rotation + +Détruire `random_password.app[""]` et relancer le plan suffit : la clé +change, `force_destroy = false` garde le compte et les objets déjà déposés +conservent leur propriétaire. diff --git a/minio/iac/consumers.tf b/minio/iac/consumers.tf new file mode 100644 index 0000000..7d3546a --- /dev/null +++ b/minio/iac/consumers.tf @@ -0,0 +1,81 @@ +# ── Les apps qui STOCKENT des objets dans MinIO ────────────────────────────── +# +# Le motif, générique, en trois pièces : +# 1. ICI : un compte de service MinIO par app, borné à SON bucket, dont les +# clés sont écrites dans `kvv2/minio/` ; +# 2. côté Vault central : l'app déclare `kv_read_paths = ["kvv2/data/minio/"]` +# dans `hashicorp-vault/iac/terraform.tfvars` — le mécanisme existait DÉJÀ +# (même recette que `kvv2/data/longhorn/gcs-backup` pour l'ERP), donc aucun +# module central n'a eu besoin d'être modifié ; +# 3. côté app : une VaultStaticSecret qui matérialise ce chemin en Secret k8s. +# +# POURQUOI les clés vivent chez MINIO et pas chez l'app : seul ce pipeline-ci +# possède les identifiants ROOT. Si chaque app créait son propre compte de +# service, il faudrait donner le root de MinIO à chaque rôle CI — c'est-à-dire +# à tout le monde. Ici il ne sort jamais d'ici, et l'app ne reçoit qu'une clé +# qui ne peut rien lire d'autre que son bucket. +# +# AJOUTER UNE APP = deux lignes : une entrée dans `var.consumers` ci-dessous +# (avec son bucket, qui doit exister dans `values.yaml` du chart), et un +# `kv_read_paths` dans le tfvars central. + +# La politique d'accès : LE bucket de l'app, rien d'autre. Ni listing des autres +# buckets, ni administration — un compte de service qui fuite ne donne accès +# qu'aux objets qu'il gérait déjà. +resource "minio_iam_policy" "app" { + for_each = { for c in var.consumers : c.app => c } + name = "${each.key}-app" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + Resource = ["arn:aws:s3:::${each.value.bucket}/*"] + }, + { + # Nécessaire pour qu'un client S3 puisse vérifier l'existence du bucket + # et lister SES objets — jamais ceux d'un autre. + Effect = "Allow" + Action = ["s3:ListBucket", "s3:GetBucketLocation"] + Resource = ["arn:aws:s3:::${each.value.bucket}"] + }, + ] + }) +} + +resource "minio_iam_user" "app" { + for_each = { for c in var.consumers : c.app => c } + name = "${each.key}-app" + # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. + secret = random_password.app[each.key].result + # `false` = on ne recrée pas l'utilisateur à chaque rotation du mot de passe : + # les objets déjà déposés gardent leur propriétaire. + force_destroy = false +} + +resource "random_password" "app" { + for_each = { for c in var.consumers : c.app => c } + length = 40 + special = false # les outils S3 transportent mal certains caractères en URL +} + +resource "minio_iam_user_policy_attachment" "app" { + for_each = { for c in var.consumers : c.app => c } + user_name = minio_iam_user.app[each.key].id + policy_name = minio_iam_policy.app[each.key].id +} + +# Les clés, déposées dans l'espace Vault de MINIO — l'app y accède par le +# `kv_read_paths` déclaré au tfvars central (pièce 2 du motif, ci-dessus). +resource "vault_kv_secret_v2" "app" { + for_each = { for c in var.consumers : c.app => c } + mount = "kvv2" + name = "minio/${each.key}" + data_json = jsonencode({ + MINIO_ENDPOINT = var.minio_endpoint + MINIO_BUCKET = each.value.bucket + MINIO_ACCESS_KEY = minio_iam_user.app[each.key].id + MINIO_SECRET_KEY = random_password.app[each.key].result + }) +} diff --git a/minio/iac/providers.tf b/minio/iac/providers.tf index 501193f..40d183c 100644 --- a/minio/iac/providers.tf +++ b/minio/iac/providers.tf @@ -4,6 +4,14 @@ terraform { source = "vault" version = "4.4.0" } + minio = { + source = "aminueza/minio" + version = "3.3.0" + } + random = { + source = "hashicorp/random" + version = "3.6.3" + } } } @@ -14,3 +22,13 @@ provider "vault" { role = "gitea_cicd_minio" } } + +# Provider MinIO — pour créer les COMPTES DE SERVICE des apps consommatrices. +# Il parle à l'API S3 publique (s3.arcodange.fr, exposée par le chart) : le +# runner CI n'est pas dans le LAN, et `.lab` ne s'y résout pas. +provider "minio" { + minio_server = var.minio_endpoint + minio_user = local.config.rootUser + minio_password = local.config.rootPassword + minio_ssl = true +} diff --git a/minio/iac/variables.tf b/minio/iac/variables.tf new file mode 100644 index 0000000..4759165 --- /dev/null +++ b/minio/iac/variables.tf @@ -0,0 +1,16 @@ +variable "minio_endpoint" { + type = string + default = "s3.arcodange.fr" + description = "Hôte de l'API S3, SANS schéma (le provider ajoute https via minio_ssl). Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." +} + +variable "consumers" { + type = list(object({ + app = string # nom de l'app — doit correspondre à son nom Vault (kvv2/) et à son rôle k8s + bucket = string # son bucket, qui doit exister dans `values.yaml` du chart (bloc `buckets`) + })) + default = [ + { app = "kadans", bucket = "kadans-videos" }, + ] + description = "Apps autorisées à stocker des objets. Chacune reçoit un compte de service borné à SON bucket, dont les clés atterrissent dans kvv2/minio/. Ajouter une app ici ne suffit pas : elle doit aussi déclarer kv_read_paths = [\"kvv2/data/minio/\"] dans hashicorp-vault/iac/terraform.tfvars." +} From 91a0f09b491371cd4bc0e53267beefd255153a14 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 09:26:05 +0200 Subject: [PATCH 2/6] =?UTF-8?q?refactor(vault)=20=E2=80=94=20lire=20ses=20?= =?UTF-8?q?identifiants=20MinIO=20devient=20une=20propri=C3=A9t=C3=A9=20de?= =?UTF-8?q?=20la=20plateforme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour fondateur : « je pensais que tools#21 contribuerait à app_policy pour une policy kvv2/minio/ ». Il a raison, et mon choix initial était le plus faible des deux. Mon objection — ne donner le droit qu'aux apps qui en ont besoin — ne tient pas à l'examen : la règle porte le NOM de l'app, donc elle ne peut jamais exposer que ses propres clés. Il n'y a aucun privilège à préserver. Une app qui ne stocke rien lit un chemin qui n'existe pas : une règle inerte, pas un droit. Son argument, lui, porte : savoir lire ses propres identifiants de stockage est une propriété de la PLATEFORME, pas une exception par application. Et `kv_read_paths` est documenté comme la trappe pour un secret appartenant à une AUTRE app (les creds GCS de Longhorn pour l'ERP) — y ranger un motif standard l'aurait rendu invisible et aurait obligé à le redéclarer à chaque app. La règle passe donc dans `app_policy`, en prod ET pour chaque instance non-prod (symétrie stricte), sur deux chemins : le document `kvv2/data/minio/` et ses descendants. Un cran plus loin que la demande : la règle est INCONDITIONNELLE, sans drapeau. Conséquence — déclarer un consommateur MinIO se fait désormais à UN SEUL endroit, `var.consumers` du pipeline minio. Aucune synchronisation à tenir entre deux fichiers, donc rien à oublier. Le `kv_read_paths` que j'avais ajouté à kadans est retiré : il faisait double emploi. tofu fmt propre · tofu validate réussi sur hashicorp-vault/iac. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- hashicorp-vault/iac/factory_auth.tf | 2 +- .../iac/modules/app_policy/main.tf | 34 +++++++++++++++++++ hashicorp-vault/iac/terraform.tfvars | 10 +----- minio/README.md | 18 ++++++---- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/hashicorp-vault/iac/factory_auth.tf b/hashicorp-vault/iac/factory_auth.tf index d9a1fc0..ba87ce1 100644 --- a/hashicorp-vault/iac/factory_auth.tf +++ b/hashicorp-vault/iac/factory_auth.tf @@ -1,5 +1,5 @@ locals { - factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin" + factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin" } diff --git a/hashicorp-vault/iac/modules/app_policy/main.tf b/hashicorp-vault/iac/modules/app_policy/main.tf index 6e7a34d..f93c0fb 100644 --- a/hashicorp-vault/iac/modules/app_policy/main.tf +++ b/hashicorp-vault/iac/modules/app_policy/main.tf @@ -178,6 +178,30 @@ data "vault_policy_document" "app" { path = "postgres/creds/${local.name}*" capabilities = ["read"] } + # Identifiants de SON compte de service MinIO, provisionné par le pipeline + # `minio` (tools/minio/iac/consumers.tf) dans l'espace Vault de MinIO. + # + # INCONDITIONNEL, et c'est voulu : le chemin porte le nom de l'app, donc cette + # règle ne peut jamais exposer que ses PROPRES clés. Une app qui ne stocke rien + # lit un chemin qui n'existe pas — une règle inerte, pas un privilège. + # + # Pourquoi ici plutôt que dans `kv_read_paths` de chaque app : savoir lire ses + # propres identifiants de stockage est une propriété de la PLATEFORME, pas une + # exception par application. `kv_read_paths` est la trappe pour un secret + # appartenant à une AUTRE app (les creds GCS de Longhorn pour l'ERP) ; y ranger + # un motif standard le rendrait invisible et obligerait à le redéclarer partout. + # Conséquence pratique : déclarer un consommateur MinIO se fait à UN seul + # endroit — `var.consumers` du pipeline minio. Aucune synchronisation à tenir. + rule { + path = "kvv2/data/minio/${local.name}/*" + capabilities = ["read", "list"] + } + rule { + # Le secret est écrit à `kvv2/minio/` (sans sous-chemin) : la règle + # ci-dessus couvre les descendants, celle-ci le document lui-même. + path = "kvv2/data/minio/${local.name}" + capabilities = ["read", "list"] + } # Extra shared paths this app's prod runtime may read (e.g. backup creds). dynamic "rule" { for_each = var.kv_read_paths @@ -204,6 +228,16 @@ data "vault_policy_document" "app_non_prod" { path = "postgres/creds/${each.key}*" capabilities = ["read"] } + # Même règle qu'en prod (voir le commentaire de vault_policy_document.app) : + # chaque instance lit les identifiants MinIO portant SON nom. + rule { + path = "kvv2/data/minio/${each.key}/*" + capabilities = ["read", "list"] + } + rule { + path = "kvv2/data/minio/${each.key}" + capabilities = ["read", "list"] + } } resource "vault_policy" "app_non_prod" { for_each = toset(local.non_prod_instances) diff --git a/hashicorp-vault/iac/terraform.tfvars b/hashicorp-vault/iac/terraform.tfvars index 14cdff4..b0921e7 100644 --- a/hashicorp-vault/iac/terraform.tfvars +++ b/hashicorp-vault/iac/terraform.tfvars @@ -24,13 +24,5 @@ applications = [ service_account_namespaces = ["tools"] }, { name = "prospection" }, - { - name = "kadans" - # Le pod lit les clés de SON compte de service MinIO, écrites par le pipeline - # `minio` dans son propre espace (minio/iac/consumers.tf). Le mécanisme est - # celui qui existait déjà pour l'ERP et les creds GCS de Longhorn — aucun - # module central n'a eu à changer. Le root de MinIO, lui, ne sort jamais du - # pipeline minio : l'app ne reçoit qu'une clé bornée à son bucket. - kv_read_paths = ["kvv2/data/minio/kadans"] - }, + { name = "kadans" }, ] diff --git a/minio/README.md b/minio/README.md index 2e8276d..8e757d4 100644 --- a/minio/README.md +++ b/minio/README.md @@ -106,16 +106,22 @@ demande aucune modification des modules Vault centraux. ``` Le plan crée une politique MinIO **bornée à ce bucket**, un compte de service, et écrit ses clés dans `kvv2/minio/mon-app`. -3. **La lecture** — dans `hashicorp-vault/iac/terraform.tfvars`, l'app déclare : - ```hcl - { name = "mon-app", kv_read_paths = ["kvv2/data/minio/mon-app"] } - ``` - `kv_read_paths` **existait déjà** (l'ERP s'en sert pour les creds GCS de - Longhorn) : rien à changer dans `app_policy` ni `app_roles`. +3. **La lecture** — *rien à faire.* Le module central `app_policy` accorde à + **toute** app la lecture de `kvv2/data/minio/`. La règle est + inconditionnelle et c'est voulu : le chemin porte le nom de l'app, donc elle + ne peut jamais exposer que ses propres clés ; une app qui ne stocke rien lit + un chemin qui n'existe pas. Puis, côté app : une `VaultStaticSecret` sur `kvv2/minio/` et l'injection des variables dans le Deployment. +> **Un seul endroit déclare un consommateur** : `var.consumers`, ici. Rien à +> synchroniser dans le tfvars central, donc rien à oublier. C'est la raison pour +> laquelle la règle vit dans le module plutôt que dans `kv_read_paths` — cette +> dernière est la trappe pour lire un secret appartenant à une AUTRE app (les +> creds GCS de Longhorn pour l'ERP) ; y ranger un motif standard le rendrait +> invisible et obligerait à le redéclarer à chaque fois. + ### Pourquoi les clés vivent ICI et pas chez l'app Seul ce pipeline possède les identifiants **root** de MinIO. Si chaque app créait From 287e3dcf1ea308516b07d8c0e70c78967092a686 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 09:38:24 +0200 Subject: [PATCH 3/6] =?UTF-8?q?refactor(minio)=20=E2=80=94=20le=20BUCKET?= =?UTF-8?q?=20est=20la=20seule=20d=C3=A9claration=20:=20plus=20de=20liste?= =?UTF-8?q?=20=C3=A0=20tenir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Je ne vois pas le mal à donner la permission de lire sur un chemin qui n'existe pas. Je préfère ne pas m'embêter avec consumers ou autre. » (fondateur, 26/07) `var.consumers` disparaît. Le plan LIT `values.yaml` du chart — le même fichier qu'Helm consomme — et provisionne un compte de service par bucket. Créer un bucket EST la déclaration : il devient impossible d'avoir un bucket sans son compte, ou un compte sans son bucket. Une liste de plus aurait été une liste à tenir synchronisée, donc une liste à oublier. La convention qui rend ça possible : UN BUCKET PAR APP, NOMMÉ COMME ELLE. Le bucket passe donc de `kadans-videos` à `kadans`. Il est VIDE aujourd'hui — le renommer maintenant ne coûte rien ; dans un mois ce serait une migration. Tout en découle sans être écrit ailleurs : le compte `-app` borné à ce seul bucket, le secret `kvv2/minio/`, et la lecture que `app_policy` accorde déjà à toute app sur `kvv2/data/minio/`. Vérifié plutôt que supposé : `yamldecode` lit bien ce values.yaml, ancres YAML comprises (testé en isolation avant d'écrire le plan). tofu fmt propre, tofu validate réussi. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- minio/README.md | 53 +++++++++++++------------ minio/iac/consumers.tf | 88 ++++++++++++++++++++++-------------------- minio/iac/variables.tf | 13 ++----- minio/values.yaml | 6 ++- 4 files changed, 81 insertions(+), 79 deletions(-) diff --git a/minio/README.md b/minio/README.md index 8e757d4..56f7aac 100644 --- a/minio/README.md +++ b/minio/README.md @@ -25,7 +25,7 @@ application vivent avec cette application. | Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO | | API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » | | Console | `minio.arcodange.lab` (Traefik) | -| Bucket | `kadans-videos`, **privé** — l'accès passe par des URL signées (ADR-0002 du dossier produit) | +| Buckets | **un par app, nommé comme elle** (`kadans`…), tous **privés** — l'accès passe par des URL signées (ADR-0002 du dossier produit). Le bucket est la SEULE déclaration : compte de service et droits Vault en découlent | | Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` | Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du @@ -96,43 +96,42 @@ une phrase claire, qu'échouer au milieu d'un téléversement. ## Donner à une app l'accès au stockage -Le motif tient en **trois pièces**, et il est générique — ajouter une app ne -demande aucune modification des modules Vault centraux. +**Une seule chose à faire** : ajouter son bucket dans `values.yaml`, **nommé +comme l'app**. -1. **Le bucket** — dans `values.yaml` de ce chart (bloc `buckets`), privé. -2. **Le compte de service** — une entrée dans `var.consumers` de `iac/consumers.tf` : - ```hcl - { app = "mon-app", bucket = "mon-app-fichiers" } - ``` - Le plan crée une politique MinIO **bornée à ce bucket**, un compte de service, - et écrit ses clés dans `kvv2/minio/mon-app`. -3. **La lecture** — *rien à faire.* Le module central `app_policy` accorde à - **toute** app la lecture de `kvv2/data/minio/`. La règle est - inconditionnelle et c'est voulu : le chemin porte le nom de l'app, donc elle - ne peut jamais exposer que ses propres clés ; une app qui ne stocke rien lit - un chemin qui n'existe pas. +```yaml +buckets: + - name: mon-app + policy: none # privé : l'accès passe par des URL signées +``` -Puis, côté app : une `VaultStaticSecret` sur `kvv2/minio/` et l'injection -des variables dans le Deployment. +Tout le reste en découle, sans autre déclaration nulle part : -> **Un seul endroit déclare un consommateur** : `var.consumers`, ici. Rien à -> synchroniser dans le tfvars central, donc rien à oublier. C'est la raison pour -> laquelle la règle vit dans le module plutôt que dans `kv_read_paths` — cette -> dernière est la trappe pour lire un secret appartenant à une AUTRE app (les -> creds GCS de Longhorn pour l'ERP) ; y ranger un motif standard le rendrait -> invisible et obligerait à le redéclarer à chaque fois. +- `iac/consumers.tf` **lit ce même fichier** et crée un compte de service + `mon-app-app`, borné à ce seul bucket, dont les clés atterrissent dans + `kvv2/minio/mon-app` ; +- le module Vault central `app_policy` accorde **déjà** à toute app la lecture de + `kvv2/data/minio/` — inconditionnellement, parce que le chemin porte + le nom de l'app et ne peut donc jamais exposer que ses propres clés. Une app + qui ne stocke rien y lit un chemin qui n'existe pas : une règle inerte. + +Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/` et à +injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple). + +> **Il n'y a volontairement AUCUNE liste de consommateurs.** Une liste de plus +> serait une liste à tenir synchronisée avec les buckets — donc une liste à +> oublier. Le bucket fait foi. ### Pourquoi les clés vivent ICI et pas chez l'app Seul ce pipeline possède les identifiants **root** de MinIO. Si chaque app créait son propre compte de service, il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. Ici il ne sort jamais, et l'app ne reçoit qu'une -clé qui **ne peut rien lire d'autre que son bucket** : ni les autres buckets, ni -l'administration. Un compte de service qui fuite ne donne accès qu'aux objets -qu'il gérait déjà. +clé qui **ne peut rien lire d'autre que son bucket**. Un compte de service qui +fuite ne donne accès qu'aux objets qu'il gérait déjà. ### Rotation Détruire `random_password.app[""]` et relancer le plan suffit : la clé -change, `force_destroy = false` garde le compte et les objets déjà déposés +change, `force_destroy = false` garde le compte, et les objets déjà déposés conservent leur propriétaire. diff --git a/minio/iac/consumers.tf b/minio/iac/consumers.tf index 7d3546a..bd4e395 100644 --- a/minio/iac/consumers.tf +++ b/minio/iac/consumers.tf @@ -1,29 +1,35 @@ -# ── Les apps qui STOCKENT des objets dans MinIO ────────────────────────────── +# ── Un compte de service par BUCKET, sans rien déclarer de plus ────────────── # -# Le motif, générique, en trois pièces : -# 1. ICI : un compte de service MinIO par app, borné à SON bucket, dont les -# clés sont écrites dans `kvv2/minio/` ; -# 2. côté Vault central : l'app déclare `kv_read_paths = ["kvv2/data/minio/"]` -# dans `hashicorp-vault/iac/terraform.tfvars` — le mécanisme existait DÉJÀ -# (même recette que `kvv2/data/longhorn/gcs-backup` pour l'ERP), donc aucun -# module central n'a eu besoin d'être modifié ; -# 3. côté app : une VaultStaticSecret qui matérialise ce chemin en Secret k8s. +# Il n'y a PAS de liste de consommateurs : elle se lit dans `values.yaml` du +# chart. Créer un bucket EST la déclaration (fondateur 2026-07-26 : « je préfère +# ne pas m'embêter avec consumers ou autre »). Rien à synchroniser entre deux +# fichiers, donc rien à oublier — et aucune divergence possible. # -# POURQUOI les clés vivent chez MINIO et pas chez l'app : seul ce pipeline-ci -# possède les identifiants ROOT. Si chaque app créait son propre compte de -# service, il faudrait donner le root de MinIO à chaque rôle CI — c'est-à-dire -# à tout le monde. Ici il ne sort jamais d'ici, et l'app ne reçoit qu'une clé -# qui ne peut rien lire d'autre que son bucket. +# La convention qui rend ça possible : UN BUCKET PAR APP, NOMMÉ COMME ELLE. +# Tout en découle sans être écrit nulle part ailleurs : +# · le compte de service `-app`, borné à ce seul bucket ; +# · le secret `kvv2/minio/` ; +# · la lecture, que le module central `app_policy` accorde déjà à TOUTE app +# sur `kvv2/data/minio/`, inconditionnellement — une app qui ne +# stocke rien y lit un chemin qui n'existe pas, ce qui n'est pas un droit. # -# AJOUTER UNE APP = deux lignes : une entrée dans `var.consumers` ci-dessous -# (avec son bucket, qui doit exister dans `values.yaml` du chart), et un -# `kv_read_paths` dans le tfvars central. +# POURQUOI LES CLÉS VIVENT ICI ET PAS CHEZ L'APP : seul ce pipeline possède les +# identifiants ROOT de MinIO. Si chaque app créait son propre compte de service, +# il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. +# Ici il ne sort jamais, et l'app ne reçoit qu'une clé qui ne peut rien lire +# d'autre que son bucket. Un compte qui fuite ne donne accès qu'aux objets qu'il +# gérait déjà. -# La politique d'accès : LE bucket de l'app, rien d'autre. Ni listing des autres -# buckets, ni administration — un compte de service qui fuite ne donne accès -# qu'aux objets qu'il gérait déjà. +locals { + # Le MÊME fichier que celui qu'Helm consomme : impossible de créer un bucket + # sans son compte de service, ou un compte sans son bucket. + buckets = [for b in yamldecode(file("${path.module}/../values.yaml")).minio.buckets : b.name] +} + +# La politique d'accès : SON bucket, rien d'autre. Ni listing des autres +# buckets, ni administration. resource "minio_iam_policy" "app" { - for_each = { for c in var.consumers : c.app => c } + for_each = toset(local.buckets) name = "${each.key}-app" policy = jsonencode({ Version = "2012-10-17" @@ -31,50 +37,50 @@ resource "minio_iam_policy" "app" { { Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] - Resource = ["arn:aws:s3:::${each.value.bucket}/*"] + Resource = ["arn:aws:s3:::${each.key}/*"] }, { - # Nécessaire pour qu'un client S3 puisse vérifier l'existence du bucket - # et lister SES objets — jamais ceux d'un autre. + # Nécessaire pour qu'un client S3 vérifie l'existence du bucket et liste + # SES objets — jamais ceux d'un autre. Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] - Resource = ["arn:aws:s3:::${each.value.bucket}"] + Resource = ["arn:aws:s3:::${each.key}"] }, ] }) } -resource "minio_iam_user" "app" { - for_each = { for c in var.consumers : c.app => c } - name = "${each.key}-app" - # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. - secret = random_password.app[each.key].result - # `false` = on ne recrée pas l'utilisateur à chaque rotation du mot de passe : - # les objets déjà déposés gardent leur propriétaire. - force_destroy = false -} - resource "random_password" "app" { - for_each = { for c in var.consumers : c.app => c } + for_each = toset(local.buckets) length = 40 special = false # les outils S3 transportent mal certains caractères en URL } +resource "minio_iam_user" "app" { + for_each = toset(local.buckets) + name = "${each.key}-app" + # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. + secret = random_password.app[each.key].result + # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les + # objets déjà déposés gardent leur propriétaire. + force_destroy = false +} + resource "minio_iam_user_policy_attachment" "app" { - for_each = { for c in var.consumers : c.app => c } + for_each = toset(local.buckets) user_name = minio_iam_user.app[each.key].id policy_name = minio_iam_policy.app[each.key].id } -# Les clés, déposées dans l'espace Vault de MINIO — l'app y accède par le -# `kv_read_paths` déclaré au tfvars central (pièce 2 du motif, ci-dessus). +# Les clés, dans l'espace Vault de MINIO. L'app y accède par la règle que +# `app_policy` accorde à toutes : `kvv2/data/minio/`. resource "vault_kv_secret_v2" "app" { - for_each = { for c in var.consumers : c.app => c } + for_each = toset(local.buckets) mount = "kvv2" name = "minio/${each.key}" data_json = jsonencode({ MINIO_ENDPOINT = var.minio_endpoint - MINIO_BUCKET = each.value.bucket + MINIO_BUCKET = each.key MINIO_ACCESS_KEY = minio_iam_user.app[each.key].id MINIO_SECRET_KEY = random_password.app[each.key].result }) diff --git a/minio/iac/variables.tf b/minio/iac/variables.tf index 4759165..adfdbcf 100644 --- a/minio/iac/variables.tf +++ b/minio/iac/variables.tf @@ -4,13 +4,6 @@ variable "minio_endpoint" { description = "Hôte de l'API S3, SANS schéma (le provider ajoute https via minio_ssl). Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." } -variable "consumers" { - type = list(object({ - app = string # nom de l'app — doit correspondre à son nom Vault (kvv2/) et à son rôle k8s - bucket = string # son bucket, qui doit exister dans `values.yaml` du chart (bloc `buckets`) - })) - default = [ - { app = "kadans", bucket = "kadans-videos" }, - ] - description = "Apps autorisées à stocker des objets. Chacune reçoit un compte de service borné à SON bucket, dont les clés atterrissent dans kvv2/minio/. Ajouter une app ici ne suffit pas : elle doit aussi déclarer kv_read_paths = [\"kvv2/data/minio/\"] dans hashicorp-vault/iac/terraform.tfvars." -} +# `consumers` a été SUPPRIMÉE (fondateur 2026-07-26) : la liste se lit dans +# `values.yaml` du chart — créer un bucket EST la déclaration. Une liste de plus +# aurait été une liste à tenir synchronisée, donc une liste à oublier. diff --git a/minio/values.yaml b/minio/values.yaml index 95d8989..6682be7 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,8 +61,12 @@ minio: &minio_config # Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # DÉRIVÉS et re-générables depuis le master local — versionner doublerait le # stockage pour un filet dont on n'a pas besoin. + # ⚠ UN BUCKET PAR APP, NOMMÉ COMME ELLE. Tout en découle sans rien redéclarer : + # le compte de service (iac/consumers.tf), le chemin Vault `kvv2/minio/`, + # et la policy que le module central accorde déjà à `kvv2/data/minio/`. + # Créer un bucket ici EST la déclaration — il n'y a pas d'autre liste à tenir. buckets: - - name: kadans-videos + - name: kadans policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) purge: false versioning: false From 0e1b6e106290765d8868f7c77cf67c0cba6f4ad1 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 09:46:32 +0200 Subject: [PATCH 4/6] =?UTF-8?q?refactor(minio)=20=E2=80=94=20lever=20la=20?= =?UTF-8?q?restriction=20=C2=AB=20un=20seul=20bucket=20par=20app=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Pourquoi sommes-nous restreints sur les buckets ? » (fondateur, 26/07) — parce que JE l'avais décidé, pour rendre tout dérivable d'un seul nom. Rien ne l'imposait, et la restriction aurait mordu au pas suivant : l'ADR-018 de Kadans prévoit DEUX paliers de transfert (aperçu 240p régénérable, travail 360p à garder), donc deux cycles de vie, donc potentiellement deux buckets aux politiques de purge différentes. Chaque bucket déclare désormais son app (`app: kadans`). C'est toujours la SEULE déclaration, au même endroit — mais le nom du bucket redevient libre, et `kadans-videos` retrouve un nom qui dit ce qu'il contient. Le compte de service devient par APP et non par bucket : une seule clé, autorisée sur tous ses buckets et eux seuls. Ajouter un bucket à une app existante ne crée donc aucune nouvelle clé — le compte existant gagne l'accès. Le secret porte `MINIO_BUCKETS` (tous) en plus de `MINIO_BUCKET` (le premier), pour que l'app n'ait pas à les redéclarer de son côté. Un bucket sans `app:` est ignoré plutôt que de faire échouer le plan : il n'aura simplement pas de compte de service, ce qui se voit immédiatement et ne casse rien d'existant. Vérifié sur le VRAI fichier, pas en théorie : le groupement rend bien {"kadans" = ["kadans-videos"]}, et avec un second bucket de la même app, {"kadans" = ["kadans-videos", "kadans-apercus"]} — le cas ADR-018 exact. tofu fmt propre, tofu validate réussi. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- minio/README.md | 29 +++++++++++++------ minio/iac/consumers.tf | 65 ++++++++++++++++++++++++------------------ minio/values.yaml | 16 +++++++---- 3 files changed, 69 insertions(+), 41 deletions(-) diff --git a/minio/README.md b/minio/README.md index 56f7aac..77cdb4f 100644 --- a/minio/README.md +++ b/minio/README.md @@ -25,7 +25,7 @@ application vivent avec cette application. | Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO | | API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » | | Console | `minio.arcodange.lab` (Traefik) | -| Buckets | **un par app, nommé comme elle** (`kadans`…), tous **privés** — l'accès passe par des URL signées (ADR-0002 du dossier produit). Le bucket est la SEULE déclaration : compte de service et droits Vault en découlent | +| Buckets | **privés**, chacun déclarant son app (`app:`) — l'accès passe par des URL signées (ADR-0002 du dossier produit). Le bucket est la SEULE déclaration : compte de service et droits Vault en découlent. Une app peut en avoir plusieurs | | Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` | Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du @@ -96,20 +96,21 @@ une phrase claire, qu'échouer au milieu d'un téléversement. ## Donner à une app l'accès au stockage -**Une seule chose à faire** : ajouter son bucket dans `values.yaml`, **nommé -comme l'app**. +**Une seule chose à faire** : déclarer son bucket dans `values.yaml`, en disant +à quelle app il appartient. ```yaml buckets: - - name: mon-app - policy: none # privé : l'accès passe par des URL signées + - name: mon-app-fichiers + app: mon-app # ← la seule déclaration + policy: none # privé : l'accès passe par des URL signées ``` -Tout le reste en découle, sans autre déclaration nulle part : +Tout le reste en découle, sans rien écrire ailleurs : -- `iac/consumers.tf` **lit ce même fichier** et crée un compte de service - `mon-app-app`, borné à ce seul bucket, dont les clés atterrissent dans - `kvv2/minio/mon-app` ; +- `iac/consumers.tf` **lit ce même fichier**, groupe les buckets par app, et crée + **un** compte de service `mon-app-app` autorisé sur **tous ses buckets et eux + seuls**. Ses clés atterrissent dans `kvv2/minio/mon-app` ; - le module Vault central `app_policy` accorde **déjà** à toute app la lecture de `kvv2/data/minio/` — inconditionnellement, parce que le chemin porte le nom de l'app et ne peut donc jamais exposer que ses propres clés. Une app @@ -117,6 +118,16 @@ Tout le reste en découle, sans autre déclaration nulle part : Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/` et à injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple). +Le secret porte `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, +`MINIO_BUCKET` (le premier) et `MINIO_BUCKETS` (tous, séparés par des virgules). + +### Plusieurs buckets pour une même app + +C'est prévu, et c'est le cas courant : deux contenus aux **cycles de vie +différents** méritent deux buckets aux politiques de purge différentes. Kadans y +viendra avec les deux paliers de l'ADR-018 — l'aperçu 240p, régénérable, et le +rendu de travail 360p, à garder. Il suffit d'une seconde entrée avec le même +`app:` ; le compte de service existant gagne l'accès, sans nouvelle clé. > **Il n'y a volontairement AUCUNE liste de consommateurs.** Une liste de plus > serait une liste à tenir synchronisée avec les buckets — donc une liste à diff --git a/minio/iac/consumers.tf b/minio/iac/consumers.tf index bd4e395..7db0766 100644 --- a/minio/iac/consumers.tf +++ b/minio/iac/consumers.tf @@ -1,35 +1,42 @@ -# ── Un compte de service par BUCKET, sans rien déclarer de plus ────────────── +# ── Un compte de service par APP, sans liste à tenir ───────────────────────── # # Il n'y a PAS de liste de consommateurs : elle se lit dans `values.yaml` du -# chart. Créer un bucket EST la déclaration (fondateur 2026-07-26 : « je préfère -# ne pas m'embêter avec consumers ou autre »). Rien à synchroniser entre deux -# fichiers, donc rien à oublier — et aucune divergence possible. +# chart. Chaque bucket y dit à quelle app il appartient (`app:`) — et c'est la +# SEULE déclaration (fondateur 2026-07-26 : « je préfère ne pas m'embêter avec +# consumers ou autre »). Rien à synchroniser entre deux fichiers, donc rien à +# oublier, et aucune divergence possible : c'est le fichier qu'Helm consomme. # -# La convention qui rend ça possible : UN BUCKET PAR APP, NOMMÉ COMME ELLE. -# Tout en découle sans être écrit nulle part ailleurs : -# · le compte de service `-app`, borné à ce seul bucket ; -# · le secret `kvv2/minio/` ; -# · la lecture, que le module central `app_policy` accorde déjà à TOUTE app -# sur `kvv2/data/minio/`, inconditionnellement — une app qui ne -# stocke rien y lit un chemin qui n'existe pas, ce qui n'est pas un droit. +# UNE APP PEUT AVOIR PLUSIEURS BUCKETS, et c'est important : rien ne justifiait +# de l'en empêcher. L'ADR-018 de Kadans prévoit déjà deux paliers de transfert +# (aperçu 240p régénérable, travail 360p à garder) — deux cycles de vie, donc +# potentiellement deux buckets aux politiques de purge différentes. Le compte de +# service est donc par APP, autorisé sur TOUS ses buckets et sur eux seuls. # # POURQUOI LES CLÉS VIVENT ICI ET PAS CHEZ L'APP : seul ce pipeline possède les # identifiants ROOT de MinIO. Si chaque app créait son propre compte de service, # il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. # Ici il ne sort jamais, et l'app ne reçoit qu'une clé qui ne peut rien lire -# d'autre que son bucket. Un compte qui fuite ne donne accès qu'aux objets qu'il -# gérait déjà. +# d'autre que ses propres buckets. Un compte qui fuite ne donne accès qu'aux +# objets qu'il gérait déjà. locals { # Le MÊME fichier que celui qu'Helm consomme : impossible de créer un bucket - # sans son compte de service, ou un compte sans son bucket. - buckets = [for b in yamldecode(file("${path.module}/../values.yaml")).minio.buckets : b.name] + # sans son compte de service, ou un compte sans ses buckets. + buckets_declares = yamldecode(file("${path.module}/../values.yaml")).minio.buckets + + # app → ses buckets. `try` : un bucket sans `app:` est ignoré plutôt que de + # faire échouer tout le plan — il n'aura simplement aucun compte de service, + # ce qui se voit tout de suite et ne casse rien d'existant. + buckets_par_app = { + for app in distinct([for b in local.buckets_declares : try(b.app, null) if try(b.app, null) != null]) : + app => [for b in local.buckets_declares : b.name if try(b.app, null) == app] + } } -# La politique d'accès : SON bucket, rien d'autre. Ni listing des autres -# buckets, ni administration. +# La politique d'accès : SES buckets, rien d'autre. Ni les autres buckets, ni +# l'administration. resource "minio_iam_policy" "app" { - for_each = toset(local.buckets) + for_each = local.buckets_par_app name = "${each.key}-app" policy = jsonencode({ Version = "2012-10-17" @@ -37,27 +44,27 @@ resource "minio_iam_policy" "app" { { Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] - Resource = ["arn:aws:s3:::${each.key}/*"] + Resource = [for b in each.value : "arn:aws:s3:::${b}/*"] }, { - # Nécessaire pour qu'un client S3 vérifie l'existence du bucket et liste - # SES objets — jamais ceux d'un autre. + # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et + # liste SES objets — jamais ceux d'une autre app. Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] - Resource = ["arn:aws:s3:::${each.key}"] + Resource = [for b in each.value : "arn:aws:s3:::${b}"] }, ] }) } resource "random_password" "app" { - for_each = toset(local.buckets) + for_each = local.buckets_par_app length = 40 special = false # les outils S3 transportent mal certains caractères en URL } resource "minio_iam_user" "app" { - for_each = toset(local.buckets) + for_each = local.buckets_par_app name = "${each.key}-app" # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. secret = random_password.app[each.key].result @@ -67,20 +74,24 @@ resource "minio_iam_user" "app" { } resource "minio_iam_user_policy_attachment" "app" { - for_each = toset(local.buckets) + for_each = local.buckets_par_app user_name = minio_iam_user.app[each.key].id policy_name = minio_iam_policy.app[each.key].id } # Les clés, dans l'espace Vault de MINIO. L'app y accède par la règle que # `app_policy` accorde à toutes : `kvv2/data/minio/`. +# +# `MINIO_BUCKETS` liste ses buckets pour que l'app n'ait pas à les redéclarer de +# son côté ; `MINIO_BUCKET` reste le premier, pour les apps qui n'en ont qu'un. resource "vault_kv_secret_v2" "app" { - for_each = toset(local.buckets) + for_each = local.buckets_par_app mount = "kvv2" name = "minio/${each.key}" data_json = jsonencode({ MINIO_ENDPOINT = var.minio_endpoint - MINIO_BUCKET = each.key + MINIO_BUCKET = each.value[0] + MINIO_BUCKETS = join(",", each.value) MINIO_ACCESS_KEY = minio_iam_user.app[each.key].id MINIO_SECRET_KEY = random_password.app[each.key].result }) diff --git a/minio/values.yaml b/minio/values.yaml index 6682be7..c14e0cc 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,12 +61,18 @@ minio: &minio_config # Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # DÉRIVÉS et re-générables depuis le master local — versionner doublerait le # stockage pour un filet dont on n'a pas besoin. - # ⚠ UN BUCKET PAR APP, NOMMÉ COMME ELLE. Tout en découle sans rien redéclarer : - # le compte de service (iac/consumers.tf), le chemin Vault `kvv2/minio/`, - # et la policy que le module central accorde déjà à `kvv2/data/minio/`. - # Créer un bucket ici EST la déclaration — il n'y a pas d'autre liste à tenir. + # Chaque bucket dit À QUELLE APP il appartient (`app:`) — et c'est la SEULE + # déclaration à faire : `iac/consumers.tf` lit ce fichier, groupe les buckets + # par app, et provisionne UN compte de service par app, autorisé sur tous SES + # buckets. Ses clés atterrissent dans `kvv2/minio/`, que le module Vault + # central autorise déjà l'app à lire. + # + # Une app peut donc avoir PLUSIEURS buckets, aux cycles de vie différents — + # l'ADR-018 de Kadans prévoit déjà deux paliers (aperçu 240p régénérable, + # travail 360p à garder), qui n'ont pas la même politique de purge. buckets: - - name: kadans + - name: kadans-videos + app: kadans policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) purge: false versioning: false From ec71571b98027f4f3c83d22767e2d95e4eabdf48 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 10:07:05 +0200 Subject: [PATCH 5/6] =?UTF-8?q?refactor(minio)=20=E2=80=94=20chacun=20son?= =?UTF-8?q?=20p=C3=A9rim=C3=A8tre=20:=20les=20buckets=20se=20d=C3=A9claren?= =?UTF-8?q?t=20depuis=20le=20d=C3=A9p=C3=B4t=20de=20l'app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Les buckets sont à déclarer dans le repo de kadans-api. On ne va pas modifier le repo tools à chaque changement d'application. Chacun son périmètre. Tools peut proposer un module pour standardiser la déclaration de buckets à la limite, mais c'est tout. » (fondateur, 26/07) C'est une erreur de fond de ma part : j'avais fait de `tools` le PROPRIÉTAIRE de déclarations qui appartiennent aux applications. À ce rythme, chaque nouveau bucket de n'importe quelle app devenait une PR sur l'infra partagée. CE QUI CHANGE. `consumers.tf` disparaît, et la liste de buckets du chart se vide. À la place, un module réutilisable `iac/modules/minio_app` : une app lui donne son nom et ses buckets, et reçoit des buckets privés, un compte de service qui ne peut rien toucher d'autre, et ses clés dans `kvv2/minio/`. L'OBSTACLE, ET SA RÉPONSE. Déclarer ses buckets depuis son propre dépôt suppose des droits d'ADMINISTRATION sur MinIO. Confier le root serait absurde : il lit et écrit tous les objets de toutes les apps. `tools` fournit donc un compte PROVISIONNEUR aux droits minimaux — créer un bucket, une politique, un compte de service — et AUCUN droit sur les objets. Une app compromise pourrait créer des buckets (une nuisance), pas lire les vidéos d'une autre. Le root, lui, ne sort toujours pas de ce pipeline. Le rôle CI de chaque app gagne la lecture de `kvv2/data/minio/provisioner` dans `app_policy` — générique, et c'est exactement le genre de standardisation qui appartient au dépôt commun. ⚠ CE QUE JE N'AI PAS PU PROUVER : les noms d'actions d'administration MinIO de la politique du provisionneur viennent de la documentation, pas d'un essai — je n'ai pas d'identifiants admin en main. Le premier `apply` les confirmera ou les corrigera. C'est le seul point non vérifié de cette PR, et il est signalé dans le code à l'endroit exact. tofu fmt propre · tofu validate réussi sur minio/iac ET hashicorp-vault/iac. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- .../iac/modules/app_policy/main.tf | 9 ++ minio/README.md | 81 ++++++++------- minio/iac/consumers.tf | 98 ------------------- minio/iac/modules/minio_app/README.md | 56 +++++++++++ minio/iac/modules/minio_app/main.tf | 89 +++++++++++++++++ minio/iac/modules/minio_app/outputs.tf | 9 ++ minio/iac/modules/minio_app/providers.tf | 14 +++ minio/iac/modules/minio_app/variables.tf | 15 +++ minio/iac/provisioner.tf | 77 +++++++++++++++ minio/values.yaml | 21 ++-- 10 files changed, 314 insertions(+), 155 deletions(-) delete mode 100644 minio/iac/consumers.tf create mode 100644 minio/iac/modules/minio_app/README.md create mode 100644 minio/iac/modules/minio_app/main.tf create mode 100644 minio/iac/modules/minio_app/outputs.tf create mode 100644 minio/iac/modules/minio_app/providers.tf create mode 100644 minio/iac/modules/minio_app/variables.tf create mode 100644 minio/iac/provisioner.tf diff --git a/hashicorp-vault/iac/modules/app_policy/main.tf b/hashicorp-vault/iac/modules/app_policy/main.tf index f93c0fb..402f5e1 100644 --- a/hashicorp-vault/iac/modules/app_policy/main.tf +++ b/hashicorp-vault/iac/modules/app_policy/main.tf @@ -36,6 +36,15 @@ data "vault_policy_document" "ops" { path = "kvv1/google/credentials" capabilities = ["read"] } + # Provisionneur MinIO : permet à l'app de créer SES buckets et SON compte de + # service depuis son propre dépôt (module tools//minio/iac/modules/minio_app), + # sans qu'on lui confie le root de MinIO. Ce compte ne peut QUE provisionner — + # aucun droit de lecture ni d'écriture sur les objets. + rule { + path = "kvv2/data/minio/provisioner" + capabilities = ["read"] + } + # read cloudflare related secrets rule { path = "kvv1/cloudflare/${local.name}*" diff --git a/minio/README.md b/minio/README.md index 77cdb4f..5d0e9c4 100644 --- a/minio/README.md +++ b/minio/README.md @@ -25,7 +25,7 @@ application vivent avec cette application. | Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO | | API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » | | Console | `minio.arcodange.lab` (Traefik) | -| Buckets | **privés**, chacun déclarant son app (`app:`) — l'accès passe par des URL signées (ADR-0002 du dossier produit). Le bucket est la SEULE déclaration : compte de service et droits Vault en découlent. Une app peut en avoir plusieurs | +| Buckets | **aucun ici** — chaque app déclare les siens depuis son dépôt (module `minio_app`). Tous privés : l'accès passe par des URL signées (ADR-0002 du dossier produit) | | Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` | Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du @@ -96,53 +96,50 @@ une phrase claire, qu'échouer au milieu d'un téléversement. ## Donner à une app l'accès au stockage -**Une seule chose à faire** : déclarer son bucket dans `values.yaml`, en disant -à quelle app il appartient. +**Rien à faire ici.** Chaque application déclare **ses** buckets **depuis son +propre dépôt**, avec le module que ce dépôt-ci fournit : -```yaml -buckets: - - name: mon-app-fichiers - app: mon-app # ← la seule déclaration - policy: none # privé : l'accès passe par des URL signées +```hcl +# iac/main.tf de l'application +data "vault_kv_secret_v2" "minio_provisioner" { + mount = "kvv2" + name = "minio/provisioner" +} + +provider "minio" { + minio_server = "s3.arcodange.fr" + minio_user = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"] + minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"] + minio_ssl = true +} + +module "stockage" { + source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" + app = "mon-app" + buckets = ["mon-app-fichiers"] + providers = { minio = minio } +} ``` -Tout le reste en découle, sans rien écrire ailleurs : +Voir `iac/modules/minio_app/README.md`. **Chacun son périmètre** : `tools` +fournit le serveur, le provisionneur et le module — pas la liste des buckets. +Sans ça, chaque bucket de chaque app deviendrait une PR sur l'infra partagée. -- `iac/consumers.tf` **lit ce même fichier**, groupe les buckets par app, et crée - **un** compte de service `mon-app-app` autorisé sur **tous ses buckets et eux - seuls**. Ses clés atterrissent dans `kvv2/minio/mon-app` ; -- le module Vault central `app_policy` accorde **déjà** à toute app la lecture de - `kvv2/data/minio/` — inconditionnellement, parce que le chemin porte - le nom de l'app et ne peut donc jamais exposer que ses propres clés. Une app - qui ne stocke rien y lit un chemin qui n'existe pas : une règle inerte. +### Ce que `tools` fournit, et pourquoi -Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/` et à -injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple). -Le secret porte `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, -`MINIO_BUCKET` (le premier) et `MINIO_BUCKETS` (tous, séparés par des virgules). +| Pièce | Rôle | +|---|---| +| Le serveur | le chart, son volume, ses ingress (interne + public) | +| Le **root** | généré ici, écrit dans `kvv2/minio/config`, **ne sort jamais** de ce pipeline | +| Le **provisionneur** | un compte aux droits d'administration MINIMAUX (créer bucket, politique, compte de service) et **aucun droit sur les objets** — lisible par le rôle CI de chaque app | +| Le **module** | `minio_app` : standardise la déclaration, sans la détenir | -### Plusieurs buckets pour une même app - -C'est prévu, et c'est le cas courant : deux contenus aux **cycles de vie -différents** méritent deux buckets aux politiques de purge différentes. Kadans y -viendra avec les deux paliers de l'ADR-018 — l'aperçu 240p, régénérable, et le -rendu de travail 360p, à garder. Il suffit d'une seconde entrée avec le même -`app:` ; le compte de service existant gagne l'accès, sans nouvelle clé. - -> **Il n'y a volontairement AUCUNE liste de consommateurs.** Une liste de plus -> serait une liste à tenir synchronisée avec les buckets — donc une liste à -> oublier. Le bucket fait foi. - -### Pourquoi les clés vivent ICI et pas chez l'app - -Seul ce pipeline possède les identifiants **root** de MinIO. Si chaque app créait -son propre compte de service, il faudrait donner ce root à chaque rôle CI — -c'est-à-dire à tout le monde. Ici il ne sort jamais, et l'app ne reçoit qu'une -clé qui **ne peut rien lire d'autre que son bucket**. Un compte de service qui -fuite ne donne accès qu'aux objets qu'il gérait déjà. +Donner le root aux apps aurait été absurde : il lit et écrit **tous** les objets +de **toutes** les apps. Le provisionneur, lui, peut créer des buckets — une +nuisance si une app est compromise — mais **pas lire les vidéos d'une autre**. ### Rotation -Détruire `random_password.app[""]` et relancer le plan suffit : la clé -change, `force_destroy = false` garde le compte, et les objets déjà déposés -conservent leur propriétaire. +Depuis l'`iac/` de l'app : détruire `module.stockage.random_password.app` et +relancer son plan. La clé change, `force_destroy = false` garde le compte, et +les objets déjà déposés conservent leur propriétaire. diff --git a/minio/iac/consumers.tf b/minio/iac/consumers.tf deleted file mode 100644 index 7db0766..0000000 --- a/minio/iac/consumers.tf +++ /dev/null @@ -1,98 +0,0 @@ -# ── Un compte de service par APP, sans liste à tenir ───────────────────────── -# -# Il n'y a PAS de liste de consommateurs : elle se lit dans `values.yaml` du -# chart. Chaque bucket y dit à quelle app il appartient (`app:`) — et c'est la -# SEULE déclaration (fondateur 2026-07-26 : « je préfère ne pas m'embêter avec -# consumers ou autre »). Rien à synchroniser entre deux fichiers, donc rien à -# oublier, et aucune divergence possible : c'est le fichier qu'Helm consomme. -# -# UNE APP PEUT AVOIR PLUSIEURS BUCKETS, et c'est important : rien ne justifiait -# de l'en empêcher. L'ADR-018 de Kadans prévoit déjà deux paliers de transfert -# (aperçu 240p régénérable, travail 360p à garder) — deux cycles de vie, donc -# potentiellement deux buckets aux politiques de purge différentes. Le compte de -# service est donc par APP, autorisé sur TOUS ses buckets et sur eux seuls. -# -# POURQUOI LES CLÉS VIVENT ICI ET PAS CHEZ L'APP : seul ce pipeline possède les -# identifiants ROOT de MinIO. Si chaque app créait son propre compte de service, -# il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. -# Ici il ne sort jamais, et l'app ne reçoit qu'une clé qui ne peut rien lire -# d'autre que ses propres buckets. Un compte qui fuite ne donne accès qu'aux -# objets qu'il gérait déjà. - -locals { - # Le MÊME fichier que celui qu'Helm consomme : impossible de créer un bucket - # sans son compte de service, ou un compte sans ses buckets. - buckets_declares = yamldecode(file("${path.module}/../values.yaml")).minio.buckets - - # app → ses buckets. `try` : un bucket sans `app:` est ignoré plutôt que de - # faire échouer tout le plan — il n'aura simplement aucun compte de service, - # ce qui se voit tout de suite et ne casse rien d'existant. - buckets_par_app = { - for app in distinct([for b in local.buckets_declares : try(b.app, null) if try(b.app, null) != null]) : - app => [for b in local.buckets_declares : b.name if try(b.app, null) == app] - } -} - -# La politique d'accès : SES buckets, rien d'autre. Ni les autres buckets, ni -# l'administration. -resource "minio_iam_policy" "app" { - for_each = local.buckets_par_app - name = "${each.key}-app" - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] - Resource = [for b in each.value : "arn:aws:s3:::${b}/*"] - }, - { - # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et - # liste SES objets — jamais ceux d'une autre app. - Effect = "Allow" - Action = ["s3:ListBucket", "s3:GetBucketLocation"] - Resource = [for b in each.value : "arn:aws:s3:::${b}"] - }, - ] - }) -} - -resource "random_password" "app" { - for_each = local.buckets_par_app - length = 40 - special = false # les outils S3 transportent mal certains caractères en URL -} - -resource "minio_iam_user" "app" { - for_each = local.buckets_par_app - name = "${each.key}-app" - # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. - secret = random_password.app[each.key].result - # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les - # objets déjà déposés gardent leur propriétaire. - force_destroy = false -} - -resource "minio_iam_user_policy_attachment" "app" { - for_each = local.buckets_par_app - user_name = minio_iam_user.app[each.key].id - policy_name = minio_iam_policy.app[each.key].id -} - -# Les clés, dans l'espace Vault de MINIO. L'app y accède par la règle que -# `app_policy` accorde à toutes : `kvv2/data/minio/`. -# -# `MINIO_BUCKETS` liste ses buckets pour que l'app n'ait pas à les redéclarer de -# son côté ; `MINIO_BUCKET` reste le premier, pour les apps qui n'en ont qu'un. -resource "vault_kv_secret_v2" "app" { - for_each = local.buckets_par_app - mount = "kvv2" - name = "minio/${each.key}" - data_json = jsonencode({ - MINIO_ENDPOINT = var.minio_endpoint - MINIO_BUCKET = each.value[0] - MINIO_BUCKETS = join(",", each.value) - MINIO_ACCESS_KEY = minio_iam_user.app[each.key].id - MINIO_SECRET_KEY = random_password.app[each.key].result - }) -} diff --git a/minio/iac/modules/minio_app/README.md b/minio/iac/modules/minio_app/README.md new file mode 100644 index 0000000..3df9edf --- /dev/null +++ b/minio/iac/modules/minio_app/README.md @@ -0,0 +1,56 @@ +# `minio_app` — déclarer ses buckets depuis SON dépôt + +Chacun son périmètre : les buckets d'une application appartiennent au dépôt de +cette application. Ce module **standardise** la déclaration, il ne la détient +pas — sans lui, chaque bucket de chaque app deviendrait une PR sur `tools`. + +## Usage + +Dans l'`iac/` de l'app : + +```hcl +# Le provisionneur MinIO : des droits d'administration MINIMAUX (créer un +# bucket, un compte de service, une politique), jamais le root — qui, lui, ne +# sort pas du pipeline `minio`. +data "vault_kv_secret_v2" "minio_provisioner" { + mount = "kvv2" + name = "minio/provisioner" +} + +provider "minio" { + minio_server = "s3.arcodange.fr" + minio_user = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"] + minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"] + minio_ssl = true +} + +module "stockage" { + source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" + app = "mon-app" # = le nom de son rôle Vault + buckets = ["mon-app-fichiers"] + providers = { minio = minio } +} +``` + +Puis, côté chart : une `VaultStaticSecret` sur `kvv2/minio/` et l'injection +des variables dans le Deployment. + +## Ce que le module garantit + +- les buckets sont **privés** — l'accès passe par des URL présignées ; +- le compte de service ne peut **rien** toucher d'autre que ces buckets-là ; +- ses clés vont dans `kvv2/minio/`, que le module Vault central autorise + déjà l'app à lire (règle **inconditionnelle** : le chemin porte le nom de + l'app, donc il ne peut exposer que ses propres clés). + +## Plusieurs buckets + +C'est le cas courant : deux contenus aux **cycles de vie différents** méritent +deux buckets. Il suffit de les lister — le compte de service existant gagne +l'accès, **sans nouvelle clé**. + +## Ce que le module ne fait PAS + +Il ne pose ni quota, ni règle de cycle de vie, ni versioning : ces choix +appartiennent à l'app et varient d'un bucket à l'autre. À ajouter le jour où +un besoin réel apparaît, pas avant. diff --git a/minio/iac/modules/minio_app/main.tf b/minio/iac/modules/minio_app/main.tf new file mode 100644 index 0000000..6d69f22 --- /dev/null +++ b/minio/iac/modules/minio_app/main.tf @@ -0,0 +1,89 @@ +# ── Module `minio_app` — une app déclare SES buckets, depuis SON dépôt ─────── +# +# Chacun son périmètre (fondateur 2026-07-26) : les buckets d'une application +# appartiennent au dépôt de cette application, pas à l'infra partagée. `tools` +# ne fait que STANDARDISER la déclaration — il ne la détient pas. Sans ça, +# chaque bucket de chaque app deviendrait une PR sur le dépôt commun. +# +# Usage, depuis l'`iac/` de l'app : +# +# module "stockage" { +# source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" +# app = "kadans" +# buckets = ["kadans-videos"] +# providers = { minio = minio } +# } +# +# Ce que le module garantit : +# · les buckets sont PRIVÉS — l'accès passe par des URL présignées ; +# · le compte de service ne peut RIEN toucher d'autre que ces buckets-là ; +# · ses clés atterrissent dans `kvv2/minio/`, que le module Vault central +# autorise déjà l'app à lire (règle inconditionnelle : le chemin porte le nom +# de l'app, donc il ne peut exposer que ses propres clés). + +resource "minio_s3_bucket" "app" { + for_each = toset(var.buckets) + bucket = each.key + acl = "private" + # Les objets sont DÉRIVÉS (rendus de transfert) et régénérables depuis les + # originaux restés sur l'appareil : un `force_destroy` accidentel ne détruit + # donc rien d'irremplaçable, mais on l'interdit quand même — la destruction + # d'un bucket doit être un geste explicite, pas un effet de bord d'un plan. + force_destroy = false +} + +# La politique : SES buckets, rien d'autre. Ni les autres, ni l'administration. +resource "minio_iam_policy" "app" { + name = "${var.app}-app" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"] + }, + { + # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et + # liste SES objets — jamais ceux d'une autre app. + Effect = "Allow" + Action = ["s3:ListBucket", "s3:GetBucketLocation"] + Resource = [for b in var.buckets : "arn:aws:s3:::${b}"] + }, + ] + }) +} + +resource "random_password" "app" { + length = 40 + special = false # les outils S3 transportent mal certains caractères en URL +} + +resource "minio_iam_user" "app" { + name = "${var.app}-app" + # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. + secret = random_password.app.result + # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les + # objets déjà déposés gardent leur propriétaire. + force_destroy = false +} + +resource "minio_iam_user_policy_attachment" "app" { + user_name = minio_iam_user.app.id + policy_name = minio_iam_policy.app.id +} + +# Les clés, dans l'espace Vault de MinIO — lu par le pod de l'app. +# `MINIO_BUCKETS` les liste toutes pour que l'app n'ait pas à les redéclarer ; +# `MINIO_BUCKET` reste le premier, pour celles qui n'en ont qu'un. +resource "vault_kv_secret_v2" "app" { + mount = "kvv2" + name = "minio/${var.app}" + data_json = jsonencode({ + MINIO_ENDPOINT = var.endpoint + MINIO_BUCKET = var.buckets[0] + MINIO_BUCKETS = join(",", var.buckets) + MINIO_ACCESS_KEY = minio_iam_user.app.id + MINIO_SECRET_KEY = random_password.app.result + }) +} diff --git a/minio/iac/modules/minio_app/outputs.tf b/minio/iac/modules/minio_app/outputs.tf new file mode 100644 index 0000000..d4f2ba0 --- /dev/null +++ b/minio/iac/modules/minio_app/outputs.tf @@ -0,0 +1,9 @@ +output "vault_path" { + value = "kvv2/minio/${var.app}" + description = "Où le pod lira ses identifiants (VaultStaticSecret). Le module Vault central autorise déjà l'app à lire ce chemin." +} + +output "buckets" { + value = var.buckets + description = "Écho des buckets créés — utile pour poser la valeur `bucket` du chart sans la réécrire." +} diff --git a/minio/iac/modules/minio_app/providers.tf b/minio/iac/modules/minio_app/providers.tf new file mode 100644 index 0000000..c85f94b --- /dev/null +++ b/minio/iac/modules/minio_app/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + configuration_aliases = [minio] + } + vault = { + source = "hashicorp/vault" + } + random = { + source = "hashicorp/random" + } + } +} diff --git a/minio/iac/modules/minio_app/variables.tf b/minio/iac/modules/minio_app/variables.tf new file mode 100644 index 0000000..f205996 --- /dev/null +++ b/minio/iac/modules/minio_app/variables.tf @@ -0,0 +1,15 @@ +variable "app" { + type = string + description = "Nom de l'app — celui de son rôle Vault. Décide du chemin du secret (kvv2/minio/) et du nom du compte de service." +} + +variable "buckets" { + type = list(string) + description = "Les buckets de cette app. Ils sont CRÉÉS ici (privés) et le compte de service n'a de droits que sur eux. Plusieurs sont possibles — deux contenus aux cycles de vie différents méritent deux buckets." +} + +variable "endpoint" { + type = string + default = "s3.arcodange.fr" + description = "Hôte de l'API S3, SANS schéma. Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." +} diff --git a/minio/iac/provisioner.tf b/minio/iac/provisioner.tf new file mode 100644 index 0000000..0aa3724 --- /dev/null +++ b/minio/iac/provisioner.tf @@ -0,0 +1,77 @@ +# ── Le compte PROVISIONNEUR : ce que `tools` doit fournir en plus du module ─── +# +# Une app qui déclare ses buckets depuis son propre dépôt doit pouvoir les +# créer — donc disposer de droits d'ADMINISTRATION sur MinIO. Lui donner le +# ROOT serait absurde : le root lit et écrit TOUS les objets de TOUTES les apps. +# +# Ce compte-ci ne peut que PROVISIONNER : créer un bucket, une politique, un +# compte de service, et les attacher. Il n'a AUCUN droit de lecture ou +# d'écriture sur les objets. Une app compromise pourrait créer des buckets — +# une nuisance —, pas lire les vidéos d'une autre. +# +# Le root, lui, reste dans `kvv2/minio/config`, que seul le rôle CI `minio` +# peut lire. +# +# ⚠ NON VÉRIFIÉ CONTRE LE SERVEUR : les noms d'actions d'administration MinIO +# ci-dessous viennent de la documentation, pas d'un essai — je n'ai pas +# d'identifiants admin en main. Le PREMIER `apply` les confirmera ou les +# corrigera ; c'est le seul point de cette PR que je ne peux pas prouver ici. +resource "minio_iam_policy" "provisioner" { + name = "provisioner" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "admin:CreateUser", + "admin:DeleteUser", + "admin:ListUsers", + "admin:GetUser", + "admin:CreatePolicy", + "admin:DeletePolicy", + "admin:GetPolicy", + "admin:ListUserPolicies", + "admin:AttachUserOrGroupPolicy", + ] + Resource = ["arn:aws:s3:::*"] + }, + { + # Créer et inspecter un bucket — mais PAS lire ni écrire ses objets : + # `s3:GetObject` et `s3:PutObject` sont volontairement absents. + Effect = "Allow" + Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"] + Resource = ["arn:aws:s3:::*"] + }, + ] + }) +} + +resource "random_password" "provisioner" { + length = 40 + special = false +} + +resource "minio_iam_user" "provisioner" { + name = "provisioner" + secret = random_password.provisioner.result + force_destroy = false +} + +resource "minio_iam_user_policy_attachment" "provisioner" { + user_name = minio_iam_user.provisioner.id + policy_name = minio_iam_policy.provisioner.id +} + +# Lisible par le rôle CI de CHAQUE app (règle générique ajoutée à `app_policy`) : +# c'est ce qui permet à une app de provisionner ses propres buckets sans qu'on +# lui confie le root. +resource "vault_kv_secret_v2" "provisioner" { + mount = "kvv2" + name = "minio/provisioner" + data_json = jsonencode({ + MINIO_ENDPOINT = var.minio_endpoint + MINIO_ACCESS_KEY = minio_iam_user.provisioner.id + MINIO_SECRET_KEY = random_password.provisioner.result + }) +} diff --git a/minio/values.yaml b/minio/values.yaml index c14e0cc..26eccad 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,21 +61,12 @@ minio: &minio_config # Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # DÉRIVÉS et re-générables depuis le master local — versionner doublerait le # stockage pour un filet dont on n'a pas besoin. - # Chaque bucket dit À QUELLE APP il appartient (`app:`) — et c'est la SEULE - # déclaration à faire : `iac/consumers.tf` lit ce fichier, groupe les buckets - # par app, et provisionne UN compte de service par app, autorisé sur tous SES - # buckets. Ses clés atterrissent dans `kvv2/minio/`, que le module Vault - # central autorise déjà l'app à lire. - # - # Une app peut donc avoir PLUSIEURS buckets, aux cycles de vie différents — - # l'ADR-018 de Kadans prévoit déjà deux paliers (aperçu 240p régénérable, - # travail 360p à garder), qui n'ont pas la même politique de purge. - buckets: - - name: kadans-videos - app: kadans - policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) - purge: false - versioning: false + # AUCUN bucket ici : chaque application déclare LES SIENS depuis son propre + # dépôt, via le module `iac/modules/minio_app` (fondateur 2026-07-26 : « on ne + # va pas modifier le repo tools à chaque changement d'application, chacun son + # périmètre »). `tools` fournit le serveur, le provisionneur et le module — + # pas la liste. + buckets: [] # Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le # laptop (ADR-0014 du dossier) — MinIO rejoint la même vue. From 7074a94d7e8f80adefe058cda15bd2c768cb26d2 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 10:19:28 +0200 Subject: [PATCH 6/6] =?UTF-8?q?docs(minio)=20=E2=80=94=20le=20raisonnement?= =?UTF-8?q?=20part=20dans=20l'ADR,=20le=20code=20garde=20ce=20qui=20prot?= =?UTF-8?q?=C3=A8ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les décisions de conception (qui déclare, qui détient, pourquoi les octets ne passent pas par l'API) vivent désormais dans `factory/doc/adr/20260726-stockage-objet-minio.md`. Le code ne garde que ce qu'un relecteur ne peut pas deviner et qui l'empêcherait de casser quelque chose : l'absence VOLONTAIRE de s3:GetObject/PutObject dans la politique du provisionneur, l'absence VOLONTAIRE de basic-auth sur l'ingress S3, le caractère inconditionnel de la règle Vault, et l'avertissement sur les noms d'actions MinIO non éprouvés. Chaque fichier pointe l'ADR. 283 → 230 lignes sur minio/iac. tofu fmt propre, tofu validate réussi sur les deux racines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA --- .../iac/modules/app_policy/main.tf | 30 +++-------- minio/iac/modules/minio_app/main.tf | 52 ++++--------------- minio/iac/modules/minio_app/outputs.tf | 4 +- minio/iac/modules/minio_app/variables.tf | 6 +-- minio/iac/providers.tf | 4 +- minio/iac/provisioner.tf | 29 +++-------- minio/iac/variables.tf | 6 +-- minio/templates/ingress-public.yaml | 20 +++---- minio/values.yaml | 17 ++---- 9 files changed, 43 insertions(+), 125 deletions(-) diff --git a/hashicorp-vault/iac/modules/app_policy/main.tf b/hashicorp-vault/iac/modules/app_policy/main.tf index 402f5e1..5cc39c1 100644 --- a/hashicorp-vault/iac/modules/app_policy/main.tf +++ b/hashicorp-vault/iac/modules/app_policy/main.tf @@ -36,10 +36,8 @@ data "vault_policy_document" "ops" { path = "kvv1/google/credentials" capabilities = ["read"] } - # Provisionneur MinIO : permet à l'app de créer SES buckets et SON compte de - # service depuis son propre dépôt (module tools//minio/iac/modules/minio_app), - # sans qu'on lui confie le root de MinIO. Ce compte ne peut QUE provisionner — - # aucun droit de lecture ni d'écriture sur les objets. + # Provisionneur MinIO — l'app crée ses buckets depuis son dépôt sans le root. + # ADR : factory/doc/adr/20260726-stockage-objet-minio.md rule { path = "kvv2/data/minio/provisioner" capabilities = ["read"] @@ -187,27 +185,16 @@ data "vault_policy_document" "app" { path = "postgres/creds/${local.name}*" capabilities = ["read"] } - # Identifiants de SON compte de service MinIO, provisionné par le pipeline - # `minio` (tools/minio/iac/consumers.tf) dans l'espace Vault de MinIO. - # - # INCONDITIONNEL, et c'est voulu : le chemin porte le nom de l'app, donc cette - # règle ne peut jamais exposer que ses PROPRES clés. Une app qui ne stocke rien - # lit un chemin qui n'existe pas — une règle inerte, pas un privilège. - # - # Pourquoi ici plutôt que dans `kv_read_paths` de chaque app : savoir lire ses - # propres identifiants de stockage est une propriété de la PLATEFORME, pas une - # exception par application. `kv_read_paths` est la trappe pour un secret - # appartenant à une AUTRE app (les creds GCS de Longhorn pour l'ERP) ; y ranger - # un motif standard le rendrait invisible et obligerait à le redéclarer partout. - # Conséquence pratique : déclarer un consommateur MinIO se fait à UN seul - # endroit — `var.consumers` du pipeline minio. Aucune synchronisation à tenir. + # Ses identifiants MinIO. INCONDITIONNEL : le chemin porte le nom de l'app, + # donc la règle ne peut exposer que ses propres clés ; une app sans stockage + # lit un chemin qui n'existe pas. + # ADR : factory/doc/adr/20260726-stockage-objet-minio.md rule { path = "kvv2/data/minio/${local.name}/*" capabilities = ["read", "list"] } rule { - # Le secret est écrit à `kvv2/minio/` (sans sous-chemin) : la règle - # ci-dessus couvre les descendants, celle-ci le document lui-même. + # Le document lui-même (la règle ci-dessus ne couvre que ses descendants). path = "kvv2/data/minio/${local.name}" capabilities = ["read", "list"] } @@ -237,8 +224,7 @@ data "vault_policy_document" "app_non_prod" { path = "postgres/creds/${each.key}*" capabilities = ["read"] } - # Même règle qu'en prod (voir le commentaire de vault_policy_document.app) : - # chaque instance lit les identifiants MinIO portant SON nom. + # Idem prod : chaque instance lit les identifiants MinIO portant SON nom. rule { path = "kvv2/data/minio/${each.key}/*" capabilities = ["read", "list"] diff --git a/minio/iac/modules/minio_app/main.tf b/minio/iac/modules/minio_app/main.tf index 6d69f22..55a6a03 100644 --- a/minio/iac/modules/minio_app/main.tf +++ b/minio/iac/modules/minio_app/main.tf @@ -1,38 +1,13 @@ -# ── Module `minio_app` — une app déclare SES buckets, depuis SON dépôt ─────── -# -# Chacun son périmètre (fondateur 2026-07-26) : les buckets d'une application -# appartiennent au dépôt de cette application, pas à l'infra partagée. `tools` -# ne fait que STANDARDISER la déclaration — il ne la détient pas. Sans ça, -# chaque bucket de chaque app deviendrait une PR sur le dépôt commun. -# -# Usage, depuis l'`iac/` de l'app : -# -# module "stockage" { -# source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" -# app = "kadans" -# buckets = ["kadans-videos"] -# providers = { minio = minio } -# } -# -# Ce que le module garantit : -# · les buckets sont PRIVÉS — l'accès passe par des URL présignées ; -# · le compte de service ne peut RIEN toucher d'autre que ces buckets-là ; -# · ses clés atterrissent dans `kvv2/minio/`, que le module Vault central -# autorise déjà l'app à lire (règle inconditionnelle : le chemin porte le nom -# de l'app, donc il ne peut exposer que ses propres clés). +# Module `minio_app` — une app déclare SES buckets depuis SON dépôt. +# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md resource "minio_s3_bucket" "app" { - for_each = toset(var.buckets) - bucket = each.key - acl = "private" - # Les objets sont DÉRIVÉS (rendus de transfert) et régénérables depuis les - # originaux restés sur l'appareil : un `force_destroy` accidentel ne détruit - # donc rien d'irremplaçable, mais on l'interdit quand même — la destruction - # d'un bucket doit être un geste explicite, pas un effet de bord d'un plan. - force_destroy = false + for_each = toset(var.buckets) + bucket = each.key + acl = "private" # l'accès passe par des URL présignées + force_destroy = false # détruire un bucket doit être un geste explicite } -# La politique : SES buckets, rien d'autre. Ni les autres, ni l'administration. resource "minio_iam_policy" "app" { name = "${var.app}-app" policy = jsonencode({ @@ -44,8 +19,6 @@ resource "minio_iam_policy" "app" { Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"] }, { - # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et - # liste SES objets — jamais ceux d'une autre app. Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = [for b in var.buckets : "arn:aws:s3:::${b}"] @@ -60,12 +33,9 @@ resource "random_password" "app" { } resource "minio_iam_user" "app" { - name = "${var.app}-app" - # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. - secret = random_password.app.result - # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les - # objets déjà déposés gardent leur propriétaire. - force_destroy = false + name = "${var.app}-app" + secret = random_password.app.result + force_destroy = false # une rotation ne recrée pas l'utilisateur : les objets gardent leur propriétaire } resource "minio_iam_user_policy_attachment" "app" { @@ -73,9 +43,7 @@ resource "minio_iam_user_policy_attachment" "app" { policy_name = minio_iam_policy.app.id } -# Les clés, dans l'espace Vault de MinIO — lu par le pod de l'app. -# `MINIO_BUCKETS` les liste toutes pour que l'app n'ait pas à les redéclarer ; -# `MINIO_BUCKET` reste le premier, pour celles qui n'en ont qu'un. +# Lu par le pod de l'app — `app_policy` lui accorde déjà ce chemin. resource "vault_kv_secret_v2" "app" { mount = "kvv2" name = "minio/${var.app}" diff --git a/minio/iac/modules/minio_app/outputs.tf b/minio/iac/modules/minio_app/outputs.tf index d4f2ba0..d2313cb 100644 --- a/minio/iac/modules/minio_app/outputs.tf +++ b/minio/iac/modules/minio_app/outputs.tf @@ -1,9 +1,9 @@ output "vault_path" { value = "kvv2/minio/${var.app}" - description = "Où le pod lira ses identifiants (VaultStaticSecret). Le module Vault central autorise déjà l'app à lire ce chemin." + description = "Où le pod lira ses identifiants (VaultStaticSecret)." } output "buckets" { value = var.buckets - description = "Écho des buckets créés — utile pour poser la valeur `bucket` du chart sans la réécrire." + description = "Écho des buckets créés." } diff --git a/minio/iac/modules/minio_app/variables.tf b/minio/iac/modules/minio_app/variables.tf index f205996..306ca73 100644 --- a/minio/iac/modules/minio_app/variables.tf +++ b/minio/iac/modules/minio_app/variables.tf @@ -1,15 +1,15 @@ variable "app" { type = string - description = "Nom de l'app — celui de son rôle Vault. Décide du chemin du secret (kvv2/minio/) et du nom du compte de service." + description = "Nom de l'app (= son rôle Vault). Décide du chemin du secret et du nom du compte de service." } variable "buckets" { type = list(string) - description = "Les buckets de cette app. Ils sont CRÉÉS ici (privés) et le compte de service n'a de droits que sur eux. Plusieurs sont possibles — deux contenus aux cycles de vie différents méritent deux buckets." + description = "Ses buckets, créés ici (privés). Le compte de service n'a de droits que sur eux." } variable "endpoint" { type = string default = "s3.arcodange.fr" - description = "Hôte de l'API S3, SANS schéma. Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." + description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN." } diff --git a/minio/iac/providers.tf b/minio/iac/providers.tf index 40d183c..9911aaa 100644 --- a/minio/iac/providers.tf +++ b/minio/iac/providers.tf @@ -23,9 +23,7 @@ provider "vault" { } } -# Provider MinIO — pour créer les COMPTES DE SERVICE des apps consommatrices. -# Il parle à l'API S3 publique (s3.arcodange.fr, exposée par le chart) : le -# runner CI n'est pas dans le LAN, et `.lab` ne s'y résout pas. +# Provider MinIO — crée le compte de provisionnement (provisioner.tf). provider "minio" { minio_server = var.minio_endpoint minio_user = local.config.rootUser diff --git a/minio/iac/provisioner.tf b/minio/iac/provisioner.tf index 0aa3724..43be8c1 100644 --- a/minio/iac/provisioner.tf +++ b/minio/iac/provisioner.tf @@ -1,21 +1,10 @@ -# ── Le compte PROVISIONNEUR : ce que `tools` doit fournir en plus du module ─── +# Compte de PROVISIONNEMENT : crée buckets, politiques et comptes de service — +# aucun droit sur les objets. C'est lui que lisent les rôles CI des apps, pour +# qu'elles déclarent leurs buckets sans qu'on leur confie le root. +# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md # -# Une app qui déclare ses buckets depuis son propre dépôt doit pouvoir les -# créer — donc disposer de droits d'ADMINISTRATION sur MinIO. Lui donner le -# ROOT serait absurde : le root lit et écrit TOUS les objets de TOUTES les apps. -# -# Ce compte-ci ne peut que PROVISIONNER : créer un bucket, une politique, un -# compte de service, et les attacher. Il n'a AUCUN droit de lecture ou -# d'écriture sur les objets. Une app compromise pourrait créer des buckets — -# une nuisance —, pas lire les vidéos d'une autre. -# -# Le root, lui, reste dans `kvv2/minio/config`, que seul le rôle CI `minio` -# peut lire. -# -# ⚠ NON VÉRIFIÉ CONTRE LE SERVEUR : les noms d'actions d'administration MinIO -# ci-dessous viennent de la documentation, pas d'un essai — je n'ai pas -# d'identifiants admin en main. Le PREMIER `apply` les confirmera ou les -# corrigera ; c'est le seul point de cette PR que je ne peux pas prouver ici. +# ⚠ Noms d'actions issus de la documentation MinIO, NON éprouvés contre le +# serveur : le premier apply les confirmera ou les corrigera. resource "minio_iam_policy" "provisioner" { name = "provisioner" policy = jsonencode({ @@ -37,8 +26,7 @@ resource "minio_iam_policy" "provisioner" { Resource = ["arn:aws:s3:::*"] }, { - # Créer et inspecter un bucket — mais PAS lire ni écrire ses objets : - # `s3:GetObject` et `s3:PutObject` sont volontairement absents. + # s3:GetObject / s3:PutObject volontairement ABSENTS. Effect = "Allow" Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"] Resource = ["arn:aws:s3:::*"] @@ -63,9 +51,6 @@ resource "minio_iam_user_policy_attachment" "provisioner" { policy_name = minio_iam_policy.provisioner.id } -# Lisible par le rôle CI de CHAQUE app (règle générique ajoutée à `app_policy`) : -# c'est ce qui permet à une app de provisionner ses propres buckets sans qu'on -# lui confie le root. resource "vault_kv_secret_v2" "provisioner" { mount = "kvv2" name = "minio/provisioner" diff --git a/minio/iac/variables.tf b/minio/iac/variables.tf index adfdbcf..5b91383 100644 --- a/minio/iac/variables.tf +++ b/minio/iac/variables.tf @@ -1,9 +1,5 @@ variable "minio_endpoint" { type = string default = "s3.arcodange.fr" - description = "Hôte de l'API S3, SANS schéma (le provider ajoute https via minio_ssl). Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." + description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN." } - -# `consumers` a été SUPPRIMÉE (fondateur 2026-07-26) : la liste se lit dans -# `values.yaml` du chart — créer un bucket EST la déclaration. Une liste de plus -# aurait été une liste à tenir synchronisée, donc une liste à oublier. diff --git a/minio/templates/ingress-public.yaml b/minio/templates/ingress-public.yaml index 4d94fa4..0ab4d0d 100644 --- a/minio/templates/ingress-public.yaml +++ b/minio/templates/ingress-public.yaml @@ -1,19 +1,11 @@ -# Exposition PUBLIQUE s3.arcodange.fr, EN PLUS du .lab interne (qui reste -# inchangé). Même motif que `grafana/templates/ingress-public.yaml` : entrypoint -# `web` — le TLS est terminé en amont par le tunnel Cloudflare (wildcard -# *.arcodange.fr → traefik.kube-system.svc:80) — et middleware crowdsec. -# -# POURQUOI c'est nécessaire (et pas un confort) : la PWA est servie en -# https://kadans.arcodange.fr. Un navigateur REFUSE une requête vers http:// -# depuis une page https (contenu mixte), et `.lab` n'est de toute façon pas -# résolvable hors du LAN. Sans cet ingress, le téléversement d'une vidéo ne peut -# marcher NI en déplacement, NI même à la maison — c'est le maillon qui manque -# à kadans-api#23 (URL présignées). +# Exposition PUBLIQUE s3.arcodange.fr — TLS terminé par le tunnel Cloudflare, +# middleware crowdsec. Le `.lab` interne reste inchangé. # # ⚠ PAS de basic-auth ici, contrairement à kadans-public : une requête S3 porte -# sa propre signature (SigV4) dans ses en-têtes ou son URL. Un défi HTTP Basic -# casserait le PUT présigné du navigateur, qui n'a aucun moyen d'y répondre. -# L'autorisation vient de l'URL signée, avec sa durée de vie courte. +# sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné +# auquel le navigateur ne peut pas répondre. +# +# ADR : factory/doc/adr/20260726-stockage-objet-minio.md apiVersion: networking.k8s.io/v1 kind: Ingress metadata: diff --git a/minio/values.yaml b/minio/values.yaml index 26eccad..9c0d545 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,11 +61,9 @@ minio: &minio_config # Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # DÉRIVÉS et re-générables depuis le master local — versionner doublerait le # stockage pour un filet dont on n'a pas besoin. - # AUCUN bucket ici : chaque application déclare LES SIENS depuis son propre - # dépôt, via le module `iac/modules/minio_app` (fondateur 2026-07-26 : « on ne - # va pas modifier le repo tools à chaque changement d'application, chacun son - # périmètre »). `tools` fournit le serveur, le provisionneur et le module — - # pas la liste. + # AUCUN bucket ici : chaque app déclare les siens depuis son dépôt, via le + # module `iac/modules/minio_app`. + # ADR : factory/doc/adr/20260726-stockage-objet-minio.md buckets: [] # Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le @@ -75,13 +73,8 @@ minio: &minio_config enabled: false # pas d'opérateur Prometheus ici : scrape par annotation environment: MINIO_PROMETHEUS_AUTH_TYPE: "public" - # CORS : le navigateur téléverse DIRECTEMENT vers MinIO depuis la PWA (URL - # présignées, kadans-api#23) — donc requête inter-origines, donc préflight - # OPTIONS. Sans cette liste, le PUT est refusé par le navigateur avant même - # de partir. Origines EXACTES, jamais « * » : une URL présignée qui fuite - # serait rejouable depuis n'importe quel site. - # .fr = la PWA publique (le cas réel, en déplacement comme à la maison) - # .lab = la même app servie en interne, pour la mise au point + # CORS : le navigateur téléverse directement (URL présignées) — origines + # EXACTES, jamais « * » : une URL qui fuite serait sinon rejouable partout. MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab" tool: