diff --git a/hashicorp-vault/iac/factory_auth.tf b/hashicorp-vault/iac/factory_auth.tf index d9a1fc0..ba87ce1 100644 --- a/hashicorp-vault/iac/factory_auth.tf +++ b/hashicorp-vault/iac/factory_auth.tf @@ -1,5 +1,5 @@ locals { - factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin" + factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin" } diff --git a/hashicorp-vault/iac/modules/app_policy/main.tf b/hashicorp-vault/iac/modules/app_policy/main.tf index 6e7a34d..5cc39c1 100644 --- a/hashicorp-vault/iac/modules/app_policy/main.tf +++ b/hashicorp-vault/iac/modules/app_policy/main.tf @@ -36,6 +36,13 @@ data "vault_policy_document" "ops" { path = "kvv1/google/credentials" 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 rule { path = "kvv1/cloudflare/${local.name}*" @@ -178,6 +185,19 @@ data "vault_policy_document" "app" { path = "postgres/creds/${local.name}*" 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). dynamic "rule" { for_each = var.kv_read_paths @@ -204,6 +224,15 @@ data "vault_policy_document" "app_non_prod" { path = "postgres/creds/${each.key}*" 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" { for_each = toset(local.non_prod_instances) diff --git a/minio/README.md b/minio/README.md index a84d3cd..5d0e9c4 100644 --- a/minio/README.md +++ b/minio/README.md @@ -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) | -| 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` | 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é 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. + + +## 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. diff --git a/minio/iac/modules/minio_app/README.md b/minio/iac/modules/minio_app/README.md new file mode 100644 index 0000000..3df9edf --- /dev/null +++ b/minio/iac/modules/minio_app/README.md @@ -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://git@192.168.1.202: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/` 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/`, 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. diff --git a/minio/iac/modules/minio_app/main.tf b/minio/iac/modules/minio_app/main.tf new file mode 100644 index 0000000..55a6a03 --- /dev/null +++ b/minio/iac/modules/minio_app/main.tf @@ -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 + }) +} diff --git a/minio/iac/modules/minio_app/outputs.tf b/minio/iac/modules/minio_app/outputs.tf new file mode 100644 index 0000000..d2313cb --- /dev/null +++ b/minio/iac/modules/minio_app/outputs.tf @@ -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." +} diff --git a/minio/iac/modules/minio_app/providers.tf b/minio/iac/modules/minio_app/providers.tf new file mode 100644 index 0000000..c85f94b --- /dev/null +++ b/minio/iac/modules/minio_app/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + configuration_aliases = [minio] + } + vault = { + source = "hashicorp/vault" + } + random = { + source = "hashicorp/random" + } + } +} diff --git a/minio/iac/modules/minio_app/variables.tf b/minio/iac/modules/minio_app/variables.tf new file mode 100644 index 0000000..306ca73 --- /dev/null +++ b/minio/iac/modules/minio_app/variables.tf @@ -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." +} diff --git a/minio/iac/providers.tf b/minio/iac/providers.tf index 501193f..9911aaa 100644 --- a/minio/iac/providers.tf +++ b/minio/iac/providers.tf @@ -4,6 +4,14 @@ terraform { source = "vault" 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" } } + +# 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 +} diff --git a/minio/iac/provisioner.tf b/minio/iac/provisioner.tf new file mode 100644 index 0000000..43be8c1 --- /dev/null +++ b/minio/iac/provisioner.tf @@ -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 + }) +} diff --git a/minio/iac/variables.tf b/minio/iac/variables.tf new file mode 100644 index 0000000..5b91383 --- /dev/null +++ b/minio/iac/variables.tf @@ -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." +} diff --git a/minio/templates/ingress-public.yaml b/minio/templates/ingress-public.yaml index 4d94fa4..0ab4d0d 100644 --- a/minio/templates/ingress-public.yaml +++ b/minio/templates/ingress-public.yaml @@ -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: diff --git a/minio/values.yaml b/minio/values.yaml index 95d8989..9c0d545 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,11 +61,10 @@ 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. - buckets: - - name: kadans-videos - policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) - purge: false - versioning: false + # 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 # 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 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: