refactor(minio) — lever la restriction « un seul bucket par app »
Helm Charts / Detect changed charts (pull_request) Successful in 17s
Helm Charts / Detect changed charts (push) Successful in 16s
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
Hashicorp Vault / Auth with gitea for vault (pull_request) Failing after 8m32s
Hashicorp Vault / Tofu - Vault IAC (pull_request) Has been skipped
Helm Charts / Application charts pgcat (push) Has been cancelled
Helm Charts / Library charts tool (push) Has been cancelled
MinIO / Tofu - minio IAC (push) Has been cancelled
MinIO / Auth with gitea for vault (push) Has started running
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped

« 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
This commit is contained in:
2026-07-26 09:46:32 +02:00
co-authored by Claude Opus 5
parent 287e3dcf1e
commit 0e1b6e1062
3 changed files with 69 additions and 41 deletions
+19 -8
View File
@@ -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 | | 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 » | | 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) | | 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` | | 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 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 ## Donner à une app l'accès au stockage
**Une seule chose à faire** : ajouter son bucket dans `values.yaml`, **nommé **Une seule chose à faire** : déclarer son bucket dans `values.yaml`, en disant
comme l'app**. à quelle app il appartient.
```yaml ```yaml
buckets: buckets:
- name: mon-app - name: mon-app-fichiers
app: mon-app # ← la seule déclaration
policy: none # privé : l'accès passe par des URL signées 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 - `iac/consumers.tf` **lit ce même fichier**, groupe les buckets par app, et crée
`mon-app-app`, borné à ce seul bucket, dont les clés atterrissent dans **un** compte de service `mon-app-app` autorisé sur **tous ses buckets et eux
`kvv2/minio/mon-app` ; seuls**. Ses clés atterrissent dans `kvv2/minio/mon-app` ;
- le module Vault central `app_policy` accorde **déjà** à toute app la lecture de - le module Vault central `app_policy` accorde **déjà** à toute app la lecture de
`kvv2/data/minio/<son nom>` — inconditionnellement, parce que le chemin porte `kvv2/data/minio/<son nom>` — inconditionnellement, parce que le chemin porte
le nom de l'app et ne peut donc jamais exposer que ses propres clés. Une app 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/<app>` et à Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/<app>` et à
injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple). 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 > **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 à > serait une liste à tenir synchronisée avec les buckets — donc une liste à
+38 -27
View File
@@ -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 # 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 # chart. Chaque bucket y dit à quelle app il appartient (`app:`) et c'est la
# ne pas m'embêter avec consumers ou autre »). Rien à synchroniser entre deux # SEULE déclaration (fondateur 2026-07-26 : « je préfère ne pas m'embêter avec
# fichiers, donc rien à oublier et aucune divergence possible. # 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. # UNE APP PEUT AVOIR PLUSIEURS BUCKETS, et c'est important : rien ne justifiait
# Tout en découle sans être écrit nulle part ailleurs : # de l'en empêcher. L'ADR-018 de Kadans prévoit déjà deux paliers de transfert
# · le compte de service `<app>-app`, borné à ce seul bucket ; # (aperçu 240p régénérable, travail 360p à garder) deux cycles de vie, donc
# · le secret `kvv2/minio/<app>` ; # potentiellement deux buckets aux politiques de purge différentes. Le compte de
# · la lecture, que le module central `app_policy` accorde déjà à TOUTE app # service est donc par APP, autorisé sur TOUS ses buckets et sur eux seuls.
# sur `kvv2/data/minio/<son nom>`, inconditionnellement une app qui ne
# stocke rien y lit un chemin qui n'existe pas, ce qui n'est pas un droit.
# #
# POURQUOI LES CLÉS VIVENT ICI ET PAS CHEZ L'APP : seul ce pipeline possède les # 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, # 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. # 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 # 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 # d'autre que ses propres buckets. Un compte qui fuite ne donne accès qu'aux
# gérait déjà. # objets qu'il gérait déjà.
locals { locals {
# Le MÊME fichier que celui qu'Helm consomme : impossible de créer un bucket # 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. # sans son compte de service, ou un compte sans ses buckets.
buckets = [for b in yamldecode(file("${path.module}/../values.yaml")).minio.buckets : b.name] 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 # La politique d'accès : SES buckets, rien d'autre. Ni les autres buckets, ni
# buckets, ni administration. # l'administration.
resource "minio_iam_policy" "app" { resource "minio_iam_policy" "app" {
for_each = toset(local.buckets) for_each = local.buckets_par_app
name = "${each.key}-app" name = "${each.key}-app"
policy = jsonencode({ policy = jsonencode({
Version = "2012-10-17" Version = "2012-10-17"
@@ -37,27 +44,27 @@ resource "minio_iam_policy" "app" {
{ {
Effect = "Allow" Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] 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 # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et
# SES objets jamais ceux d'un autre. # liste SES objets jamais ceux d'une autre app.
Effect = "Allow" Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"] 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" { resource "random_password" "app" {
for_each = toset(local.buckets) for_each = local.buckets_par_app
length = 40 length = 40
special = false # les outils S3 transportent mal certains caractères en URL special = false # les outils S3 transportent mal certains caractères en URL
} }
resource "minio_iam_user" "app" { resource "minio_iam_user" "app" {
for_each = toset(local.buckets) for_each = local.buckets_par_app
name = "${each.key}-app" name = "${each.key}-app"
# Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi.
secret = random_password.app[each.key].result secret = random_password.app[each.key].result
@@ -67,20 +74,24 @@ resource "minio_iam_user" "app" {
} }
resource "minio_iam_user_policy_attachment" "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 user_name = minio_iam_user.app[each.key].id
policy_name = minio_iam_policy.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 # 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/<son nom>`. # `app_policy` accorde à toutes : `kvv2/data/minio/<son nom>`.
#
# `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" { resource "vault_kv_secret_v2" "app" {
for_each = toset(local.buckets) for_each = local.buckets_par_app
mount = "kvv2" mount = "kvv2"
name = "minio/${each.key}" name = "minio/${each.key}"
data_json = jsonencode({ data_json = jsonencode({
MINIO_ENDPOINT = var.minio_endpoint 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_ACCESS_KEY = minio_iam_user.app[each.key].id
MINIO_SECRET_KEY = random_password.app[each.key].result MINIO_SECRET_KEY = random_password.app[each.key].result
}) })
+11 -5
View File
@@ -61,12 +61,18 @@ minio: &minio_config
# Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # 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 # 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. # 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 : # Chaque bucket dit À QUELLE APP il appartient (`app:`) — et c'est la SEULE
# le compte de service (iac/consumers.tf), le chemin Vault `kvv2/minio/<nom>`, # déclaration à faire : `iac/consumers.tf` lit ce fichier, groupe les buckets
# et la policy que le module central accorde déjà à `kvv2/data/minio/<app>`. # par app, et provisionne UN compte de service par app, autorisé sur tous SES
# Créer un bucket ici EST la déclaration — il n'y a pas d'autre liste à tenir. # buckets. Ses clés atterrissent dans `kvv2/minio/<app>`, 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: buckets:
- name: kadans - name: kadans-videos
app: kadans
policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier)
purge: false purge: false
versioning: false versioning: false