feat(minio) — un compte de service par app consommatrice, borné à son bucket #21

Merged
arcodange merged 6 commits from arcodange/minio-comptes-de-service into main 2026-07-26 10:46:45 +02:00
3 changed files with 69 additions and 41 deletions
Showing only changes of commit 0e1b6e1062 - Show all commits
+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 |
| 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
- 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/<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
@@ -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 à
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 à
+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
# 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>-app`, borné à ce seul bucket ;
# · le secret `kvv2/minio/<app>` ;
# · la lecture, que le module central `app_policy` accorde déjà à TOUTE app
# 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.
# 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/<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" {
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
})
+11 -5
View File
@@ -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/<nom>`,
# et la policy que le module central accorde déjà à `kvv2/data/minio/<app>`.
# 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/<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:
- 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