""" Multi-tenant middleware for Django applications. Provides tenant isolation and context management for all requests. Supports multiple tenant identification methods and security features. """ import logging from django.db import connection from django.http import Http404, HttpResponseForbidden from django.conf import settings from django.utils.deprecation import MiddlewareMixin from django.contrib.auth.middleware import get_user from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.urls import resolve from django.utils import timezone from django.db import transaction import re import uuid from ..models.tenant import Tenant from ..models.user import User logger = logging.getLogger(__name__) class TenantMiddleware(MiddlewareMixin): """ Middleware to identify and isolate tenants for each request. """ def __init__(self, get_response): self.get_response = get_response self.tenant_model = Tenant self.user_model = User # Configure tenant identification methods self.host_based_domains = getattr(settings, 'TENANT_HOST_BASED_DOMAINS', True) self.path_based_prefix = getattr(settings, 'TENANT_PATH_BASED_PREFIX', '/tenant/') self.header_based = getattr(settings, 'TENANT_HEADER_BASED', True) self.tenant_header = getattr(settings, 'TENANT_HEADER_NAME', 'X-Tenant-ID') # Security settings self.enforce_tenant_isolation = getattr(settings, 'ENFORCE_TENANT_ISOLATION', True) self.public_paths = getattr(settings, 'PUBLIC_PATHS', [ '/api/v1/auth/', '/api/v1/public/', '/health/', '/metrics/', '/api/v1/tenants/register', '/admin/login/', '/static/', '/media/', ]) # Cache settings self.cache_enabled = getattr(settings, 'TENANT_CACHE_ENABLED', True) self.cache_timeout = getattr(settings, 'TENANT_CACHE_TIMEOUT', 300) # 5 minutes # Admin paths that bypass tenant isolation self.admin_paths = getattr(settings, 'ADMIN_PATHS', [ '/admin/', '/api/v1/admin/', ]) # API prefix that requires tenant context self.api_prefix = getattr(settings, 'API_PREFIX', '/api/v1/') # Initialize regex patterns self.public_path_patterns = [re.compile(path) for path in self.public_paths] self.admin_path_patterns = [re.compile(path) for path in self.admin_paths] def process_request(self, request): """ Process incoming request to identify tenant. """ request.tenant = None request.tenant_id = None request.tenant_context = {} request.is_tenant_request = False # Skip tenant processing for public paths if self._is_public_path(request.path): logger.debug(f"Skipping tenant processing for public path: {request.path}") return None # Skip tenant processing for admin paths (handled by authentication) if self._is_admin_path(request.path): logger.debug(f"Skipping tenant processing for admin path: {request.path}") return None # Try to identify tenant using various methods tenant = self._identify_tenant(request) if not tenant: # For API paths that require tenant context, return 404 if request.path.startswith(self.api_prefix): logger.warning(f"Tenant not found for API request: {request.path}") raise Http404("Tenant not found") # For other paths, allow but without tenant context return None # Set tenant context request.tenant = tenant request.tenant_id = tenant.id request.tenant_context = self._build_tenant_context(tenant) request.is_tenant_request = True # Set database connection tenant context self._set_database_tenant_context(tenant) # Set cache tenant context if self.cache_enabled: self._set_cache_tenant_context(tenant) # Validate tenant status if not self._validate_tenant_status(tenant): logger.warning(f"Tenant {tenant.id} is not active: {tenant.status}") return HttpResponseForbidden("Tenant account is not active") # Validate user access to tenant if authenticated user = get_user(request) if user.is_authenticated and not user.is_superuser: if not self._validate_user_tenant_access(user, tenant): logger.warning(f"User {user.id} attempted to access tenant {tenant.id}") return HttpResponseForbidden("Access denied to this tenant") logger.debug(f"Tenant context set: {tenant.name} ({tenant.id})") return None def process_response(self, request, response): """ Clean up tenant context after request processing. """ # Clear database tenant context self._clear_database_tenant_context() # Clear cache tenant context if self.cache_enabled: self._clear_cache_tenant_context() return response def process_exception(self, request, exception): """ Handle exceptions and clean up tenant context. """ # Clear database tenant context self._clear_database_tenant_context() # Clear cache tenant context if self.cache_enabled: self._clear_cache_tenant_context() return None def _is_public_path(self, path): """ Check if path is public and doesn't require tenant context. """ return any(pattern.match(path) for pattern in self.public_path_patterns) def _is_admin_path(self, path): """ Check if path is admin path. """ return any(pattern.match(path) for pattern in self.admin_path_patterns) def _identify_tenant(self, request): """ Identify tenant using various methods. """ tenant = None # Method 1: Host-based tenant identification if self.host_based_domains and not tenant: tenant = self._identify_tenant_by_host(request) # Method 2: Path-based tenant identification if not tenant: tenant = self._identify_tenant_by_path(request) # Method 3: Header-based tenant identification if self.header_based and not tenant: tenant = self._identify_tenant_by_header(request) # Method 4: Subdomain-based tenant identification if not tenant: tenant = self._identify_tenant_by_subdomain(request) # Method 5: User-based tenant identification (if authenticated) if not tenant: tenant = self._identify_tenant_by_user(request) return tenant def _identify_tenant_by_host(self, request): """ Identify tenant by hostname. """ host = request.get_host().split(':')[0] # Remove port # Try to find tenant by custom domain tenant = self._get_tenant_from_cache(f'tenant:host:{host}') if not tenant: try: tenant = self.tenant_model.objects.filter( domain_mappings__contains=[host], status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:host:{host}', tenant) except Exception as e: logger.error(f"Error identifying tenant by host {host}: {e}") return tenant def _identify_tenant_by_path(self, request): """ Identify tenant by URL path. """ if request.path.startswith(self.path_based_prefix): tenant_slug = request.path[len(self.path_based_prefix):].split('/')[0] if tenant_slug: tenant = self._get_tenant_from_cache(f'tenant:slug:{tenant_slug}') if not tenant: try: tenant = self.tenant_model.objects.filter( slug=tenant_slug, status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:slug:{tenant_slug}', tenant) except Exception as e: logger.error(f"Error identifying tenant by slug {tenant_slug}: {e}") return tenant return None def _identify_tenant_by_header(self, request): """ Identify tenant by request header. """ tenant_id = request.headers.get(self.tenant_header) or request.headers.get('X-Tenant-ID') if tenant_id: # Try to parse as UUID try: tenant_uuid = uuid.UUID(tenant_id) tenant = self._get_tenant_from_cache(f'tenant:id:{tenant_uuid}') if not tenant: try: tenant = self.tenant_model.objects.filter( id=tenant_uuid, status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:id:{tenant_uuid}', tenant) except Exception as e: logger.error(f"Error identifying tenant by ID {tenant_id}: {e}") return tenant except ValueError: # Not a UUID, try as slug tenant = self._get_tenant_from_cache(f'tenant:slug:{tenant_id}') if not tenant: try: tenant = self.tenant_model.objects.filter( slug=tenant_id, status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:slug:{tenant_id}', tenant) except Exception as e: logger.error(f"Error identifying tenant by header {tenant_id}: {e}") return tenant return None def _identify_tenant_by_subdomain(self, request): """ Identify tenant by subdomain. """ host = request.get_host().split(':')[0] # Extract subdomain parts = host.split('.') if len(parts) > 2: subdomain = parts[0] tenant = self._get_tenant_from_cache(f'tenant:subdomain:{subdomain}') if not tenant: try: tenant = self.tenant_model.objects.filter( slug=subdomain, status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:subdomain:{subdomain}', tenant) except Exception as e: logger.error(f"Error identifying tenant by subdomain {subdomain}: {e}") return tenant return None def _identify_tenant_by_user(self, request): """ Identify tenant by authenticated user. """ user = get_user(request) if user.is_authenticated and hasattr(user, 'tenant_id'): tenant = self._get_tenant_from_cache(f'tenant:id:{user.tenant_id}') if not tenant: try: tenant = self.tenant_model.objects.filter( id=user.tenant_id, status__in=['ACTIVE', 'PENDING'] ).first() if tenant: self._set_tenant_cache(f'tenant:id:{user.tenant_id}', tenant) except Exception as e: logger.error(f"Error identifying tenant by user {user.id}: {e}") return tenant return None def _build_tenant_context(self, tenant): """ Build tenant context dictionary. """ return { 'tenant_id': tenant.id, 'tenant_name': tenant.name, 'tenant_slug': tenant.slug, 'tenant_status': tenant.status, 'tenant_plan': tenant.subscription_plan, 'tenant_industry': tenant.business_type, 'tenant_timezone': tenant.timezone, 'tenant_currency': tenant.currency, 'tenant_locale': tenant.locale, 'tenant_features': tenant.get_features(), 'tenant_limits': { 'users': tenant.get_user_limits(), 'modules': tenant.get_module_limits(), }, 'is_trial': tenant.is_on_trial, 'trial_ends_at': tenant.trial_ends_at.isoformat() if tenant.trial_ends_at else None, 'subscription_active': tenant.subscription_active, 'subscription_ends_at': tenant.subscription_ends_at.isoformat() if tenant.subscription_ends_at else None, } def _validate_tenant_status(self, tenant): """ Validate that tenant is in acceptable state. """ if tenant.status == 'SUSPENDED': return False elif tenant.status == 'TERMINATED': return False elif tenant.status == 'PENDING' and not tenant.is_on_trial: return False return True def _validate_user_tenant_access(self, user, tenant): """ Validate that user has access to the specified tenant. """ # Superusers have access to all tenants if user.is_superuser: return True # Check if user belongs to tenant if user.tenant_id == tenant.id: return True # Check if user is staff with admin access if user.is_staff and user.role in ['ADMIN', 'MANAGER']: return True return False def _set_database_tenant_context(self, tenant): """ Set tenant context for database connection. """ if hasattr(connection, 'set_tenant'): connection.set_tenant(tenant) # Set session variable for PostgreSQL RLS if connection.vendor == 'postgresql': with connection.cursor() as cursor: cursor.execute("SET app.current_tenant_id = %s", [str(tenant.id)]) def _clear_database_tenant_context(self): """ Clear tenant context from database connection. """ if hasattr(connection, 'clear_tenant'): connection.clear_tenant() # Clear session variable for PostgreSQL RLS if connection.vendor == 'postgresql': with connection.cursor() as cursor: cursor.execute("RESET app.current_tenant_id") def _set_cache_tenant_context(self, tenant): """ Set tenant context for cache keys. """ if hasattr(cache, 'set_tenant'): cache.set_tenant(tenant) def _clear_cache_tenant_context(self): """ Clear tenant context from cache. """ if hasattr(cache, 'clear_tenant'): cache.clear_tenant() def _get_tenant_from_cache(self, cache_key): """ Get tenant from cache. """ if not self.cache_enabled: return None try: return cache.get(cache_key) except Exception as e: logger.error(f"Error getting tenant from cache {cache_key}: {e}") return None def _set_tenant_cache(self, cache_key, tenant): """ Set tenant in cache. """ if not self.cache_enabled: return try: cache.set(cache_key, tenant, self.cache_timeout) except Exception as e: logger.error(f"Error setting tenant cache {cache_key}: {e}") def _tenant_cache_key(self, prefix, tenant): """ Generate cache key for tenant. """ return f"tenant:{prefix}:{tenant.id}" class TenantIsolationMiddleware(MiddlewareMixin): """ Middleware to enforce tenant isolation at the application level. """ def __init__(self, get_response): self.get_response = get_response self.enforce_isolation = getattr(settings, 'ENFORCE_TENANT_ISOLATION', True) self.debug_mode = getattr(settings, 'DEBUG', False) def process_view(self, request, view_func, view_args, view_kwargs): """ Validate tenant isolation before view execution. """ if not self.enforce_isolation: return None # Skip isolation checks for public paths if not hasattr(request, 'tenant') or not request.tenant: return None # Validate that view has access to tenant data if hasattr(view_func, 'tenant_required') and view_func.tenant_required: if not request.tenant: raise PermissionDenied("Tenant context required for this view") # Validate tenant-specific view permissions if hasattr(view_func, 'tenant_permissions'): required_permissions = view_func.tenant_permissions user = get_user(request) if not user.is_authenticated: raise PermissionDenied("Authentication required") if not user.is_superuser: # Check user permissions against required permissions user_permissions = user.get_tenant_permissions() for resource, permissions in required_permissions.items(): if resource not in user_permissions: raise PermissionDenied(f"No access to {resource}") user_perms = user_permissions[resource] for perm in permissions: if perm not in user_perms: raise PermissionDenied(f"Missing {perm} permission for {resource}") return None def process_response(self, request, response): """ Add tenant isolation headers to response. """ if hasattr(request, 'tenant') and request.tenant: response['X-Tenant-ID'] = str(request.tenant.id) response['X-Tenant-Slug'] = request.tenant.slug if self.debug_mode: response['X-Tenant-Name'] = request.tenant.name response['X-Tenant-Plan'] = request.tenant.subscription_plan return response class TenantActivityMiddleware(MiddlewareMixin): """ Middleware to track tenant activity and usage. """ def __init__(self, get_response): self.get_response = get_response self.tracking_enabled = getattr(settings, 'TENANT_ACTIVITY_TRACKING_ENABLED', True) def process_response(self, request, response): """ Track tenant activity after request completion. """ if not self.tracking_enabled: return response if hasattr(request, 'tenant') and request.tenant: # Track API calls if request.path.startswith('/api/'): self._track_api_usage(request, response) # Track user activity user = get_user(request) if user.is_authenticated: self._track_user_activity(request, user, response) return response def _track_api_usage(self, request, response): """ Track API usage for tenant. """ try: # This would integrate with your usage tracking system # For now, we'll just log the activity logger.info(f"API usage tracked for tenant {request.tenant.id}: {request.method} {request.path}") except Exception as e: logger.error(f"Error tracking API usage: {e}") def _track_user_activity(self, request, user, response): """ Track user activity within tenant. """ try: # Update user last login time if not user.last_login or (timezone.now() - user.last_login).seconds > 300: # 5 minutes user.last_login = timezone.now() user.save(update_fields=['last_login']) # Log user activity logger.info(f"User activity tracked: {user.id} in tenant {request.tenant.id}") except Exception as e: logger.error(f"Error tracking user activity: {e}") def tenant_required(view_func): """ Decorator to mark view as requiring tenant context. """ view_func.tenant_required = True return view_func def tenant_permissions(**permissions): """ Decorator to specify required tenant permissions for view. Usage: @tenant_permissions(users=['read', 'write'], billing=['read']) def my_view(request): ... """ def decorator(view_func): view_func.tenant_permissions = permissions return view_func return decorator