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
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Unreleased
----------
* nothing unreleased

[8.8.0] - 2026-08-07
---------------------
* feat: add pipeline steps for logistration filters

[8.7.3] - 2026-07-27
---------------------
* feat: add multiple SSO tenants during devstack provisioning
Expand Down
11 changes: 9 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,15 @@
'sphinx.ext.ifconfig',
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinxcontrib.mermaid',
'myst_parser',
]

# Render fenced ```mermaid code blocks in Markdown (MyST) sources through the
# sphinxcontrib.mermaid directive, so the same fences render both on GitHub and
# in the Sphinx-built docs.
myst_fence_as_directive = ["mermaid"]

import enterprise

# Configure Django for autodoc usage
Expand Down Expand Up @@ -65,8 +72,8 @@
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# Markdown sources (e.g. docs/decisions/*.md) are parsed by myst_parser.
source_suffix = ['.rst', '.md']

# The encoding of source files.
#
Expand Down
138 changes: 138 additions & 0 deletions docs/decisions/0016-logistration-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 0016. Centralize enterprise logistration logic behind openedx-filters

## Status

Accepted

## Context

The `user_authn` module in openedx-platform
(`openedx/core/djangoapps/user_authn/`) includes several direct imports of
enterprise functions from `enterprise_support` and `enterprise` to customize
login and registration behavior for enterprise learners. The
enterprise-specific business logic includes:

- Enriching the legacy logistration page context and the authentication MFE
context with enterprise customer branding and sidebar data.
- Setting/deleting enterprise cookies on the rendered logistration response.
- Pre-filling and locking the email field on the login form during an enterprise
SSO (third-party auth) pipeline.
- Hiding provider-prefilled registration fields when an enterprise SSO provider
skips the registration form.
- Redirecting learners linked to multiple enterprise customers to the enterprise
selection page after login.

These imports make edx-enterprise a hard dependency of core authentication code.
As part of the broader initiative to convert edx-enterprise into an optional
Open edX plugin, all `enterprise` / `enterprise_support` imports must be removed
from openedx-platform and replaced with generic hooks whose enterprise-specific
implementations live in this repository (edx-enterprise).

## Decision

We will replace the enterprise-specific logic in `user_authn` with six new
enterprise-agnostic openedx filters, and reimplement the enterprise behavior as
filter pipeline steps within edx-enterprise:

| openedx-filter (triggered by platform) | edx-enterprise pipeline step | Business Logic |
| ---------------------------------------- | ------------------------------------------- | --------------------------------------------------------- |
| `LogistrationViewContextGenerated` | `LogistrationViewEnterpriseContextEnricher` | add enterprise branding, sidebar, slug login URL (legacy) |
| `AuthnMFEContextGenerated` | `AuthnMFEEnterpriseContextEnricher` | add enterprise branding to the authentication MFE context |
| `LogistrationViewRenderCompleted` | `LogistrationViewEnterpriseCookieSetter` | set experiment cookie, delete enterprise customer cookie |
| `LoginFormGenerated` | `LoginFormEnterpriseOverrides` | pre-fill and lock email from SSO identity |
| `RegistrationFormGenerated` | `RegistrationFormEnterpriseOverrides` | hide prefilled fields when provider skips registration |
| `LoginAltRedirectURLRequested` | `PostLoginEnterpriseRedirect` | send multi-enterprise learners to selection page |

These six filters live in the new `authentication` architecture subdomain in openedx-filters.

### Logistration flow (high level)

Each block is a page, an endpoint, or a filter paired with the enterprise
pipeline step that implements it (shaded).

