Resolves critical bug where photos of products with the same ID in different tenants were overwriting each other. Implemented complete isolation of media files between tenants using custom Django storage backend. ## Changes ### New Files - products/utils/storage.py: TenantAwareFileSystemStorage backend * Automatically adds tenant_id to file paths on disk * Prevents cross-tenant file access with security checks * Stores clean paths in DB for portability - products/tests/test_multi_tenant_photos.py: Comprehensive tests * 5 tests covering isolation, security, and configuration * All tests passing ✅ - MULTITENANT_PHOTO_FIX.md: Complete documentation ### Modified Files - settings.py: Configured DEFAULT_FILE_STORAGE to use TenantAwareFileSystemStorage - products/models/photos.py: * Converted upload_to from strings to callable functions * Updated ProductPhoto, ProductKitPhoto, ProductCategoryPhoto * Added tenant isolation documentation - products/tasks.py: Added documentation about file structure - products/utils/image_processor.py: Added documentation - products/utils/image_service.py: Added documentation ## Architecture **On disk:** media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext **In DB:** products/{entity_id}/{photo_id}/{size}.ext Tenant ID is automatically added/removed during file operations. ## Security - Storage rejects cross-tenant file access - Proper tenant context validation - Integration with django-tenants schema system ## Testing - All 5 multi-tenant photo tests pass - Verified photo paths are isolated per tenant - Verified storage rejects cross-tenant access - Verified configuration is correct ## Future-proof - Ready for S3 migration (just change storage backend) - No breaking changes to existing code - Clean separation of concerns Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
238 lines
11 KiB
Markdown
238 lines
11 KiB
Markdown
# Решение: Изоляция фотографий товаров между тенантами
|
||
|
||
## Проблема
|
||
|
||
Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами 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 для БД
|
||
- `delete()` - Удаляет файлы с проверкой принадлежности тенанту (безопасность)
|
||
- `exists()` - Проверяет существование с валидацией тенанта
|
||
- `url()` - Генерирует URL с проверкой безопасности
|
||
|
||
**Безопасность:** Storage предотвращает доступ к файлам других тенантов и выбрасывает исключение при попытке кросс-тенантного доступа.
|
||
|
||
### 2. Обновлена конфигурация Django
|
||
|
||
**Файл:** `myproject/settings.py`
|
||
|
||
```python
|
||
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
|
||
- ✅ Что качество фото устанавливается корректно
|
||
|
||
```bash
|
||
# Запуск тестов
|
||
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 при операциях чтения/удаления:
|
||
|
||
```python
|
||
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 хранилище:
|
||
|
||
```python
|
||
# Просто замените одну строку в 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
|
||
```
|
||
|
||
### Проверка структуры файлов
|
||
|
||
```bash
|
||
# На диске файлы организованы так:
|
||
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
|
||
**Статус:** ✅ Готово к продакшену
|