Skip to content

feat: add TPA pipeline step and signal handler for enterprise#2549

Open
pwnage101 wants to merge 2 commits intomasterfrom
pwnage101/ENT-11566
Open

feat: add TPA pipeline step and signal handler for enterprise#2549
pwnage101 wants to merge 2 commits intomasterfrom
pwnage101/ENT-11566

Conversation

@pwnage101
Copy link
Copy Markdown
Contributor

@pwnage101 pwnage101 commented Mar 5, 2026

2 commits: One to update business logic, and one to enhance local SAML testing tooling.

commit c28cf8e7112b6a1323bd859d1d1a01f1f16805db
Author: Troy Sankey <tsankey@2u.com>
Date:   Wed Apr 22 09:36:05 2026 -0700

    test: Enhance and formalize keycloak-powered local IdP capabilities
    
    John Nagro originally leveraged Keycloak to provide an identity provider
    for testing SAML-based third party auth (TPA) in edx devstack, but it
    was not broadly usable because it lacked scripts for keycloak
    provisioning, LMS TPA provisioning, and other necessary scaffolding to
    tie it all together.
    
    ENT-11566

commit 364025a3d786f0a84736236a424ea92356dec602
Author: Troy Sankey <tsankey@2u.com>
Date:   Thu Apr 16 15:17:06 2026 -0700

    feat: add TPA pipeline step and social auth disconnect handler
    
    - Add enterprise_associate_by_email pipeline step, replacing the removed pipeline step from platform.
    - Add signal handler to unlink enterprise users from an IdP (signal: SAMLAccountDisconnected)
    - Inject all enterprise-specific TPA pipeline steps via plugin_settings.
    
    ENT-11566
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

ENT-11566


blocks:


Testing

Do stuff to make sure devstack has the following branches cloned:

  • edx-enterprise: pwnage101/ENT-11566
  • edx-platform: pwnage101/ENT-11566-edx

From the devstack repo:

  • make lms-up
  • Then do stuff to install /edx/src/edx-enterprise as editable in the LMS container.

From the edx-enterprise repo:

  • make dev.up.keycloak
  • make dev.provision.keycloak
    • This step provisions both keycloak and the LMS with a SAML-based third party auth setup.

