Merge pull request 'feat(minio) — un compte de service par app consommatrice, borné à son bucket' (#21) from arcodange/minio-comptes-de-service into main
Helm Charts / Detect changed charts (push) Successful in 19s
Hashicorp Vault / Auth with gitea for vault (push) Successful in 2m58s
MinIO / Auth with gitea for vault (push) Successful in 5m52s
Helm Charts / Library charts tool (push) Has been skipped
Hashicorp Vault / Tofu - Vault IAC (push) Successful in 53s
MinIO / Tofu - minio IAC (push) Successful in 33s
Helm Charts / Application charts pgcat (push) Has been skipped

This commit was merged in pull request #21.
This commit is contained in:
2026-07-26 10:46:39 +02:00
13 changed files with 328 additions and 28 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
locals { locals {
factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin" factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin"
} }
@@ -36,6 +36,13 @@ data "vault_policy_document" "ops" {
path = "kvv1/google/credentials" path = "kvv1/google/credentials"
capabilities = ["read"] capabilities = ["read"]
} }
# 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"]
}
# read cloudflare related secrets # read cloudflare related secrets
rule { rule {
path = "kvv1/cloudflare/${local.name}*" path = "kvv1/cloudflare/${local.name}*"
@@ -178,6 +185,19 @@ data "vault_policy_document" "app" {
path = "postgres/creds/${local.name}*" path = "postgres/creds/${local.name}*"
capabilities = ["read"] capabilities = ["read"]
} }
# 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 document lui-même (la règle ci-dessus ne couvre que ses descendants).
path = "kvv2/data/minio/${local.name}"
capabilities = ["read", "list"]
}
# Extra shared paths this app's prod runtime may read (e.g. backup creds). # Extra shared paths this app's prod runtime may read (e.g. backup creds).
dynamic "rule" { dynamic "rule" {
for_each = var.kv_read_paths for_each = var.kv_read_paths
@@ -204,6 +224,15 @@ data "vault_policy_document" "app_non_prod" {
path = "postgres/creds/${each.key}*" path = "postgres/creds/${each.key}*"
capabilities = ["read"] capabilities = ["read"]
} }
# Idem prod : 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" { resource "vault_policy" "app_non_prod" {
for_each = toset(local.non_prod_instances) for_each = toset(local.non_prod_instances)
+52 -1
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) |
| Bucket | `kadans-videos`, **privé** l'accès passe par des URL signées (ADR-0002 du dossier produit) | | 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` | | 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
@@ -92,3 +92,54 @@ 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
**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 :
```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 }
}
```
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.
### Ce que `tools` fournit, et pourquoi
| 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 |
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
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.
+56
View File
@@ -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://[email protected]: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/<app>` 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/<app>`, 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.
+57
View File
@@ -0,0 +1,57 @@
# 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" # l'accès passe par des URL présignées
force_destroy = false # détruire un bucket doit être un geste explicite
}
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}/*"]
},
{
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"
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" {
user_name = minio_iam_user.app.id
policy_name = minio_iam_policy.app.id
}
# 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}"
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
})
}
+9
View File
@@ -0,0 +1,9 @@
output "vault_path" {
value = "kvv2/minio/${var.app}"
description = "Où le pod lira ses identifiants (VaultStaticSecret)."
}
output "buckets" {
value = var.buckets
description = "Écho des buckets créés."
}
+14
View File
@@ -0,0 +1,14 @@
terraform {
required_providers {
minio = {
source = "aminueza/minio"
configuration_aliases = [minio]
}
vault = {
source = "hashicorp/vault"
}
random = {
source = "hashicorp/random"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
variable "app" {
type = string
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 = "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."
}
+16
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,11 @@ provider "vault" {
role = "gitea_cicd_minio" role = "gitea_cicd_minio"
} }
} }
# Provider MinIO crée le compte de provisionnement (provisioner.tf).
provider "minio" {
minio_server = var.minio_endpoint
minio_user = local.config.rootUser
minio_password = local.config.rootPassword
minio_ssl = true
}
+62
View File
@@ -0,0 +1,62 @@
# 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
#
# 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({
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:::*"]
},
{
# 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:::*"]
},
]
})
}
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
}
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
})
}
+5
View File
@@ -0,0 +1,5 @@
variable "minio_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."
}
+6 -14
View File
@@ -1,19 +1,11 @@
# Exposition PUBLIQUE s3.arcodange.fr, EN PLUS du .lab interne (qui reste # Exposition PUBLIQUE s3.arcodange.fr — TLS terminé par le tunnel Cloudflare,
# inchangé). Même motif que `grafana/templates/ingress-public.yaml` : entrypoint # middleware crowdsec. Le `.lab` interne reste inchangé.
# `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).
# #
# ⚠ PAS de basic-auth ici, contrairement à kadans-public : une requête S3 porte # ⚠ 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 # sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné
# casserait le PUT présigné du navigateur, qui n'a aucun moyen d'y répondre. # auquel le navigateur ne peut pas répondre.
# L'autorisation vient de l'URL signée, avec sa durée de vie courte. #
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
+6 -12
View File
@@ -61,11 +61,10 @@ 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.
buckets: # AUCUN bucket ici : chaque app déclare les siens depuis son dépôt, via le
- name: kadans-videos # module `iac/modules/minio_app`.
policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) # ADR : factory/doc/adr/20260726-stockage-objet-minio.md
purge: false buckets: []
versioning: false
# Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le # Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le
# laptop (ADR-0014 du dossier) — MinIO rejoint la même vue. # laptop (ADR-0014 du dossier) — MinIO rejoint la même vue.
@@ -74,13 +73,8 @@ minio: &minio_config
enabled: false # pas d'opérateur Prometheus ici : scrape par annotation enabled: false # pas d'opérateur Prometheus ici : scrape par annotation
environment: environment:
MINIO_PROMETHEUS_AUTH_TYPE: "public" MINIO_PROMETHEUS_AUTH_TYPE: "public"
# CORS : le navigateur téléverse DIRECTEMENT vers MinIO depuis la PWA (URL # CORS : le navigateur téléverse directement (URL présignées) — origines
# présignées, kadans-api#23) — donc requête inter-origines, donc préflight # EXACTES, jamais « * » : une URL qui fuite serait sinon rejouable partout.
# 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
MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab" MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab"
tool: tool: