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
9 changed files with 43 additions and 125 deletions
Showing only changes of commit 7074a94d7e - Show all commits
+8 -22
View File
@@ -36,10 +36,8 @@ data "vault_policy_document" "ops" {
path = "kvv1/google/credentials"
capabilities = ["read"]
}
# Provisionneur MinIO : permet à l'app de créer SES buckets et SON compte de
# service depuis son propre dépôt (module tools//minio/iac/modules/minio_app),
# sans qu'on lui confie le root de MinIO. Ce compte ne peut QUE provisionner —
# aucun droit de lecture ni d'écriture sur les objets.
# 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"]
@@ -187,27 +185,16 @@ data "vault_policy_document" "app" {
path = "postgres/creds/${local.name}*"
capabilities = ["read"]
}
# Identifiants de SON compte de service MinIO, provisionné par le pipeline
# `minio` (tools/minio/iac/consumers.tf) dans l'espace Vault de MinIO.
#
# INCONDITIONNEL, et c'est voulu : le chemin porte le nom de l'app, donc cette
# règle ne peut jamais exposer que ses PROPRES clés. Une app qui ne stocke rien
# lit un chemin qui n'existe pas — une règle inerte, pas un privilège.
#
# Pourquoi ici plutôt que dans `kv_read_paths` de chaque app : savoir lire ses
# propres identifiants de stockage est une propriété de la PLATEFORME, pas une
# exception par application. `kv_read_paths` est la trappe pour 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 partout.
# Conséquence pratique : déclarer un consommateur MinIO se fait à UN seul
# endroit — `var.consumers` du pipeline minio. Aucune synchronisation à tenir.
# 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 secret est écrit à `kvv2/minio/<app>` (sans sous-chemin) : la règle
# ci-dessus couvre les descendants, celle-ci le document lui-même.
# Le document lui-même (la règle ci-dessus ne couvre que ses descendants).
path = "kvv2/data/minio/${local.name}"
capabilities = ["read", "list"]
}
@@ -237,8 +224,7 @@ data "vault_policy_document" "app_non_prod" {
path = "postgres/creds/${each.key}*"
capabilities = ["read"]
}
# Même règle qu'en prod (voir le commentaire de vault_policy_document.app) :
# chaque instance lit les identifiants MinIO portant SON nom.
# Idem prod : chaque instance lit les identifiants MinIO portant SON nom.
rule {
path = "kvv2/data/minio/${each.key}/*"
capabilities = ["read", "list"]
+6 -38
View File
@@ -1,38 +1,13 @@
# ── Module `minio_app` — une app déclare SES buckets, depuis SON dépôt ───────
#
# Chacun son périmètre (fondateur 2026-07-26) : les buckets d'une application
# appartiennent au dépôt de cette application, pas à l'infra partagée. `tools`
# ne fait que STANDARDISER la déclaration — il ne la détient pas. Sans ça,
# chaque bucket de chaque app deviendrait une PR sur le dépôt commun.
#
# Usage, depuis l'`iac/` de l'app :
#
# module "stockage" {
# source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
# app = "kadans"
# buckets = ["kadans-videos"]
# providers = { minio = minio }
# }
#
# 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 atterrissent 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).
# 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"
# Les objets sont DÉRIVÉS (rendus de transfert) et régénérables depuis les
# originaux restés sur l'appareil : un `force_destroy` accidentel ne détruit
# donc rien d'irremplaçable, mais on l'interdit quand même — la destruction
# d'un bucket doit être un geste explicite, pas un effet de bord d'un plan.
force_destroy = false
acl = "private" # l'accès passe par des URL présignées
force_destroy = false # détruire un bucket doit être un geste explicite
}
# La politique : SES buckets, rien d'autre. Ni les autres, ni l'administration.
resource "minio_iam_policy" "app" {
name = "${var.app}-app"
policy = jsonencode({
@@ -44,8 +19,6 @@ resource "minio_iam_policy" "app" {
Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"]
},
{
# 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 = [for b in var.buckets : "arn:aws:s3:::${b}"]
@@ -61,11 +34,8 @@ resource "random_password" "app" {
resource "minio_iam_user" "app" {
name = "${var.app}-app"
# Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi.
secret = random_password.app.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
force_destroy = false # une rotation ne recrée pas l'utilisateur : les objets gardent leur propriétaire
}
resource "minio_iam_user_policy_attachment" "app" {
@@ -73,9 +43,7 @@ resource "minio_iam_user_policy_attachment" "app" {
policy_name = minio_iam_policy.app.id
}
# Les clés, dans l'espace Vault de MinIO — lu par le pod de l'app.
# `MINIO_BUCKETS` les liste toutes pour que l'app n'ait pas à les redéclarer ;
# `MINIO_BUCKET` reste le premier, pour celles qui n'en ont qu'un.
# 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}"
+2 -2
View File
@@ -1,9 +1,9 @@
output "vault_path" {
value = "kvv2/minio/${var.app}"
description = "Où le pod lira ses identifiants (VaultStaticSecret). Le module Vault central autorise déjà l'app à lire ce chemin."
description = "Où le pod lira ses identifiants (VaultStaticSecret)."
}
output "buckets" {
value = var.buckets
description = "Écho des buckets créés — utile pour poser la valeur `bucket` du chart sans la réécrire."
description = "Écho des buckets créés."
}
+3 -3
View File
@@ -1,15 +1,15 @@
variable "app" {
type = string
description = "Nom de l'app — celui de son rôle Vault. Décide du chemin du secret (kvv2/minio/<app>) et du nom du compte de service."
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 = "Les buckets de cette app. Ils sont CRÉÉS ici (privés) et le compte de service n'a de droits que sur eux. Plusieurs sont possibles — deux contenus aux cycles de vie différents méritent deux buckets."
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 et `.lab` ne s'y résout pas."
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
}
+1 -3
View File
@@ -23,9 +23,7 @@ provider "vault" {
}
}
# 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 crée le compte de provisionnement (provisioner.tf).
provider "minio" {
minio_server = var.minio_endpoint
minio_user = local.config.rootUser
+7 -22
View File
@@ -1,21 +1,10 @@
# Le compte PROVISIONNEUR : ce que `tools` doit fournir en plus du module
# 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
#
# Une app qui déclare ses buckets depuis son propre dépôt doit pouvoir les
# créer donc disposer de droits d'ADMINISTRATION sur MinIO. Lui donner le
# ROOT serait absurde : le root lit et écrit TOUS les objets de TOUTES les apps.
#
# Ce compte-ci ne peut que PROVISIONNER : créer un bucket, une politique, un
# compte de service, et les attacher. Il n'a AUCUN droit de lecture ou
# d'écriture sur les objets. Une app compromise pourrait créer des buckets
# une nuisance , pas lire les vidéos d'une autre.
#
# Le root, lui, reste dans `kvv2/minio/config`, que seul le rôle CI `minio`
# peut lire.
#
# NON VÉRIFIÉ CONTRE LE SERVEUR : les noms d'actions d'administration MinIO
# ci-dessous viennent de la documentation, pas d'un essai je n'ai pas
# d'identifiants admin en main. Le PREMIER `apply` les confirmera ou les
# corrigera ; c'est le seul point de cette PR que je ne peux pas prouver ici.
# 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({
@@ -37,8 +26,7 @@ resource "minio_iam_policy" "provisioner" {
Resource = ["arn:aws:s3:::*"]
},
{
# Créer et inspecter un bucket mais PAS lire ni écrire ses objets :
# `s3:GetObject` et `s3:PutObject` sont volontairement absents.
# 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:::*"]
@@ -63,9 +51,6 @@ resource "minio_iam_user_policy_attachment" "provisioner" {
policy_name = minio_iam_policy.provisioner.id
}
# Lisible par le rôle CI de CHAQUE app (règle générique ajoutée à `app_policy`) :
# c'est ce qui permet à une app de provisionner ses propres buckets sans qu'on
# lui confie le root.
resource "vault_kv_secret_v2" "provisioner" {
mount = "kvv2"
name = "minio/provisioner"
+1 -5
View File
@@ -1,9 +1,5 @@
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."
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
}
# `consumers` a é 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 é une liste à tenir synchronisée, donc une liste à oublier.
+6 -14
View File
@@ -1,19 +1,11 @@
# Exposition PUBLIQUE s3.arcodange.fr, EN PLUS du .lab interne (qui reste
# inchangé). Même motif que `grafana/templates/ingress-public.yaml` : entrypoint
# `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).
# Exposition PUBLIQUE s3.arcodange.fr — TLS terminé par le tunnel Cloudflare,
# middleware crowdsec. Le `.lab` interne reste inchangé.
#
# ⚠ 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
# casserait le PUT présigné du navigateur, qui n'a aucun moyen d'y répondre.
# L'autorisation vient de l'URL signée, avec sa durée de vie courte.
# sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné
# auquel le navigateur ne peut pas répondre.
#
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
+5 -12
View File
@@ -61,11 +61,9 @@ 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.
# AUCUN bucket ici : chaque application déclare LES SIENS depuis son propre
# dépôt, via le module `iac/modules/minio_app` (fondateur 2026-07-26 : « on ne
# va pas modifier le repo tools à chaque changement d'application, chacun son
# périmètre »). `tools` fournit le serveur, le provisionneur et le module —
# pas la liste.
# AUCUN bucket ici : chaque app déclare les siens depuis son dépôt, via le
# module `iac/modules/minio_app`.
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
buckets: []
# Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le
@@ -75,13 +73,8 @@ minio: &minio_config
enabled: false # pas d'opérateur Prometheus ici : scrape par annotation
environment:
MINIO_PROMETHEUS_AUTH_TYPE: "public"
# CORS : le navigateur téléverse DIRECTEMENT vers MinIO depuis la PWA (URL
# présignées, kadans-api#23) — donc requête inter-origines, donc préflight
# 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
# CORS : le navigateur téléverse directement (URL présignées) — origines
# EXACTES, jamais « * » : une URL qui fuite serait sinon rejouable partout.
MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab"
tool: