Files
octopus/MULTITENANT_PHOTO_FIX.md
Andrey Smakotin ff40a9c1f0 Fix: Implement tenant-aware file storage for photo isolation
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>
2025-11-23 20:05:20 +03:00

238 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 для БД
- `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
**Статус:** ✅ Готово к продакшену