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
5 changed files with 162 additions and 1 deletions
Showing only changes of commit 4ca4a05370 - Show all commits
+9 -1
View File
@@ -24,5 +24,13 @@ applications = [
service_account_namespaces = ["tools"] service_account_namespaces = ["tools"]
}, },
{ name = "prospection" }, { 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"]
},
] ]
+38
View File
@@ -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é 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 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. 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/<app>` 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["<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.
+81
View File
@@ -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/<app>` ;
# 2. côté Vault central : l'app déclare `kv_read_paths = ["kvv2/data/minio/<app>"]`
# 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
})
}
+18
View File
@@ -4,6 +4,14 @@ terraform {
source = "vault" source = "vault"
version = "4.4.0" 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" 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
}
+16
View File
@@ -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/<app>) 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/<app>. Ajouter une app ici ne suffit pas : elle doit aussi déclarer kv_read_paths = [\"kvv2/data/minio/<app>\"] dans hashicorp-vault/iac/terraform.tfvars."
}