security: restrict MinIO app to one bucket

This commit is contained in:
ik
2026-09-12 16:01:00 +07:00
parent 926182a6cf
commit 868278fdba
10 changed files with 23 additions and 15 deletions
+2
View File
@@ -42,6 +42,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
+2 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings
from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport
@@ -32,7 +33,7 @@ def readiness_report(
ready = False ready = False
try: try:
s3.list_buckets() s3.head_bucket(Bucket=settings.s3_bucket)
components["minio"] = {"status": "ready"} components["minio"] = {"status": "ready"}
except Exception: except Exception:
components["minio"] = {"status": "unavailable"} components["minio"] = {"status": "unavailable"}
+1 -5
View File
@@ -6,7 +6,6 @@ from functools import lru_cache
import boto3 import boto3
from botocore.client import BaseClient from botocore.client import BaseClient
from botocore.exceptions import ClientError
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from .config import settings from .config import settings
@@ -63,10 +62,7 @@ def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type:
body, extension, mime = prepare_image(raw) body, extension, mime = prepare_image(raw)
key = f"reports/{uuid.uuid4()}.{extension}" key = f"reports/{uuid.uuid4()}.{extension}"
s3 = client() s3 = client()
try: s3.head_bucket(Bucket=settings.s3_bucket)
s3.head_bucket(Bucket=settings.s3_bucket)
except ClientError:
s3.create_bucket(Bucket=settings.s3_bucket)
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime) s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
return key return key
+4 -3
View File
@@ -11,12 +11,13 @@ from app.readiness import readiness_report
class AvailableStorage: class AvailableStorage:
def list_buckets(self) -> dict[str, list[object]]: def head_bucket(self, *, Bucket: str) -> dict[str, object]:
return {"Buckets": []} assert Bucket
return {}
class UnavailableStorage: class UnavailableStorage:
def list_buckets(self) -> None: def head_bucket(self, *, Bucket: str) -> None:
raise ConnectionError("fixture unavailable") raise ConnectionError("fixture unavailable")
+9 -1
View File
@@ -85,13 +85,21 @@ services:
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" && test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" && test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null && mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null &&
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json &&
mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null &&
(mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") && (mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") &&
mc admin policy attach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null (mc admin policy detach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null 2>&1 || true) &&
mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null &&
mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
mc stat "app/$$S3_BUCKET" >/dev/null &&
! mc ls app >/dev/null 2>&1
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
depends_on: depends_on:
minio: {condition: service_healthy} minio: {condition: service_healthy}
networks: [backend] networks: [backend]
+1 -1
View File
@@ -21,7 +21,7 @@ openssl rand -base64 36 # отдельные значения для БД, ADMI
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ' docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
``` ```
Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер проверяет это, API получает только application credentials. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер root-доступом создаёт `S3_BUCKET`, назначает приложению только list/location этого bucket и get/put/delete его объектов, затем проверяет отсутствие глобального list. API получает только application credentials и не может создавать bucket. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
## 3. Проверка и первый запуск ## 3. Проверка и первый запуск
+1 -1
View File
@@ -25,7 +25,7 @@ export BACKUP_DIRECTORY="$backup_root"
env_file=${COMPOSE_ENV_FILE:-.env.production} env_file=${COMPOSE_ENV_FILE:-.env.production}
compose="docker compose --env-file $env_file -f compose.production.yaml" compose="docker compose --env-file $env_file -f compose.production.yaml"
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump" $compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump"
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"' $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"'
tar -czf "$target/minio.tar.gz" -C "$target" minio tar -czf "$target/minio.tar.gz" -C "$target" minio
rm -rf "$target/minio" rm -rf "$target/minio"
(cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS) (cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS)
+1 -1
View File
@@ -26,6 +26,6 @@ env_file=${COMPOSE_ENV_FILE:-.env.production}
compose="docker compose --env-file $env_file -f compose.production.yaml" compose="docker compose --env-file $env_file -f compose.production.yaml"
$compose stop api web $compose stop api web
$compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump" $compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump"
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 || true; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"' $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"'
$compose up -d ${RESTORE_START_SERVICES:-minio api web proxy} $compose up -d ${RESTORE_START_SERVICES:-minio api web proxy}
echo "Restore completed from: $source_dir" echo "Restore completed from: $source_dir"
+1 -1
View File
@@ -19,7 +19,7 @@ trap cleanup EXIT INT TERM
$compose up -d --wait db minio $compose up -d --wait db minio
$compose run --rm minio-init $compose run --rm minio-init
$compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null $compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc stat "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null
./deploy/backup.sh "$drill_root" ./deploy/backup.sh "$drill_root"
backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1) backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1)
+1 -1
View File
@@ -43,7 +43,7 @@
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером. - [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска. - [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
- [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере. - [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере.
- [ ] **Q08 · Политика MinIO.** Ограничить app credentials одним bucket и добавить безопасную автоматическую проверку policy; root credentials оставить только bootstrap-задаче. - [x] **Q08 · Политика MinIO.** Production bootstrap создаёт bucket и отдельную policy только с bucket location/list и get/put/delete его объектов. App credentials проверяются через bucket stat и отрицательную проверку глобального list; API больше не требует `ListAllMyBuckets` и не пытается создавать bucket. Root credentials остаются только у init-задачи.
- [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных. - [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных.
- [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog. - [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog.