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

240 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Решение: Изоляция фотографий товаров между тенантами
## Проблема
Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами 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`
```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
**Статус:** ✅ Готово к продакшену