Files
octopus/MULTITENANT_PHOTO_FIX.md
Andrey Smakotin 30f21989d6 Fix: Use storage.url() for tenant-aware image URLs in frontend
Updated ImageService to use default_storage.url() instead of manually
constructing URLs. This ensures images displayed on the frontend correctly
include the tenant_id in the path, enabling proper file access within
multi-tenant environment.

Changes:
- ImageService.get_url() now delegates to default_storage.url()
- All image URLs now include /media/tenants/{tenant_id}/ path
- Ensures consistent behavior with TenantAwareFileSystemStorage
- Frontend photos now display correctly with tenant isolation

Result:
- Thumbnail URLs: /media/tenants/papa/products/4/28/thumb.webp
- Medium URLs: /media/tenants/papa/products/4/28/medium.webp
- Large URLs: /media/tenants/papa/products/4/28/large.webp
- Original URLs: /media/tenants/papa/products/4/28/original.jpg

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 20:55:02 +03:00

11 KiB
Raw Blame History

Решение: Изоляция фотографий товаров между тенантами

Проблема

Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами ID=1 использовали одни и те же файлы:

media/products/1/1/original.jpg  ← перезатиралось для каждого тенанта

Решение: Tenant-Aware FileSystemStorage

Реализована полная изоляция файлов между тенантами через custom Django storage backend.

Архитектура

На диске (физическое хранилище):

media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
media/tenants/{tenant_id}/kits/{entity_id}/{photo_id}/{size}.ext
media/tenants/{tenant_id}/categories/{entity_id}/{photo_id}/{size}.ext

В базе данных (для экономии и мобильности):

products/{entity_id}/{photo_id}/{size}.ext
kits/{entity_id}/{photo_id}/{size}.ext
categories/{entity_id}/{photo_id}/{size}.ext

Tenant ID добавляется/удаляется автоматически при работе с файлами.

Реализованные изменения

1. Создан Custom Storage Backend

Файл: products/utils/storage.py

Класс TenantAwareFileSystemStorage расширяет стандартный Django FileSystemStorage:

  • _get_tenant_id() - Получает ID текущего тенанта из контекста django-tenants
  • _get_tenant_path() - Добавляет tenant_id в начало пути
  • get_available_name() - Проверяет уникальность на диске, но возвращает путь БЕЗ tenant_id для БД
  • _save() - Сохраняет файл с tenant_id на диск, но возвращает путь БЕЗ tenant_id для БД
  • _open() - Открывает файл, добавляя tenant_id если необходимо (критично для Celery!)
  • path() - Преобразует относительные пути в полные системные пути с tenant_id
  • delete() - Удаляет файлы с проверкой принадлежности тенанту (безопасность)
  • exists() - Проверяет существование с валидацией тенанта
  • url() - Генерирует URL с проверкой безопасности

Безопасность: Storage предотвращает доступ к файлам других тенантов и выбрасывает исключение при попытке кросс-тенантного доступа.

2. Обновлена конфигурация Django

Файл: myproject/settings.py

DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'

3. Обновлены модели фотографий

Файл: products/models/photos.py

  • Заменены жесткие upload_to='products/temp/' на callable функции
  • Функции генерируют пути БЕЗ tenant_id (добавляется автоматически storage)
  • Добавлены комментарии о мультитенантности в docstring каждого класса

Функции upload_to:

  • get_product_photo_upload_path()products/temp/{filename}
  • get_kit_photo_upload_path()kits/temp/{filename}
  • get_category_photo_upload_path()categories/temp/{filename}

4. Обновлены утилиты обработки фотографий

Файлы:

  • products/utils/image_processor.py - Добавлены комментарии о мультитенантности
  • products/utils/image_service.py - Добавлены комментарии о структуре путей
  • products/tasks.py - Обновлены комментарии о мультитенантности в Celery задачах

Важно: Эти файлы работают как есть благодаря архитектуре storage!

5. Созданы комплексные тесты

Файл: products/tests/test_multi_tenant_photos.py

Тесты проверяют:

  • Что пути в БД не содержат tenant_id (для мобильности)
  • Что пути на диске содержат tenant_id (для изоляции)
  • Что фотографии разных тенантов сохраняются в разные места
  • Что storage отказывает в доступе к файлам других тенантов
  • Что storage настроен в settings
  • Что качество фото устанавливается корректно
# Запуск тестов
cd myproject
python manage.py test products.tests.test_multi_tenant_photos -v 2

Результат: Все 5 тестов проходят успешно

Как это работает

Сценарий загрузки фото

  1. Пользователь загружает фото в tenant1

    • Django создает ProductPhoto объект
    • Пользователь указывает файл (temporary)
  2. BasePhoto.save() срабатывает

    • Проверяет контекст (connection.schema_name = 'tenant_1')
    • Запускает Celery задачу для асинхронной обработки
  3. ImageField сохраняет файл

    • Вызывает TenantAwareFileSystemStorage._save()
    • Storage:
      • Добавляет tenant_id: tenants/tenant_1/products/temp/image.jpg
      • Сохраняет на диск: media/tenants/tenant_1/products/temp/image.jpg
      • Возвращает БД путь БЕЗ tenant_id: products/temp/image.jpg
    • Django сохраняет в БД: products/temp/image.jpg
  4. Celery обрабатывает фото в фоне

    • Активирует schema: connection.set_schema('tenant_1')
    • Читает фото из БД (путь products/temp/image.jpg)
    • Storage автоматически добавляет tenant_id при чтении
    • Обрабатывает и создает размеры
    • Сохраняет обработанные файлы
    • Обновляет БД с путем: products/{entity_id}/{photo_id}/original.jpg
  5. Когда пользователь заходит в Tenant2

    • Товар с ID=1 в tenant2 имеет разные фото
    • Файлы хранятся в: media/tenants/tenant_2/products/1/{photo_id}/
    • Не пересекаются с tenant1!

Безопасность

Защита от кросс-тенантного доступа

Storage проверяет tenant_id при операциях чтения/удаления:

def delete(self, name):
    tenant_id = self._get_tenant_id()
    if not name.startswith(f"tenants/{tenant_id}/"):
        raise RuntimeError(f"Cannot delete file - belongs to different tenant")

Если пользователь попытается обратиться к файлу другого тенанта - получит исключение.

Преимущества решения

Полная изоляция - Файлы разных тенантов физически разделены Безопасность - Storage предотвращает кросс-тенантный доступ Чистота БД - Пути в БД не содержат tenant_id (более мобильно) Минимум изменений - ImageProcessor и ImageService работают без изменений Асинхронность - Celery полностью поддерживает мультитенантность Масштабируемость - Готово к переходу на S3 в будущем Протестировано - 5 комплексных тестов проходят успешно

Путь к облаку (S3)

В будущем очень легко перейти на S3 хранилище:

# Просто замените одну строку в settings.py:

# Текущая конфигурация
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'

# Облачное хранилище (S3)
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'

# Структура путей остается той же!
# S3: s3://bucket/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext

Структура paths останется идентичной - нужны только зависимости и конфигурация подключения к S3.

Отладка

Логирование

Все операции логируются с префиксом [Storage] для удобства отладки:

[Storage] Extracted tenant_id=tenant_1 from schema=tenant_1
[Storage] get_available_name: products/temp/image.jpg → checking disk with: tenants/tenant_1/products/temp/image.jpg
[Storage] Stripped tenant prefix: tenants/tenant_1/products/temp/image_lKjH.jpg → products/temp/image_lKjH.jpg
[Storage] _save: products/temp/image.jpg → tenants/tenant_1/products/temp/image.jpg

Проверка структуры файлов

# На диске файлы организованы так:
media/
├── tenants/
│   ├── tenant_1/
│   │   └── products/
│   │       └── temp/
│   │           └── image.jpg
│   └── tenant_2/
│       └── products/
│           └── temp/
│               └── image.jpg

Миграция (если были старые фото)

Для проекта указано - начинаем с чистого листа, без миграции старых фото.

Если в будущем понадобится мигрировать старые данные:

  1. Напишите management команду для перемещения файлов
  2. Обновите пути в БД
  3. Используйте storage для добавления tenant_id в пути

Контрольный список

  • Custom storage backend создан
  • Settings обновлены
  • Модели фотографий обновлены
  • Комментарии добавлены во все утилиты
  • Тесты написаны и проходят
  • Безопасность валидирована
  • Документация готова

Следующие шаги

Когда проект вырастет:

  1. S3 миграция - замените storage backend на S3
  2. CDN - настройте CloudFront для ускорения доставки
  3. Бэкапы - настройте S3 versioning и lifecycle policies
  4. Мониторинг - добавьте метрики для отслеживания использования storage

Дата: 2025-11-23 Статус: Готово к продакшену