Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Unreleased
----------
* nothing unreleased

[8.7.3] - 2026-07-27
---------------------
* feat: add multiple SSO tenants during devstack provisioning
Comment thread
pwnage101 marked this conversation as resolved.

* The real reason for the version bump: Added ``assign_system_wide_enterprise_role`` management command and updated other ones.

[8.7.2] - 2026-07-27
---------------------
* chore: upgrade python requirements
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ services:
env_file: keycloak-devstack.env
volumes:
- ./keycloak-devstack.properties:/opt/keycloak-config-cli.properties:ro
- ./keycloak-devstack-realm.json:/config/keycloak-devstack-realm.json:ro
- ./keycloak-realms:/config:ro
command: --spring.config.additional-location=/opt/keycloak-config-cli.properties
networks:
- devstack_default
Expand Down
59 changes: 38 additions & 21 deletions docs/saml_testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,29 @@ Provisioning configures **both** Keycloak and the LMS in a single step:

Under the hood this runs two commands:

1. ``keycloak-config-cli`` imports the realm definition
(``keycloak-devstack-realm.json``) into Keycloak, creating a ``devstack``
realm with a SAML client and a test user.
2. ``provision-tpa.py`` runs inside the LMS container to create the matching
``SAMLConfiguration``, ``SAMLProviderConfig``, ``EnterpriseCustomer`` link,
and a pre-linked LMS learner account.

All shared configuration values (URLs, entity IDs, OIDs, test credentials) live
in ``keycloak-devstack.env`` so the two sides stay in sync.
1. ``keycloak-config-cli`` imports every realm definition in
``keycloak-realms/`` into Keycloak. Each file is one tenant realm (currently
``gryffindor`` and ``slytherin``), each with a SAML client and two test users.
2. ``provision-tpa.py`` runs inside the LMS container and, for each tenant,
creates the matching ``SAMLProviderConfig``, the ``EnterpriseCustomer`` link,
branding (logo + colors), and a login-flow LMS learner account. A single
shared ``SAMLConfiguration`` (the LMS service-provider config) is created once.

A "tenant" is one Keycloak realm plus one enterprise customer. The realm name,
the SAML slug, the ``provider_id`` (``saml-<name>``), and the enterprise slug are
all the same arbitrary token (e.g. ``gryffindor``), so one memorable name
identifies everything about the tenant. Adding a tenant means dropping a new
``keycloak-realms/<name>.json`` and adding a matching entry to the ``TENANTS``
list in ``provision-tpa.py``.

Shared configuration (the Keycloak URL, the LMS entity ID, the ACS URL, and the
attribute OIDs) plus each tenant's SSO usernames live in
``keycloak-devstack.env``. The usernames are the single source of truth: the
realm JSON substitutes them via ``$(env:...)`` and ``provision-tpa.py`` reads the
same variables, so a username is defined in exactly one place.

The examples below use the ``gryffindor`` tenant; ``slytherin`` behaves
identically -- substitute its name to test tenant isolation.

Host setup
----------
Expand All @@ -71,25 +85,28 @@ Testing the SAML login flow

1. Navigate to the SAML login URL:

``http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=keycloak-devstack``
``http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=gryffindor``

2. You should be redirected to the Keycloak login page at
``http://edx.devstack.keycloak:8080/realms/devstack/...``.
``http://edx.devstack.keycloak:8080/realms/gryffindor/...``.

3. Log in with the test credentials:

========= =========================
Username ``keycloak_learner``
========= =============================
Username ``gryffindor_learner``
Password ``testpass``
========= =========================
========= =============================

4. Validate that you were **not** prompted to log into the existing LMS user.
The ``enterprise_associate_by_email`` pipeline step should discover that the
pre-provisioned LMS learner is already associated with the SAML-enabled
enterprise customer, so LMS authentication is skipped.

5. Validate that you have been redirected to the LMS learner dashboard and are
logged in as ``keycloak_test_learner``.
logged in as ``gryffindor_learner``. The ``enterprise_associate_by_email``
step matches the SSO identity to the LMS account by **email**
(``gryffindor_learner@example.com``); the usernames happening to match here is
incidental -- association never uses the username.

Testing the SAML disconnect flow
--------------------------------
Expand All @@ -108,12 +125,12 @@ Triggering the disconnect via the Account MFE

http://localhost:1997/#linked-accounts

3. Find the Keycloak Devstack IdP entry (matches
SAMLProviderConfig.name) and click **Unlink Keycloak Devstack IdP
3. Find the Gryffindor IdP entry (matches
SAMLProviderConfig.name) and click **Unlink Gryffindor IdP
account**.

4. The button should settle into the "unconnected" state with a "Sign in with
Keycloak Devstack IdP" link. indicating the MFE received a successful
Gryffindor IdP" link. indicating the MFE received a successful
disconnect response.

Verifying the disconnect
Expand All @@ -129,7 +146,7 @@ Verifying the disconnect

[THIRD_PARTY_AUTH] Emitting SAMLAccountDisconnected signal for user_id=<id>, backend=tpa-saml
[ENTERPRISE] _unlink_enterprise_user_from_idp called for user_id=<id>, backend=tpa-saml
Enterprise learner {keycloak_learner@example.com} successfully unlinked from Enterprise Customer {<name>}
Enterprise learner {gryffindor_learner@example.com} successfully unlinked from Enterprise Customer {<name>}

Resetting state to repeat the test
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -142,10 +159,10 @@ The simplest reset is to re-run provisioning:

Then navigate to the SAML login URL again to re-link:

http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=keycloak-devstack
http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=gryffindor

Note: re-running provisioning is necessary because when you clicked the
**Unlink Keycloak Devstack IdP account** button, the SAML disconnect handler
**Unlink Gryffindor IdP account** button, the SAML disconnect handler
did more than just disconnect from the IdP, it also unlinked the
EnterpriseCustomerUser. This is only recoverable by an admin or system
operator, hence the need to use the provision script. Yes, that means in prod
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "8.7.2"
__version__ = "8.7.3"
10 changes: 10 additions & 0 deletions enterprise/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ class CourseModes:
SYSTEM_ENTERPRISE_CATALOG_ADMIN_ROLE = 'enterprise_catalog_admin'
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE = 'enterprise_provisioning_admin'

# All recognised system-wide enterprise role names, used to validate role
# assignment requests.
SYSTEM_WIDE_ENTERPRISE_ROLES = frozenset({
ENTERPRISE_LEARNER_ROLE,
ENTERPRISE_ADMIN_ROLE,
ENTERPRISE_OPERATOR_ROLE,
SYSTEM_ENTERPRISE_CATALOG_ADMIN_ROLE,
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE,
})

ENTERPRISE_DASHBOARD_ADMIN_ROLE = 'dashboard_admin'
ENTERPRISE_CATALOG_ADMIN_ROLE = 'catalog_admin'
ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE = 'enrollment_api_admin'
Expand Down
Loading