
В современных веб-приложениях хранение файлов часто отдают специализированным объектным хранилищам, таким как S3. Это удобно, масштабируемо и надежно. Однако здесь возникает классическая проблема проектирования: как обеспечить безопасный доступ к файлам, когда архитектура требует, чтобы хранилище было приватным, а бизнес-логика прав доступа — централизованной? Оставлять S3-бакет публичным — плохая практика, а проксировать каждый запрос на скачивание через бэкенд-сервис — значит, превратить его в «бутылочное горлышко», которое неизбежно захлебнется при росте трафика.
В этой статье я познакомлю вас с реализацией бэкенд-сервиса, который решает эту проблему элегантно: S3 отвечает за хранение и отправку данных, а мое приложение — за проверку прав и генерацию одноразовых, короткоживущих ключей доступа (Pre-signed URL). Я разберу архитектуру решения, покажу, как настроить безопасное разграничение доступа между публичными и приватными объектами, а также продемонстрирую механизм, который позволяет клиентам скачивать файлы напрямую из хранилища, минуя сервер приложения.
Материал будет полезен бэкенд-разработчикам, системным архитекторам и DevOps-инженерам, которые сталкиваются с задачами организации безопасного хранения и выдачи файлов, ищут способы снижения нагрузки на API своего сервиса и стремятся построить надежную систему разграничения прав доступа с использованием современных объектных хранилищ.
Концепция: проксирование vs Pre-signed сессии
При проектировании сервиса возник вопрос: как организовать передачу файлов пользователям?
Вариант 1: Проксирование через бэкенд (выбран для загрузки файлов через административные CLI-команды).
В этой схеме клиент запрашивает файл у API, приложение читает его из S3 и «отдает» клиенту.
Плюсы: полный контроль над потоком данных.
Минусы: сервер превращается в «узкое место» (bottleneck). Приложение задействует лишние ресурсы CPU и RAM на обработку I/O, а пропускная способность моего канала неизбежно ограничивает скорость скачивания для всех пользователей одновременно.
Вариант 2: Pre-signed URL (выбран для скачивания файлов пользователями).
Я делегирую передачу данных непосредственно S3-хранилищу.
Как это работает: клиент обращается к моему API, приложение проверяет права в PostgreSQL, валидирует метаданные и генерирует «подписанную» ссылку с ограниченным временем жизни. Клиент скачивает файл напрямую из S3, минуя мой сервер.
Итог: Я превратил бэкенд из «транспортной трубы» в легковесный «валидатор». Это позволяет моему приложению оставаться отзывчивым даже при передаче гигабайтных архивов, так как основной файловый трафик проходит мимо API.
Примечание: Архитектура остаётся гибкой. Для административных задач (загрузка файлов, управление контентом) я использую прямой канал через aioboto3 — это даёт строгий контроль. А массовые пользовательские запросы идут по модели прямого доступа. При этом в текущей реализации загрузка файлов в S3 пользователями через Pre-signed URL не используется. В итоге я получил лучшее от обоих миров: безопасность и аудит там, где они необходимы, и максимальное быстродействие — там, где это критично.
Стек технологий и архитектура
Для сервиса я выбрал стек, который даёт высокую производительность при минимальных накладных расходах. В основе — разделение ответственности: приложение управляет доступом, а хранилище — данными.
Технологический стек:
FastAPI: Как основной фреймворк. Его асинхронная природа (async/await) позволяет обрабатывать большое количество запросов к API, не блокируя основной поток при обращении к базе данных или S3-совместимому хранилищу.
PostgreSQL: Я использую эту БД для хранения состояний и метаданных. Реляционная модель надёжно связывает пользователей, их права и метаданные объектов и сохраняет целостность данных при сложных проверках доступа.
S3-совместимое хранилище: Я остановился на протоколе S3, так как он — де-факто стандарт индустрии. Использование библиотеки aioboto3 позволяет мне абстрагироваться от конкретного провайдера и при необходимости легко перенести данные в другое облако.
Docker и Docker Compose: Чтобы сделать развертывание воспроизводимым и предсказуемым, я упаковал все компоненты (приложение, БД и прокси-сервер) в Docker-контейнеры. Это устраняет проблему «на моей машине работает» и упрощает жизнь любому, кто захочет запустить проект.
Архитектура:
Моя архитектура исключает бэкенд как посредника при передаче файлов. Весь обмен данными укладывается в три этапа:
Запрос к API: Клиент обращается к моему сервису, передавая информацию о названиях бакета, файла и свой токен доступа.
Валидация: Приложение извлекает метаданные из PostgreSQL, проверяет права пользователя (владелец, публичный/приватный статус) и, если проверка пройдена, генерирует Pre-signed URL.
Прямое взаимодействие: клиент получает временный Pre-signed URL и скачивает файл из S3, минуя сервер приложения.
Гибкая конфигурация: время жизни ссылок
Один из важнейших параметров сервиса — время жизни ссылки. В файле .env я вынес этот параметр в отдельную настройку:
EXPIRES_IN=10800
Значение 10800 (ровно 3 часа) — баланс между безопасностью и удобством. Ссылка успевает дойти до получателя, но не остаётся валидной «вечно», что снижает риск компрометации данных. Сам файл по истечении её срока удалён не будет.
Поведение Pre-signed URL не гарантирует непрерывность загрузки при истечении срока действия ссылки. Если файл уже передаётся по установленному HTTP-соединению, загрузка обычно завершается успешно. Однако при медленном соединении, повторных HTTP-запросах или докачке через Range-запросы истечение срока действия URL может привести к ошибке доступа (403 Forbidden).
Модель данных
Для реализации гибкой системы прав доступа я вынес метаданные в PostgreSQL, полностью отделив их от логики самого S3-хранилища. Это позволило мне выполнять сложные проверки доступа на уровне базы данных до того, как будет сформирован Pre-signed URL.
Моя модель данных построена на трех уровнях (Бакет → Ключ → Файл), что обеспечивает строгую структуру хранения. Вот как выглядит модель для хранения файла (src/delivery_s3_temporary_links/database/models/buckets.py):
# ... Другой код ... class FileS3(IDMixin, TimestampMixin, Base): """Модель хранения файла в s3""" __tablename__ = 'file' owner_id: Mapped[uuid.UUID] = mapped_column( UUID, ForeignKey('user.id'), nullable=False ) object_key_id: Mapped[int] = mapped_column( ForeignKey('object_key.id'), nullable=False ) object_key: Mapped['ObjectKey'] = relationship( back_populates="files" ) name: Mapped[str] = mapped_column(String(length=255), nullable=False) # ... Другой код ...
Ключевые преимущества этого подхода:
Нормализация: Модель
ObjectKeyсодержит поле access_type (перечисление private или public), что позволяет мне централизованно управлять правами доступа для целых групп объектов.Целостность и аудит:
owner_idиfile_hashпозволяют мгновенно отследить владельца контента и проверять уникальность файлов, не обращаясь к S3.Безопасность: Проверка доступа происходит через SQLAlchemy-запрос. Если
access_typeобъекта —private, я проверяю принадлежностьowner_idтекущему пользователю в БД. Только после получения всех метаданных я разрешаю генерацию ссылки, что исключает несанкционированный доступ к объектам.
Такая структура позволяет мне легко масштабировать права доступа, добавляя новые правила в модель, не затрагивая при этом сами данные в хранилище.
Реализация логики безопасности
В основе безопасности сервиса — проверка авторизации на базе Redis. Я отказался от классической проверки JWT «на лету» (без обращения к БД) в пользу stateful-авторизации: так можно мгновенно аннулировать права доступа, когда это нужно.
Архитектура модуля авторизации:
Модуль работает прозрачно для эндпоинтов благодаря Dependency Injection в FastAPI. Вся логика инкапсулирована в два основных компонента:
ClientRedis— синглтон, управляющий жизненным циклом подключения к Redis.require_redis_token— Dependency, которая выступает в роли «стража» (gatekeeper) для защищенных маршрутов.
Ключевой механизм: require_redis_token
Вместо того чтобы вручную проверять токен в каждом обработчике, я использую Dependency Injection. Так можно сосредоточиться на бизнес-логике, оставив проверку безопасности на «периферии» сервиса.
async def require_redis_token(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> str: """Проверяет наличие токена в Redis и валидность сессии.""" if creds is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing token') token = creds.credentials redis_obj = get_redis_client_instance() # Ключ формируется на основе префикса из настроек, обеспечивая неймспейсинг user_id = await redis_obj.redis_client.get(f'{settings.settings_redis.prefix}:token:{token}') if not user_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid token') return user_id
Почему я выбрал этот подход?
Контроль состояния: Активные сессии хранятся в Redis. Чтобы «выбросить» пользователя из системы, достаточно удалить ключ из Redis — доступ прекратится сразу, не дожидаясь, пока истечёт срок JWT.
Производительность: redis.
asyncioпозволяет проверять авторизацию, не блокируя Event Loop FastAPI, что критично при высокой нагрузке.Чистота кода: Декоратор
@lru_cacheдля получения клиента Redis переиспользует соединение, а не создаёт новое на каждый запрос.
Благодаря Redis авторизация остаётся быстрой даже при большом количестве запросов, а управление пользовательскими сессиями становится более предсказуемым и контролируемым.
Проектирование API
API спроектировано согласно принципам RESTful, с акцентом на простоту и предсказуемость. Сервис выполняет узкоспециализированную задачу — безопасно выдает временные ссылки (Pre-signed URL) к объектам в S3-хранилище.
Организация эндпоинтов
Я придерживаюсь модульного подхода: все маршруты разделены логически и подключены к основному приложению через APIRouter. Это позволяет масштабировать API и регистрировать новые модули без раздувания main.py.
Работа с API строится на трех «китах»:
Pydantic-схемы: Если данные приходят в некорректном формате, API возвращает ошибку 422.
Dependency Injection: Внедрение зависимостей позволяет вынести инфраструктурную логику из обработчиков.
Service Layer: Обработчики (routes) не содержат логики работы с S3 или базой данных; они лишь валидируют запрос и делегируют выполнение сервисному слою.
Все эндпоинты объявлены как async, что дает высокую производительность при обработке множества одновременных запросов.
Анатомия защищенного эндпоинта
Чтобы понять, как методы превращают запрос в безопасную операцию с S3, я покажу один из них для примера:
@file_s3_router.get('/{bucket_name}/private/{folder}/{name}', dependencies=[Depends(handle_exceptions_s3), Depends(require_redis_token)]) async def get_file_secret_link( bucket_name: str, folder: str, name: str, user_id: str = Depends(require_redis_token) ): file = await get_file_by_name(name=name) if not file: raise HTTPException(status_code=404, detail='File not found') # Проверка владения и типа доступа if str(file.owner_id) != user_id: raise HTTPException(status_code=403, detail='Access denied') if file.object_key.access_type != 'private': raise HTTPException(status_code=403, detail='File is not private') return await get_temporary_link( bucket_name=bucket_name, key=f'{folder}/{name}', expires_in=settings.settings_s3.expires_in )
Безопасность под капотом:
Декларативность: Благодаря
dependenciesпроверкиhandle_exceptions_s3иrequire_redis_tokenотрабатывают до выполнения тела функции. Если токен невалиден, код внутри не начнет выполняться.Двухуровневая защита: Я сочетаю аутентификацию через Redis с проверкой прав доступа к конкретному объекту в БД (RBAC/ABAC).
Безопасность по умолчанию: Обязательное указание
require_redis_tokenв сигнатуре гарантирует, что ни один защищенный маршрут не останется открытым «по ошибке».
Преимущества подхода:
Тестируемость: Вынос бизнес-логики в сервисный слой позволяет тестировать её изолированно от транспортного уровня.
Документируемость: FastAPI автоматически генерирует интерактивную Swagger/OpenAPI документацию, которая служит как справочником для фронтенд-разработчиков, так и инструментом для быстрого тестирования в браузере.
Такой подход позволил создать сервис, который не только эффективно выполняет свою задачу, но и остается удобным в поддержке.
Работа с S3
Чтобы обеспечить высокую производительность и масштабируемость, я реализовал работу с S3 на асинхронной библиотеке aioboto3. Так приложение не блокирует основной Event Loop при тяжелых операциях ввода-вывода (I/O) с хранилищем.
Генерация временных ссылок (get_temporary_link)
Эта функция — сердце сервиса. Она использует generate_presigned_url для создания URL с ограниченным временем жизни.
async def get_temporary_link(bucket_name: str, key: str, expires_in: int) -> dict[str , int | str]: """Получает временную ссылку для GET запроса на файл""" client_s3 = get_s3_client_instance() async with await client_s3.get_s3_client() as s3: temporary_link = await s3.generate_presigned_url( Params={'Bucket': bucket_name, 'Key': key}, ClientMethod='get_object', HttpMethod='GET', ExpiresIn=expires_in ) return {'link': temporary_link, 'status_code': status.HTTP_200_OK}
expires_in: Время жизни ссылки в секундах, определяемое настройкой из .env. Это дает баланс между безопасностью и удобством пользователя.
Безопасность: Ссылка действительна только для указанного бакета и ключа объекта.
Инфраструктурные операции (create_bucket_s3, create_file_s3)
Первая функция создаёт бакет в s3.
async def create_bucket_s3(bucket_name: str) -> dict[str, int | str]: """Создаёт новый бакет в S3 и возвращает статус операции""" client_s3 = get_s3_client_instance() async with await client_s3.get_s3_client() as s3: try: # Проверяем существует ли бакет await s3.head_bucket(Bucket=bucket_name) return { 'status_code': status.HTTP_200_OK, 'detail': 'Bucket already exists' } except ClientError as err: error_code = err.response['Error']['Code'] # Бакета нет -> создаём if error_code in ('404', 'NoSuchBucket'): try: await s3.create_bucket(Bucket=bucket_name) return { 'status_code': status.HTTP_201_CREATED, 'detail': 'Bucket created successfully' } except ClientError as err: logger.error(err.response) return { 'status_code': status.HTTP_500_INTERNAL_SERVER_ERROR, 'detail': 'Failed create bucket' } # Бакет существует, но нет доступа if error_code == '403': return { 'status_code': status.HTTP_403_FORBIDDEN, 'detail': 'Bucket exists but access denied' } # Остальные ошибки logger.error(err.response) return { 'status_code': status.HTTP_500_INTERNAL_SERVER_ERROR, 'detail': 'S3 error' }
Вторая функция создаст (передаст) новый файл в s3.
async def create_file_s3(bucket_name: str, key: str, path_file: str) -> dict[str, int | str]: """Создаёт новый файл в S3 и возвращает статус операции""" client_s3 = get_s3_client_instance() async with await client_s3.get_s3_client() as s3: try: # Создаём новый файл (если нет ключа создаст автоматически) await s3.upload_file( Filename=path_file, Bucket=bucket_name, Key=key ) return { 'status_code': status.HTTP_201_CREATED, 'detail': 'Key created successfully' } except ClientError as err: error_code = err.response['Error']['Code'] # Если нет бакета if error_code == 'NoSuchBucket': return { 'status_code': status.HTTP_404_NOT_FOUND, 'detail': 'Bucket not found' } # Есть бакет, но нет доступа if error_code == '403': return { 'status_code': status.HTTP_403_FORBIDDEN, 'detail': 'Access denied' } # Остальные ошибки return { 'status_code': status.HTTP_500_INTERNAL_SERVER_ERROR, 'detail': 'S3 error' }
Механика работы функций:
create_bucket_s3: Использует методhead_bucketдля проверки существования бакета. Если бакет найден, возвращает200 OK. Если возвращается ошибка 404 (бакет отсутствует), безопасно инициализирует новый бакет с ответом201 Created. При ошибке 403 логирует отказ в доступе.create_file_s3: Асинхронно загружает локальный файл в хранилище через методupload_file. При успешном завершении возвращает статус201 Created. Если целевой бакет не существует в системе или к нему закрыт доступ, метод корректно перехватывает исключение и транслирует его в статус404 Not Foundили403 Forbiddenсоответственно.
Для административных задач (инициализация бакетов и загрузка контента) реализованы методы, которые обрабатывают специфичные ошибки ClientError протокола S3:
Обработка состояний: Проверка наличия бакета (
head_bucket) перед созданием позволяет избежать ошибок дублирования.Изоляция ошибок: Приложение перехватывает коды ошибок S3 (такие как
NoSuchBucket,403 Access Denied) и конвертирует их в стандартные HTTP-статусы FastAPI (404, 403, 500), что делает API предсказуемым для фронтенд-клиентов.
Инфраструктура: Docker
Чтобы сервис можно было быстро развернуть как локально, так и на сервере, я полностью контейнеризировал инфраструктуру через Docker Compose. Такой подход избавляет от ручной настройки зависимостей и гарантирует одинаковое окружение для разработки и production.
В отдельный контейнер я вынес FastAPI-приложение, PostgreSQL, Redis и Nginx. Каждый сервис отвечает только за свою задачу:
PostgreSQL хранит пользователей, метаданные файлов и информацию о правах доступа;
Redis используется для хранения токенов и работы с авторизацией;
FastAPI реализует бизнес-логику сервиса;
Nginx выступает reverse proxy: принимает внешние HTTP(S)-запросы и перенаправляет их во внутренний контейнер FastAPI. Работу с S3 он не проксирует — файлы скачиваются напрямую между клиентом и хранилищем по Pre-signed URL.
Конфигурация Dockerfile (docker/links_s3_app/Dockerfile) построена с акцентом на скорость сборки и минимальный размер итогового образа:
docker/links_s3_app/Dockerfile:
FROM python:3.13-slim WORKDIR /app ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 COPY . /app RUN pip install --no-cache-dir uv RUN uv sync --no-dev --frozen ENV AWS_CA_BUNDLE=/etc/ssl/certs/GlobalSign_Root_CA_-_R6.pem CMD ["sh", "-c", "uv run alembic upgrade head && uv run delivery-s3-temporary-links"]
О конфигурации:
Базовый образ: Использую
python:3.13-slim. В нем нет лишнего системного мусора, что уменьшает вектор атаки и ускоряет деплой.Менеджер пакетов uv: Я отказался от стандартного
pipв пользуuv. Он собирает зависимости в разы быстрее. Флаг--frozenгарантирует воспроизводимость окружения на продакшене, а--no-devотсекает тяжелые инструменты тестирования и линтеры.Точка запуска: Команда CMD через shell-скрипт автоматически накатывает миграции Alembic перед стартом самого приложения. В рамках текущей реализации миграции выполняются на этапе старта контейнера. Такой подход упрощает деплой, но ограничивает гибкость CI/CD и усложняет управление откатами. В production-средах миграции обычно выносятся в отдельный этап пайплайна или init-container.
SSL-валидация (AWS_CA_BUNDLE): Переменная указывает библиотекам
boto3/aioboto3нативный путь к доверенному корневому сертификату (GlobalSign Root CA). Это позволяет сохранить strict-проверку SSL-соединений (защита от MitM-атак) и избавиться от хардкода параметров verify в коде приложения. Контекст безопасности полностью изолирован на уровне инфраструктуры контейнера.
Оркестрация сервисов
В файле docker-compose.yml я изолировал все компоненты системы во внутреннюю сеть links_s3. Наружу смотрят только порты приложения, СУБД и прокси-сервера:
docker-compose.yml:
services: file_db: container_name: file_db image: postgres:latest restart: always environment: - POSTGRES_DB=${NAME_DB} - POSTGRES_USER=${USER_DB} - POSTGRES_PASSWORD=${PASSWORD_DB} ports: - "${EXTERNAL_PORT_DB}:${PORT_DB}" networks: - links_s3 healthcheck: test: ["CMD-SHELL", "pg_isready -U $USER_DB -d $NAME_DB"] interval: 5s timeout: 5s retries: 10 start_period: 10s volumes: - file_db:/var/lib/postgresql redis: image: redis:latest container_name: redis-token restart: unless-stopped command: [ "redis-server", "--appendonly", "yes", "--user", "$REDIS_USER", "on", ">$REDIS_PASSWD", "~*", "+@all", "--user", "default", "off" ] environment: REDIS_USER: ${REDIS_USER} REDIS_PASSWD: ${REDIS_PASSWD} ports: - "${PORT_REDIS}:${PORT_REDIS}" volumes: - redis_data:/data networks: - links_s3 healthcheck: test: ["CMD-SHELL", "redis-cli --user $REDIS_USER -a $REDIS_PASSWD ping | grep -q PONG || exit 1"] interval: 5s timeout: 3s retries: 10 links_s3_app: container_name: links_s3_app restart: unless-stopped build: context: . dockerfile: docker/links_s3_app/Dockerfile env_file: - .env ports: - "${PORT_APP}:${PORT_APP}" networks: - links_s3 depends_on: file_db: condition: service_healthy redis: condition: service_healthy volumes: - ./shared:/app/shared nginx: image: nginx:latest container_name: nginx restart: on-failure environment: PORT_HTTP_NGINX: ${PORT_HTTP_NGINX} PORT_HTTPS_NGINX: ${PORT_HTTPS_NGINX} ports: - "${PORT_HTTP_NGINX}:${PORT_HTTP_NGINX}" - "${PORT_HTTPS_NGINX}:${PORT_HTTPS_NGINX}" depends_on: - links_s3_app networks: - links_s3 volumes: - ./nginx:/etc/nginx - /etc/letsencrypt:/etc/letsencrypt volumes: redis_data: file_db: networks: links_s3:
Ключевые моменты конфигурации:
Безопасность Redis: Я не использую дефолтные настройки. Через аргументы команды я отключаю стандартного пользователя (
default off) и создаю выделенного юзера со сложным паролем и полными правами (+@all). Режим--appendonly yesгарантирует, что кэш токенов не пропадет при перезапуске контейнера.Контроль готовности (Healthcheck): Для Redis и PostgreSQL настроены честные healthcheck’и с проверкой доступности и авторизации. Сервис приложения
links_s3_appне начнет обрабатывать трафик, пока Redis не ответит PONG, а PostgreSQL не подтвердит готовность принимать подключения черезpg_isready.Сохранение данных: База данных и кэш вынесены в именованные volume (
file_db, redis_data), что защищает информацию от уничтожения при обновлении контейнеров. Для шаринга тяжелых файлов между хостом и приложением используется bind-mount папки./shared.
nginx/nginx.conf:
Точка входа для трафика — Nginx (nginx/nginx.conf). Его задача — терминация SSL-трафика и перенаправление запросов к бэкенду.
Терминация SSL-трафика означает, что Nginx сам расшифровывает входящие HTTPS-запросы от пользователей и передает их на бэкенд (links_s3_app) уже в чистом HTTP-виде.
Зачем я это сделал:
Централизованное управление безопасностью: Все SSL-сертификаты хранятся и обновляются в одном месте (в контейнере Nginx), что упрощает ротацию и обслуживание TLS.
Разделение ответственности: Бэкенд полностью изолирован от деталей работы TLS и занимается исключительно бизнес-логикой и обработкой HTTP-запросов.
events {} http { server { listen 443 ssl; server_name your-domain.ru; ssl_certificate /etc/letsencrypt/live/your-domain.ru/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.ru/privkey.pem; location / { proxy_pass http://links_s3_app:8055; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } # редирект с http на https server { listen 80; server_name your-domain.ru; location / { return 301 https://$host$request_uri; } } }
Зачем это сделано:
Строгий HTTPS: Весь незащищенный трафик с 80-го порта жестко редиректится на 443-й с кодом 301.
Интеграция с Let’s Encrypt: Директория
/etc/letsencryptпроброшена внутрь контейнера Nginx. Это позволяет бесшовно обновлять SSL-сертификаты с помощьюcertbotна хост-машине без остановки веб-сервера.Проброс заголовков: бэкенд за прокси получает информацию о клиенте через
X-Forwarded-For(цепочка IP-адресов), а такжеX-Real-IP(IP непосредственного клиента на уровне прокси) иX-Forwarded-Proto(протокол запроса), что критически важно для корректной генерации ссылок, логирования и анализа подозрительной активности.
Тестирование
Чтобы проверить, что сервис не только держит нагрузку, но и обеспечивает заявленную безопасность, я провёл серию практических тестов.. Я не ограничивался простым запуском кода — моей целью было продемонстрировать, как бэкенд принимает решения, основываясь на данных, и как S3 отдает контент только при наличии валидного ключа.
1. Подготовка инфраструктуры и данных
Перед тестированием я убедился, что все компоненты окружения запущены. Для удобства я сделал Makefile — он берёт на себя всю рутину с командами.. Вместо длинных вызовов Docker я использую упрощённые команды.
Чтобы посмотреть список команд, выполните в корне проекта:
make help
Часть вывода команды в terminal:
make start-app Запустить приложение в фоне
Для инициализации сервисов я выполняю команду:
make start-app
После успешного запуска я перешел по адресу https://s3-files.arduinum628.ru/docs#. Здесь развернут Swagger UI — мой основной инструмент для интерактивного тестирования всех API-методов.

Чтобы сымитировать обычную работу системы, я создал учётные записи администратора и рядового пользователя:
make create-admin name=admin email=admin@example.com passwd=12345 make create-user name=user1 email=user1@example.com passwd=12345
Команды выше — это CLI-команды, которые я написал для удобного заполнения базы тестовыми и другими данными. Ими пользуется администратор сервиса.
2. Конфигурация хранилища
Чтобы продемонстрировать логическое разделение на public и private зоны, я подготовил конфигурационный YAML-файл, описывающий структуру бакетов проекта.
Файл shared/buckets.yml:
buckets: - name: project-files objects: - name: logs status: private - name: docs status: public
Далее, используя данные из этого файла, я заполнил базу данных Postgresql и s3 хранилище следующими командами:
make create-buckets path-seed='/app/shared/buckets.yml' make create-objectskeys path-seed='/app/shared/buckets.yml'
Папка shared — это монтируемая в контейнер директория. В неё удобно вручную загружать любые файлы и папки для отправки в S3, а также копировать конфигурационные файлы для заполнения базы данных.
Скриншот ниже подтверждает, что бакет был успешно инициализирован в системе:

3. Загрузка тестовых данных
Теперь я наполнил созданные зоны контентом. Я загрузил лог-файл (в приватную зону) и текстовый документ (в публичную):
Теперь я наполнил созданные зоны контентом. Я загрузил лог-файл (в приватную зону) и текстовый документ (в публичную):
make create-file path-file='/app/shared/log-2026-05-06_19-20-17.log' email=admin@example.com bucket-name=project-files obj-name=logs make create-file path-file='/app/shared/data.txt' email=admin@example.com bucket-name=project-files obj-name=docs
В результате в хранилище s3 были созданы два файла:

Список бакетов
4. Верификация сценариев доступа
Сценарий А: Ограничение доступа к API
Сначала я попробовал обратиться к приватному ресурсу без аутентификации. Ожидаемо сервер вернул ошибку 401 Unauthorized — личность пользователя не подтверждена.

Сценарий Б: Успешная авторизация и доступ к публичным файлам
Далее я залогинился под созданным пользователем user1.


Я получил токен и запросил ссылку на публичный файл docs. Сервис проверил статус объекта и выдал временную подписанную ссылку.

После выхода из системы (logout) повторный запрос к этому же эндпоинту привел к отказу:


Сценарий В: Защита приватных данных (FORBIDDEN)
Это ключевой тест. Я попробовал запросить ссылку на файл logs (status: private). Поскольку у текущего пользователя нет прав владельца файла, сервер запретил выдачу ключа.

Сценарий Г: Права владельца файла и скачивание
Теперь я вошел под администратором, обладающим правами доступа к приватным логам:


Как видно из проведенных тестов, система корректно разделяет права: публичный контент доступен для всех авторизованных пользователей, а приватный — только тогда, когда логика моего Бэкенд-сервиса подтверждает наличие необходимых полномочий.
Получив временную ссылку, я перешел по ней в браузере. Видно, что файл успешно загружен — это подтверждает, что ссылка корректно работает:

Напоследок я проверил файл логирования, чтобы убедиться, что все данные сохранены:

Этот файл был создан логгером, когда я писал и отлаживал код. Судя по информации в логе, я проверял сценарий, когда objectkey уже существовал в базе данных, а я пытался создать ещё один такой же.
Заключение и планы на будущее
В этой статье я реализовал прототип бэкенд-сервиса, который кардинально меняет подход к работе с файлами в S3. Вместо того чтобы полагаться на публичные права доступа в облаке или перегружать сервер передачей файлового трафика, я вынес бизнес-логику авторизации на уровень приложения, а работу с данными оставил объектному хранилищу.
Теперь моя система гарантирует, что:
Хранилище остаётся полностью закрытым для внешнего мира.
Доступ к объектам выдаётся только после строгой проверки прав.
Пользователи скачивают файлы напрямую из S3 по коротким временным ссылкам (Pre-signed URL).
Пока я реализовал функции первого MVP, который уже можно использовать как каркас для безопасной загрузки и выдачи файлов.
Ссылка на итоговый open-source проект Доставка s3 временных ссылок (Delivery s3 temporary links.
Что в планах?
Мой проект находится в активной разработке, и в ближайшее время я планирую значительно расширить его возможности:
Загрузка через Pre-signed URL POST: Сейчас данные заполняются через aioboto3. Соответствующие скрипты реализованы для CLI-команд администратора сервиса. Это удобно для начального наполнения БД и S3. Для обычных пользователей я планирую реализовать загрузку напрямую от клиента в S3, чтобы сервер вообще не участвовал в передаче данных и снимал нагрузку с сервиса.
Сложная ролевая модель: Я собираюсь интегрировать RBAC (Role-Based Access Control), чтобы гибко управлять правами не только владельцев файлов, но и целых групп или проектов.
Аудит и логирование: Для соответствия требованиям безопасности я добавлю полноценное журналирование всех предоставлений ссылок и попыток доступа.
Автоматизация Lifecycle: Я планирую внедрить автоматическое удаление старых файлов с помощью S3 Lifecycle-политик, чтобы очищать данные, потерявшие актуальность.
Этот проект — отличная база для любой системы, работающей с контентом: от корпоративных хранилищ документов до облачных платформ для творческих команд. Буду рад услышать ваши предложения по развитию проекта в комментариях!
Автор статьи: @Arduinum
НЛО прилетело и оставило здесь промокод для читателей нашего блога:-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
