521 lines
20 KiB
Python
521 lines
20 KiB
Python
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)
|