From your MacBook:

  • Add this line to /etc/hosts:
    • 127.0.0.1 edx.devstack.keycloak
  • Navigate to: http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=keycloak-devstack
    • Validate that you've been redirected to keycloak (http://edx.devstack.keycloak:8080/realms/...)
    • Login to keycloak IdP using keycloak_learner / testpass
    • Validate that you were NOT prompted to log into the existing LMS user (the enterprise_associate_by_email should successfully discover that the existing LMS learner is already associated with the SAML-enabled customer, so LMS authentication can be skipped.
    • Validate that you've been redirected to the LMS learner dashboard and you're logged in as keycloak_test_learner

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 16, 2026

Codecov Report

❌ Patch coverage is 97.29730% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 85.96%. Comparing base (daa14a2) to head (eff75a8).

Files with missing lines Patch % Lines
enterprise/tpa_pipeline.py 97.05% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2549      +/-   ##
==========================================
+ Coverage   85.91%   85.96%   +0.04%     
==========================================
  Files         250      250              
  Lines       16604    16666      +62     
  Branches     1639     1650      +11     
==========================================
+ Hits        14266    14327      +61     
- Misses       2001     2002       +1     
  Partials      337      337              
Flag Coverage Δ
unittests 85.96% <97.29%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 12 times, most recently from bb11c4b to ff7139b Compare April 20, 2026 20:02
@pwnage101 pwnage101 requested a review from Copilot April 20, 2026 20:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds enterprise-specific hooks into the third-party-auth (TPA) flow to better support enterprise SSO behavior in the Open edX platform, including a new pipeline step for conditional email association and a platform signal handler to unlink enterprise users on SAML disconnect.

Changes:

  • Add enterprise_associate_by_email pipeline step and inject it into SOCIAL_AUTH_PIPELINE via enterprise plugin settings.
  • Add a platform-consumed signal handler for SAML social-auth disconnects to unlink enterprise user associations.
  • Add/adjust dependencies and tests; bump package version and changelog entry.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
enterprise/tpa_pipeline.py Adds enterprise email-association pipeline step for SAML flows.
enterprise/settings/common.py Injects enterprise pipeline steps into the platform’s social-auth pipeline.
enterprise/platform_signal_handlers.py Adds SAML disconnect handler + unlink helper.
enterprise/apps.py Connects the platform’s SAMLAccountDisconnected signal to the new handler.
enterprise/utils.py Adds get_user_from_email helper and type hints for email lookup utilities.
enterprise/settings/test.py Enables ENABLE_ENTERPRISE_INTEGRATION in test settings.
tests/test_tpa_pipeline.py Adds unit tests for enterprise_associate_by_email.
tests/test_settings.py Adds tests ensuring pipeline injection placement and idempotency.
tests/test_platform_signal_handlers.py Adds tests for unlink helper + disconnect signal handler behavior.
requirements/base.in Adds python3-saml to base dependencies.
requirements/test*.txt, requirements/doc.txt, requirements/dev.txt Locks transitive deps for SAML support (python3-saml, xmlsec, isodate, etc.).
enterprise/__init__.py Bumps version to 7.0.5.
CHANGELOG.rst Adds 7.0.5 release entry for the new feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread enterprise/tpa_pipeline.py
Comment thread enterprise/platform_signal_handlers.py Outdated
Comment thread enterprise/apps.py Outdated
Comment thread enterprise/platform_signal_handlers.py Outdated
Comment thread enterprise/tpa_pipeline.py
Comment thread enterprise/tpa_pipeline.py
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 5 times, most recently from abe8d19 to 21cb498 Compare April 20, 2026 22:16
Comment thread enterprise/apps.py
"""
from enterprise.signals import handle_user_post_save # pylint: disable=import-outside-toplevel

from django.db.models.signals import post_save, pre_migrate # pylint: disable=C0415, # isort:skip
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, importing post_save and pre_migrate never needed to be deferred. In fact, importing signals generally doesn't need to be deferred. It's just the handlers that need to be deferred because they often import models.

Comment thread enterprise/constants.py
# with an EnterpriseCustomer when applicable. This it the unique identifier
# used to ensure that signal receiver is only called once.
USER_POST_SAVE_DISPATCH_UID = "user_post_save_upgrade_pending_enterprise_customer_user"
USER_POST_SAVE_DISPATCH_UID = "enterprise.user_post_save_upgrade_pending_enterprise_customer_user"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like good practice to prefix the dispatch UID with something unique.

@pwnage101 pwnage101 marked this pull request as ready for review April 20, 2026 22:33
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 4 times, most recently from ff7d54f to d5bc766 Compare April 22, 2026 19:48
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 6 times, most recently from 9b990e7 to ef61665 Compare April 22, 2026 21:20
- Add enterprise_associate_by_email pipeline step, replacing the removed pipeline step from platform.
- Add signal handler to unlink enterprise users from an IdP (signal: SAMLAccountDisconnected)
- Inject all enterprise-specific TPA pipeline steps via plugin_settings.

ENT-11566

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 2 times, most recently from 7cbaaa2 to c28cf8e Compare April 22, 2026 22:18
@pwnage101 pwnage101 requested a review from Copilot April 22, 2026 22:43
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread enterprise/signals.py
Comment thread enterprise/settings/common.py
Comment thread enterprise/tpa_pipeline.py
Comment thread enterprise/signals.py
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 2 times, most recently from 3ba4e30 to 2a8db44 Compare April 22, 2026 22:59
@pwnage101
Copy link
Copy Markdown
Contributor Author

TODO: Come up with stage validation plan. Audit logging and potentially come up with a datadog dashboard to monitor rollout.

Comment thread provision-tpa.py
Comment thread provision-tpa.py
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch 2 times, most recently from 5514661 to 26f1d96 Compare April 23, 2026 19:40
@pwnage101
Copy link
Copy Markdown
Contributor Author

@kiram15 I did just make a major fix to the final instructions printed to the console at the end of the provisioning script:

diff --git a/provision-tpa.py b/provision-tpa.py
index ebfb8600..9ba49475 100644
--- a/provision-tpa.py
+++ b/provision-tpa.py
@@ -38,7 +38,9 @@ SAML_SLUG = os.environ['SAML_SLUG']
 OID_EMAIL = os.environ['OID_EMAIL']
 OID_GIVEN_NAME = os.environ['OID_GIVEN_NAME']
 OID_SURNAME = os.environ['OID_SURNAME']
+TEST_USERNAME = os.environ['TEST_USERNAME']
 TEST_EMAIL = os.environ['TEST_EMAIL']
+TEST_PASSWORD = os.environ['TEST_PASSWORD']
 TEST_FIRST_NAME = os.environ['TEST_FIRST_NAME']
 TEST_LAST_NAME = os.environ['TEST_LAST_NAME']
 LMS_USERNAME = os.environ['LMS_USERNAME']
@@ -191,4 +193,5 @@ print(f'EnterpriseCustomerUser {action}: active={ecu.active}')
 # ---------------------------------------------------------------------------
 print('\n=== LMS TPA provisioning complete ===')
 print(f'SAML login URL: http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp={SAML_SLUG}')
-print(f'Test user: {LMS_USERNAME} / {LMS_PASSWORD} (email: {TEST_EMAIL})')
+print(f'Keycloak login: {TEST_USERNAME} / {TEST_PASSWORD}')
+print(f'LMS login: {TEST_EMAIL} / {LMS_PASSWORD}')

Basically, I was telling the user the wrong username/password to use on the IdP (keycloak). In practice, I think the flow involves logging into both keycloak AND the LMS, so both sets of usernames/passwords are needed.

@pwnage101
Copy link
Copy Markdown
Contributor Author

Here's what should happen:

Screen.Recording.2026-04-23.at.12.47.48.mov

@pwnage101
Copy link
Copy Markdown
Contributor Author

pwnage101 commented Apr 23, 2026

UGH---my unclean devstack led me to believe there should have been an LMS login prompt, when actually the whole point of the injected pipeline step (enterprise_associate_by_email) is to avoid the LMS login prompt. Starting over again with a cleaner devstack results in successfully skipping LMS login:

Screen.Recording.2026-04-23.at.14.05.12.mov

Applied this fix to the docs to reflect my findings:

diff --git a/docs/saml_testing.rst b/docs/saml_testing.rst
index 9ed9b376..848bd45b 100644
--- a/docs/saml_testing.rst
+++ b/docs/saml_testing.rst
@@ -83,8 +83,13 @@ Testing the SAML login flow
    Password   ``testpass``
    =========  =========================
 
-4. After successful authentication you should be redirected back to the LMS
-   learner dashboard, logged in as ``keycloak_test_learner``.
+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``.
 
 Stopping Keycloak
 -----------------
diff --git a/provision-tpa.py b/provision-tpa.py
index 9ba49475..2d3bb5bf 100644
--- a/provision-tpa.py
+++ b/provision-tpa.py
@@ -194,4 +194,3 @@ print(f'EnterpriseCustomerUser {action}: active={ecu.active}')
 print('\n=== LMS TPA provisioning complete ===')
 print(f'SAML login URL: http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp={SAML_SLUG}')
 print(f'Keycloak login: {TEST_USERNAME} / {TEST_PASSWORD}')
-print(f'LMS login: {TEST_EMAIL} / {LMS_PASSWORD}')

Here's my cleanup script (run in the LMS) for posterity:

Cleanup Script

from django.contrib.auth import get_user_model
from social_django.models import UserSocialAuth
from common.djangoapps.student.models import UserProfile
from common.djangoapps.third_party_auth.models import (
    SAMLConfiguration,
    SAMLProviderConfig,
    SAMLProviderData,
)
from enterprise.models import (
    EnterpriseCustomerIdentityProvider,
    EnterpriseCustomerUser,
)

User = get_user_model()

# Constants from keycloak-devstack.env
SAML_SLUG = 'keycloak-devstack'
IDP_ENTITY_ID = 'http://edx.devstack.keycloak:8080/realms/devstack'
PROVIDER_ID = f'saml-{SAML_SLUG}'
LMS_USERNAME = 'keycloak_test_learner'

# Step 8: Remove pre-linked enterprise learner
try:
    learner = User.objects.get(username=LMS_USERNAME)
    UserSocialAuth.objects.filter(user=learner).delete()
    EnterpriseCustomerUser.objects.filter(user_id=learner.id).delete()
    UserProfile.objects.filter(user=learner).delete()
    learner.delete()
    print(f'Deleted user {LMS_USERNAME} and related records')
except User.DoesNotExist:
    print(f'User {LMS_USERNAME} not found, skipping')

# Step 4: Remove EnterpriseCustomerIdentityProvider
d = EnterpriseCustomerIdentityProvider.objects.filter(provider_id=PROVIDER_ID).delete()
print(f'EnterpriseCustomerIdentityProvider: {d}')

# Steps 5-6: Remove SAMLProviderData (fetched metadata)
d = SAMLProviderData.objects.filter(entity_id=IDP_ENTITY_ID).delete()
print(f'SAMLProviderData: {d}')

# Step 3: Remove SAMLProviderConfig versions
d = SAMLProviderConfig.objects.filter(slug=SAML_SLUG).delete()
print(f'SAMLProviderConfig: {d}')

# Step 2: Remove SAMLConfiguration (global SP config)
# Note: this is shared across all SAML providers. Skip if you have other providers.
d = SAMLConfiguration.objects.filter(slug='default').delete()
print(f'SAMLConfiguration: {d}')

# Step 1 (seed_enterprise_devstack_data) is left intact -- the
# EnterpriseCustomer and other seed data may be used elsewhere.

print('\nDone. Run provisioning again with: docker compose down -v keycloak && make dev.up.keycloak && make dev.provision.keycloak')

John Nagro originally leveraged Keycloak to provide an identity provider
for testing SAML-based third party auth (TPA) in edx devstack, but it
was not broadly usable because it lacked scripts for keycloak
provisioning, LMS TPA provisioning, and other necessary scaffolding to
tie it all together.

ENT-11566
@pwnage101 pwnage101 force-pushed the pwnage101/ENT-11566 branch from 26f1d96 to eff75a8 Compare April 23, 2026 21:15
Copy link
Copy Markdown
Contributor

@iloveagent57 iloveagent57 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome job on the keycloak stuff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants