Add openedx-tenant-api plugin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Dynamic MFE Configuration Middleware for Multi-Tenant Open edX
|
||||
|
||||
This middleware automatically injects tenant-specific MFE configuration
|
||||
into the MFE Config API responses based on the request host.
|
||||
|
||||
Usage:
|
||||
Add to MIDDLEWARE in settings:
|
||||
MIDDLEWARE += ['mfe_dynamic_middleware.DynamicMFEConfigMiddleware']
|
||||
|
||||
This allows new tenants to work automatically without manual MFE_CONFIG_OVERRIDES configuration.
|
||||
"""
|
||||
|
||||
import re
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class DynamicMFEConfigMiddleware:
|
||||
"""
|
||||
Middleware that dynamically injects tenant-specific MFE configuration.
|
||||
|
||||
This works by intercepting MFE Config API requests and injecting the
|
||||
appropriate configuration based on the request's hostname.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
# Compile regex patterns for performance
|
||||
self.tenant_mfe_pattern = re.compile(r'^(\w+)\.apps\.local\.openedx\.io(:(\d+))?$')
|
||||
self.tenant_lms_pattern = re.compile(r'^(\w+)\.local\.openedx\.io(:(\d+))?$')
|
||||
|
||||
def __call__(self, request):
|
||||
"""Process the request and inject dynamic MFE config if needed."""
|
||||
# Check if this is an MFE Config API request
|
||||
if request.path.startswith('/api/mfe_config/v1'):
|
||||
# Get tenant config and store it on the request
|
||||
request.dynamic_mfe_config = self._get_tenant_config(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def _get_tenant_config(self, request):
|
||||
"""
|
||||
Get MFE configuration for the tenant based on request host.
|
||||
|
||||
Priority:
|
||||
1. X-MFE-Origin header (set by webpack proxy - preserves original MFE tenant host)
|
||||
2. request.get_host() (standard Host header)
|
||||
"""
|
||||
# Priority 1: X-MFE-Origin header from webpack proxy
|
||||
mfe_origin = request.META.get('HTTP_X_MFE_ORIGIN', '')
|
||||
if mfe_origin:
|
||||
# Parse origin to get host
|
||||
host = mfe_origin.split('://', 1)[-1]
|
||||
else:
|
||||
# Priority 2: Standard Host header
|
||||
host = request.get_host()
|
||||
|
||||
# Try to extract tenant name from host
|
||||
tenant = self._extract_tenant(host)
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
# Skip default/system tenants
|
||||
if tenant in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
return None
|
||||
|
||||
# Generate MFE config for this tenant
|
||||
return self._generate_config(tenant)
|
||||
|
||||
def _extract_tenant(self, host):
|
||||
"""Extract tenant name from request host."""
|
||||
# Pattern: <tenant>.apps.local.openedx.io:PORT (MFE)
|
||||
match = self.tenant_mfe_pattern.match(host)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Pattern: <tenant>.local.openedx.io:PORT (LMS)
|
||||
match = self.tenant_lms_pattern.match(host)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def _generate_config(self, tenant):
|
||||
"""Generate MFE configuration for a tenant."""
|
||||
base_domain = "local.openedx.io"
|
||||
apps_domain = f"{tenant}.apps.local.openedx.io"
|
||||
|
||||
lms_url = f"http://{tenant}.{base_domain}:8000"
|
||||
lms_domain = f"{tenant}.{base_domain}"
|
||||
|
||||
return {
|
||||
"BASE_URL": lms_domain,
|
||||
"LMS_BASE_URL": lms_url,
|
||||
"SITE_NAME": f"{tenant}.{base_domain}",
|
||||
|
||||
# Auth
|
||||
"LOGIN_URL": f"{lms_url}/login",
|
||||
"LOGOUT_URL": f"{lms_url}/logout",
|
||||
"REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh",
|
||||
|
||||
# MFE URLs - use apps subdomain since MFEs are only accessible there
|
||||
"AUTHN_MICROFRONTEND_URL": f"http://{apps_domain}:1999/authn",
|
||||
"ACCOUNT_MICROFRONTEND_URL": f"http://{apps_domain}:1997/account/",
|
||||
"PROFILE_MICROFRONTEND_URL": f"http://{apps_domain}:1995/profile/u/",
|
||||
"LEARNING_MICROFRONTEND_URL": f"http://{apps_domain}:2000/learning",
|
||||
"LEARNER_HOME_MICROFRONTEND_URL": f"http://{apps_domain}:1996/learner-dashboard/",
|
||||
"COURSE_AUTHORING_MICROFRONTEND_URL": f"http://{apps_domain}:2001/authoring",
|
||||
"DISCUSSIONS_MICROFRONTEND_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",
|
||||
|
||||
# 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",
|
||||
|
||||
# Other
|
||||
"CSRF_TOKEN_API_PATH": "/csrf/api/v1/token",
|
||||
"LANGUAGE_PREFERENCE_COOKIE_NAME": "openedx-language-preference",
|
||||
"SESSION_COOKIE_DOMAIN": ".local.openedx.io",
|
||||
}
|
||||
|
||||
|
||||
def get_dynamic_mfe_config_for_api(request, mfe_name=None):
|
||||
"""
|
||||
Get dynamic MFE config for the MFE Config API.
|
||||
|
||||
This function can be called from the MFE Config API view to get
|
||||
tenant-specific configuration.
|
||||
|
||||
Args:
|
||||
request: Django HTTP request
|
||||
mfe_name: Optional MFE name for MFE-specific overrides
|
||||
|
||||
Returns:
|
||||
dict or None: Tenant-specific config, or None if not a tenant request
|
||||
"""
|
||||
# Check if middleware already processed this request
|
||||
if hasattr(request, 'dynamic_mfe_config'):
|
||||
return request.dynamic_mfe_config
|
||||
|
||||
# Otherwise, process it
|
||||
middleware = DynamicMFEConfigMiddleware(lambda r: r)
|
||||
return middleware._get_tenant_config(request)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Patch for MFE Config API to support multi-tenant dynamic configuration.
|
||||
|
||||
This module patches the MFE Config API to inject tenant-specific configuration
|
||||
based on the X-MFE-Origin header (from webpack proxy) or Host header.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def get_host_from_request(request):
|
||||
"""
|
||||
Extract the tenant host from the request.
|
||||
|
||||
Priority:
|
||||
1. X-MFE-Origin header (set by webpack proxy - preserves original MFE tenant host)
|
||||
2. request.get_host() (standard Host header)
|
||||
"""
|
||||
mfe_origin = request.META.get('HTTP_X_MFE_ORIGIN', '')
|
||||
if mfe_origin:
|
||||
# Parse origin (e.g., "http://mondaytest.apps.local.openedx.io:1999") to get host
|
||||
return mfe_origin.split('://', 1)[-1]
|
||||
return request.get_host()
|
||||
|
||||
|
||||
def get_tenant_config(host):
|
||||
"""
|
||||
Generate tenant-specific MFE configuration based on request host.
|
||||
|
||||
Args:
|
||||
host: The request host (e.g., 'talent1.apps.local.openedx.io:1999')
|
||||
|
||||
Returns:
|
||||
dict or None: Tenant config if recognized, None otherwise
|
||||
"""
|
||||
# Pattern for MFE subdomain: talent1.apps.local.openedx.io
|
||||
mfe_match = re.match(r'^(\w+)\.apps\.local\.openedx\.io(:\d+)?$', host)
|
||||
if mfe_match:
|
||||
tenant = mfe_match.group(1)
|
||||
if tenant not in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
return _generate_config(tenant)
|
||||
|
||||
# Pattern for LMS subdomain: talent1.local.openedx.io
|
||||
lms_match = re.match(r'^(\w+)\.local\.openedx\.io(:\d+)?$', host)
|
||||
if lms_match:
|
||||
tenant = lms_match.group(1)
|
||||
if tenant not in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
return _generate_config(tenant)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _generate_config(tenant):
|
||||
"""Generate MFE configuration for a tenant."""
|
||||
lms_domain = f"{tenant}.local.openedx.io"
|
||||
lms_url = f"http://{lms_domain}:8000"
|
||||
apps_base = f"http://{tenant}.apps.local.openedx.io"
|
||||
|
||||
# Look up the Site's display name from the database, fallback to "TenantName Learning"
|
||||
site_name = lms_domain
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
site = Site.objects.filter(domain=lms_domain).first()
|
||||
if site and site.name and site.name != site.domain:
|
||||
site_name = site.name
|
||||
else:
|
||||
# No matching Site or Site has same name as domain — use "Mondaytest Learning" style
|
||||
site_name = f"{tenant.replace('-', ' ').title()} Learning"
|
||||
except Exception:
|
||||
site_name = f"{tenant.replace('-', ' ').title()} Learning"
|
||||
|
||||
return {
|
||||
"BASE_URL": apps_base,
|
||||
"LMS_BASE_URL": lms_url,
|
||||
"SITE_NAME": site_name,
|
||||
"PLATFORM_NAME": site_name,
|
||||
"LOGIN_URL": f"{lms_url}/login",
|
||||
"LOGOUT_URL": f"{lms_url}/logout",
|
||||
"REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh",
|
||||
"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",
|
||||
"LEARNER_HOME_MICROFRONTEND_URL": f"{apps_base}:1996/learner-dashboard/",
|
||||
"ACCOUNT_MICROFRONTEND_URL": f"{apps_base}:1997/account/",
|
||||
"ACCOUNT_SETTINGS_URL": f"{apps_base}:1997/account/",
|
||||
"PROFILE_MICROFRONTEND_URL": f"{apps_base}:1995/profile/u/",
|
||||
"AUTHN_MICROFRONTEND_URL": f"{apps_base}:1999/authn",
|
||||
"LEARNING_MICROFRONTEND_URL": f"{apps_base}:2000/learning",
|
||||
"LEARNING_BASE_URL": f"{apps_base}:2000/learning",
|
||||
"COURSE_AUTHORING_MICROFRONTEND_URL": f"{apps_base}:2001/authoring",
|
||||
"DISCUSSIONS_MICROFRONTEND_URL": f"{apps_base}:2002/discussions",
|
||||
"DISCUSSIONS_MFE_BASE_URL": f"{apps_base}:2002/discussions",
|
||||
"WRITABLE_GRADEBOOK_URL": f"{apps_base}:1994/gradebook",
|
||||
"COMMUNICATIONS_MICROFRONTEND_URL": f"{apps_base}:1984/communications",
|
||||
"ORA_GRADING_MICROFRONTEND_URL": f"{apps_base}:1993/ora-grading",
|
||||
"ADMIN_CONSOLE_MICROFRONTEND_URL": f"{apps_base}:2025/admin-console",
|
||||
"ADMIN_CONSOLE_URL": f"{apps_base}:2025/admin-console",
|
||||
"ACCOUNT_PROFILE_URL": f"{apps_base}:1995/profile",
|
||||
}
|
||||
|
||||
|
||||
def patch_mfe_config_api():
|
||||
"""
|
||||
Patch the MFE Config API to inject tenant-specific configuration.
|
||||
|
||||
This patches the MFEConfigView.get() method to inject tenant-specific
|
||||
configuration based on the X-MFE-Origin header or Host header.
|
||||
"""
|
||||
try:
|
||||
from lms.djangoapps.mfe_config_api.views import MFEConfigView
|
||||
|
||||
# Store the original get method
|
||||
original_get = MFEConfigView.get
|
||||
|
||||
@wraps(original_get)
|
||||
def patched_get(self, request, *args, **kwargs):
|
||||
"""Patched get that injects tenant config."""
|
||||
# Get tenant config using X-MFE-Origin header (from webpack proxy) or Host header
|
||||
host = get_host_from_request(request)
|
||||
tenant_config = get_tenant_config(host)
|
||||
|
||||
# Call original get
|
||||
response = original_get(self, request, *args, **kwargs)
|
||||
|
||||
# Inject tenant config into the JsonResponse content
|
||||
if tenant_config and hasattr(response, 'content'):
|
||||
try:
|
||||
# Parse the JSON response and inject tenant config
|
||||
content = response.content.decode('utf-8')
|
||||
data = json.loads(content)
|
||||
# Merge tenant config (tenant overrides take precedence)
|
||||
data.update(tenant_config)
|
||||
# Re-create the response with updated content
|
||||
from django.http import JsonResponse
|
||||
new_response = JsonResponse(data, status=response.status_code)
|
||||
# Copy headers
|
||||
for header, value in response.items():
|
||||
if header.lower() not in ('content-type', 'content-length'):
|
||||
new_response[header] = value
|
||||
return new_response
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
# Apply the patch
|
||||
MFEConfigView.get = patched_get
|
||||
print("[PatchMFEConfig] MFE Config API patched successfully for multi-tenant support")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PatchMFEConfig] Failed to patch MFE Config API: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Tenant-aware MFE Configuration Middleware for eox-tenant multi-tenant setup.
|
||||
|
||||
This middleware intercepts MFE Config API requests and injects tenant-specific
|
||||
configuration based on the request's subdomain.
|
||||
"""
|
||||
|
||||
import re
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class TenantMFEConfigMiddleware:
|
||||
"""
|
||||
Middleware that provides tenant-specific MFE configuration.
|
||||
|
||||
This ensures MFEs get the correct URLs for their tenant subdomain.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.tenant_mfe_pattern = re.compile(r'^(\w+)\.apps\.local\.openedx\.io(:\d+)?$')
|
||||
self.tenant_lms_pattern = re.compile(r'^(\w+)\.local\.openedx\.io(:\d+)?$')
|
||||
|
||||
def __call__(self, request):
|
||||
"""Process request and inject tenant-specific MFE config."""
|
||||
# Check if this is an MFE Config API request
|
||||
if request.path == '/api/mfe_config/v1' or request.path.startswith('/api/mfe_config/v1'):
|
||||
tenant_config = self._get_tenant_config(request)
|
||||
if tenant_config:
|
||||
# Store config on request for later use
|
||||
request.tenant_mfe_config = tenant_config
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# If we have tenant config, modify the response
|
||||
if hasattr(request, 'tenant_mfe_config') and hasattr(response, 'data'):
|
||||
self._inject_tenant_config(response, request.tenant_mfe_config)
|
||||
|
||||
return response
|
||||
|
||||
def _get_tenant_config(self, request):
|
||||
"""
|
||||
Get MFE configuration for the tenant based on request host.
|
||||
|
||||
Priority:
|
||||
1. X-MFE-Origin header (set by webpack proxy - preserves original MFE tenant host)
|
||||
2. request.get_host() (standard Host header)
|
||||
"""
|
||||
# Priority 1: X-MFE-Origin header from webpack proxy
|
||||
# This preserves the original MFE tenant host (e.g., mondaytest.apps.local.openedx.io:1999)
|
||||
# even when webpack proxy changes the Host header to local.openedx.io:8000
|
||||
mfe_origin = request.META.get('HTTP_X_MFE_ORIGIN', '')
|
||||
if mfe_origin:
|
||||
# Parse origin (e.g., "http://mondaytest.apps.local.openedx.io:1999") to get host
|
||||
# Remove scheme
|
||||
host = mfe_origin.split('://', 1)[-1]
|
||||
else:
|
||||
# Priority 2: Standard Host header
|
||||
host = request.get_host()
|
||||
|
||||
# Try MFE subdomain pattern: talent1.apps.local.openedx.io
|
||||
match = self.tenant_mfe_pattern.match(host)
|
||||
if match:
|
||||
tenant = match.group(1)
|
||||
if tenant not in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
return self._generate_config(tenant)
|
||||
|
||||
# Try LMS subdomain pattern: talent1.local.openedx.io
|
||||
match = self.tenant_lms_pattern.match(host)
|
||||
if match:
|
||||
tenant = match.group(1)
|
||||
if tenant not in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
return self._generate_config(tenant)
|
||||
|
||||
return None
|
||||
|
||||
def _generate_config(self, tenant):
|
||||
"""Generate tenant-specific MFE configuration."""
|
||||
lms_url = f"http://{tenant}.local.openedx.io:8000"
|
||||
apps_base = f"http://{tenant}.apps.local.openedx.io"
|
||||
|
||||
return {
|
||||
"BASE_URL": apps_base,
|
||||
"LMS_BASE_URL": lms_url,
|
||||
"SITE_NAME": f"{tenant}.local.openedx.io",
|
||||
"LOGIN_URL": f"{lms_url}/login",
|
||||
"LOGOUT_URL": f"{lms_url}/logout",
|
||||
"REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh",
|
||||
"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",
|
||||
# Critical: Learner dashboard URL for post-login redirect
|
||||
"LEARNER_HOME_MICROFRONTEND_URL": f"{apps_base}:1996/learner-dashboard/",
|
||||
"ACCOUNT_MICROFRONTEND_URL": f"{apps_base}:1997/account/",
|
||||
"ACCOUNT_SETTINGS_URL": f"{apps_base}:1997/account/",
|
||||
"PROFILE_MICROFRONTEND_URL": f"{apps_base}:1995/profile/u/",
|
||||
"AUTHN_MICROFRONTEND_URL": f"{apps_base}:1999/authn",
|
||||
"LEARNING_MICROFRONTEND_URL": f"{apps_base}:2000/learning",
|
||||
"LEARNING_BASE_URL": f"{apps_base}:2000/learning",
|
||||
"COURSE_AUTHORING_MICROFRONTEND_URL": f"{apps_base}:2001/authoring",
|
||||
"DISCUSSIONS_MICROFRONTEND_URL": f"{apps_base}:2002/discussions",
|
||||
"DISCUSSIONS_MFE_BASE_URL": f"{apps_base}:2002/discussions",
|
||||
"WRITABLE_GRADEBOOK_URL": f"{apps_base}:1994/gradebook",
|
||||
"COMMUNICATIONS_MICROFRONTEND_URL": f"{apps_base}:1984/communications",
|
||||
"ORA_GRADING_MICROFRONTEND_URL": f"{apps_base}:1993/ora-grading",
|
||||
"ADMIN_CONSOLE_MICROFRONTEND_URL": f"{apps_base}:2025/admin-console",
|
||||
"ADMIN_CONSOLE_URL": f"{apps_base}:2025/admin-console",
|
||||
"ACCOUNT_PROFILE_URL": f"{apps_base}:1995/profile",
|
||||
}
|
||||
|
||||
def _inject_tenant_config(self, response, tenant_config):
|
||||
"""Inject tenant config into the MFE Config API response."""
|
||||
if not isinstance(response.data, dict):
|
||||
return
|
||||
|
||||
# Update the response data with tenant config
|
||||
# This overrides any default values with tenant-specific ones
|
||||
mfe_name = None
|
||||
|
||||
# Check if request has mfe parameter
|
||||
if hasattr(response, 'renderer_context'):
|
||||
request = response.renderer_context.get('request')
|
||||
if request:
|
||||
mfe_name = request.GET.get('mfe')
|
||||
|
||||
if mfe_name and mfe_name in response.data:
|
||||
# MFE-specific response structure
|
||||
response.data[mfe_name].update(tenant_config)
|
||||
else:
|
||||
# Direct response structure
|
||||
response.data.update(tenant_config)
|
||||
|
||||
|
||||
class TenantRedirectMiddleware:
|
||||
"""
|
||||
Middleware that sets tenant-specific LEARNER_HOME_MICROFRONTEND_URL for LMS redirects.
|
||||
|
||||
This ensures that after login via non-MFE flows, users go to the correct tenant.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.tenant_pattern = re.compile(r'^(\w+)\.apps\.local\.openedx\.io(:\d+)?$')
|
||||
|
||||
def __call__(self, request):
|
||||
"""Process request and set tenant-specific redirect URL."""
|
||||
host = request.get_host()
|
||||
|
||||
match = self.tenant_pattern.match(host)
|
||||
if match:
|
||||
tenant = match.group(1)
|
||||
if tenant not in ['local', 'studio', 'apps', 'meilisearch', 'www']:
|
||||
# Generate tenant-specific learner dashboard URL
|
||||
tenant_learner_url = f"http://{tenant}.apps.local.openedx.io:1996/learner-dashboard/"
|
||||
|
||||
# Store original
|
||||
original_url = getattr(settings, 'LEARNER_HOME_MICROFRONTEND_URL', None)
|
||||
|
||||
# Override for this request
|
||||
settings.LEARNER_HOME_MICROFRONTEND_URL = tenant_learner_url
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# Restore original
|
||||
if original_url:
|
||||
settings.LEARNER_HOME_MICROFRONTEND_URL = original_url
|
||||
|
||||
return response
|
||||
|
||||
return self.get_response(request)
|
||||
Reference in New Issue
Block a user