import logging from rest_framework import status from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.authentication import SessionAuthentication, BasicAuthentication from django.db import transaction from eox_tenant.models import TenantConfig, Route from .models import TenantProvisioningLog from .serializers import CreateTenantSerializer, TenantAdminCreateSerializer from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError from django.conf import settings User = get_user_model() def _strip_ports_from_sources(sources): """ Strip port numbers from EDNX_ACCOUNT_REGISTRATION_SOURCES entries. eox_tenant.auth.TenantAwareAuthBackend uses re.match(pattern+"$", site) to check UserSignupSource.site against authorized_sources. UserSignupSource.site stores the bare domain (no port), but the config was previously generated with ports like "tenant.local.openedx.io:8000". These patterns would never match because re.match("tenant.local.openedx.io:8000$", "tenant.local.openedx.io") fails (extra ":8000" in pattern). This strips ":PORT" suffixes so patterns become bare domains that match UserSignupSource.site values correctly. """ if not sources: return sources return [src.split(':')[0] for src in sources] class CsrfExemptSessionAuthentication(SessionAuthentication): """Session auth that doesn't require CSRF for API requests.""" def enforce_csrf(self, request): return # Default auth classes for API views API_AUTH_CLASSES = [CsrfExemptSessionAuthentication, BasicAuthentication] logger = logging.getLogger(__name__) def generate_mfe_config(tenant_name, platform_name, theme_name, org_filter=None, ednx_tenant_restrict_users=True, ednx_tenant_user_filter_enabled=True, ednx_use_signal=True, ednx_account_registration_sources=None): """Generate complete MFE configuration for a tenant.""" lms_domain = f"{tenant_name}.local.openedx.io" apps_domain = f"{tenant_name}.apps.local.openedx.io" lms_url = f"http://{lms_domain}:8000" apps_base = f"http://{apps_domain}" # CORS and CSRF origins for all MFE ports # IMPORTANT: Must include apps_domain (MFE subdomains) not just lms_domain mfe_origins = [ f"http://{lms_domain}:8000", f"http://{apps_domain}:1999", f"http://{apps_domain}:1996", f"http://{apps_domain}:1997", f"http://{apps_domain}:1995", f"http://{apps_domain}:2000", f"http://{apps_domain}:2001", f"http://{apps_domain}:2002", # Also include main domain variants for completeness f"http://{lms_domain}:1999", f"http://{lms_domain}:1996", f"http://{lms_domain}:1997", f"http://{lms_domain}:1995", f"http://{lms_domain}:2000", f"http://{lms_domain}:2001", f"http://{lms_domain}:2002", ] config = { # Base configuration "LMS_BASE": f"{lms_domain}:8000", "SITE_NAME": lms_domain, "PLATFORM_NAME": platform_name or f"{tenant_name.title()} Learning", "LMS_ROOT_URL": lms_url, # Theme configuration - CRITICAL for LMS theming "THEME_NAME": theme_name, "DEFAULT_SITE_THEME": theme_name, "ENABLE_COMPREHENSIVE_THEMING": True, # MFE Base URLs (use main domain) "BASE_URL": lms_domain, "LMS_BASE_URL": lms_url, "MFE_HOST": f"{lms_domain}:8000", # Auth URLs "LOGIN_URL": f"{lms_url}/login", "LOGOUT_URL": f"{lms_url}/logout", "REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh", # Branding "LOGO_URL": f"{lms_url}/theming/asset/images/logo.png", "LOGO_TRADEMARK_URL": f"{lms_url}/theming/asset/images/logo.png", "LOGO_WHITE_URL": f"{lms_url}/theming/asset/images/logo.png", "FAVICON_URL": f"{lms_url}/favicon.ico", # MFE URLs - Critical for proper redirects (use apps subdomain since MFEs are only accessible there) "LEARNER_HOME_MICROFRONTEND_URL": f"http://{apps_domain}:1996/learner-dashboard/", "ACCOUNT_MICROFRONTEND_URL": f"http://{apps_domain}:1997/account/", "ACCOUNT_SETTINGS_URL": f"http://{apps_domain}:1997/account/", "PROFILE_MICROFRONTEND_URL": f"http://{apps_domain}:1995/profile/u/", "AUTHN_MICROFRONTEND_URL": f"http://{apps_domain}:1999/authn", "AUTHN_MICROFRONTEND_DOMAIN": f"{apps_domain}:1999/authn", "LEARNING_MICROFRONTEND_URL": f"http://{apps_domain}:2000/learning", "LEARNING_BASE_URL": f"http://{apps_domain}:2000/learning", "COURSE_AUTHORING_MICROFRONTEND_URL": f"http://{apps_domain}:2001/authoring", "DISCUSSIONS_MICROFRONTEND_URL": f"http://{apps_domain}:2002/discussions", "DISCUSSIONS_MFE_BASE_URL": f"http://{apps_domain}:2002/discussions", "WRITABLE_GRADEBOOK_URL": f"http://{apps_domain}:1994/gradebook", "COMMUNICATIONS_MICROFRONTEND_URL": f"http://{apps_domain}:1984/communications", "ORA_GRADING_MICROFRONTEND_URL": f"http://{apps_domain}:1993/ora-grading", "ADMIN_CONSOLE_MICROFRONTEND_URL": f"http://{apps_domain}:2025/admin-console", "ADMIN_CONSOLE_URL": f"http://{apps_domain}:2025/admin-console", "ACCOUNT_PROFILE_URL": f"http://{apps_domain}:1995/profile", # MFE Config Object - Required for MFEs to load configuration # Note: Authn MFE is only accessible via apps subdomain "MFE_CONFIG": { "BASE_URL": lms_domain, "LMS_BASE_URL": lms_url, "LOGIN_URL": f"http://{apps_domain}:1999/authn/login", "LOGOUT_URL": f"http://{apps_domain}:1999/logout", "LOGIN_REDIRECT_URL": f"http://{apps_domain}:1999/authn/login", "REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh", "SITE_NAME": platform_name or f"{tenant_name.title()} Learning", "ACCOUNT_SETTINGS_URL": f"http://{apps_domain}:1997/account/", "ACCOUNT_PROFILE_URL": f"http://{apps_domain}:1995/profile", "LEARNING_BASE_URL": f"http://{apps_domain}:2000/learning", "COURSE_AUTHORING_MICROFRONTEND_URL": f"http://{apps_domain}:2001/authoring", "DISCUSSIONS_MFE_BASE_URL": f"http://{apps_domain}:2002/discussions", "STUDIO_BASE_URL": "http://studio.local.openedx.io:8001", }, # CORS Configuration - Required for MFEs to communicate with LMS "CORS_ALLOW_CREDENTIALS": True, "CORS_ORIGIN_WHITELIST": mfe_origins, "CSRF_TRUSTED_ORIGINS": mfe_origins, "CSRF_COOKIE_DOMAIN": lms_domain, "CSRF_COOKIE_SAMESITE": "Lax", "CSRF_COOKIE_SECURE": not settings.DEBUG, # Feature flags "ENABLE_AUTHN_MICROFRONTEND": True, "ENABLE_LEARNING_MICROFRONTEND": True, "ENABLE_PROFILE_MICROFRONTEND": True, "ENABLE_DISCUSSIONS_MFE": True, "ENABLE_LEARNER_HOME_MFE": True, "ENABLE_DYNAMIC_REGISTRATION": True, # Organization filter "course_org_filter": org_filter or [], # EDNX settings for tenant isolation "EDNX_TENANT_RESTRICT_USERS": ednx_tenant_restrict_users, "EDNX_TENANT_USER_FILTER_ENABLED": ednx_tenant_user_filter_enabled, "EDNX_USE_SIGNAL": ednx_use_signal, # Include both LMS domain and apps domain for MFE login. # NOTE: UserSignupSource.site stores the bare domain (no port), so patterns # must NOT include :8000 or :1999. eox_tenant auth uses re.match(pattern+"$", site) # which would fail to match "mondaytest.local.openedx.io" against "mondaytest.local.openedx.io:8000$". "EDNX_ACCOUNT_REGISTRATION_SOURCES": ednx_account_registration_sources or [ lms_domain, f"{tenant_name}.apps.local.openedx.io" ], # Session cookie configuration for tenant isolation # Use wildcard domain (.local.openedx.io) to allow session sharing across subdomains # e.g., mondaytest.local.openedx.io and studio.mondaytest.local.openedx.io "SESSION_COOKIE_NAME": f"sessionid_{tenant_name.replace('-', '')}", "SESSION_COOKIE_DOMAIN": ".local.openedx.io", # Wildcard for all subdomains "SESSION_COOKIE_SAMESITE": "Lax", "SESSION_COOKIE_SECURE": not settings.DEBUG, } return config @api_view(['POST']) @authentication_classes(API_AUTH_CLASSES) @permission_classes([IsAdminUser]) def create_tenant(request): """ Create a new tenant with full MFE configuration. POST /api/tenant/v1/create Request Body: { "tenant_name": "talent2", "platform_name": "Talent 2 Learning", "theme_name": "indigo", "org_filter": ["org1", "org2"] } """ serializer = CreateTenantSerializer(data=request.data) if not serializer.is_valid(): return Response({ 'status': 'error', 'errors': serializer.errors }, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data tenant_name = data['tenant_name'] platform_name = data.get('platform_name', f"{tenant_name.title()} Learning") theme_name = data.get('theme_name', 'indigo') org_filter = data.get('org_filter', []) # Extract EDNX settings (with defaults) ednx_tenant_restrict_users = data.get('ednx_tenant_restrict_users', True) ednx_tenant_user_filter_enabled = data.get('ednx_tenant_user_filter_enabled', True) ednx_use_signal = data.get('ednx_use_signal', True) ednx_account_registration_sources = data.get('ednx_account_registration_sources', None) external_key = f"{tenant_name}.local.openedx.io" lms_domain = f"{tenant_name}.local.openedx.io" apps_domain = f"{tenant_name}.apps.local.openedx.io" # Check if tenant already exists if TenantConfig.objects.filter(external_key=external_key).exists(): return Response({ 'status': 'error', 'message': f"Tenant '{tenant_name}' already exists" }, status=status.HTTP_409_CONFLICT) if Route.objects.filter(domain=lms_domain).exists(): return Response({ 'status': 'error', 'message': f"Route for domain '{lms_domain}' already exists" }, status=status.HTTP_409_CONFLICT) log_entry = None try: with transaction.atomic(): # Generate MFE configuration with EDNX settings lms_configs = generate_mfe_config( tenant_name, platform_name, theme_name, org_filter, ednx_tenant_restrict_users=ednx_tenant_restrict_users, ednx_tenant_user_filter_enabled=ednx_tenant_user_filter_enabled, ednx_use_signal=ednx_use_signal, ednx_account_registration_sources=ednx_account_registration_sources ) # Create TenantConfig tenant_config = TenantConfig.objects.create( external_key=external_key, lms_configs=lms_configs, studio_configs={ "PLATFORM_NAME": platform_name, "SITE_NAME": f"studio.{lms_domain}", }, theming_configs={ "THEME_NAME": theme_name, }, meta={ "created_via": "tenant_api", "created_by": request.user.username, } ) # Create Route for LMS Route.objects.create( domain=lms_domain, config=tenant_config ) # Create audit log log_entry = TenantProvisioningLog.objects.create( tenant_name=tenant_name, external_key=external_key, created_by=request.user, status='success', tenant_config_id=tenant_config.id ) logger.info(f"Tenant '{tenant_name}' created successfully by {request.user.username}") return Response({ 'status': 'success', 'tenant_id': tenant_config.id, 'tenant_name': tenant_name, 'lms_url': f"http://{lms_domain}:8000", 'authn_url': f"http://{apps_domain}:1999/authn", 'learner_dashboard_url': f"http://{apps_domain}:1996/learner-dashboard/", 'message': 'Tenant created successfully. Add DNS entries to your hosts file.' }) except Exception as e: logger.exception(f"Failed to create tenant '{tenant_name}'") # Create failure log if log_entry is None: TenantProvisioningLog.objects.create( tenant_name=tenant_name, external_key=external_key, created_by=request.user, status='failed', error_message=str(e) ) else: log_entry.status = 'failed' log_entry.error_message = str(e) log_entry.save() return Response({ 'status': 'error', 'message': 'An internal error occurred while creating the tenant' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) @authentication_classes(API_AUTH_CLASSES) @permission_classes([IsAdminUser]) def list_tenants(request): """ List all tenants created via API. GET /api/tenant/v1/list """ logs = TenantProvisioningLog.objects.filter(status='success').select_related('created_by') data = [] for log in logs: data.append({ 'tenant_name': log.tenant_name, 'external_key': log.external_key, 'created_at': log.created_at.isoformat(), 'created_by': log.created_by.username, 'tenant_config_id': log.tenant_config_id, }) return Response({ 'count': len(data), 'tenants': data }) @api_view(['DELETE']) @authentication_classes(API_AUTH_CLASSES) @permission_classes([IsAdminUser]) def delete_tenant(request, tenant_name): """ Delete a tenant. DELETE /api/tenant/v1/delete/{tenant_name} """ try: external_key = f"{tenant_name}.local.openedx.io" tenant_config = TenantConfig.objects.get(external_key=external_key) # Delete associated routes Route.objects.filter(config=tenant_config).delete() # Delete tenant config tenant_config.delete() # Update log TenantProvisioningLog.objects.filter(tenant_name=tenant_name).update( status='deleted' ) logger.info(f"Tenant '{tenant_name}' deleted by {request.user.username}") return Response({ 'status': 'success', 'message': f"Tenant '{tenant_name}' deleted successfully" }) except TenantConfig.DoesNotExist: return Response({ 'status': 'error', 'message': f"Tenant '{tenant_name}' not found" }, status=status.HTTP_404_NOT_FOUND) except Exception as e: logger.exception(f"Failed to delete tenant '{tenant_name}'") return Response({ 'status': 'error', 'message': 'An internal error occurred while deleting the tenant' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) def health_check(request): """ Health check endpoint (no auth required). GET /api/tenant/v1/health """ try: # Check eox-tenant is installed from eox_tenant.models import TenantConfig tenant_count = TenantConfig.objects.count() return Response({ 'status': 'healthy', 'eox_tenant_installed': True, 'tenant_count': tenant_count }) except ImportError: return Response({ 'status': 'unhealthy', 'eox_tenant_installed': False, 'error': 'eox-tenant not installed' }, status=status.HTTP_503_SERVICE_UNAVAILABLE) @api_view(['POST']) @authentication_classes(API_AUTH_CLASSES) @permission_classes([IsAdminUser]) def create_tenant_admin(request): """ Create a new admin user for a tenant with proper Open edX roles. POST /api/tenant/v1/admin/create Request Body: { "tenant_name": "acmecorp", "username": "acme_admin", "email": "admin@acme.com", "password": "secure_password123", "org_name": "acme" } """ # Check if user is superuser if not request.user.is_superuser: return Response({ 'status': 'error', 'message': 'Only superusers can create tenant admins' }, status=status.HTTP_403_FORBIDDEN) serializer = TenantAdminCreateSerializer(data=request.data) if not serializer.is_valid(): return Response({ 'status': 'error', 'errors': serializer.errors }, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data tenant_name = data['tenant_name'] username = data['username'] email = data['email'] password = data['password'] org_name = data['org_name'] try: with transaction.atomic(): # Create the user user = User.objects.create_user( username=username, email=email, password=password, is_staff=True ) # Create user profile - required for login to work from common.djangoapps.student.models import UserProfile UserProfile.objects.create( user=user, name=username.title() ) # Import roles from student app from common.djangoapps.student.roles import OrgStaffRole, CourseCreatorRole # Assign OrgStaffRole for the organization OrgStaffRole(org_name).add_users(user) # Assign CourseCreatorRole CourseCreatorRole().add_users(user) # Create UserSignupSource record — REQUIRED for eox_tenant's TenantAwareAuthBackend # to allow login. The auth backend checks usersignupsource_set.all() and denies login # if the user has no matching signup source for the current tenant domain. # Without this, login returns 400 "User not authorized to perform this action". from common.djangoapps.student.models import UserSignupSource lms_domain = f"{tenant_name}.local.openedx.io" UserSignupSource.objects.create( user=user, site=lms_domain, ) # Log the action logger.info( f"Tenant admin created: {username} for tenant {tenant_name} " f"by {request.user.username}" ) return Response({ 'status': 'success', 'message': f"Admin user '{username}' created successfully for tenant '{tenant_name}'", 'user': { 'id': user.id, 'username': user.username, 'email': user.email, 'tenant': tenant_name, 'org': org_name, 'roles': ['OrgStaffRole', 'CourseCreatorRole'] } }) except Exception as e: logger.exception(f"Failed to create tenant admin '{username}'") return Response({ 'status': 'error', 'message': 'An internal error occurred while creating the tenant admin' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)