465 lines
12 KiB
Markdown
465 lines
12 KiB
Markdown
|
|
# Multi-Tenant Admin System - Implementation Guide
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This system transforms SoundWave into a multi-tenant platform where:
|
||
|
|
- **Admins** can manage all users and their content
|
||
|
|
- **Users** have isolated YouTube accounts, channels, playlists, and audio files
|
||
|
|
- Each user operates as if they have their own Docker container
|
||
|
|
- Resource limits (storage, channels, playlists) are enforced per user
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
### User Isolation Model
|
||
|
|
|
||
|
|
```
|
||
|
|
Admin User (is_admin=True)
|
||
|
|
├── Can create/manage all users
|
||
|
|
├── Access all content across users
|
||
|
|
└── Set resource quotas
|
||
|
|
|
||
|
|
Regular User
|
||
|
|
├── Own YouTube accounts
|
||
|
|
├── Own channels (subscriptions)
|
||
|
|
├── Own playlists
|
||
|
|
├── Own audio files
|
||
|
|
└── Cannot see other users' data
|
||
|
|
```
|
||
|
|
|
||
|
|
### Database Schema Changes
|
||
|
|
|
||
|
|
**Account Model** (`user/models.py`):
|
||
|
|
```python
|
||
|
|
- storage_quota_gb: int (default 50 GB)
|
||
|
|
- storage_used_gb: float (tracked automatically)
|
||
|
|
- max_channels: int (default 50)
|
||
|
|
- max_playlists: int (default 100)
|
||
|
|
- user_notes: text (admin notes)
|
||
|
|
- created_by: ForeignKey to admin who created user
|
||
|
|
```
|
||
|
|
|
||
|
|
**UserYouTubeAccount Model** (NEW):
|
||
|
|
```python
|
||
|
|
- user: ForeignKey to Account
|
||
|
|
- account_name: str (friendly name)
|
||
|
|
- youtube_channel_id: str
|
||
|
|
- youtube_channel_name: str
|
||
|
|
- cookies_file: text (for authentication)
|
||
|
|
- auto_download: bool
|
||
|
|
- download_quality: choices
|
||
|
|
```
|
||
|
|
|
||
|
|
**Channel Model** (UPDATED):
|
||
|
|
```python
|
||
|
|
+ owner: ForeignKey to Account
|
||
|
|
+ youtube_account: ForeignKey to UserYouTubeAccount
|
||
|
|
+ auto_download: bool per channel
|
||
|
|
+ download_quality: choices per channel
|
||
|
|
```
|
||
|
|
|
||
|
|
**Audio Model** (UPDATED):
|
||
|
|
```python
|
||
|
|
+ owner: ForeignKey to Account
|
||
|
|
```
|
||
|
|
|
||
|
|
**Playlist Model** (UPDATED):
|
||
|
|
```python
|
||
|
|
+ owner: ForeignKey to Account
|
||
|
|
+ auto_download: bool per playlist
|
||
|
|
```
|
||
|
|
|
||
|
|
### Unique Constraints
|
||
|
|
|
||
|
|
- **Channel**: `(owner, channel_id)` - Each user can subscribe once per channel
|
||
|
|
- **Audio**: `(owner, youtube_id)` - Each user can have one copy of each video
|
||
|
|
- **Playlist**: `(owner, playlist_id)` - Each user can subscribe once per playlist
|
||
|
|
|
||
|
|
## Backend Implementation
|
||
|
|
|
||
|
|
### Middleware (`config/middleware.py`)
|
||
|
|
|
||
|
|
**UserIsolationMiddleware**:
|
||
|
|
- Adds `request.filter_by_user()` helper
|
||
|
|
- Automatically filters querysets by owner
|
||
|
|
- Admins bypass filtering
|
||
|
|
|
||
|
|
**StorageQuotaMiddleware**:
|
||
|
|
- Tracks storage usage
|
||
|
|
- Prevents uploads when quota exceeded
|
||
|
|
|
||
|
|
### Permissions (`common/permissions.py`)
|
||
|
|
|
||
|
|
**IsOwnerOrAdmin**:
|
||
|
|
- Users can only access their own objects
|
||
|
|
- Admins can access everything
|
||
|
|
|
||
|
|
**CanManageUsers**:
|
||
|
|
- Only admins can manage users
|
||
|
|
|
||
|
|
**WithinQuotaLimits**:
|
||
|
|
- Checks storage/channel/playlist quotas
|
||
|
|
- Admins bypass quota checks
|
||
|
|
|
||
|
|
### Admin API (`user/views_admin.py`)
|
||
|
|
|
||
|
|
**UserManagementViewSet**:
|
||
|
|
```python
|
||
|
|
GET /api/user/admin/users/ # List users
|
||
|
|
POST /api/user/admin/users/ # Create user
|
||
|
|
GET /api/user/admin/users/{id}/ # User details
|
||
|
|
PATCH /api/user/admin/users/{id}/ # Update user
|
||
|
|
GET /api/user/admin/users/{id}/stats/ # User statistics
|
||
|
|
POST /api/user/admin/users/{id}/reset_storage/
|
||
|
|
POST /api/user/admin/users/{id}/reset_2fa/
|
||
|
|
POST /api/user/admin/users/{id}/toggle_active/
|
||
|
|
GET /api/user/admin/users/{id}/channels/
|
||
|
|
GET /api/user/admin/users/{id}/playlists/
|
||
|
|
GET /api/user/admin/users/system_stats/ # System-wide stats
|
||
|
|
```
|
||
|
|
|
||
|
|
**UserYouTubeAccountViewSet**:
|
||
|
|
```python
|
||
|
|
GET /api/user/admin/youtube-accounts/ # List accounts
|
||
|
|
POST /api/user/admin/youtube-accounts/ # Add account
|
||
|
|
GET /api/user/admin/youtube-accounts/{id}/ # Account details
|
||
|
|
PATCH /api/user/admin/youtube-accounts/{id}/ # Update account
|
||
|
|
DELETE /api/user/admin/youtube-accounts/{id}/ # Delete account
|
||
|
|
POST /api/user/admin/youtube-accounts/{id}/verify/ # Verify credentials
|
||
|
|
POST /api/user/admin/youtube-accounts/{id}/toggle_active/
|
||
|
|
```
|
||
|
|
|
||
|
|
### Django Admin (`user/admin_users.py`)
|
||
|
|
|
||
|
|
Enhanced admin interface with:
|
||
|
|
- User list with storage/channel/playlist counts
|
||
|
|
- Visual storage progress bars
|
||
|
|
- Bulk actions (reset storage, disable users, reset 2FA)
|
||
|
|
- YouTube account management
|
||
|
|
- Per-user notes
|
||
|
|
|
||
|
|
## Frontend Implementation
|
||
|
|
|
||
|
|
### AdminUsersPage Component
|
||
|
|
|
||
|
|
**Features**:
|
||
|
|
- System statistics dashboard (users, content, storage)
|
||
|
|
- Users table with status, storage, content counts
|
||
|
|
- Create user dialog with full settings
|
||
|
|
- Edit user dialog with quota management
|
||
|
|
- User details modal with comprehensive info
|
||
|
|
- Quick actions (activate/deactivate, reset storage, reset 2FA)
|
||
|
|
|
||
|
|
**UI Components**:
|
||
|
|
```tsx
|
||
|
|
- System stats cards (users, content, storage)
|
||
|
|
- Users table (sortable, filterable)
|
||
|
|
- Create user form (username, email, password, quotas)
|
||
|
|
- Edit user form (quotas, status, permissions)
|
||
|
|
- User details modal (all stats and metadata)
|
||
|
|
- Actions menu (edit, toggle, reset)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Migration Strategy
|
||
|
|
|
||
|
|
### Step 1: Run Migrations
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Create migrations
|
||
|
|
python manage.py makemigrations user channel audio playlist
|
||
|
|
|
||
|
|
# Apply migrations
|
||
|
|
python manage.py migrate
|
||
|
|
|
||
|
|
# Create superuser
|
||
|
|
python manage.py createsuperuser
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Data Migration
|
||
|
|
|
||
|
|
For existing data, create a data migration to set owner fields:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Create empty migration
|
||
|
|
python manage.py makemigrations --empty user --name set_default_owner
|
||
|
|
|
||
|
|
# Edit migration file
|
||
|
|
def set_default_owner(apps, schema_editor):
|
||
|
|
Account = apps.get_model('user', 'Account')
|
||
|
|
Channel = apps.get_model('channel', 'Channel')
|
||
|
|
Audio = apps.get_model('audio', 'Audio')
|
||
|
|
Playlist = apps.get_model('playlist', 'Playlist')
|
||
|
|
|
||
|
|
# Get or create default admin user
|
||
|
|
admin = Account.objects.filter(is_superuser=True).first()
|
||
|
|
if not admin:
|
||
|
|
admin = Account.objects.create_superuser(
|
||
|
|
username='admin',
|
||
|
|
email='admin@example.com',
|
||
|
|
password='changeme'
|
||
|
|
)
|
||
|
|
|
||
|
|
# Assign owner to existing records
|
||
|
|
Channel.objects.filter(owner__isnull=True).update(owner=admin)
|
||
|
|
Audio.objects.filter(owner__isnull=True).update(owner=admin)
|
||
|
|
Playlist.objects.filter(owner__isnull=True).update(owner=admin)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Update Views
|
||
|
|
|
||
|
|
Update existing views to use owner filtering:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Before
|
||
|
|
Audio.objects.all()
|
||
|
|
|
||
|
|
# After
|
||
|
|
Audio.objects.filter(owner=request.user)
|
||
|
|
# or use middleware
|
||
|
|
request.filter_by_user(Audio.objects.all())
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Update Serializers
|
||
|
|
|
||
|
|
Ensure owner is set on create:
|
||
|
|
|
||
|
|
```python
|
||
|
|
def perform_create(self, serializer):
|
||
|
|
serializer.save(owner=self.request.user)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Usage Examples
|
||
|
|
|
||
|
|
### Admin Creating User
|
||
|
|
|
||
|
|
```bash
|
||
|
|
POST /api/user/admin/users/
|
||
|
|
{
|
||
|
|
"username": "john_doe",
|
||
|
|
"email": "john@example.com",
|
||
|
|
"password": "secure123",
|
||
|
|
"password_confirm": "secure123",
|
||
|
|
"storage_quota_gb": 100,
|
||
|
|
"max_channels": 75,
|
||
|
|
"max_playlists": 150,
|
||
|
|
"is_admin": false,
|
||
|
|
"is_active": true,
|
||
|
|
"user_notes": "Premium user - increased quotas"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### User Adding YouTube Account
|
||
|
|
|
||
|
|
```bash
|
||
|
|
POST /api/user/admin/youtube-accounts/
|
||
|
|
{
|
||
|
|
"account_name": "Personal YouTube",
|
||
|
|
"youtube_channel_id": "UCxxxxxxxx",
|
||
|
|
"youtube_channel_name": "John's Channel",
|
||
|
|
"cookies_file": "# Netscape HTTP Cookie File...",
|
||
|
|
"auto_download": true,
|
||
|
|
"download_quality": "high"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### User Subscribing to Channel
|
||
|
|
|
||
|
|
```bash
|
||
|
|
POST /api/channels/
|
||
|
|
{
|
||
|
|
"channel_id": "UCxxxxxxxx",
|
||
|
|
"channel_name": "Tech Channel",
|
||
|
|
"youtube_account": 1, # User's YouTube account ID
|
||
|
|
"subscribed": true,
|
||
|
|
"auto_download": true,
|
||
|
|
"download_quality": "auto"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Resource Quota Enforcement
|
||
|
|
|
||
|
|
### Storage Quota
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Checked before download
|
||
|
|
if user.storage_used_gb >= user.storage_quota_gb:
|
||
|
|
raise PermissionDenied("Storage quota exceeded")
|
||
|
|
|
||
|
|
# Updated after download
|
||
|
|
file_size_gb = file_size_bytes / (1024**3)
|
||
|
|
user.storage_used_gb += file_size_gb
|
||
|
|
user.save()
|
||
|
|
|
||
|
|
# Updated after deletion
|
||
|
|
user.storage_used_gb -= file_size_gb
|
||
|
|
user.save()
|
||
|
|
```
|
||
|
|
|
||
|
|
### Channel Limit
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Checked before subscribing
|
||
|
|
if not user.can_add_channel:
|
||
|
|
raise PermissionDenied(f"Channel limit reached ({user.max_channels})")
|
||
|
|
|
||
|
|
# Property in Account model
|
||
|
|
@property
|
||
|
|
def can_add_channel(self):
|
||
|
|
current_count = self.channels.count()
|
||
|
|
return current_count < self.max_channels
|
||
|
|
```
|
||
|
|
|
||
|
|
### Playlist Limit
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Checked before creating
|
||
|
|
if not user.can_add_playlist:
|
||
|
|
raise PermissionDenied(f"Playlist limit reached ({user.max_playlists})")
|
||
|
|
|
||
|
|
# Property in Account model
|
||
|
|
@property
|
||
|
|
def can_add_playlist(self):
|
||
|
|
current_count = self.playlists.count()
|
||
|
|
return current_count < self.max_playlists
|
||
|
|
```
|
||
|
|
|
||
|
|
## Security Considerations
|
||
|
|
|
||
|
|
### Data Isolation
|
||
|
|
|
||
|
|
1. **Queryset Filtering**: All queries automatically filtered by owner
|
||
|
|
2. **Middleware**: UserIsolationMiddleware enforces filtering
|
||
|
|
3. **Permissions**: IsOwnerOrAdmin checks object-level permissions
|
||
|
|
4. **Admin Bypass**: Admins can access all data for management
|
||
|
|
|
||
|
|
### Authentication
|
||
|
|
|
||
|
|
1. **User Authentication**: Standard Django auth with 2FA support
|
||
|
|
2. **YouTube Authentication**: Cookie-based (stored per user)
|
||
|
|
3. **API Authentication**: Token-based with user context
|
||
|
|
|
||
|
|
### File Storage
|
||
|
|
|
||
|
|
User files should be stored in isolated directories:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# File path structure
|
||
|
|
/media/
|
||
|
|
└── users/
|
||
|
|
├── user_1/
|
||
|
|
│ ├── audio/
|
||
|
|
│ ├── thumbnails/
|
||
|
|
│ └── cookies/
|
||
|
|
├── user_2/
|
||
|
|
│ ├── audio/
|
||
|
|
│ ├── thumbnails/
|
||
|
|
│ └── cookies/
|
||
|
|
└── ...
|
||
|
|
```
|
||
|
|
|
||
|
|
## Celery Tasks
|
||
|
|
|
||
|
|
Update tasks to respect user isolation:
|
||
|
|
|
||
|
|
```python
|
||
|
|
@shared_task
|
||
|
|
def download_audio(audio_id, user_id):
|
||
|
|
audio = Audio.objects.get(id=audio_id, owner_id=user_id)
|
||
|
|
user = audio.owner
|
||
|
|
|
||
|
|
# Use user's YouTube account
|
||
|
|
youtube_account = audio.channel.youtube_account
|
||
|
|
cookies_file = youtube_account.cookies_file if youtube_account else None
|
||
|
|
|
||
|
|
# Download to user's directory
|
||
|
|
output_path = f'/media/users/user_{user_id}/audio/'
|
||
|
|
|
||
|
|
# Check quota before download
|
||
|
|
if user.storage_used_gb >= user.storage_quota_gb:
|
||
|
|
raise Exception("Storage quota exceeded")
|
||
|
|
|
||
|
|
# Download...
|
||
|
|
|
||
|
|
# Update storage
|
||
|
|
user.storage_used_gb += file_size_gb
|
||
|
|
user.save()
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
### Test User Isolation
|
||
|
|
|
||
|
|
```python
|
||
|
|
def test_user_cannot_access_other_user_data():
|
||
|
|
user1 = Account.objects.create_user('user1', 'user1@test.com', 'pass')
|
||
|
|
user2 = Account.objects.create_user('user2', 'user2@test.com', 'pass')
|
||
|
|
|
||
|
|
audio1 = Audio.objects.create(owner=user1, youtube_id='xxx')
|
||
|
|
audio2 = Audio.objects.create(owner=user2, youtube_id='yyy')
|
||
|
|
|
||
|
|
# User1 should only see their audio
|
||
|
|
assert Audio.objects.filter(owner=user1).count() == 1
|
||
|
|
assert Audio.objects.filter(owner=user2).count() == 1
|
||
|
|
```
|
||
|
|
|
||
|
|
### Test Quota Enforcement
|
||
|
|
|
||
|
|
```python
|
||
|
|
def test_storage_quota_enforced():
|
||
|
|
user = Account.objects.create_user(
|
||
|
|
'user', 'user@test.com', 'pass',
|
||
|
|
storage_quota_gb=10,
|
||
|
|
storage_used_gb=10
|
||
|
|
)
|
||
|
|
|
||
|
|
# Should fail when quota exceeded
|
||
|
|
with pytest.raises(PermissionDenied):
|
||
|
|
download_audio(audio_id, user.id)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Performance Optimization
|
||
|
|
|
||
|
|
### Database Indexes
|
||
|
|
|
||
|
|
```python
|
||
|
|
class Meta:
|
||
|
|
indexes = [
|
||
|
|
models.Index(fields=['owner', 'youtube_id']),
|
||
|
|
models.Index(fields=['owner', 'channel_id']),
|
||
|
|
models.Index(fields=['owner', '-published_date']),
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
### Query Optimization
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Use select_related for foreign keys
|
||
|
|
Audio.objects.filter(owner=user).select_related('owner')
|
||
|
|
|
||
|
|
# Use prefetch_related for reverse relations
|
||
|
|
User.objects.prefetch_related('channels', 'playlists', 'audio_files')
|
||
|
|
```
|
||
|
|
|
||
|
|
### Caching
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Cache user stats
|
||
|
|
cache_key = f'user_stats_{user.id}'
|
||
|
|
stats = cache.get(cache_key)
|
||
|
|
if not stats:
|
||
|
|
stats = calculate_user_stats(user)
|
||
|
|
cache.set(cache_key, stats, 300) # 5 minutes
|
||
|
|
```
|
||
|
|
|
||
|
|
## Future Enhancements
|
||
|
|
|
||
|
|
- [ ] User groups and team accounts
|
||
|
|
- [ ] Shared playlists between users
|
||
|
|
- [ ] Storage pooling for organizations
|
||
|
|
- [ ] Usage analytics per user
|
||
|
|
- [ ] API rate limiting per user
|
||
|
|
- [ ] Custom branding per user
|
||
|
|
- [ ] Billing and subscription management
|
||
|
|
- [ ] OAuth integration for YouTube
|
||
|
|
- [ ] Automated quota adjustment based on usage
|
||
|
|
- [ ] User data export/import
|