В современных веб-приложениях хранение файлов часто отдают специализированным объектным хранилищам, таким как 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-контейнеры. Это устраняет проблему «на моей машине работает» и упрощает жизнь любому, кто захочет запустить проект.

Архитектура:

Моя архитектура исключает бэкенд как посредника при передаче файлов. Весь обмен данными укладывается в три этапа:

  1. Запрос к API: Клиент обращается к моему сервису, передавая информацию о названиях бакета, файла и свой токен доступа.

  2. Валидация: Приложение извлекает метаданные из PostgreSQL, проверяет права пользователя (владелец, публичный/приватный статус) и, если проверка пройдена, генерирует Pre-signed URL.

  3. Прямое взаимодействие: клиент получает временный 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)
# ... Другой код ...

Ключевые преимущества этого подхода:

  1. Нормализация: Модель ObjectKey содержит поле access_type (перечисление private или public), что позволяет мне централизованно управлять правами доступа для целых групп объектов.

  2. Целостность и аудит: owner_id и file_hash позволяют мгновенно отследить владельца контента и проверять уникальность файлов, не обращаясь к S3.

  3. Безопасность: Проверка доступа происходит через SQLAlchemy-запрос. Если access_type объекта — private, я проверяю принадлежность owner_id текущему пользователю в БД. Только после получения всех метаданных я разрешаю генерацию ссылки, что исключает несанкционированный доступ к объектам.

Такая структура позволяет мне легко масштабировать права доступа, добавляя новые правила в модель, не затрагивая при этом сами данные в хранилище.

Реализация логики безопасности

В основе безопасности сервиса — проверка авторизации на базе Redis. Я отказался от классической проверки JWT «на лету» (без обращения к БД) в пользу stateful-авторизации: так можно мгновенно аннулировать права доступа, когда это нужно.

Архитектура модуля авторизации:

Модуль работает прозрачно для эндпоинтов благодаря Dependency Injection в FastAPI. Вся логика инкапсулирована в два основных компонента:

  1. ClientRedis — синглтон, управляющий жизненным циклом подключения к Redis.

  2. 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
    )

Безопасность под капотом:

  1. Декларативность: Благодаря dependencies проверки handle_exceptions_s3 и require_redis_token отрабатывают до выполнения тела функции. Если токен невалиден, код внутри не начнет выполняться.

  2. Двухуровневая защита: Я сочетаю аутентификацию через Redis с проверкой прав доступа к конкретному объекту в БД (RBAC/ABAC).

  3. Безопасность по умолчанию: Обязательное указание 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 (такие как NoSuchBucket403 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"]

О конфигурации:

  1. Базовый образ: Использую python:3.13-slim. В нем нет лишнего системного мусора, что уменьшает вектор атаки и ускоряет деплой.

  2. Менеджер пакетов uv: Я отказался от стандартного pip в пользу uv. Он собирает зависимости в разы быстрее. Флаг --frozen гарантирует воспроизводимость окружения на продакшене, а --no-dev отсекает тяжелые инструменты тестирования и линтеры.

  3. Точка запуска: Команда CMD через shell-скрипт автоматически накатывает миграции Alembic перед стартом самого приложения. В рамках текущей реализации миграции выполняются на этапе старта контейнера. Такой подход упрощает деплой, но ограничивает гибкость CI/CD и усложняет управление откатами. В production-средах миграции обычно выносятся в отдельный этап пайплайна или init-container.

  4. 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:

Ключевые моменты конфигурации:

  1. Безопасность Redis: Я не использую дефолтные настройки. Через аргументы команды я отключаю стандартного пользователя (default off) и создаю выделенного юзера со сложным паролем и полными правами (+@all). Режим --appendonly yes гарантирует, что кэш токенов не пропадет при перезапуске контейнера.

  2. Контроль готовности (Healthcheck): Для Redis и PostgreSQL настроены честные healthcheck’и с проверкой доступности и авторизации. Сервис приложения links_s3_app не начнет обрабатывать трафик, пока Redis не ответит PONG, а PostgreSQL не подтвердит готовность принимать подключения через pg_isready.

  3. Сохранение данных: База данных и кэш вынесены в именованные volume (file_db, redis_data), что защищает информацию от уничтожения при обновлении контейнеров. Для шаринга тяжелых файлов между хостом и приложением используется bind-mount папки ./shared.

nginx/nginx.conf:

Точка входа для трафика — Nginx (nginx/nginx.conf). Его задача — терминация SSL-трафика и перенаправление запросов к бэкенду.

Терминация SSL-трафика означает, что Nginx сам расшифровывает входящие HTTPS-запросы от пользователей и передает их на бэкенд (links_s3_app) уже в чистом HTTP-виде.

Зачем я это сделал:

  1. Централизованное управление безопасностью: Все SSL-сертификаты хранятся и обновляются в одном месте (в контейнере Nginx), что упрощает ротацию и обслуживание TLS.

  2. Разделение ответственности: Бэкенд полностью изолирован от деталей работы 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;
        }
    }
}

Зачем это сделано:

  1. Строгий HTTPS: Весь незащищенный трафик с 80-го порта жестко редиректится на 443-й с кодом 301.

  2. Интеграция с Let’s Encrypt: Директория /etc/letsencrypt проброшена внутрь контейнера Nginx. Это позволяет бесшовно обновлять SSL-сертификаты с помощью certbot на хост-машине без остановки веб-сервера.

  3. Проброс заголовков: бэкенд за прокси получает информацию о клиенте через 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 — личность пользователя не подтверждена.

Логин статус 401
Логин статус 401

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

Далее я залогинился под созданным пользователем user1.

Логин статус 200
Логин статус 200
Авторизация
Авторизация

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

Публичная ссылка статус 200
Публичная ссылка статус 200

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

Логаут
Логаут
Публичная ссылка статус 401
Публичная ссылка статус 401

Сценарий В: Защита приватных данных (FORBIDDEN)

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

Публичная ссылка статус 403
Публичная ссылка статус 403

Сценарий Г: Права владельца файла и скачивание

Теперь я вошел под администратором, обладающим правами доступа к приватным логам:

Логин статус 200
Логин статус 200
Приватная ссылка статус 200
Приватная ссылка статус 200

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

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

Скачивание приватного файла
Скачивание приватного файла

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

Содержимое файла
Содержимое файла

Этот файл был создан логгером, когда я писал и отлаживал код. Судя по информации в логе, я проверял сценарий, когда objectkey уже существовал в базе данных, а я пытался создать ещё один такой же.

Заключение и планы на будущее

В этой статье я реализовал прототип бэкенд-сервиса, который кардинально меняет подход к работе с файлами в S3. Вместо того чтобы полагаться на публичные права доступа в облаке или перегружать сервер передачей файлового трафика, я вынес бизнес-логику авторизации на уровень приложения, а работу с данными оставил объектному хранилищу.

Теперь моя система гарантирует, что:

  1. Хранилище остаётся полностью закрытым для внешнего мира.

  2. Доступ к объектам выдаётся только после строгой проверки прав.

  3. Пользователи скачивают файлы напрямую из 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.