Files
smoothschedule/core/permissions.py
poduck 2e111364a2 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>
2025-11-27 01:43:20 -05:00

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