Initial commit: SmoothSchedule multi-tenant scheduling platform
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>
This commit is contained in:
285
core/permissions.py
Normal file
285
core/permissions.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user