diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 56f73689..468beeec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -74,10 +74,11 @@ services: retries: 3 # Idempotent bucket bootstrap + service-account creation. - # Runs once per `docker compose up` and exits 0; `--ignore-existing` and - # the user-add fallback are safe on re-deploy. + # Runs once per `docker compose up` and exits 0. The entrypoint is + # extracted to infra/minio/bootstrap.sh so the (non-trivial) idempotent + # logic is readable, reviewable, and unit-testable as a script rather + # than YAML-escaped shell. create-buckets: - # Pinned mc client release for reproducibility; Renovate keeps it current. image: minio/mc:RELEASE.2025-08-13T08-35-41Z depends_on: minio: @@ -87,16 +88,9 @@ services: environment: MINIO_PASSWORD: ${MINIO_PASSWORD} MINIO_APP_PASSWORD: ${MINIO_APP_PASSWORD} - entrypoint: > - /bin/sh -c " - set -e; - /usr/bin/mc alias set myminio http://minio:9000 archiv $$MINIO_PASSWORD; - /usr/bin/mc mb myminio/familienarchiv --ignore-existing; - /usr/bin/mc anonymous set private myminio/familienarchiv; - /usr/bin/mc admin user add myminio archiv-app $$MINIO_APP_PASSWORD || /usr/bin/mc admin user enable myminio archiv-app; - /usr/bin/mc admin policy attach myminio readwrite --user archiv-app 2>/dev/null || true; - /usr/bin/mc admin user info myminio archiv-app | grep -q readwrite || { echo 'FATAL: archiv-app is missing the readwrite policy'; exit 1; }; - " + volumes: + - ./infra/minio/bootstrap.sh:/bootstrap.sh:ro + entrypoint: ["/bin/sh", "/bootstrap.sh"] # Dev-only mail catcher; gated behind the staging profile so production # never starts it. Staging workflow runs with `--profile staging`. diff --git a/infra/minio/bootstrap.sh b/infra/minio/bootstrap.sh new file mode 100755 index 00000000..5394a0ea --- /dev/null +++ b/infra/minio/bootstrap.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Idempotent MinIO bootstrap for the Familienarchiv stack. +# +# Runs on every `docker compose up` (the create-buckets service is one-shot, +# no restart). Each step swallows the "already exists" error so the script +# is safe to re-run. +# +# What it does: +# 1. Register the MinIO alias using the root credentials +# 2. Create the application bucket if missing +# 3. Lock the bucket to private (defense in depth) +# 4. Create/enable the `archiv-app` service account (least-privilege user) +# 5. Install a bucket-scoped policy `archiv-app-policy`: +# - GetObject/PutObject/DeleteObject on familienarchiv/* +# - ListBucket + GetBucketLocation on familienarchiv +# (Replaces MinIO's built-in `readwrite` which grants s3:* on *.) +# 6. Attach the policy to `archiv-app` +# 7. Fatal assertion: read back the user and confirm the policy is bound. +# Uses `case` (POSIX) for substring match — the minio/mc image ships +# coreutils + bash but NOT grep/awk/sed. +# +# Required env vars: MINIO_PASSWORD, MINIO_APP_PASSWORD +set -e + +mc alias set myminio http://minio:9000 archiv "$MINIO_PASSWORD" + +mc mb myminio/familienarchiv --ignore-existing +mc anonymous set private myminio/familienarchiv + +mc admin user add myminio archiv-app "$MINIO_APP_PASSWORD" \ + || mc admin user enable myminio archiv-app + +cat > /tmp/archiv-app-policy.json <<'POLICY' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": ["arn:aws:s3:::familienarchiv/*"] + }, + { + "Effect": "Allow", + "Action": ["s3:ListBucket", "s3:GetBucketLocation"], + "Resource": ["arn:aws:s3:::familienarchiv"] + } + ] +} +POLICY + +mc admin policy create myminio archiv-app-policy /tmp/archiv-app-policy.json 2>/dev/null \ + || mc admin policy update myminio archiv-app-policy /tmp/archiv-app-policy.json + +mc admin policy attach myminio archiv-app-policy --user archiv-app 2>/dev/null || true + +INFO=$(mc admin user info myminio archiv-app) +case "$INFO" in + *archiv-app-policy*) + echo "archiv-app bound to archiv-app-policy" + ;; + *) + echo "FATAL: archiv-app is missing the bucket-scoped policy" + echo "----- user info -----" + echo "$INFO" + exit 1 + ;; +esac