security(minio): scope archiv-app to bucket-only IAM policy

Replaces MinIO's built-in `readwrite` policy (which grants s3:* on
arn:aws:s3:::* — every bucket present and future) with a bucket-scoped
custom policy `archiv-app-policy`:

  - s3:GetObject / s3:PutObject / s3:DeleteObject on familienarchiv/*
  - s3:ListBucket / s3:GetBucketLocation on familienarchiv

The previous configuration silently regressed the least-privilege guarantee
that the service-account separation was supposed to provide: a future
second bucket (logs, backups, mc-mirror staging) would have been
read/write/delete-accessible to a compromised backend.

While at it, two follow-on fixes:

  1. Extract the entrypoint to infra/minio/bootstrap.sh. The previous
     inline `/bin/sh -c "..."` was already at the YAML-escaping ceiling;
     adding the policy-JSON heredoc would have made it unreadable.

  2. Replace the `| grep -q readwrite || exit 1` fatal-check with a
     POSIX `case` substring match. The minio/mc image ships coreutils +
     bash but NOT grep/awk/sed — the original check was a no-op that
     ALWAYS exited 1 (verified locally). The new check passes on the
     first invocation and on every subsequent re-deploy.

Idempotency verified locally: two consecutive `docker compose run --rm
create-buckets` invocations both exit 0 with the user bound to the
new policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-11 13:07:56 +02:00
parent 9652894aa4
commit 91f70e652d
2 changed files with 74 additions and 13 deletions

View File

@@ -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`.