```mermaid
%%{init: {"flowchart": {"rankSpacing": 30}}}%%
flowchart TD
classDef ent fill:#d4e6f9,stroke:#1f6feb,color:#0a3069

LogistrationViewContextGenerated["<b>LogistrationViewContextGenerated</b><br/>→&nbsp;<b>LogistrationViewEnterpriseContextEnricher</b><br/><i>add enterprise branding, sidebar, slug login URL</i>"]:::ent
AuthnMFEContextGenerated["<b>AuthnMFEContextGenerated</b><br/>→&nbsp;<b>AuthnMFEEnterpriseContextEnricher</b><br/><i>add enterprise branding to the authn MFE context</i>"]:::ent
LogistrationViewRenderCompleted["<b>LogistrationViewRenderCompleted</b><br/>→&nbsp;<b>LogistrationViewEnterpriseCookieSetter</b><br/><i>set experiment cookie, delete enterprise customer cookie</i>"]:::ent
LoginFormGenerated["<b>LoginFormGenerated</b><br/>→&nbsp;<b>LoginFormEnterpriseOverrides</b><br/><i>pre-fill and lock email from SSO identity</i>"]:::ent
RegistrationFormGenerated["<b>RegistrationFormGenerated</b><br/>→&nbsp;<b>RegistrationFormEnterpriseOverrides</b><br/><i>hide prefilled fields when provider skips registration</i>"]:::ent
LoginAltRedirectURLRequested["<b>LoginAltRedirectURLRequested</b><br/>→&nbsp;<b>PostLoginEnterpriseRedirect</b><br/><i>send multi-enterprise learners to selection page</i>"]:::ent

LP["/login and /register routes<br/>"]
MFE["Authn MFE page<br/>(frontend-app-authn)"]
FORMS["Login/registration<br/>FormDescription generation"]
LOGINPOST["Login session POST endpoint"]
REGPOST["Registration POST endpoint"]
RENDERED["Legacy /login or /register<br/>template rendered"]
SEL["Enterprise selection page"]
DEST["Post-login destination"]

LP -- "else,<br/>use AuthN MFE" --> MFE
LP -- "TPA hint or running SAML pipeline:<br/>stay on legacy page" --> LogistrationViewContextGenerated
LogistrationViewContextGenerated --> RENDERED
RENDERED --> LogistrationViewRenderCompleted
LogistrationViewRenderCompleted -- "Get FormDescription<br/>from python API" --> FORMS
MFE --> AuthnMFEContextGenerated
AuthnMFEContextGenerated -- "Get FormDescription<br/>from REST API" --> FORMS
%% invisible edge: pin AuthnMFEContextGenerated to the same rank as
%% LogistrationViewContextGenerated so both *ContextGenerated filters render on the same level
AuthnMFEContextGenerated ~~~ RENDERED
FORMS -- "is /login route" --> LoginFormGenerated
FORMS -- "is /register route" --> RegistrationFormGenerated
%% invisible edge: pin LoginFormGenerated to the same rank as
%% RegistrationFormGenerated so both *FormGenerated filters render on the same level
LoginFormGenerated ~~~ REGPOST
LoginFormGenerated -- "user submits credentials" --> LOGINPOST
RegistrationFormGenerated -- "user submits registration" --> REGPOST
LOGINPOST -- "MFE enabled and the request is for first-party auth" --> LoginAltRedirectURLRequested
LOGINPOST -- "otherwise" --> DEST
LoginAltRedirectURLRequested -- "multiple linked enterprises" --> SEL
LoginAltRedirectURLRequested -- "single/no enterprise,<br/>or direct-enrollment" --> DEST
SEL -- "continue to original next URL" --> DEST
REGPOST -- "account created and logged in" --> DEST
```

### Integration Testing

As part of this work, the SAML provisioning suite within edx-enterprise (`make
dev.provision.keycloak`) will be significantly enhanced to support the many
test scenarios, especially multi-IdP test scenarios.

Additionally, a new integration test script
`scripts/provision-integration-test-ENT-11568.sh` will facilitate in rendering
the exact test scenario setup and expected outcomes.

## Consequences

- The entire `user_authn` platform module (`openedx/core/djangoapps/user_authn/`)
no longer imports `enterprise_support` or `enterprise`, bringing us one step
closer to removing edx-enterprise as a hard platform dependency.
- Enterprise-specific logistration behavior is now centrally discoverable in one
module (`enterprise/filters/logistration.py`) instead of being spread across
platform views and `enterprise_support` helpers.
- The added filters created in service of this ticket can also serve as
general-purpose hooks for other logistration-altering plugins.

## Rejected Alternatives

- **Pluggable overrides** — As the primary plugin hook alternative to
openedx-filters, pluggable overrides would have been a poor choice
semantically because all the hook locations are reasonably general purpose.
- **Django signals** — rejected: every hook here must return data to the caller
(context, form description, redirect URL), which signals do not support well.
- **Middleware** — rejected: logistration customization is view-specific;
middleware would run on every request for logic that applies to a handful of
endpoints. This approach could have significant performance impact.

## References

- JIRA: ENT-11568
- openedx-filters PR: https://github.com/openedx/openedx-filters/pull/337
- edx-enterprise PR: https://github.com/openedx/edx-enterprise/pull/2662
- edx-enterprise PR: https://github.com/openedx/edx-enterprise/pull/2551
- openedx/openedx-platform PR: https://github.com/openedx/openedx-platform/pull/38105
- edx/edx-platform PR (2U fork): https://github.com/edx/edx-platform/pull/407
124 changes: 30 additions & 94 deletions docs/saml_testing.rst
Comment thread
pwnage101 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ Prerequisites
-------------

* A running `devstack <https://github.com/openedx/devstack>`_ environment with
the LMS container up.
the following containers up and running:

* ``make dev.up.lms+enterprise-catalog+frontend-app-account+frontend-app-authn``

* The edx-enterprise branch you want to test installed as an editable package
inside the LMS container (``/edx/src/edx-enterprise``).
* Docker Compose (the ``docker compose`` CLI plugin).
inside the LMS container (``pip install -e /edx/src/edx-enterprise``).

* The following line added to ``/etc/hosts`` on your host machine:

* ``127.0.0.1 edx.devstack.keycloak``

Starting Keycloak
-----------------
Expand All @@ -26,10 +32,8 @@ From the **edx-enterprise** repository root:

$ make dev.up.keycloak

This starts a Keycloak 26.x container (``edx.devstack.keycloak``) on the
devstack Docker network, exposed at ``http://localhost:8080``. Keycloak data is
persisted in a Docker volume (``keycloak_data``) so the container can be stopped
and restarted without losing state.
This starts a Keycloak container (``edx.devstack.keycloak``) on the
devstack Docker network, exposed at ``http://localhost:8080``.

Provisioning
------------
Expand All @@ -40,45 +44,20 @@ Provisioning configures **both** Keycloak and the LMS in a single step:

$ make dev.provision.keycloak

Under the hood this runs two commands:

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``.
Under the hood this accomplishes the following:

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.
* [Keycloak] Provisions two "realms" within Keycloak, ``gryffindor`` and ``slytherin``, each representing an IdP.

The examples below use the ``gryffindor`` tenant; ``slytherin`` behaves
identically -- substitute its name to test tenant isolation.
* [Keycloak] Provisions several Keycloak users within those realms.

Host setup
----------
* [LMS] provisions a SAMLConfiguration record to globally enable SAML auth.

The LMS redirects to Keycloak using the Docker hostname
``edx.devstack.keycloak``. Your browser needs to resolve that name to
localhost.
* [LMS] provisions matching LMS records for each of the Keycloak realms:

Add this line to ``/etc/hosts`` on the machine where your browser runs (your
laptop, **not** a remote codespace):

.. code-block:: text

127.0.0.1 edx.devstack.keycloak
* EnterpriseCustomer
* SAMLProviderConfig + EnterpriseCustomerIdentityProvider (to link the enterprise customer with the Keycloak IdP)
* EnterpriseCustomerBrandingConfiguration (to give the login/registration pages a distinctive look)
* User + EnterpriseCustomerUser (to provide LMS-side users corresponding to the IdP side users).

Testing the SAML login flow
----------------------------
Expand All @@ -92,10 +71,10 @@ Testing the SAML login flow

3. Log in with the test credentials:

========= =============================
========= ======================
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
Expand All @@ -111,65 +90,26 @@ Testing the SAML login flow
Testing the SAML disconnect flow
--------------------------------

Triggering the disconnect via the Account MFE
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1. Bring up the Account MFE container (devstack runs it on port 1997):

.. code-block:: bash

$ make dev.up.frontend-app-account

2. In the same browser session where you completed the SAML login, navigate to
the Linked Accounts section:
1. In the same browser session where you completed the SAML login, navigate to
the Linked Accounts section within the Account MFE:

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

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

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

Verifying the disconnect
~~~~~~~~~~~~~~~~~~~~~~~~

1. **LMS logs** -- tail the LMS container and grep for the new debug lines:

.. code-block:: bash

$ docker logs --tail 500 edx.devstack.lms 2>&1 | grep -E 'SAMLAccountDisconnected|_unlink_enterprise_user_from_idp|successfully unlinked'

You should see all three lines, in order::

[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 {gryffindor_learner@example.com} successfully unlinked from Enterprise Customer {<name>}
Resetting state to repeat tests
-------------------------------

Resetting state to repeat the test
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The simplest reset is to re-run provisioning:
The simplest reset is to re-run idempotent provisioning:

.. code-block:: bash

$ make dev.provision.keycloak

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

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

Note: re-running provisioning is necessary because when you clicked the
**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
if a learner accidentally clicks the unlink-from-IdP button, they ALSO get
unlinked from the enterprise itself and need to reach out to their admin to get
re-linked to the enterprise.

Stopping Keycloak
-----------------

Expand Down Expand Up @@ -197,9 +137,5 @@ Troubleshooting

**Keycloak admin console**
The Keycloak admin console is available at
``http://localhost:8080/admin/master/console/`` with credentials
``http://edx.devstack.keycloak:8080/admin/master/console/`` with credentials
``admin`` / ``admin``.

**Account MFE shows no linked providers**
UserSocialAuth likely has no row for this user -- complete the SAML
login first.
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.3"
__version__ = "8.8.0"
Loading