diff --git a/openedx-tenant-api/MANIFEST.in b/openedx-tenant-api/MANIFEST.in new file mode 100644 index 0000000..760302a --- /dev/null +++ b/openedx-tenant-api/MANIFEST.in @@ -0,0 +1 @@ +recursive-include openedx_tenant_api * diff --git a/openedx-tenant-api/README.md b/openedx-tenant-api/README.md new file mode 100644 index 0000000..f35b12d --- /dev/null +++ b/openedx-tenant-api/README.md @@ -0,0 +1,236 @@ +# Open edX Tenant API + +REST API for programmatic tenant creation in Open edX with eox-tenant. + +## Features + +- Create tenants with automatic MFE configuration +- List all provisioned tenants +- Delete tenants +- Health check endpoint +- Audit logging of all tenant operations + +## Installation + +### 1. Add to Tutor Config + +Edit your Tutor `config.yml`: + +```yaml +MOUNTS: + - /path/to/openedx-tenant-api + +OPENEDX_EXTRA_PIP_REQUIREMENTS: + - -e /mnt/openedx-tenant-api +``` + +### 2. Restart LMS + +```bash +tutor dev restart lms +``` + +### 3. Install Package and Run Migrations + +```bash +# Install in running container +docker exec tutor_dev-lms-1 sh -c "pip install -e /mnt/openedx-tenant-api --no-build-isolation" + +# Create migrations +docker exec tutor_dev-lms-1 sh -c "cd /openedx/edx-platform && python manage.py lms makemigrations openedx_tenant_api" + +# Run migrations +docker exec tutor_dev-lms-1 sh -c "cd /openedx/edx-platform && python manage.py lms migrate openedx_tenant_api" +``` + +## API Endpoints + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/tenant/v1/health` | GET | No | Health check | +| `/api/tenant/v1/list` | GET | Yes (Admin) | List all tenants | +| `/api/tenant/v1/create` | POST | Yes (Admin) | Create new tenant | +| `/api/tenant/v1/delete/{tenant_name}` | DELETE | Yes (Admin) | Delete tenant | + +## Usage + +### Health Check + +```bash +curl http://local.openedx.io:8000/api/tenant/v1/health +``` + +Response: +```json +{ + "status": "healthy", + "eox_tenant_installed": true, + "tenant_count": 2 +} +``` + +### Create Tenant + +Requires admin user authentication. Supports Basic Auth, JWT Bearer, or Session auth. + +```python +import requests + +# Using Basic Authentication (simplest) +response = requests.post( + 'http://local.openedx.io:8000/api/tenant/v1/create', + auth=('admin', 'your-password'), + json={ + 'tenant_name': 'talent1', + 'platform_name': 'Talent 1 Learning', + 'theme_name': 'indigo', + 'org_filter': ['org1'] # Optional + } +) + +print(response.json()) +``` + +```bash +# Using cURL with Basic Auth +curl -X POST http://local.openedx.io:8000/api/tenant/v1/create \ + -u admin:your-password \ + -H "Content-Type: application/json" \ + -d '{ + "tenant_name": "talent1", + "platform_name": "Talent 1 Learning", + "theme_name": "indigo" + }' +``` + +Response: +```json +{ + "status": "success", + "tenant_id": 123, + "tenant_name": "talent1", + "lms_url": "http://talent1.local.openedx.io:8000", + "authn_url": "http://talent1.apps.local.openedx.io:1999/authn", + "learner_dashboard_url": "http://talent1.apps.local.openedx.io:1996/learner-dashboard/", + "message": "Tenant created successfully. Add DNS entries to your hosts file." +} +``` + +### List Tenants + +```python +import requests + +response = requests.get( + 'http://local.openedx.io:8000/api/tenant/v1/list', + headers={'Authorization': 'Bearer YOUR_TOKEN'} +) + +print(response.json()) +``` + +### Delete Tenant + +```python +import requests + +response = requests.delete( + 'http://local.openedx.io:8000/api/tenant/v1/delete/talent1', + headers={'Authorization': 'Bearer YOUR_TOKEN'} +) + +print(response.json()) +``` + +## Testing via Django Shell + +For testing without authentication: + +```bash +docker exec tutor_dev-lms-1 sh -c "cd /openedx/edx-platform && python manage.py lms shell" << 'EOF' +import django +django.setup() + +from django.db import transaction +from django.contrib.auth import get_user_model +from eox_tenant.models import TenantConfig, Route + +User = get_user_model() +admin = User.objects.filter(is_superuser=True).first() + +tenant_name = 'talent1' +platform_name = 'Talent 1 Learning' +theme_name = 'indigo' + +external_key = f'{tenant_name}.local.openedx.io' +lms_domain = f'{tenant_name}.local.openedx.io' + +# Generate MFE config +lms_configs = { + 'LMS_BASE': f'{lms_domain}:8000', + 'SITE_NAME': lms_domain, + 'PLATFORM_NAME': platform_name, + 'THEME_NAME': theme_name, + 'BASE_URL': f'http://{tenant_name}.apps.local.openedx.io', + 'LMS_BASE_URL': f'http://{lms_domain}:8000', + 'LEARNER_HOME_MICROFRONTEND_URL': f'http://{tenant_name}.apps.local.openedx.io:1996/learner-dashboard/', + 'AUTHN_MICROFRONTEND_URL': f'http://{tenant_name}.apps.local.openedx.io:1999/authn', + # ... add other MFE URLs as needed +} + +with transaction.atomic(): + tenant_config = TenantConfig.objects.create( + external_key=external_key, + lms_configs=lms_configs, + theming_configs={'THEME_NAME': theme_name}, + meta={'created_by': admin.username} + ) + Route.objects.create(domain=lms_domain, config=tenant_config) + print(f'Tenant {tenant_name} created!') +EOF +``` + +## Hosts File Configuration + +After creating a tenant, add these entries to your hosts file: + +``` +127.0.0.1 talent1.local.openedx.io +127.0.0.1 talent1.apps.local.openedx.io +127.0.0.1 studio.talent1.local.openedx.io +``` + +## MFE Configuration + +The API automatically generates MFE URLs for the tenant: + +- Authn: `http://{tenant}.apps.local.openedx.io:1999/authn` +- Learner Dashboard: `http://{tenant}.apps.local.openedx.io:1996/learner-dashboard/` +- Account: `http://{tenant}.apps.local.openedx.io:1997/account/` +- Profile: `http://{tenant}.apps.local.openedx.io:1995/profile/u/` +- Learning: `http://{tenant}.apps.local.openedx.io:2000/learning` +- Discussions: `http://{tenant}.apps.local.openedx.io:2002/discussions` +- Gradebook: `http://{tenant}.apps.local.openedx.io:1994/gradebook` +- Communications: `http://{tenant}.apps.local.openedx.io:1984/communications` +- ORA Grading: `http://{tenant}.apps.local.openedx.io:1993/ora-grading` +- Admin Console: `http://{tenant}.apps.local.openedx.io:2025/admin-console` +- Course Authoring: `http://{tenant}.apps.local.openedx.io:2001/authoring` + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ External App │────▶│ Tenant API │────▶│ eox-tenant │ +│ (Your System) │ │ (Django REST) │ │ (TenantConfig) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ TenantProvisioningLog │ + │ (Audit Trail) │ + └──────────────────┘ +``` + +## License + +MIT diff --git a/openedx-tenant-api/__pycache__/test_ednx_settings.cpython-313-pytest-9.0.2.pyc b/openedx-tenant-api/__pycache__/test_ednx_settings.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..58b331f Binary files /dev/null and b/openedx-tenant-api/__pycache__/test_ednx_settings.cpython-313-pytest-9.0.2.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/PKG-INFO b/openedx-tenant-api/openedx_tenant_api.egg-info/PKG-INFO new file mode 100644 index 0000000..7da1f6f --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.1 +Name: openedx-tenant-api +Version: 0.1.0 +Summary: REST API for programmatic tenant creation in Open edX with eox-tenant +Requires-Python: >=3.8 +Requires-Dist: Django>=3.2 +Requires-Dist: djangorestframework>=3.12.0 +Requires-Dist: eox-tenant>=10.0.0 diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/SOURCES.txt b/openedx-tenant-api/openedx_tenant_api.egg-info/SOURCES.txt new file mode 100644 index 0000000..c7cd23d --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +MANIFEST.in +README.md +setup.py +openedx_tenant_api/__init__.py +openedx_tenant_api/apps.py +openedx_tenant_api/models.py +openedx_tenant_api/serializers.py +openedx_tenant_api/urls.py +openedx_tenant_api/views.py +openedx_tenant_api.egg-info/PKG-INFO +openedx_tenant_api.egg-info/SOURCES.txt +openedx_tenant_api.egg-info/dependency_links.txt +openedx_tenant_api.egg-info/entry_points.txt +openedx_tenant_api.egg-info/requires.txt +openedx_tenant_api.egg-info/top_level.txt \ No newline at end of file diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/dependency_links.txt b/openedx-tenant-api/openedx_tenant_api.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/entry_points.txt b/openedx-tenant-api/openedx_tenant_api.egg-info/entry_points.txt new file mode 100644 index 0000000..02c776c --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/entry_points.txt @@ -0,0 +1,5 @@ +[cms.djangoapp] +openedx_tenant_api = openedx_tenant_api.apps:OpenEdxTenantApiConfig + +[lms.djangoapp] +openedx_tenant_api = openedx_tenant_api.apps:OpenEdxTenantApiConfig diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/requires.txt b/openedx-tenant-api/openedx_tenant_api.egg-info/requires.txt new file mode 100644 index 0000000..1bbd0f4 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/requires.txt @@ -0,0 +1,3 @@ +Django>=3.2 +djangorestframework>=3.12.0 +eox-tenant>=10.0.0 diff --git a/openedx-tenant-api/openedx_tenant_api.egg-info/top_level.txt b/openedx-tenant-api/openedx_tenant_api.egg-info/top_level.txt new file mode 100644 index 0000000..f8f1022 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api.egg-info/top_level.txt @@ -0,0 +1 @@ +openedx_tenant_api diff --git a/openedx-tenant-api/openedx_tenant_api/__init__.py b/openedx-tenant-api/openedx_tenant_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/__init__.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..910145e Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/__init__.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/apps.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/apps.cpython-311.pyc new file mode 100644 index 0000000..1cb1890 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/apps.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/cms_oauth2_auth.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/cms_oauth2_auth.cpython-311.pyc new file mode 100644 index 0000000..d4853a0 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/cms_oauth2_auth.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/models.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..07169b0 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/models.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/serializers.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/serializers.cpython-311.pyc new file mode 100644 index 0000000..a305955 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/serializers.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/urls.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000..c62fe3e Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/urls.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/__pycache__/views.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/__pycache__/views.cpython-311.pyc new file mode 100644 index 0000000..b0cad54 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/__pycache__/views.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/apps.py b/openedx-tenant-api/openedx_tenant_api/apps.py new file mode 100644 index 0000000..b24cab7 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/apps.py @@ -0,0 +1,38 @@ +from django.apps import AppConfig + + +class OpenEdxTenantApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'openedx_tenant_api' + verbose_name = 'Open edX Tenant API' + + def ready(self): + # Add URL patterns to LMS urls + self._add_url_patterns() + + def _add_url_patterns(self): + """Add tenant API URL patterns to LMS URLconf.""" + try: + from django.urls import path, include + from django.conf import settings + import lms.urls + + # Check if already added + url_path = path("api/tenant/", include("openedx_tenant_api.urls", namespace="tenant_api")) + + # Add to urlpatterns if not already present + pattern_exists = False + for pattern in lms.urls.urlpatterns: + if hasattr(pattern, 'pattern') and pattern.pattern.describe().startswith("'^api/tenant/'"): + pattern_exists = True + break + + if not pattern_exists: + lms.urls.urlpatterns.append(url_path) + except ImportError: + # LMS URLs not available (e.g., in CMS) + pass + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Could not add tenant API URLs: {e}") diff --git a/openedx-tenant-api/openedx_tenant_api/cms_oauth2_auth.py b/openedx-tenant-api/openedx_tenant_api/cms_oauth2_auth.py new file mode 100644 index 0000000..000d0ac --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/cms_oauth2_auth.py @@ -0,0 +1,103 @@ +""" +OAuth2 Bearer Token Authentication Middleware for CMS. + +The Studio MFE (course-authoring MFE) authenticates API calls to CMS using +OAuth2 Bearer tokens obtained from the LMS during login. + +When the user logs in via the authn MFE: +1. LMS sets a Django session cookie +2. LMS returns an OAuth2 access token +3. The MFE stores the access token in localStorage + +On every API request to CMS, the MFE's Axios sends: + Authorization: Bearer + +This middleware intercepts those requests, validates the Bearer token +against the LMS userinfo endpoint, and attaches the Django user to the request. +""" +import logging + +logger = logging.getLogger(__name__) + + +class CMSOAuth2BearerAuthMiddleware: + """ + Middleware that authenticates CMS API requests using OAuth2 Bearer tokens. + + The Studio MFE (course-authoring MFE) stores an OAuth2 access token + obtained from LMS login in localStorage, and sends it as: + Authorization: Bearer + on every API request to CMS. + + This middleware validates the token against the LMS userinfo endpoint + and attaches the authenticated Django user to the request. + """ + + def __init__(self, get_response): + self.get_response = get_response + self.lms_userinfo_url = "http://lms:8000/oauth2/user_info" + + def __call__(self, request): + # Check for Bearer token in Authorization header + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + logger.debug( + "CMSOAuth2BearerAuth: path=%s auth_header=%r", + request.path, auth_header[:30] if auth_header else None + ) + if auth_header.startswith('Bearer '): + token = auth_header[7:] + if token: + user = self._validate_token_get_user(token) + if user: + # Attach the user to the request + # This makes request.user available for Django's auth checks + request.user = user + # Cache the user to prevent re-evaluation + request._cached_user = user + logger.debug( + "CMSOAuth2BearerAuth: token validated for user=%s (id=%s)", + user.username, user.id + ) + else: + logger.debug("OAuth2 Bearer token validation failed") + else: + # No Bearer token - let session auth handle it + pass + + return self.get_response(request) + + def _validate_token_get_user(self, token): + """Call LMS userinfo endpoint and return Django User.""" + import requests + try: + resp = requests.get( + self.lms_userinfo_url, + headers={'Authorization': f'Bearer {token}'}, + timeout=5, + ) + if resp.status_code != 200: + logger.debug( + "LMS userinfo returned %s for token validation", + resp.status_code + ) + return None + + user_info = resp.json() + username = user_info.get('username') + if not username: + logger.debug("No username in LMS userinfo response") + return None + + # Get the Django User object + from django.contrib.auth import get_user_model + User = get_user_model() + try: + user = User.objects.get(username=username) + logger.debug("Found Django user: %s (id=%s)", username, user.id) + return user + except User.DoesNotExist: + logger.debug("Django user not found: %s", username) + return None + except Exception as e: + logger.debug("Error validating OAuth2 token: %s", e) + return None diff --git a/openedx-tenant-api/openedx_tenant_api/migrations/0001_initial.py b/openedx-tenant-api/openedx_tenant_api/migrations/0001_initial.py new file mode 100644 index 0000000..a1b4810 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.11 on 2026-03-12 04:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TenantProvisioningLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tenant_name', models.CharField(db_index=True, max_length=255)), + ('external_key', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)), + ('error_message', models.TextField(blank=True)), + ('tenant_config_id', models.IntegerField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['tenant_name', 'created_at'], name='openedx_ten_tenant__4f322f_idx')], + }, + ), + ] diff --git a/openedx-tenant-api/openedx_tenant_api/migrations/__init__.py b/openedx-tenant-api/openedx_tenant_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/0001_initial.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..3977326 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/__init__.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..59d0b50 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/models.py b/openedx-tenant-api/openedx_tenant_api/models.py new file mode 100644 index 0000000..04cc3ae --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/models.py @@ -0,0 +1,25 @@ +from django.db import models +from django.contrib.auth.models import User + + +class TenantProvisioningLog(models.Model): + """Audit log for tenant creation via API.""" + tenant_name = models.CharField(max_length=255, db_index=True) + external_key = models.CharField(max_length=255) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=50, choices=[ + ('success', 'Success'), + ('failed', 'Failed'), + ]) + error_message = models.TextField(blank=True) + tenant_config_id = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['tenant_name', 'created_at']), + ] + + def __str__(self): + return f"{self.tenant_name} - {self.status} - {self.created_at}" diff --git a/openedx-tenant-api/openedx_tenant_api/serializers.py b/openedx-tenant-api/openedx_tenant_api/serializers.py new file mode 100644 index 0000000..6b74fa8 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/serializers.py @@ -0,0 +1,112 @@ +from rest_framework import serializers +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 eox_tenant.models import TenantConfig + +User = get_user_model() + + +class CreateTenantSerializer(serializers.Serializer): + """Serializer for tenant creation request.""" + tenant_name = serializers.RegexField( + regex=r'^[a-z0-9-]+$', + max_length=63, + help_text="Tenant name (lowercase alphanumeric and hyphens only)" + ) + platform_name = serializers.CharField(max_length=255, required=False) + theme_name = serializers.CharField(max_length=255, required=False, default='indigo') + org_filter = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="List of course organizations for this tenant" + ) + + # EDNX settings (all optional with sensible defaults) + ednx_tenant_restrict_users = serializers.BooleanField( + required=False, + default=True, + help_text="Restrict users to their assigned tenant (EDNX_TENANT_RESTRICT_USERS)" + ) + ednx_tenant_user_filter_enabled = serializers.BooleanField( + required=False, + default=True, + help_text="Enable user filtering by tenant (EDNX_TENANT_USER_FILTER_ENABLED)" + ) + ednx_use_signal = serializers.BooleanField( + required=False, + default=True, + help_text="Use signals for tenant context (EDNX_USE_SIGNAL)" + ) + ednx_account_registration_sources = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Allowed account registration sources (auto-generated from tenant domain if not provided)" + ) + + def validate_tenant_name(self, value): + """Ensure tenant name doesn't use reserved words.""" + reserved = ['local', 'studio', 'apps', 'meilisearch', 'www', 'api', 'admin'] + if value in reserved: + raise serializers.ValidationError(f"'{value}' is a reserved name") + return value + + +class TenantResponseSerializer(serializers.Serializer): + """Serializer for tenant creation response.""" + status = serializers.CharField() + tenant_id = serializers.IntegerField() + tenant_name = serializers.CharField() + lms_url = serializers.CharField() + authn_url = serializers.CharField() + learner_dashboard_url = serializers.CharField() + + +class TenantAdminCreateSerializer(serializers.Serializer): + """Serializer for tenant admin creation request.""" + tenant_name = serializers.CharField( + max_length=63, + help_text="Tenant name (must exist)" + ) + username = serializers.CharField( + max_length=150, + help_text="Username for the admin user" + ) + email = serializers.EmailField( + help_text="Email address for the admin user" + ) + password = serializers.CharField( + write_only=True, + help_text="Password for the admin user" + ) + org_name = serializers.CharField( + max_length=255, + help_text="Organization name for role assignment" + ) + + def validate_tenant_name(self, value): + """Validate that tenant exists.""" + external_key = f"{value}.local.openedx.io" + if not TenantConfig.objects.filter(external_key=external_key).exists(): + raise serializers.ValidationError("Tenant does not exist") + return value + + def validate_username(self, value): + """Validate username is unique.""" + if User.objects.filter(username=value).exists(): + raise serializers.ValidationError("Username already exists") + return value + + def validate_email(self, value): + """Validate email is unique.""" + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError("Email already exists") + return value + + def validate_password(self, value): + """Validate password meets Django security requirements.""" + try: + validate_password(value) + except DjangoValidationError as e: + raise serializers.ValidationError(list(e.messages)) + return value diff --git a/openedx-tenant-api/openedx_tenant_api/settings/__init__.py b/openedx-tenant-api/openedx_tenant_api/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openedx-tenant-api/openedx_tenant_api/settings/__pycache__/__init__.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/settings/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..35d2cef Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/settings/__pycache__/__init__.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/__init__.py b/openedx-tenant-api/openedx_tenant_api/settings/lms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/__init__.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8f35f04 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/__init__.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/patch_mfe_config.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/patch_mfe_config.cpython-311.pyc new file mode 100644 index 0000000..8d663ac Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/patch_mfe_config.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/tenant_mfe_middleware.cpython-311.pyc b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/tenant_mfe_middleware.cpython-311.pyc new file mode 100644 index 0000000..f0f7f58 Binary files /dev/null and b/openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/tenant_mfe_middleware.cpython-311.pyc differ diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/mfe_dynamic_middleware.py b/openedx-tenant-api/openedx_tenant_api/settings/lms/mfe_dynamic_middleware.py new file mode 100644 index 0000000..0bfdba9 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/settings/lms/mfe_dynamic_middleware.py @@ -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: .apps.local.openedx.io:PORT (MFE) + match = self.tenant_mfe_pattern.match(host) + if match: + return match.group(1) + + # Pattern: .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) diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/patch_mfe_config.py b/openedx-tenant-api/openedx_tenant_api/settings/lms/patch_mfe_config.py new file mode 100644 index 0000000..417e9cf --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/settings/lms/patch_mfe_config.py @@ -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 diff --git a/openedx-tenant-api/openedx_tenant_api/settings/lms/tenant_mfe_middleware.py b/openedx-tenant-api/openedx_tenant_api/settings/lms/tenant_mfe_middleware.py new file mode 100644 index 0000000..9da6de2 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/settings/lms/tenant_mfe_middleware.py @@ -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) diff --git a/openedx-tenant-api/openedx_tenant_api/urls.py b/openedx-tenant-api/openedx_tenant_api/urls.py new file mode 100644 index 0000000..8fb4a66 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = 'openedx_tenant_api' + +urlpatterns = [ + # Tenant management + path('v1/create', views.create_tenant, name='create_tenant'), + path('v1/list', views.list_tenants, name='list_tenants'), + path('v1/delete/', views.delete_tenant, name='delete_tenant'), + + # Tenant admin management + path('v1/admin/create', views.create_tenant_admin, name='create_tenant_admin'), + + # Health check (no auth) + path('v1/health', views.health_check, name='health_check'), +] diff --git a/openedx-tenant-api/openedx_tenant_api/views.py b/openedx-tenant-api/openedx_tenant_api/views.py new file mode 100644 index 0000000..bcbe5d1 --- /dev/null +++ b/openedx-tenant-api/openedx_tenant_api/views.py @@ -0,0 +1,520 @@ +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) diff --git a/openedx-tenant-api/setup.py b/openedx-tenant-api/setup.py new file mode 100644 index 0000000..e617e44 --- /dev/null +++ b/openedx-tenant-api/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup, find_packages + +setup( + name='openedx-tenant-api', + version='0.1.0', + description='REST API for programmatic tenant creation in Open edX with eox-tenant', + packages=find_packages(), + include_package_data=True, + install_requires=[ + 'Django>=3.2', + 'djangorestframework>=3.12.0', + 'eox-tenant>=10.0.0', + ], + python_requires='>=3.8', + entry_points={ + 'lms.djangoapp': [ + 'openedx_tenant_api = openedx_tenant_api.apps:OpenEdxTenantApiConfig', + ], + 'cms.djangoapp': [ + 'openedx_tenant_api = openedx_tenant_api.apps:OpenEdxTenantApiConfig', + ], + }, +) diff --git a/openedx-tenant-api/test_e2e/.claude/commands/opsx/apply.md b/openedx-tenant-api/test_e2e/.claude/commands/opsx/apply.md new file mode 100644 index 0000000..bf23721 --- /dev/null +++ b/openedx-tenant-api/test_e2e/.claude/commands/opsx/apply.md @@ -0,0 +1,152 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - Context file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read the files listed in `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.