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