From 2b7027e37d975557ab0ce64aeb2b39d35533e057 Mon Sep 17 00:00:00 2001 From: DamarKusumo Date: Fri, 10 Apr 2026 08:20:57 +0700 Subject: [PATCH] Add openedx-tenant-api plugin Co-Authored-By: Claude Opus 4.6 --- openedx-tenant-api/MANIFEST.in | 1 + openedx-tenant-api/README.md | 236 ++++++ ...ednx_settings.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 20094 bytes .../openedx_tenant_api.egg-info/PKG-INFO | 8 + .../openedx_tenant_api.egg-info/SOURCES.txt | 15 + .../dependency_links.txt | 1 + .../entry_points.txt | 5 + .../openedx_tenant_api.egg-info/requires.txt | 3 + .../openedx_tenant_api.egg-info/top_level.txt | 1 + .../openedx_tenant_api/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 163 bytes .../__pycache__/apps.cpython-311.pyc | Bin 0 -> 2327 bytes .../cms_oauth2_auth.cpython-311.pyc | Bin 0 -> 4837 bytes .../__pycache__/models.cpython-311.pyc | Bin 0 -> 1986 bytes .../__pycache__/serializers.cpython-311.pyc | Bin 0 -> 6239 bytes .../__pycache__/urls.cpython-311.pyc | Bin 0 -> 816 bytes .../__pycache__/views.cpython-311.pyc | Bin 0 -> 20375 bytes openedx-tenant-api/openedx_tenant_api/apps.py | 38 + .../openedx_tenant_api/cms_oauth2_auth.py | 103 +++ .../migrations/0001_initial.py | 34 + .../openedx_tenant_api/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 2112 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 174 bytes .../openedx_tenant_api/models.py | 25 + .../openedx_tenant_api/serializers.py | 112 +++ .../openedx_tenant_api/settings/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 170 bytes .../settings/lms/__init__.py | 0 .../lms/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 174 bytes .../patch_mfe_config.cpython-311.pyc | Bin 0 -> 7153 bytes .../tenant_mfe_middleware.cpython-311.pyc | Bin 0 -> 13674 bytes .../settings/lms/mfe_dynamic_middleware.py | 149 ++++ .../settings/lms/patch_mfe_config.py | 156 ++++ .../settings/lms/tenant_mfe_middleware.py | 171 ++++ openedx-tenant-api/openedx_tenant_api/urls.py | 17 + .../openedx_tenant_api/views.py | 520 ++++++++++++ openedx-tenant-api/setup.py | 23 + .../test_e2e/.claude/commands/opsx/apply.md | 152 ++++ .../test_e2e/.claude/commands/opsx/archive.md | 157 ++++ .../.claude/commands/opsx/bulk-archive.md | 242 ++++++ .../.claude/commands/opsx/continue.md | 114 +++ .../test_e2e/.claude/commands/opsx/explore.md | 174 ++++ .../test_e2e/.claude/commands/opsx/ff.md | 94 +++ .../test_e2e/.claude/commands/opsx/new.md | 69 ++ .../test_e2e/.claude/commands/opsx/onboard.md | 525 ++++++++++++ .../test_e2e/.claude/commands/opsx/sync.md | 134 ++++ .../test_e2e/.claude/commands/opsx/verify.md | 164 ++++ .../skills/openspec-apply-change/SKILL.md | 156 ++++ .../skills/openspec-archive-change/SKILL.md | 114 +++ .../openspec-bulk-archive-change/SKILL.md | 246 ++++++ .../skills/openspec-continue-change/SKILL.md | 118 +++ .../.claude/skills/openspec-explore/SKILL.md | 290 +++++++ .../skills/openspec-ff-change/SKILL.md | 101 +++ .../skills/openspec-new-change/SKILL.md | 74 ++ .../.claude/skills/openspec-onboard/SKILL.md | 529 ++++++++++++ .../skills/openspec-sync-specs/SKILL.md | 138 ++++ .../skills/openspec-verify-change/SKILL.md | 168 ++++ openedx-tenant-api/test_e2e/.gitignore | 9 + .../test_e2e/.quint/agents/.gitkeep | 0 openedx-tenant-api/test_e2e/.quint/context.md | 89 +++ .../test_e2e/.quint/decisions/.gitkeep | 0 ...t-assertion-gap-in-course-creation-step.md | 25 + ...st-save-redirect-view-live-verification.md | 25 + .../test_e2e/.quint/evidence/.gitkeep | 0 ...-h0-1-test-assertion-gap-false-positive.md | 16 + ...-h0-1-test-assertion-gap-false-positive.md | 13 + ...ne-assertions-in-existing-test-function.md | 13 + ...object-method-for-combined-verification.md | 13 + ...ne-assertions-in-existing-test-function.md | 13 + ...object-method-for-combined-verification.md | 13 + .../test_e2e/.quint/knowledge/L0/.gitkeep | 0 ...ateral-separate-dedicated-test-function.md | 12 + .../test_e2e/.quint/knowledge/L1/.gitkeep | 0 .../test_e2e/.quint/knowledge/L2/.gitkeep | 0 .../h0-1-test-assertion-gap-false-positive.md | 12 + ...ne-assertions-in-existing-test-function.md | 12 + ...object-method-for-combined-verification.md | 12 + .../.quint/knowledge/invalid/.gitkeep | 0 ...ourse-creation-backend-failure-real-bug.md | 12 + ...h0-3-environment-or-org-filter-mismatch.md | 12 + openedx-tenant-api/test_e2e/.quint/quint.db | Bin 0 -> 118784 bytes .../test_e2e/.quint/sessions/.gitkeep | 0 openedx-tenant-api/test_e2e/.quint/state.json | 8 + openedx-tenant-api/test_e2e/__init__.py | 1 + openedx-tenant-api/test_e2e/api_client.py | 288 +++++++ openedx-tenant-api/test_e2e/cleanup.py | 574 +++++++++++++ openedx-tenant-api/test_e2e/conftest.py | 435 ++++++++++ openedx-tenant-api/test_e2e/cookies.txt | 4 + openedx-tenant-api/test_e2e/cors_monitor.py | 101 +++ .../test_e2e/cors_multitenant.py | 77 ++ .../.openspec.yaml | 2 + .../design.md | 46 ++ .../evidence/integration-tests.log | 33 + .../evidence/verification_result.json | 57 ++ .../evidence/verify.log | 60 ++ .../proposal.md | 28 + .../specs/course-creation-assertions/spec.md | 50 ++ .../tasks.md | 20 + .../.openspec.yaml | 2 + .../design.md | 57 ++ .../evidence/integration-tests.log | 23 + .../evidence/verification_result.json | 45 ++ .../evidence/verify.log | 57 ++ .../proposal.md | 27 + .../spec.md | 37 + .../tasks.md | 36 + .../test_e2e/openspec/config.yaml | 20 + .../test_e2e/report_generator.py | 240 ++++++ .../test_e2e/requirements-test.txt | 6 + openedx-tenant-api/test_e2e/studio_page.py | 604 ++++++++++++++ .../test_e2e/test_tenant_e2e.py | 751 ++++++++++++++++++ openedx-tenant-api/test_ednx_settings.py | 203 +++++ 112 files changed, 9570 insertions(+) create mode 100644 openedx-tenant-api/MANIFEST.in create mode 100644 openedx-tenant-api/README.md create mode 100644 openedx-tenant-api/__pycache__/test_ednx_settings.cpython-313-pytest-9.0.2.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/PKG-INFO create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/SOURCES.txt create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/dependency_links.txt create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/entry_points.txt create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/requires.txt create mode 100644 openedx-tenant-api/openedx_tenant_api.egg-info/top_level.txt create mode 100644 openedx-tenant-api/openedx_tenant_api/__init__.py create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/__init__.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/apps.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/cms_oauth2_auth.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/models.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/serializers.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/urls.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/__pycache__/views.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/apps.py create mode 100644 openedx-tenant-api/openedx_tenant_api/cms_oauth2_auth.py create mode 100644 openedx-tenant-api/openedx_tenant_api/migrations/0001_initial.py create mode 100644 openedx-tenant-api/openedx_tenant_api/migrations/__init__.py create mode 100644 openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/migrations/__pycache__/__init__.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/models.py create mode 100644 openedx-tenant-api/openedx_tenant_api/serializers.py create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/__init__.py create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/__pycache__/__init__.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/__init__.py create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/__init__.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/patch_mfe_config.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/__pycache__/tenant_mfe_middleware.cpython-311.pyc create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/mfe_dynamic_middleware.py create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/patch_mfe_config.py create mode 100644 openedx-tenant-api/openedx_tenant_api/settings/lms/tenant_mfe_middleware.py create mode 100644 openedx-tenant-api/openedx_tenant_api/urls.py create mode 100644 openedx-tenant-api/openedx_tenant_api/views.py create mode 100644 openedx-tenant-api/setup.py create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/apply.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/archive.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/bulk-archive.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/continue.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/explore.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/ff.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/new.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/onboard.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/sync.md create mode 100644 openedx-tenant-api/test_e2e/.claude/commands/opsx/verify.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-apply-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-archive-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-bulk-archive-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-continue-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-explore/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-ff-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-new-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-onboard/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-sync-specs/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.claude/skills/openspec-verify-change/SKILL.md create mode 100644 openedx-tenant-api/test_e2e/.gitignore create mode 100644 openedx-tenant-api/test_e2e/.quint/agents/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/context.md create mode 100644 openedx-tenant-api/test_e2e/.quint/decisions/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/decisions/DRR-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step.md create mode 100644 openedx-tenant-api/test_e2e/.quint/decisions/DRR-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-30-audit_report-h0-1-test-assertion-gap-false-positive.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-30-internal-h0-1-test-assertion-gap-false-positive.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-31-audit_report-h1-naive-inline-assertions-in-existing-test-function.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-31-audit_report-h2-standard-page-object-method-for-combined-verification.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-31-internal-h1-naive-inline-assertions-in-existing-test-function.md create mode 100644 openedx-tenant-api/test_e2e/.quint/evidence/2026-03-31-internal-h2-standard-page-object-method-for-combined-verification.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L0/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L0/h3-lateral-separate-dedicated-test-function.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L1/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L2/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L2/h0-1-test-assertion-gap-false-positive.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L2/h1-naive-inline-assertions-in-existing-test-function.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/L2/h2-standard-page-object-method-for-combined-verification.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/invalid/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/invalid/h0-2-course-creation-backend-failure-real-bug.md create mode 100644 openedx-tenant-api/test_e2e/.quint/knowledge/invalid/h0-3-environment-or-org-filter-mismatch.md create mode 100644 openedx-tenant-api/test_e2e/.quint/quint.db create mode 100644 openedx-tenant-api/test_e2e/.quint/sessions/.gitkeep create mode 100644 openedx-tenant-api/test_e2e/.quint/state.json create mode 100644 openedx-tenant-api/test_e2e/__init__.py create mode 100644 openedx-tenant-api/test_e2e/api_client.py create mode 100644 openedx-tenant-api/test_e2e/cleanup.py create mode 100644 openedx-tenant-api/test_e2e/conftest.py create mode 100644 openedx-tenant-api/test_e2e/cookies.txt create mode 100644 openedx-tenant-api/test_e2e/cors_monitor.py create mode 100644 openedx-tenant-api/test_e2e/cors_multitenant.py create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/.openspec.yaml create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/design.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/evidence/integration-tests.log create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/evidence/verification_result.json create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/evidence/verify.log create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/proposal.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/specs/course-creation-assertions/spec.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/archive/2026-03-30-drr-2026-03-30-fix-e2e-test-assertion-gap-in-course-creation-step/tasks.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/.openspec.yaml create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/design.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/evidence/integration-tests.log create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/evidence/verification_result.json create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/evidence/verify.log create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/proposal.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/specs/course-redirect-viewlive-verification/spec.md create mode 100644 openedx-tenant-api/test_e2e/openspec/changes/drr-2026-03-31-e2e-test-enhancement-post-save-redirect-view-live-verification/tasks.md create mode 100644 openedx-tenant-api/test_e2e/openspec/config.yaml create mode 100644 openedx-tenant-api/test_e2e/report_generator.py create mode 100644 openedx-tenant-api/test_e2e/requirements-test.txt create mode 100644 openedx-tenant-api/test_e2e/studio_page.py create mode 100644 openedx-tenant-api/test_e2e/test_tenant_e2e.py create mode 100644 openedx-tenant-api/test_ednx_settings.py 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 0000000000000000000000000000000000000000..58b331fed0031c5c42aa6e3dc97d0b3dc6d3b609 GIT binary patch literal 20094 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.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 0000000000000000000000000000000000000000..910145e7aec3bc847fd5f5acd47d687ef373df01 GIT binary patch literal 163 zcmZ3^%ge<81aGZ3WrFC(AOZ#$p^VRLK*n^26oz01O-8?!3`I;p{%4TnFEjnzyb}HV zg4Dd!lnULF)V##J65YgtOqftSL?|946d#|Nmst`YuUAm{i^C>2KczG$)vkyYXbi}r VVtyd;ftit!@dE>lC}IYR0RXw-Cl>$! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1cb18900c86c732bb914b157a7595f4adfc8d317 GIT binary patch literal 2327 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 GNd5=zNZe}x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07169b012fa5d07d43158bd2b10cd863ab935725 GIT binary patch literal 1986 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b0cad5473810997fe7266faaee2cdbe6277c1444 GIT binary patch literal 20375 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 + +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 0000000000000000000000000000000000000000..3977326336f567471d26c22de2fc52b52c1c12c6 GIT binary patch literal 2112 zcmZuyL2MI86y06h>$T$qJ0x*KLY$<8#vp=8D59bYm?Wl@grFw$U{zYJGm~YFy=!LI zAxSSe^w1->=D;DRv=u&7J>}2?M~-7@57wNJDplfU1c!=C`)Ai10%6uO`~ROm|3CkE zGy7|IcU*w>&&@xLT0{{3=1R~&_u89#@cKnS0un1iQG~Ztk*c9$NaVIqC0vaZBUQO5 zi-IKF5>WUj0Yz}69PO+Z_Ctd347%TnQ6#PiS@{)L?`B1&*w}FlQ@67ci&qVuXpUi- zHj}Fs!WDb1v(9^+=qPXg6*(sC;VDW;EQTZjhsyzG0n117gYPrwe$&OG;`zn`3z86D z9WQqsT0|tGFp4zf{V=SB?zeRbMLo|%U;#g+5!;u*3;Ll~{39==(Y5~p3W${Oe7b{G zNNjW;^Xd60&Iy#%C6sFP9?N*r^Xxeit?yX0e$OYUA?zpQ95@y!?Rh1GNH|sQ4dk-N z7Os^~9@_c>>1fRxpM4xxSJ0p?da?V16)1`l@PAGuvC#$S~6`Pvr+#}Y8VFPo3N{g1xBm)(gbbCG z7G#Q9rV1wvJD5q1wcT~$J!Upy)v$@trV0UhhXSkW<@K5A>2-*@>ESeR1=$c8$_2V& z5d^g~br$t%gzapIoQ2$Ef&++JHSbv8&8C^6dSz8r7FSg`<@E{%IiaeL>RN?|0}X;0 z4(tr!hmZBykF{D&D}f`zfDB=CZHw?bg`LRXU;ReCzq0V1y0kpMu*edJ9tO5q*POTQ zOZ+Y&{CZ%Cn}(hTLKHZD7MpvZ5nt?{2Y##?Rot0bg^l&a#BI~TI_~(z3l_nKZr%a@ z$f&HDy2T_^0($Ub0s~oeF26FDpI=~++df^Onw@Lyci|0y5Z*0ZteVcnPL(hC6~3U= zjKCBWotxfYiWhUa+$G?kSDLBX7U7qyHz*+34%{(2htJEN-kn=*S%^*k9Cwv3epB4Z z8%-teD*1Lu>XUaC+i@Y5;d3Lc)a1)lKTV%|vf-xB9fV(;p+%LNgr@Cg+IG`6O**aA z2p!Gs$?jX=m&hK7vhtAV4@6aWUUM^AV5gj7UA>s}ZnzUP~Q93@o zch()BKIo(4v(52YcYKy6ueVa4(}}Bl-@6l64;(-`vD}lIY~;;_lh gPbtkwwJTx;ng+73m>)=dU}j`w{J;PsikN|704%a9ApigX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8f35f04658d747301d85c9d422b09de197202b8d GIT binary patch literal 174 zcmZ3^%ge<81QVMNXM*U*AOZ#$p^VRLK*n^26oz01O-8?!3`I;p{%4TnFGv0Sg4Dd! zlnQ+y)-A|MEJ@2R%7qEWm!#$;=9R=J7G&xdrlIY~ k;;_lhPbtkwwJTx;nh3J9m>)=dU}j`w{J;PsikN|70Qwgzpa1{> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8d663ac846ff37c9f5c99efdf3af81c858de17e2 GIT binary patch literal 7153 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 Dfkdh9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f0f7f58b203afae11dd1e24e7ed40383fe253253 GIT binary patch literal 13674 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 literal 0 HcmV?d00001 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.