This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""
|
|
Smooth Schedule Hijack Permissions
|
|
Implements the masquerading "Matrix" - strict rules for who can impersonate whom
|
|
"""
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
|
|
def can_hijack(hijacker, hijacked):
|
|
"""
|
|
Determine if hijacker (the admin) can masquerade as hijacked (target user).
|
|
|
|
The Matrix:
|
|
┌──────────────────────┬─────────────────────────────────────────────────┐
|
|
│ Hijacker Role │ Can Hijack │
|
|
├──────────────────────┼─────────────────────────────────────────────────┤
|
|
│ SUPERUSER │ Anyone (full god mode) │
|
|
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
|
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
|
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
|
│ Others │ Nobody │
|
|
└──────────────────────┴─────────────────────────────────────────────────┘
|
|
|
|
Args:
|
|
hijacker: User attempting to impersonate (the admin)
|
|
hijacked: User being impersonated (the target)
|
|
|
|
Returns:
|
|
bool: True if hijack is allowed, False otherwise
|
|
|
|
Security Notes:
|
|
- Never allow self-hijacking
|
|
- Never allow hijacking of superusers (except by other superusers)
|
|
- Always validate tenant boundaries for tenant-scoped roles
|
|
- Log all hijack attempts (success and failure) for audit
|
|
"""
|
|
from users.models import User
|
|
|
|
# Safety check: can't hijack yourself
|
|
if hijacker.id == hijacked.id:
|
|
return False
|
|
|
|
# Safety check: only superusers can hijack other superusers
|
|
if hijacked.role == User.Role.SUPERUSER and hijacker.role != User.Role.SUPERUSER:
|
|
return False
|
|
|
|
# Rule 1: SUPERUSER can hijack anyone
|
|
if hijacker.role == User.Role.SUPERUSER:
|
|
return True
|
|
|
|
# Rule 2: PLATFORM_SUPPORT can hijack tenant users
|
|
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
|
return hijacked.role in [
|
|
User.Role.TENANT_OWNER,
|
|
User.Role.TENANT_MANAGER,
|
|
User.Role.TENANT_STAFF,
|
|
User.Role.CUSTOMER,
|
|
]
|
|
|
|
# Rule 3: PLATFORM_SALES can only hijack temporary demo accounts
|
|
if hijacker.role == User.Role.PLATFORM_SALES:
|
|
return hijacked.is_temporary
|
|
|
|
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
|
if hijacker.role == User.Role.TENANT_OWNER:
|
|
# Must be in same tenant
|
|
if not hijacker.tenant or not hijacked.tenant:
|
|
return False
|
|
if hijacker.tenant.id != hijacked.tenant.id:
|
|
return False
|
|
|
|
# Can only hijack staff and customers, not other owners/managers
|
|
return hijacked.role in [
|
|
User.Role.TENANT_STAFF,
|
|
User.Role.CUSTOMER,
|
|
]
|
|
|
|
# Default: deny
|
|
return False
|
|
|
|
|
|
def can_hijack_or_403(hijacker, hijacked):
|
|
"""
|
|
Same as can_hijack but raises PermissionDenied instead of returning False.
|
|
Useful for views that want to use exception handling.
|
|
|
|
Args:
|
|
hijacker: User attempting to impersonate
|
|
hijacked: User being impersonated
|
|
|
|
Raises:
|
|
PermissionDenied: If hijack is not allowed
|
|
|
|
Returns:
|
|
bool: True if allowed (never returns False, raises instead)
|
|
"""
|
|
if not can_hijack(hijacker, hijacked):
|
|
raise PermissionDenied(
|
|
f"User {hijacker.email} ({hijacker.get_role_display()}) "
|
|
f"is not authorized to masquerade as {hijacked.email} ({hijacked.get_role_display()})"
|
|
)
|
|
return True
|
|
|
|
|
|
def get_hijackable_users(hijacker):
|
|
"""
|
|
Get queryset of all users that the hijacker can masquerade as.
|
|
Useful for building "Masquerade as..." dropdowns in the UI.
|
|
|
|
Args:
|
|
hijacker: User who wants to hijack
|
|
|
|
Returns:
|
|
QuerySet: Users that can be hijacked by this user
|
|
"""
|
|
from users.models import User
|
|
|
|
# Start with all users except self
|
|
qs = User.objects.exclude(id=hijacker.id)
|
|
|
|
# Apply filters based on hijacker role
|
|
if hijacker.role == User.Role.SUPERUSER:
|
|
# Superuser can hijack anyone
|
|
return qs
|
|
|
|
elif hijacker.role == User.Role.PLATFORM_SUPPORT:
|
|
# Can hijack all tenant-level users
|
|
return qs.filter(role__in=[
|
|
User.Role.TENANT_OWNER,
|
|
User.Role.TENANT_MANAGER,
|
|
User.Role.TENANT_STAFF,
|
|
User.Role.CUSTOMER,
|
|
])
|
|
|
|
elif hijacker.role == User.Role.PLATFORM_SALES:
|
|
# Only temporary demo accounts
|
|
return qs.filter(is_temporary=True)
|
|
|
|
elif hijacker.role == User.Role.TENANT_OWNER:
|
|
# Only staff in same tenant
|
|
if not hijacker.tenant:
|
|
return qs.none()
|
|
|
|
return qs.filter(
|
|
tenant=hijacker.tenant,
|
|
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
|
)
|
|
|
|
else:
|
|
# No one else can hijack
|
|
return qs.none()
|
|
|
|
|
|
def validate_hijack_chain(request):
|
|
"""
|
|
Validate that hijack chains are not too deep.
|
|
Prevents: Admin1 -> Admin2 -> Admin3 -> User scenarios.
|
|
|
|
Smooth Schedule Security Policy: Maximum hijack depth is 1.
|
|
You cannot hijack while already hijacked.
|
|
|
|
Args:
|
|
request: Django request object
|
|
|
|
Raises:
|
|
PermissionDenied: If already in a hijack session
|
|
|
|
Returns:
|
|
bool: True if allowed to start new hijack
|
|
"""
|
|
hijack_history = request.session.get('hijack_history', [])
|
|
|
|
if len(hijack_history) > 0:
|
|
raise PermissionDenied(
|
|
"Cannot start a new masquerade session while already masquerading. "
|
|
"Please exit your current session first."
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
# ==============================================================================
|
|
# SaaS Quota Enforcement ("Hard Block" Logic)
|
|
# ==============================================================================
|
|
|
|
def HasQuota(feature_code):
|
|
"""
|
|
Permission factory for SaaS tier limit enforcement.
|
|
|
|
Returns a DRF permission class that blocks write operations when
|
|
tenant has exceeded their quota for a specific feature.
|
|
|
|
The "Hard Block": Prevents resource creation when tenant hits limit.
|
|
|
|
Usage:
|
|
class ResourceViewSet(ModelViewSet):
|
|
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
|
|
|
Args:
|
|
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')
|
|
|
|
Returns:
|
|
QuotaPermission class configured for the feature
|
|
|
|
How it Works:
|
|
1. Read operations (GET/HEAD/OPTIONS) always allowed
|
|
2. Write operations check current usage vs tier limit
|
|
3. If usage >= limit, raises PermissionDenied (403)
|
|
"""
|
|
from rest_framework.permissions import BasePermission
|
|
from django.apps import apps
|
|
|
|
class QuotaPermission(BasePermission):
|
|
"""
|
|
Dynamically generated permission class for quota checking.
|
|
"""
|
|
|
|
# Map feature codes to model paths for usage counting
|
|
# CRITICAL: This map must be populated for the permission to work
|
|
USAGE_MAP = {
|
|
'MAX_RESOURCES': 'schedule.Resource',
|
|
'MAX_USERS': 'users.User',
|
|
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
|
# Add more mappings as needed
|
|
}
|
|
|
|
def has_permission(self, request, view):
|
|
"""
|
|
Check if tenant has quota for this operation.
|
|
|
|
Returns True for read operations, checks quota for writes.
|
|
"""
|
|
# Allow all read-only operations (GET, HEAD, OPTIONS)
|
|
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
|
return True
|
|
|
|
# Get tenant from request
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
# No tenant in request - allow for public schema operations
|
|
return True
|
|
|
|
# Get the model to check usage against
|
|
model_path = self.USAGE_MAP.get(feature_code)
|
|
if not model_path:
|
|
# Feature not mapped - fail safe by allowing
|
|
# (Production: you'd want to log this as a configuration error)
|
|
return True
|
|
|
|
# Get the model class
|
|
try:
|
|
app_label, model_name = model_path.split('.')
|
|
Model = apps.get_model(app_label, model_name)
|
|
except (ValueError, LookupError):
|
|
# Invalid model path - fail safe
|
|
return True
|
|
|
|
# Get the tier limit for this tenant
|
|
try:
|
|
from core.models import TierLimit
|
|
limit = TierLimit.objects.get(
|
|
tier=tenant.subscription_tier,
|
|
feature_code=feature_code
|
|
).limit
|
|
except TierLimit.DoesNotExist:
|
|
# No limit defined - allow (unlimited)
|
|
return True
|
|
|
|
# Count current usage
|
|
# NOTE: django-tenants automatically scopes this query to tenant schema
|
|
current_count = Model.objects.count()
|
|
|
|
# The "Hard Block": Enforce the limit
|
|
if current_count >= limit:
|
|
# Quota exceeded - deny the operation
|
|
from rest_framework.exceptions import PermissionDenied
|
|
raise PermissionDenied(
|
|
f"Quota exceeded: You have reached your plan limit of {limit} "
|
|
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
|
|
f"Please upgrade your subscription to add more."
|
|
)
|
|
|
|
# Quota available - allow the operation
|
|
return True
|
|
|
|
return QuotaPermission
|