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:
@@ -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`.
|
||||
|
||||
67
infra/minio/bootstrap.sh
Executable file
67
infra/minio/bootstrap.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user