From 151ced5fe927d12d5e50a31f86f923e3048f3b69 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Thu, 23 Jul 2026 17:25:09 -0700 Subject: [PATCH] feat: add multiple SSO tenants during devstack provisioning The goal is ultimately to support integration testing logistration flows which depend on many different enterprise SSO configurations, including multi-tenant configurations. Changes directly in service of that goal: - Update provision-tpa.py to provision multiple SSO tenants (gryffindor, slytherin). - Add devstack_api.py helpers for SAML providers, enterprise branding, etc. - Add ENT-11568 integration-test script. Cleanup work along the way: - Eliminated legacy code that provisioned several redundant worker and admin users (devstack already provisions them). - Add `assign_system_wide_enterprise_role` management command to finally fix this: https://github.com/edx/devstack/blob/4e42eeda/provision-enterprise-access.sh#L25 - [Performance] Add --no-create-users flag to seed_enterprise_devstack_data command ENT-11568 --- CHANGELOG.rst | 6 + docker-compose.yml | 2 +- docs/saml_testing.rst | 59 ++-- enterprise/__init__.py | 2 +- enterprise/constants.py | 10 + enterprise/devstack_api.py | 317 +++++++++++------ .../assign_system_wide_enterprise_role.py | 105 ++++++ .../create_enterprise_linked_learner.py | 2 +- .../commands/seed_enterprise_devstack_data.py | 93 +++-- enterprise/roles_api.py | 56 ++- keycloak-devstack.env | 80 +++-- keycloak-devstack.properties | 6 +- keycloak-realms/gryffindor.json | 116 ++++++ .../slytherin.json | 27 +- provision-tpa.py | 330 ++++++++++-------- provision-tpa/gryffindor.png | Bin 0 -> 30487 bytes provision-tpa/slytherin.png | Bin 0 -> 28244 bytes .../provision-integration-test-ENT-11568.sh | 221 ++++++++++++ ...test_assign_system_wide_enterprise_role.py | 83 +++++ .../test_seed_enterprise_devstack_data.py | 73 ++++ tests/test_enterprise/test_devstack_api.py | 307 +++++++++++++++- tests/test_roles_api.py | 100 +++++- 22 files changed, 1623 insertions(+), 372 deletions(-) create mode 100644 enterprise/management/commands/assign_system_wide_enterprise_role.py create mode 100644 keycloak-realms/gryffindor.json rename keycloak-devstack-realm.json => keycloak-realms/slytherin.json (77%) create mode 100644 provision-tpa/gryffindor.png create mode 100644 provision-tpa/slytherin.png create mode 100755 scripts/provision-integration-test-ENT-11568.sh create mode 100644 tests/test_enterprise/management/test_assign_system_wide_enterprise_role.py create mode 100644 tests/test_enterprise/management/test_seed_enterprise_devstack_data.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 99e3b66b03..15420ae3bf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,12 @@ Unreleased ---------- * nothing unreleased +[8.7.3] - 2026-07-27 +--------------------- +* feat: add multiple SSO tenants during devstack provisioning + + * 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 diff --git a/docker-compose.yml b/docker-compose.yml index 242fe8c07f..7416d52c77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/saml_testing.rst b/docs/saml_testing.rst index d8c869a01d..0577bbf34a 100644 --- a/docs/saml_testing.rst +++ b/docs/saml_testing.rst @@ -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-``), 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/.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 ---------- @@ -71,17 +85,17 @@ 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 @@ -89,7 +103,10 @@ Testing the SAML login flow 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 -------------------------------- @@ -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 @@ -129,7 +146,7 @@ Verifying the disconnect [THIRD_PARTY_AUTH] Emitting SAMLAccountDisconnected signal for user_id=, backend=tpa-saml [ENTERPRISE] _unlink_enterprise_user_from_idp called for user_id=, backend=tpa-saml - Enterprise learner {keycloak_learner@example.com} successfully unlinked from Enterprise Customer {} + Enterprise learner {gryffindor_learner@example.com} successfully unlinked from Enterprise Customer {} Resetting state to repeat the test ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -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 diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 635868a1c3..c9137a22d7 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "8.7.2" +__version__ = "8.7.3" diff --git a/enterprise/constants.py b/enterprise/constants.py index 381a1e693a..a30fc9fd44 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -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' diff --git a/enterprise/devstack_api.py b/enterprise/devstack_api.py index c72202f36f..f6c4a0c518 100644 --- a/enterprise/devstack_api.py +++ b/enterprise/devstack_api.py @@ -10,7 +10,7 @@ """ import logging -from typing import Any +import os from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -18,31 +18,28 @@ from django.contrib import auth from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.sites.models import Site +from django.core.files import File from django.db import transaction from django.db.utils import IntegrityError from django.utils.text import slugify from consent.models import DataSharingConsent +from enterprise import roles_api from enterprise.constants import ( - ENTERPRISE_ADMIN_ROLE, - ENTERPRISE_CATALOG_ADMIN_ROLE, - ENTERPRISE_DASHBOARD_ADMIN_ROLE, ENTERPRISE_DATA_API_ACCESS_GROUP, ENTERPRISE_ENROLLMENT_API_ACCESS_GROUP, - ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE, ENTERPRISE_OPERATOR_ROLE, - ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, + SYSTEM_WIDE_ENTERPRISE_ROLES, ) from enterprise.models import ( EnterpriseCourseEnrollment, EnterpriseCustomer, + EnterpriseCustomerBrandingConfiguration, EnterpriseCustomerCatalog, + EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser, - EnterpriseFeatureRole, - EnterpriseFeatureUserRoleAssignment, - SystemWideEnterpriseRole, - SystemWideEnterpriseUserRoleAssignment, + PendingEnterpriseCustomerUser, ) try: @@ -51,6 +48,11 @@ CourseEnrollment = None UserProfile = None +try: + from common.djangoapps.third_party_auth.models import SAMLProviderConfig +except ImportError: + SAMLProviderConfig = None + LOGGER = logging.getLogger(__name__) User = auth.get_user_model() Group = auth.models.Group @@ -134,18 +136,127 @@ def get_or_create_enterprise_catalog(enterprise_customer: EnterpriseCustomer) -> return catalog -def get_or_create_user(username: str, is_staff: bool = False) -> AbstractBaseUser: +def update_or_create_enterprise_branding( + enterprise_customer: EnterpriseCustomer, + logo_path: str | None = None, + primary_color: str | None = None, + secondary_color: str | None = None, + tertiary_color: str | None = None, +) -> EnterpriseCustomerBrandingConfiguration: """ - Returns a User with the given username, creating it if needed. + Returns the branding configuration for the given customer, creating it if + needed, and applies any supplied logo and accent colors. - New users are created with email "{username}@example.com" and password - "edx". A UserProfile row is also ensured when the platform UserProfile - model is importable. + Args: + enterprise_customer: The EnterpriseCustomer to brand (one-to-one). + logo_path: Absolute path to a .png logo file to upload. Ignored when the + config already has a logo; a warning is logged if the path is supplied + but no file exists there. + primary_color: Optional primary accent color as a hex string, e.g. + "#740001". + secondary_color: Optional secondary accent color as a hex string. + tertiary_color: Optional tertiary accent color as a hex string. + + Returns: + The existing or newly created (and updated) branding configuration. + """ + branding, _ = EnterpriseCustomerBrandingConfiguration.objects.get_or_create( + enterprise_customer=enterprise_customer, + ) + if logo_path and not branding.logo: + if os.path.isfile(logo_path): + with open(logo_path, 'rb') as logo_file: + # save=False: persist the image together with the colors below in one save(). + branding.logo.save(os.path.basename(logo_path), File(logo_file), save=False) + LOGGER.info('Set branding logo for %s from %s', enterprise_customer.slug, logo_path) + else: + LOGGER.warning('Branding logo not found at %s; leaving logo unset', logo_path) + if primary_color is not None: + branding.primary_color = primary_color + if secondary_color is not None: + branding.secondary_color = secondary_color + if tertiary_color is not None: + branding.tertiary_color = tertiary_color + branding.save() + return branding + + +def create_enterprise_saml_provider( + enterprise_customer: EnterpriseCustomer, + slug: str, + name: str, + entity_id: str, + metadata_source: str, + site: Site | None = None, + attr_user_permanent_id: str = '', + attr_email: str = '', + attr_first_name: str = '', + attr_last_name: str = '', +) -> EnterpriseCustomerIdentityProvider: + """ + Creates a SAML IdP for the given customer and links it to the enterprise. + + Args: + enterprise_customer: The EnterpriseCustomer to link the IdP to. + slug: The SAMLProviderConfig slug, e.g. "gryffindor". The provider_id is + derived from it by SAMLProviderConfig as "saml-". + name: Human-readable display name for the provider. + entity_id: The IdP's SAML entity id (issuer). + metadata_source: URL from which to pull the IdP's SAML metadata. + site: The Site the provider belongs to. Defaults to the current site. + attr_user_permanent_id: SAML attribute mapped to the user's permanent id. + attr_email: SAML attribute mapped to the user's email. + attr_first_name: SAML attribute mapped to the user's first name. + attr_last_name: SAML attribute mapped to the user's last name. + + Returns: + The existing or newly created EnterpriseCustomerIdentityProvider link. + """ + if site is None: + site = Site.objects.get_current() + provider_config = SAMLProviderConfig( + site=site, + slug=slug, + name=name, + entity_id=entity_id, + metadata_source=metadata_source, + enabled=True, + visible=True, + skip_registration_form=True, + skip_email_verification=True, + send_to_registration_first=True, + attr_user_permanent_id=attr_user_permanent_id, + attr_email=attr_email, + attr_first_name=attr_first_name, + attr_last_name=attr_last_name, + ) + provider_config.save() + ecidp, _ = EnterpriseCustomerIdentityProvider.objects.get_or_create( + provider_id=provider_config.provider_id, + enterprise_customer=enterprise_customer, + ) + return ecidp + + +def get_or_create_user( + username: str, + email: str = '', + is_staff: bool = False, + first_name: str = '', + last_name: str = '', +) -> AbstractBaseUser: + """ + Get or create a User, as well as upserting a corresponding UserProfile. + + Note: New users are created with password "edx" Args: username: The username for the user to look up or create. - is_staff: If True, the created user is marked as Django staff. Has - no effect when the user already exists. + email: Optional email address for the user. Defaults to + "{username}@example.com" when omitted. + is_staff: If True, the created user is marked as Django staff. + first_name: Optional given name. + last_name: Optional surname. Returns: The existing or newly created User instance. @@ -154,18 +265,21 @@ def get_or_create_user(username: str, is_staff: bool = False) -> AbstractBaseUse with transaction.atomic(): user = User.objects.create_user( username=username, - email=f'{username}@example.com', + email=email or f'{username}@example.com', password='edx', is_staff=is_staff, + first_name=first_name, + last_name=last_name, ) LOGGER.info('Created user: %s', username) except IntegrityError: user = User.objects.get(username=username) LOGGER.info('Using existing user: %s', username) + profile_name = f'{first_name} {last_name}'.strip() or 'Test Enterprise User' UserProfile.objects.update_or_create( user=user, - defaults={'name': 'Test Enterprise User'}, + defaults={'name': profile_name}, ) return user @@ -176,42 +290,68 @@ def get_or_create_enterprise_user( role: str, enterprise_customer: EnterpriseCustomer | None = None, applies_to_all_contexts: bool = False, -) -> dict[str, Any] | None: + email: str = '', + first_name: str = '', + last_name: str = '', +) -> AbstractBaseUser | None: """ - Creates or retrieves a user with the given enterprise role. + Get or create an LMS user with the given enterprise role assignment. - Adds the user to the appropriate Django groups and creates system-wide - and feature role assignments. + Note: This does NOT actually link the user to the enterprise. For learners + and customer admins, you'll also need to call link_user_to_enterprise(). Args: username: The username for the user to look up or create. - role: One of ENTERPRISE_LEARNER_ROLE, ENTERPRISE_ADMIN_ROLE, or - ENTERPRISE_OPERATOR_ROLE. Any other value is treated as - unrecognised and causes the function to return None. - enterprise_customer: The EnterpriseCustomer to scope the role - assignment to. Omit (or pass None) together with - applies_to_all_contexts=True for operator/super-admin users. - applies_to_all_contexts: If True, the system-wide role assignment - applies across all enterprise contexts rather than the specific - enterprise_customer. + role: The name of the system-wide role to assign, e.g. "enterprise_learner". + enterprise_customer: The EnterpriseCustomer to scope the role assignment to. + applies_to_all_contexts: If True, the system-wide role assignment applies to all enterprises. + email: Optional email address, passed through to the created User. + first_name: Optional given name, passed through to the created User. + last_name: Optional surname, passed through to the created User. Returns: - A dict with "user" and "role" keys describing the resulting - assignment, or None if role is not one of the recognised values. + The created or retrieved User, or None if role is not one of the + recognised values. """ - valid_roles = [ENTERPRISE_LEARNER_ROLE, ENTERPRISE_ADMIN_ROLE, ENTERPRISE_OPERATOR_ROLE] - if role not in valid_roles: + if role not in SYSTEM_WIDE_ENTERPRISE_ROLES: LOGGER.warning('User not created. Role %s not recognised.', role) return None is_staff = role == ENTERPRISE_OPERATOR_ROLE - user = get_or_create_user(username, is_staff=is_staff) + user = get_or_create_user( + username=username, + email=email, + is_staff=is_staff, + first_name=first_name, + last_name=last_name, + ) - _add_user_to_groups(user, role) - _create_system_wide_role_assignment(user, role, enterprise_customer, applies_to_all_contexts) - _create_feature_role_assignments(user, role) + _add_user_to_legacy_groups(user=user, role=role) + roles_api.assign_role( + user=user, + role_name=role, + enterprise_customer=enterprise_customer, + applies_to_all_contexts=applies_to_all_contexts, + ) + + return user + + +def seed_global_operator_user() -> AbstractBaseUser | None: + """ + Idempotently creates a global enterprise operator user. + + Helpful for authenticating against this user in Postman for testing + enterprise API functionality. - return {'user': user, 'role': role} + Returns: + The created or retrieved User, or None if creation failed. + """ + return get_or_create_enterprise_user( + username='enterprise_openedx_operator', + role=ENTERPRISE_OPERATOR_ROLE, + applies_to_all_contexts=True, + ) def link_user_to_enterprise( @@ -222,12 +362,6 @@ def link_user_to_enterprise( """ Creates or updates an EnterpriseCustomerUser linking a user to an enterprise. - Args: - user: The User to link. - enterprise_customer: The EnterpriseCustomer to link the user to. - active: Whether the link should be marked active. Updates the - active flag on an existing link. - Returns: A tuple of (ecu, created) where ecu is the EnterpriseCustomerUser instance and created is True if it was created on this call. @@ -247,6 +381,23 @@ def link_user_to_enterprise( return ecu, created +def delete_user_and_enterprise_links(email: str) -> int: + """ + Deletes any LMS user(s) with the given email along with their enterprise + associations, returning the number of users deleted. + + Returns: + The number of User rows deleted (0 if none matched). + """ + deleted_count = 0 + for user in User.objects.filter(email=email): + EnterpriseCustomerUser.objects.filter(user_id=user.id).delete() + user.delete() + deleted_count += 1 + PendingEnterpriseCustomerUser.objects.filter(user_email=email).delete() + return deleted_count + + def enroll_learner_in_course( user: AbstractBaseUser, course_id: str, @@ -257,28 +408,25 @@ def enroll_learner_in_course( """ Enrolls a user in a course under an enterprise customer. + This low-level enrollment helper can never be used in production, but is + indispensable for integration test environments and devstack provisioning + which cannot always use the standard enrollment code paths. + Creates (idempotently): - - a platform CourseEnrollment - - an EnterpriseCourseEnrollment - - a DataSharingConsent record (granted=grant_dsc) + - CourseEnrollment + - EnterpriseCourseEnrollment + - DataSharingConsent Args: user: The User to enroll. - course_id: The course-run key (e.g. "course-v1:edX+DemoX+Demo_Course"). - enterprise_customer: The EnterpriseCustomer that owns the - subsidized enrollment. - mode: The CourseEnrollment mode to use when creating the platform - enrollment. Has no effect when the platform enrollment already - exists. - grant_dsc: Whether the DataSharingConsent record should be marked - as granted. + course_id: The courserun key (e.g. "course-v1:edX+DemoX+Demo_Course"). + enterprise_customer: The EnterpriseCustomer that owns the subsidized enrollment. + mode: The CourseEnrollment mode to use when creating the platform enrollment. + grant_dsc: Whether the DataSharingConsent record should be marked as granted. Raises: ValueError: course_id is not a valid course key. - EnterpriseCustomerUser.DoesNotExist: user is not already linked to - enterprise_customer. Callers must catch this and surface a - friendlier message (e.g. by calling link_user_to_enterprise - first, or by translating the exception at the CLI boundary). + EnterpriseCustomerUser.DoesNotExist: user is not already linked. """ try: course_key = CourseKey.from_string(course_id) @@ -336,43 +484,16 @@ def enroll_learner_in_course( # Internal helpers (not part of the public API) # --------------------------------------------------------------------------- -def _add_user_to_groups(user: AbstractBaseUser, role: str) -> None: - """Adds non-learner users to the enterprise data/enrollment API groups.""" +def _add_user_to_legacy_groups(user: AbstractBaseUser, role: str) -> None: + """Adds non-learner users to the enterprise data/enrollment API groups. + + Django groups are a legacy technique to authorize access to certain older + enterprise API endpoints, and is distinct from the newer edx-rbac + system-wide enterprise roles. Until all the legacy APIs consumed by + frontend-app-admin-portal are modernized to leverage edx-rbac authz, this + step to provision the group memberships are still required. + """ if role == ENTERPRISE_LEARNER_ROLE: return Group.objects.get(name=ENTERPRISE_DATA_API_ACCESS_GROUP).user_set.add(user) Group.objects.get(name=ENTERPRISE_ENROLLMENT_API_ACCESS_GROUP).user_set.add(user) - - -def _create_system_wide_role_assignment( - user: AbstractBaseUser, - role: str, - enterprise_customer: EnterpriseCustomer | None, - applies_to_all_contexts: bool, -) -> None: - """Creates a system-wide role assignment if one does not already exist.""" - system_role, _ = SystemWideEnterpriseRole.objects.get_or_create(name=role) - kwargs = { - 'user': user, - 'role': system_role, - 'applies_to_all_contexts': applies_to_all_contexts, - } - if enterprise_customer is not None: - kwargs['enterprise_customer'] = enterprise_customer - if not SystemWideEnterpriseUserRoleAssignment.objects.filter(**kwargs).exists(): - SystemWideEnterpriseUserRoleAssignment.objects.create(**kwargs) - - -def _create_feature_role_assignments(user: AbstractBaseUser, role: str) -> None: - """Creates feature role assignments for admin/operator users.""" - if role == ENTERPRISE_LEARNER_ROLE: - return - feature_roles = [ - ENTERPRISE_CATALOG_ADMIN_ROLE, - ENTERPRISE_DASHBOARD_ADMIN_ROLE, - ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, - ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, - ] - for feature_role_name in feature_roles: - feature_role, _ = EnterpriseFeatureRole.objects.get_or_create(name=feature_role_name) - EnterpriseFeatureUserRoleAssignment.objects.get_or_create(user=user, role=feature_role) diff --git a/enterprise/management/commands/assign_system_wide_enterprise_role.py b/enterprise/management/commands/assign_system_wide_enterprise_role.py new file mode 100644 index 0000000000..bc95e7fbcd --- /dev/null +++ b/enterprise/management/commands/assign_system_wide_enterprise_role.py @@ -0,0 +1,105 @@ +""" +Management command for assigning a system-wide enterprise role to an existing user. +""" + +import logging + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError + +from enterprise import roles_api +from enterprise.models import EnterpriseCustomer + +LOGGER = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + """ + Assign a system-wide enterprise role to an existing user. + + The user must already exist (this command never creates it). Exactly one of + --all-contexts or --enterprise-customer must be given. + + Example usage: + $ ./manage.py lms assign_system_wide_enterprise_role \ + --username enterprise_worker \ + --role enterprise_openedx_operator \ + --all-contexts + $ ./manage.py lms assign_system_wide_enterprise_role \ + --username admin_acme \ + --role enterprise_admin \ + --enterprise-customer acme-corp + """ + + help = 'Assign a system-wide enterprise role to an existing user.' + + def add_arguments(self, parser): + parser.add_argument( + '--username', + required=True, + help='Username of the existing user to assign the role to.', + ) + parser.add_argument( + '--role', + required=True, + help='System-wide role name, e.g. enterprise_openedx_operator.', + ) + scope = parser.add_mutually_exclusive_group(required=True) + scope.add_argument( + '--all-contexts', + action='store_true', + dest='all_contexts', + help='Assign the role across all enterprise contexts.', + ) + scope.add_argument( + '--enterprise-customer', + dest='enterprise_customer', + metavar='SLUG_OR_UUID', + help='Slug or UUID of the enterprise customer to scope the role to.', + ) + + def handle(self, *args, **options): + username = options['username'] + role = options['role'] + + try: + user = User.objects.get(username=username) + except User.DoesNotExist as exc: + raise CommandError(f"User '{username}' does not exist.") from exc + + enterprise_customer = None + if options['enterprise_customer']: + enterprise_customer = self._resolve_enterprise_customer(options['enterprise_customer']) + + try: + assignment, created = roles_api.assign_role( + user=user, + role_name=role, + enterprise_customer=enterprise_customer, + applies_to_all_contexts=options['all_contexts'], + ) + except roles_api.UnknownSystemWideRoleError as exc: + raise CommandError(f"Role '{role}' is not a recognised system-wide enterprise role.") from exc + LOGGER.info( + '%s system-wide role assignment: user=%s role=%s scope=%s', + 'Created' if created else 'Found existing', + user.username, + role, + 'all-contexts' if options['all_contexts'] else enterprise_customer.slug, + ) + return str(assignment.pk) + + def _resolve_enterprise_customer(self, identifier): + """Return the EnterpriseCustomer matching identifier by slug, else by UUID.""" + try: + return EnterpriseCustomer.objects.get(slug=identifier) + except EnterpriseCustomer.DoesNotExist: + pass + try: + return EnterpriseCustomer.objects.get(uuid=identifier) + except (EnterpriseCustomer.DoesNotExist, ValidationError, ValueError) as exc: + raise CommandError( + f"EnterpriseCustomer with slug or UUID '{identifier}' does not exist." + ) from exc diff --git a/enterprise/management/commands/create_enterprise_linked_learner.py b/enterprise/management/commands/create_enterprise_linked_learner.py index 223b61f9ae..0d8c4b4e38 100644 --- a/enterprise/management/commands/create_enterprise_linked_learner.py +++ b/enterprise/management/commands/create_enterprise_linked_learner.py @@ -57,7 +57,7 @@ def handle(self, *args, **options): "Passing the same enterprise twice would overwrite the link and flip it inactive." ) - user = get_or_create_user(username) + user = get_or_create_user(username=username) for index, name in enumerate(enterprise_names): try: diff --git a/enterprise/management/commands/seed_enterprise_devstack_data.py b/enterprise/management/commands/seed_enterprise_devstack_data.py index f2b5c11ffc..3fa2a4c773 100644 --- a/enterprise/management/commands/seed_enterprise_devstack_data.py +++ b/enterprise/management/commands/seed_enterprise_devstack_data.py @@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand -from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE, ENTERPRISE_OPERATOR_ROLE +from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE from enterprise.devstack_api import ( ensure_enterprise_groups, get_or_create_enterprise_catalog, @@ -16,6 +16,7 @@ get_or_create_enterprise_user, get_or_create_site, link_user_to_enterprise, + seed_global_operator_user, ) LOGGER = logging.getLogger(__name__) @@ -28,6 +29,7 @@ class Command(BaseCommand): Example usage: $ ./manage.py lms seed_enterprise_devstack_data $ ./manage.py lms seed_enterprise_devstack_data --enterprise-name "Acme Corp" + $ ./manage.py lms seed_enterprise_devstack_data --no-create-users """ help = ''' @@ -43,6 +45,14 @@ def add_arguments(self, parser): default='Test Enterprise', help='Name of enterprise to be created. Defaults to "Test Enterprise".' ) + parser.add_argument( + '--no-create-users', + action='store_true', + dest='no_create_users', + default=False, + help='Skip creating enterprise role users (global and tenant-scoped); ' + 'only seed the enterprise customer, catalog, and groups.', + ) def handle(self, *args, **options): enterprise_name = options['enterprise_name'] @@ -55,67 +65,56 @@ def handle(self, *args, **options): enterprise_customer = get_or_create_enterprise_customer(name=enterprise_name, site=site) enterprise_catalog = get_or_create_enterprise_catalog(enterprise_customer) - LOGGER.info('\nCreating enterprise users and assigning roles...') + if options['no_create_users']: + LOGGER.info('\nSkipping user creation (--no-create-users).') + LOGGER.info( + textwrap.dedent( + '''\nSuccessfully seeded a new enterprise with the following data: + \n| Enterprise Customer: %s (%s) + \n| Enterprise Catalog: %s (%s) + ''' + ), + enterprise_customer.name, + enterprise_customer.uuid, + enterprise_catalog.title, + enterprise_catalog.uuid, + ) + return + + # The global operator user applies across all enterprises, so it is + # seeded once and never linked to a specific enterprise. + LOGGER.info('\nCreating global enterprise operator user...') + seed_global_operator_user() + + LOGGER.info('\nCreating tenant-scoped enterprise users and assigning roles...') slug = enterprise_customer.slug - enterprise_users = [ + lms_users = [ get_or_create_enterprise_user( - username=f'{ENTERPRISE_LEARNER_ROLE}_{slug}', + username=f'enterprise_learner_{slug}', role=ENTERPRISE_LEARNER_ROLE, enterprise_customer=enterprise_customer, ), get_or_create_enterprise_user( - username=f'{ENTERPRISE_ADMIN_ROLE}_{slug}', + username=f'enterprise_admin_{slug}', role=ENTERPRISE_ADMIN_ROLE, enterprise_customer=enterprise_customer, ), - # Super admin with the admin role on all enterprises. - get_or_create_enterprise_user( - username=ENTERPRISE_ADMIN_ROLE, - role=ENTERPRISE_ADMIN_ROLE, - applies_to_all_contexts=True, - ), - get_or_create_enterprise_user( - username=ENTERPRISE_OPERATOR_ROLE, - role=ENTERPRISE_OPERATOR_ROLE, - applies_to_all_contexts=True, - ), - # Service workers as operators for all enterprises. - get_or_create_enterprise_user( - username='license-manager_worker', - role=ENTERPRISE_OPERATOR_ROLE, - applies_to_all_contexts=True, - ), - get_or_create_enterprise_user( - username='enterprise-catalog_worker', - role=ENTERPRISE_OPERATOR_ROLE, - applies_to_all_contexts=True, - ), - get_or_create_enterprise_user( - username='enterprise_worker', - role=ENTERPRISE_OPERATOR_ROLE, - applies_to_all_contexts=True, - ), - get_or_create_enterprise_user( - username='ecommerce_worker', - role=ENTERPRISE_OPERATOR_ROLE, - applies_to_all_contexts=True, - ), ] for i in range(2): - enterprise_users.append(get_or_create_enterprise_user( + lms_users.append(get_or_create_enterprise_user( username=f'{slug}_learner_{i + 1}', role=ENTERPRISE_LEARNER_ROLE, enterprise_customer=enterprise_customer, )) - LOGGER.info('\nLinking users to enterprise...') - enterprise_linked_users = [] - for enterprise_user in enterprise_users: - if enterprise_user is None: + LOGGER.info('\nLinking tenant-scoped users to enterprise...') + serialized_enterprise_linked_users = [] + for lms_user in lms_users: + if lms_user is None: continue - ecu, _ = link_user_to_enterprise(enterprise_user['user'], enterprise_customer) - enterprise_linked_users.append({ - 'user_id': ecu.user_id, + ecu, _ = link_user_to_enterprise(user=lms_user, enterprise_customer=enterprise_customer) + serialized_enterprise_linked_users.append({ + 'lms_user_id': lms_user.id, 'enterprise_customer_user_id': ecu.id, 'username': ecu.username, }) @@ -132,6 +131,6 @@ def handle(self, *args, **options): enterprise_customer.uuid, enterprise_catalog.title, enterprise_catalog.uuid, - len(enterprise_linked_users), - json.dumps(enterprise_linked_users, sort_keys=True, indent=2), + len(serialized_enterprise_linked_users), + json.dumps(serialized_enterprise_linked_users, sort_keys=True, indent=2), ) diff --git a/enterprise/roles_api.py b/enterprise/roles_api.py index 34db583e12..2c13e2a88d 100644 --- a/enterprise/roles_api.py +++ b/enterprise/roles_api.py @@ -3,14 +3,21 @@ """ from cache_memoize import cache_memoize +from django.contrib.auth.base_user import AbstractBaseUser + from enterprise.constants import ( ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE, ENTERPRISE_OPERATOR_ROLE, SYSTEM_ENTERPRISE_CATALOG_ADMIN_ROLE, SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, + SYSTEM_WIDE_ENTERPRISE_ROLES, ) -from enterprise.models import SystemWideEnterpriseRole, SystemWideEnterpriseUserRoleAssignment +from enterprise.models import EnterpriseCustomer, SystemWideEnterpriseRole, SystemWideEnterpriseUserRoleAssignment + + +class UnknownSystemWideRoleError(Exception): + """Raised when an unrecognised system-wide role name is requested.""" # django-cache-memoize lets us explicitly declare a prefix @@ -65,13 +72,48 @@ def roles_by_name(): } -def assign_learner_role(user, enterprise_customer=None, applies_to_all_contexts=False): +def assign_role( + user: AbstractBaseUser, + role_name: str, + enterprise_customer: EnterpriseCustomer | None = None, + applies_to_all_contexts: bool = False, +) -> tuple[SystemWideEnterpriseUserRoleAssignment, bool]: """ - Assigns the given user the `enterprise_learner` role in the given customer. + Idempotently assigns the named system-wide role to the given user. + + ``applies_to_all_contexts`` is only applied when the assignment is created; + it is not part of the lookup, so a repeat call returns the existing row + rather than attempting a duplicate insert. + + Args: + user: The User to assign the role to. + role_name: The name of the system-wide role, e.g. "enterprise_learner". + enterprise_customer: The EnterpriseCustomer to scope the assignment to. + applies_to_all_contexts: If True, the assignment applies across all enterprises. + + Returns: + A tuple of (assignment, created). + + Raises: + UnknownSystemWideRoleError: if ``role_name`` is not a recognised role. """ + if role_name not in SYSTEM_WIDE_ENTERPRISE_ROLES: + raise UnknownSystemWideRoleError(role_name) return SystemWideEnterpriseUserRoleAssignment.objects.get_or_create( user=user, - role=learner_role(), + role=get_or_create_system_wide_role(role_name), + enterprise_customer=enterprise_customer, + defaults={'applies_to_all_contexts': applies_to_all_contexts}, + ) + + +def assign_learner_role(user, enterprise_customer=None, applies_to_all_contexts=False): + """ + Assigns the given user the `enterprise_learner` role in the given customer. + """ + return assign_role( + user, + ENTERPRISE_LEARNER_ROLE, enterprise_customer=enterprise_customer, applies_to_all_contexts=applies_to_all_contexts, ) @@ -81,9 +123,9 @@ def assign_admin_role(user, enterprise_customer=None, applies_to_all_contexts=Fa """ Assigns the given user the `enterprise_admin` role in the given customer. """ - return SystemWideEnterpriseUserRoleAssignment.objects.get_or_create( - user=user, - role=admin_role(), + return assign_role( + user, + ENTERPRISE_ADMIN_ROLE, enterprise_customer=enterprise_customer, applies_to_all_contexts=applies_to_all_contexts, ) diff --git a/keycloak-devstack.env b/keycloak-devstack.env index fee97a33fe..d281a9d574 100644 --- a/keycloak-devstack.env +++ b/keycloak-devstack.env @@ -1,19 +1,16 @@ -# Environment variables for keycloak-config-cli variable substitution. -# These are the single source of truth for values shared between the Keycloak -# realm config (keycloak-devstack-realm.json) and LMS setup (runbooks/07.md). +# Environment variables containing the SINGLE SOURCE OF TRUTH for values shared +# between both keycloak provisioning and LMS Third Party Auth provisioning. -# Keycloak realm name. All SAML IdP objects live inside this realm. -REALM_NAME=devstack +# =========================================================================== +# Shared across both Gryffindor and Slytherin tenants. +# =========================================================================== # Keycloak base URL (Docker hostname, reachable from the LMS container and host). +# Reminder: Manually configure this /etc/hosts entry: "127.0.0.1 edx.devstack.keycloak" KEYCLOAK_URL=http://edx.devstack.keycloak:8080 -# SAMLProviderConfig slug used in the LMS. The provider_id registered with -# python-social-auth will be "saml-{SAML_SLUG}" (e.g. "saml-test-saml-idp"). -SAML_SLUG=keycloak-devstack - -# LMS Service Provider entity ID. Must match SAMLConfiguration.entity_id in -# the LMS (runbook step 3b) AND the Client ID of the SAML client in Keycloak. +# LMS Service Provider entity ID. +# Must match the LMS SAMLConfiguration.entity_id AND the Keycloak SAML Client ID. SP_ENTITY_ID=http://localhost:18000 # SAML Assertion Consumer Service URL — the LMS endpoint that receives the SAML @@ -21,8 +18,8 @@ SP_ENTITY_ID=http://localhost:18000 ACS_URL=http://localhost:18000/auth/complete/tpa-saml/ # Standard OIDs for SAML assertion attributes. The SAMLProviderConfig in the -# LMS (runbook step 3c) must reference the same OIDs so the pipeline can -# extract user details from the assertion. +# LMS (see provision-tpa.py) references the same OIDs so the pipeline can extract +# user details from the assertion. # OID_EMAIL: RFC 2798 mail # OID_GIVEN_NAME: X.520 givenName # OID_SURNAME: X.520 sn (surname) @@ -30,18 +27,45 @@ OID_EMAIL=urn:oid:0.9.2342.19200300.100.1.3 OID_GIVEN_NAME=urn:oid:2.5.4.42 OID_SURNAME=urn:oid:2.5.4.4 -# Keycloak test user credentials. A user with these attributes is created in -# the Keycloak realm for SAML login testing. TEST_USERNAME intentionally differs -# from LMS_USERNAME to verify that SAML association works by email matching, not -# username matching. -TEST_USERNAME=keycloak_learner -TEST_EMAIL=keycloak_learner@example.com -TEST_PASSWORD=testpass -TEST_FIRST_NAME=Keycloak -TEST_LAST_NAME=Learner - -# LMS test user credentials. This LMS user is pre-linked to the enterprise -# customer so that enterprise_associate_by_email can match them to the Keycloak -# user above during SAML login. The email must match TEST_EMAIL. -LMS_USERNAME=keycloak_test_learner -LMS_PASSWORD=edx +# Keycloak-side password for every SSO test user. Injected into the realm JSON +# user credentials (keycloak-realms/*.json) at import time and echoed by +# provision-tpa.py as the login hint. +SSO_PASSWORD=testpass + +# =========================================================================== +# Gryffindor IdP + Enterprise + couple of learners +# =========================================================================== +GRYFFINDOR_REALM=gryffindor +GRYFFINDOR_ENTERPRISE_NAME=Gryffindor +GRYFFINDOR_PRIMARY_COLOR=#740001 +GRYFFINDOR_SECONDARY_COLOR=#D3A625 +GRYFFINDOR_TERTIARY_COLOR=#EEBA30 +# An SSO user fully linked to an existing LMS user. +GRYFFINDOR_LEARNER_USERNAME=gryffindor_learner +GRYFFINDOR_LEARNER_EMAIL=gryffindor_learner@example.com +GRYFFINDOR_LEARNER_FIRST_NAME=Harry +GRYFFINDOR_LEARNER_LAST_NAME=Potter +# A "newcomer" is an SSO user who does not have an LMS user. +GRYFFINDOR_NEWCOMER_USERNAME=gryffindor_newcomer +GRYFFINDOR_NEWCOMER_EMAIL=gryffindor_newcomer@example.com +GRYFFINDOR_NEWCOMER_FIRST_NAME=Newcomer +GRYFFINDOR_NEWCOMER_LAST_NAME=Gryffindor + +# =========================================================================== +# Slytherin IdP + Enterprise + couple of learners +# =========================================================================== +SLYTHERIN_REALM=slytherin +SLYTHERIN_ENTERPRISE_NAME=Slytherin +SLYTHERIN_PRIMARY_COLOR=#1A472A +SLYTHERIN_SECONDARY_COLOR=#2A623D +SLYTHERIN_TERTIARY_COLOR=#AAAAAA +# An SSO user fully linked to an existing LMS user. +SLYTHERIN_LEARNER_USERNAME=slytherin_learner +SLYTHERIN_LEARNER_EMAIL=slytherin_learner@example.com +SLYTHERIN_LEARNER_FIRST_NAME=Draco +SLYTHERIN_LEARNER_LAST_NAME=Malfoy +# A "newcomer" is an SSO user who does not have an LMS user. +SLYTHERIN_NEWCOMER_USERNAME=slytherin_newcomer +SLYTHERIN_NEWCOMER_EMAIL=slytherin_newcomer@example.com +SLYTHERIN_NEWCOMER_FIRST_NAME=Newcomer +SLYTHERIN_NEWCOMER_LAST_NAME=Slytherin diff --git a/keycloak-devstack.properties b/keycloak-devstack.properties index e67a20c7cc..ce5cbc2944 100644 --- a/keycloak-devstack.properties +++ b/keycloak-devstack.properties @@ -6,8 +6,10 @@ keycloak.url=http://edx.devstack.keycloak:8080 keycloak.user=admin keycloak.password=admin -# Realm JSON file to import (mounted into the container at /config/). -import.files.locations=/config/keycloak-devstack-realm.json +# Directory of realm JSON files to import (mounted into the container at +# /config/). Every tenant is a separate realm file (keycloak-realms/*.json); +# config-cli imports them all. +import.files.locations=/config # Enable $(VAR) substitution in the realm JSON so that environment variables # from keycloak-devstack.env are resolved at import time. diff --git a/keycloak-realms/gryffindor.json b/keycloak-realms/gryffindor.json new file mode 100644 index 0000000000..0652e51866 --- /dev/null +++ b/keycloak-realms/gryffindor.json @@ -0,0 +1,116 @@ +{ + "realm": "$(env:GRYFFINDOR_REALM)", + "enabled": true, + "clientScopes": [ + { + "name": "saml-user-attributes", + "protocol": "saml", + "protocolMappers": [ + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "friendly.name": "email", + "attribute.name": "$(env:OID_EMAIL)", + "attribute.nameformat": "URI Reference" + } + }, + { + "name": "firstName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "friendly.name": "givenName", + "attribute.name": "$(env:OID_GIVEN_NAME)", + "attribute.nameformat": "URI Reference" + } + }, + { + "name": "lastName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "friendly.name": "sn", + "attribute.name": "$(env:OID_SURNAME)", + "attribute.nameformat": "URI Reference" + } + } + ] + }, + { + "name": "role_list", + "protocol": "saml", + "protocolMappers": [ + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "true", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + } + ], + "clients": [ + { + "clientId": "$(env:SP_ENTITY_ID)", + "protocol": "saml", + "enabled": true, + "rootUrl": "$(env:SP_ENTITY_ID)", + "redirectUris": ["$(env:ACS_URL)*"], + "adminUrl": "$(env:ACS_URL)", + "attributes": { + "saml.assertion.signature": "true", + "saml.force.post.binding": "true", + "saml_assertion_consumer_url_post": "$(env:ACS_URL)", + "saml_name_id_format": "email", + "saml.force.name.id.format": "true", + "saml.client.signature": "false" + }, + "defaultClientScopes": ["saml-user-attributes", "role_list"] + } + ], + "users": [ + { + "username": "$(env:GRYFFINDOR_LEARNER_USERNAME)", + "email": "$(env:GRYFFINDOR_LEARNER_EMAIL)", + "emailVerified": true, + "enabled": true, + "firstName": "$(env:GRYFFINDOR_LEARNER_FIRST_NAME)", + "lastName": "$(env:GRYFFINDOR_LEARNER_LAST_NAME)", + "credentials": [ + { + "type": "password", + "value": "$(env:SSO_PASSWORD)", + "temporary": false + } + ] + }, + { + "username": "$(env:GRYFFINDOR_NEWCOMER_USERNAME)", + "email": "$(env:GRYFFINDOR_NEWCOMER_EMAIL)", + "emailVerified": true, + "enabled": true, + "firstName": "$(env:GRYFFINDOR_NEWCOMER_FIRST_NAME)", + "lastName": "$(env:GRYFFINDOR_NEWCOMER_LAST_NAME)", + "credentials": [ + { + "type": "password", + "value": "$(env:SSO_PASSWORD)", + "temporary": false + } + ] + } + ] +} diff --git a/keycloak-devstack-realm.json b/keycloak-realms/slytherin.json similarity index 77% rename from keycloak-devstack-realm.json rename to keycloak-realms/slytherin.json index 0da3a3d669..c90960ba12 100644 --- a/keycloak-devstack-realm.json +++ b/keycloak-realms/slytherin.json @@ -1,5 +1,5 @@ { - "realm": "$(env:REALM_NAME)", + "realm": "$(env:SLYTHERIN_REALM)", "enabled": true, "clientScopes": [ { @@ -83,16 +83,31 @@ ], "users": [ { - "username": "$(env:TEST_USERNAME)", - "email": "$(env:TEST_EMAIL)", + "username": "$(env:SLYTHERIN_LEARNER_USERNAME)", + "email": "$(env:SLYTHERIN_LEARNER_EMAIL)", "emailVerified": true, "enabled": true, - "firstName": "$(env:TEST_FIRST_NAME)", - "lastName": "$(env:TEST_LAST_NAME)", + "firstName": "$(env:SLYTHERIN_LEARNER_FIRST_NAME)", + "lastName": "$(env:SLYTHERIN_LEARNER_LAST_NAME)", "credentials": [ { "type": "password", - "value": "$(env:TEST_PASSWORD)", + "value": "$(env:SSO_PASSWORD)", + "temporary": false + } + ] + }, + { + "username": "$(env:SLYTHERIN_NEWCOMER_USERNAME)", + "email": "$(env:SLYTHERIN_NEWCOMER_EMAIL)", + "emailVerified": true, + "enabled": true, + "firstName": "$(env:SLYTHERIN_NEWCOMER_FIRST_NAME)", + "lastName": "$(env:SLYTHERIN_NEWCOMER_LAST_NAME)", + "credentials": [ + { + "type": "password", + "value": "$(env:SSO_PASSWORD)", "temporary": false } ] diff --git a/provision-tpa.py b/provision-tpa.py index 2d3bb5bf5e..7be4ef15e2 100644 --- a/provision-tpa.py +++ b/provision-tpa.py @@ -4,67 +4,100 @@ Run inside the LMS container via: manage.py lms shell < provision-tpa.py -All configuration is read from environment variables (see keycloak-devstack.env). +SHARED configuration -- the one Keycloak service and the single LMS service +provider -- is read from environment variables (see keycloak-devstack.env). + +PER-TENANT configuration lives in the TENANTS list below. A "tenant" is one +Keycloak realm plus one Open edX enterprise customer, both sharing the same +Keycloak service and the same LMS. For each tenant the realm name, the +SAMLProviderConfig slug, the provider_id (``saml-``), and the enterprise +slug are all the SAME arbitrary token (e.g. "gryffindor"), so one memorable name +identifies everything about the tenant. The Keycloak side of each tenant (its +realm and SSO users) is defined in keycloak-realms/.json; the per-tenant +user emails below MUST match that file. """ import os import sys -from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.sites.models import Site from django.core.management import call_command -from common.djangoapps.student.models import UserProfile -from common.djangoapps.third_party_auth.models import ( - SAMLConfiguration, - SAMLProviderConfig, - SAMLProviderData, +import enterprise +from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderData +from enterprise.constants import ENTERPRISE_LEARNER_ROLE +from enterprise.devstack_api import ( + create_enterprise_saml_provider, + delete_user_and_enterprise_links, + ensure_enterprise_groups, + get_or_create_enterprise_user, + link_user_to_enterprise, + seed_global_operator_user, + update_or_create_enterprise_branding, ) -from enterprise.models import ( - EnterpriseCustomer, - EnterpriseCustomerIdentityProvider, - EnterpriseCustomerUser, -) - -User = get_user_model() +from enterprise.models import EnterpriseCustomer # --------------------------------------------------------------------------- -# Read configuration from environment (sourced from keycloak-devstack.env) +# Shared configuration (one Keycloak service, one LMS service provider) # --------------------------------------------------------------------------- KEYCLOAK_URL = os.environ['KEYCLOAK_URL'] -REALM_NAME = os.environ['REALM_NAME'] SP_ENTITY_ID = os.environ['SP_ENTITY_ID'] -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'] -LMS_PASSWORD = os.environ['LMS_PASSWORD'] -# Derived constants -IDP_ENTITY_ID = f'{KEYCLOAK_URL}/realms/{REALM_NAME}' -IDP_METADATA_URL = f'{IDP_ENTITY_ID}/protocol/saml/descriptor' -PROVIDER_ID = f'saml-{SAML_SLUG}' +# Keycloak-side password entered at the IdP (matches keycloak-realms/*.json, +# which is injected with the same SSO_PASSWORD env var at realm import time). +SSO_PASSWORD = os.environ['SSO_PASSWORD'] + +# provision-tpa/ ships alongside the enterprise package (in devstack's editable +# install, /edx/src/edx-enterprise/provision-tpa). Derive it from the package +# location rather than hard-coding the mount path. +LOGO_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(enterprise.__file__))), + 'provision-tpa', +) # --------------------------------------------------------------------------- -# Step 1: Seed enterprise devstack data -# --------------------------------------------------------------------------- -print('\n--- Step 1: Seed enterprise devstack data ---') -call_command('seed_enterprise_devstack_data') +# Per-tenant configuration +# --------------------------------------------------------------------------- +# Each "tenant" represents an fictional organization with their own (keycloak-backed) IdP +# and associated enterprise within the database. +TENANTS = [ + { + 'name': os.environ['GRYFFINDOR_REALM'], + 'enterprise_name': os.environ['GRYFFINDOR_ENTERPRISE_NAME'], + 'primary_color': os.environ['GRYFFINDOR_PRIMARY_COLOR'], + 'secondary_color': os.environ['GRYFFINDOR_SECONDARY_COLOR'], + 'tertiary_color': os.environ['GRYFFINDOR_TERTIARY_COLOR'], + 'learner_username': os.environ['GRYFFINDOR_LEARNER_USERNAME'], + 'learner_email': os.environ['GRYFFINDOR_LEARNER_EMAIL'], + 'learner_first_name': os.environ['GRYFFINDOR_LEARNER_FIRST_NAME'], + 'learner_last_name': os.environ['GRYFFINDOR_LEARNER_LAST_NAME'], + 'newcomer_username': os.environ['GRYFFINDOR_NEWCOMER_USERNAME'], + 'newcomer_email': os.environ['GRYFFINDOR_NEWCOMER_EMAIL'], + }, + { + 'name': os.environ['SLYTHERIN_REALM'], + 'enterprise_name': os.environ['SLYTHERIN_ENTERPRISE_NAME'], + 'primary_color': os.environ['SLYTHERIN_PRIMARY_COLOR'], + 'secondary_color': os.environ['SLYTHERIN_SECONDARY_COLOR'], + 'tertiary_color': os.environ['SLYTHERIN_TERTIARY_COLOR'], + 'learner_username': os.environ['SLYTHERIN_LEARNER_USERNAME'], + 'learner_email': os.environ['SLYTHERIN_LEARNER_EMAIL'], + 'learner_first_name': os.environ['SLYTHERIN_LEARNER_FIRST_NAME'], + 'learner_last_name': os.environ['SLYTHERIN_LEARNER_LAST_NAME'], + 'newcomer_username': os.environ['SLYTHERIN_NEWCOMER_USERNAME'], + 'newcomer_email': os.environ['SLYTHERIN_NEWCOMER_EMAIL'], + }, +] + +site = Site.objects.get_current() # --------------------------------------------------------------------------- -# Step 2: Create SAMLConfiguration (global SP config) +# Step 1: Create the shared SAMLConfiguration (service-provider config) # --------------------------------------------------------------------------- -print('\n--- Step 2: Create SAMLConfiguration ---') -site = Site.objects.get_current() -# SAMLConfiguration is a ConfigurationModel with KEY_FIELDS = ('site_id', 'slug'). -# Multiple rows per (site, slug) is expected — each row is a version. -# Just create a new version with the desired settings. +# This simply enables SAML for this LMS installation. +print('\n--- Step 1: Create shared SAMLConfiguration ---') saml_config = SAMLConfiguration( site=site, slug='default', @@ -75,50 +108,105 @@ print(f'Created SAMLConfiguration version (slug=default, entity_id={SP_ENTITY_ID})') # --------------------------------------------------------------------------- -# Step 3: Create SAMLProviderConfig (Keycloak IdP) -# --------------------------------------------------------------------------- -print('\n--- Step 3: Create SAMLProviderConfig ---') -# SAMLProviderConfig is a ConfigurationModel with KEY_FIELDS = ('slug',). -# Multiple rows per slug is expected — each row is a version. -provider_config = SAMLProviderConfig( - site=site, - slug=SAML_SLUG, - name='Keycloak Devstack IdP', - entity_id=IDP_ENTITY_ID, - metadata_source=IDP_METADATA_URL, - enabled=True, - visible=True, - skip_registration_form=True, - skip_email_verification=True, - send_to_registration_first=True, - attr_user_permanent_id=OID_EMAIL, - attr_email=OID_EMAIL, - attr_first_name=OID_GIVEN_NAME, - attr_last_name=OID_SURNAME, -) -provider_config.save() -print(f'Created SAMLProviderConfig version (slug={SAML_SLUG}, entity_id={IDP_ENTITY_ID})') - -# --------------------------------------------------------------------------- -# Step 4: Create EnterpriseCustomerIdentityProvider -# --------------------------------------------------------------------------- -print('\n--- Step 4: Create EnterpriseCustomerIdentityProvider ---') -ec = EnterpriseCustomer.objects.get(slug='test-enterprise') -ecidp, created = EnterpriseCustomerIdentityProvider.objects.get_or_create( - provider_id=PROVIDER_ID, - enterprise_customer=ec, -) -action = 'Created' if created else 'Already exists' -print(f'{action}: provider_id={PROVIDER_ID}, enterprise={ec.name}') - -# --------------------------------------------------------------------------- -# Step 5: Fetch SAML metadata from Keycloak -# --------------------------------------------------------------------------- -print('\n--- Step 5: Fetch SAML metadata (saml --pull) ---') -# saml --pull fetches metadata for ALL enabled providers. If any pre-existing -# provider has an unreachable metadata URL, the command will fail. +# Step 2: Seed the globally-scoped enterprise operator user (once, not per tenant) +# --------------------------------------------------------------------------- +# The operator applies across all enterprises, so it is created a single time +# here and never linked to a specific enterprise. +print('\n--- Step 2: Seed global enterprise operator user ---') +ensure_enterprise_groups() +seed_global_operator_user() + +# --------------------------------------------------------------------------- +# Step 3: Provision each tenant (enterprise + IdP + branding + users) +# --------------------------------------------------------------------------- +for tenant in TENANTS: + name = tenant['name'] + enterprise_name = tenant['enterprise_name'] + idp_display_name = f'{enterprise_name} IdP' + idp_entity_id = f'{KEYCLOAK_URL}/realms/{name}' + idp_metadata_url = f'{idp_entity_id}/protocol/saml/descriptor' + logo_filename = f'{name}.png' # provision-tpa/.png + print(f'\n=== Tenant "{enterprise_name}" (realm/slug={name}) ===') + + # Step A: Seed the enterprise customer (catalog + groups) without any role + # users -- the global users are seeded once above, and this tenant's SSO + # login account is created in Step D. The enterprise slug is + # slugify(enterprise_name) == name, matching the realm/provider name. + print(f'--- Step A: Seed enterprise "{enterprise_name}" ---') + call_command('seed_enterprise_devstack_data', enterprise_name=enterprise_name, no_create_users=True) + ec = EnterpriseCustomer.objects.get(slug=name) + + # Step B: Create the tenant's SAML IdP (SAMLProviderConfig) and link it to the + # enterprise so tpa_hint and SSO logins resolve enterprise context. + print('--- Step B: Create SAML IdP and link to enterprise ---') + create_enterprise_saml_provider( + enterprise_customer=ec, + slug=name, + name=idp_display_name, + entity_id=idp_entity_id, + metadata_source=idp_metadata_url, + site=site, + attr_user_permanent_id=OID_EMAIL, + attr_email=OID_EMAIL, + attr_first_name=OID_GIVEN_NAME, + attr_last_name=OID_SURNAME, + ) + print(f'Created SAML IdP saml-{name} (entity_id={idp_entity_id}) linked to {ec.name}') + + # Step C: Set enterprise branding (logo + house colors) so the logistration + # sidebar renders a visually distinct, verifiable brand per tenant. + print('--- Step C: Set enterprise branding ---') + update_or_create_enterprise_branding( + enterprise_customer=ec, + logo_path=os.path.join(LOGO_DIR, logo_filename), + primary_color=tenant['primary_color'], + secondary_color=tenant['secondary_color'], + tertiary_color=tenant['tertiary_color'], + ) + print(f'Branding set (logo={logo_filename}, colors {tenant["primary_color"]}/{tenant["secondary_color"]})') + + # Step D: Create the fully-linked LMS user. + # "Fully-linked" means this SSO user in Keycloak is linked to an existing + # LMS user, AND the LMS user is linked to an existing enterprise customer. + print('--- Step D: Create fully-linked LMS user ---') + learner = get_or_create_enterprise_user( + username=tenant['learner_username'], + role=ENTERPRISE_LEARNER_ROLE, + enterprise_customer=ec, + email=tenant['learner_email'], + first_name=tenant['learner_first_name'], + last_name=tenant['learner_last_name'], + ) + link_user_to_enterprise(user=learner, enterprise_customer=ec) + print(f'Fully-linked account ready: {learner.username} ({learner.email})') + + # Step E: Enforce that the newcomer SSO user has NO LMS user. + # Registering via SSO in a prior run creates one, so delete it every run to + # idempotently converge back to a "no account" state. + print('--- Step E: Ensure newcomer has no LMS user ---') + newcomer_email = tenant['newcomer_email'] + deleted = delete_user_and_enterprise_links(email=newcomer_email) + if deleted: + print(f'Deleted {deleted} stale LMS account(s) for {newcomer_email}') + print(f'Newcomer has no LMS account: {tenant["newcomer_username"]}') + +# --------------------------------------------------------------------------- +# Step 4: Fetch SAML metadata for all providers (single pull) +# --------------------------------------------------------------------------- +# Update the LMS with SAML metadata from Keycloak. +# +# Keycloak in devstack is served over plain HTTP (edx.devstack.keycloak:8080), +# but openedx-platform enforces HTTPS on SAML metadata URLs as an SSRF defense +# (validate_saml_metadata_url, added upstream in 70a56246 / GHSA-328g-7h4g-r2m9). +# Bypass that guard for this local-only pull only; scoping the patch to this call +# keeps the security check active everywhere else. The validator is imported by +# name into the tasks module, so patch it there. +from unittest import mock + +print('\n--- Step 4: Fetch SAML metadata (saml --pull) ---') try: - call_command('saml', pull=True) + with mock.patch('common.djangoapps.third_party_auth.tasks.validate_saml_metadata_url'): + call_command('saml', pull=True) except Exception as exc: print(f'\nERROR: saml --pull failed: {exc}') print('This usually means a pre-existing SAMLProviderConfig has an unreachable metadata URL.') @@ -126,71 +214,25 @@ sys.exit(1) # --------------------------------------------------------------------------- -# Step 6: Verify SAMLProviderData +# Step 5: Verify SAMLProviderData for each tenant # --------------------------------------------------------------------------- -print('\n--- Step 6: Verify SAMLProviderData ---') -provider_data = SAMLProviderData.objects.filter(entity_id=IDP_ENTITY_ID) -if provider_data.exists(): - d = provider_data.latest('fetched_at') - print(f'SAMLProviderData fetched at {d.fetched_at}') - print(f'SSO URL: {d.sso_url}') - print(f'Public key present: {bool(d.public_key)}') -else: - print('ERROR: No SAMLProviderData found. Check saml --pull output above.') - sys.exit(1) - -# --------------------------------------------------------------------------- -# Step 7: Verify pipeline injection -# --------------------------------------------------------------------------- -print('\n--- Step 7: Verify pipeline injection ---') -pipeline = settings.SOCIAL_AUTH_PIPELINE -email_step = 'enterprise.tpa_pipeline.enterprise_associate_by_email' -logistration_step = 'enterprise.tpa_pipeline.handle_enterprise_logistration' -missing = [] -if email_step not in pipeline: - missing.append(email_step) -if logistration_step not in pipeline: - missing.append(logistration_step) -if missing: - print(f'ERROR: Missing pipeline steps: {missing}') - sys.exit(1) -print('Pipeline injection verified (enterprise_associate_by_email, handle_enterprise_logistration)') - -# --------------------------------------------------------------------------- -# Step 8: Create pre-linked enterprise learner -# --------------------------------------------------------------------------- -print('\n--- Step 8: Create pre-linked enterprise learner ---') -learner, created = User.objects.get_or_create( - username=LMS_USERNAME, - defaults={ - 'email': TEST_EMAIL, - 'is_active': True, - }, -) -if created: - learner.set_password(LMS_PASSWORD) - learner.save() - print(f'Created LMS user: {learner.username} (email={learner.email})') -else: - print(f'LMS user already exists: {learner.username}') - -UserProfile.objects.get_or_create( - user=learner, - defaults={'name': f'{TEST_FIRST_NAME} {TEST_LAST_NAME}'}, -) -print('UserProfile ensured') - -ecu, created = EnterpriseCustomerUser.objects.get_or_create( - enterprise_customer=ec, - user_id=learner.id, - defaults={'active': True}, -) -action = 'Created' if created else 'Already exists' -print(f'EnterpriseCustomerUser {action}: active={ecu.active}') +print('\n--- Step 5: Verify SAMLProviderData ---') +for tenant in TENANTS: + idp_entity_id = f'{KEYCLOAK_URL}/realms/{tenant["name"]}' + provider_data = SAMLProviderData.objects.filter(entity_id=idp_entity_id) + if provider_data.exists(): + d = provider_data.latest('fetched_at') + print(f'{tenant["name"]}: fetched {d.fetched_at}, sso_url={d.sso_url}, public_key={bool(d.public_key)}') + else: + print(f'ERROR: No SAMLProviderData for {tenant["name"]} ({idp_entity_id}). Check saml --pull output.') + sys.exit(1) # --------------------------------------------------------------------------- # Done # --------------------------------------------------------------------------- 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}') +for tenant in TENANTS: + print(f'\n[{tenant["enterprise_name"]}]') + print(f' SAML SSO URL: http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp={tenant["name"]}') + print(f' Fully-linked (has LMS account): Keycloak {tenant["learner_username"]} / {SSO_PASSWORD}') + print(f' Newcomer (no LMS account): Keycloak {tenant["newcomer_username"]} / {SSO_PASSWORD}') diff --git a/provision-tpa/gryffindor.png b/provision-tpa/gryffindor.png new file mode 100644 index 0000000000000000000000000000000000000000..e12490528247dfaa185c6935c72f262d608c6f98 GIT binary patch literal 30487 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 matching LMS account -> LOGIN flow + ${SLYTHERIN_LEARNER_USERNAME} / testpass -> matching LMS account -> LOGIN flow + ${GRYFFINDOR_NEWCOMER_USERNAME} / testpass -> no LMS account -> REGISTRATION flow + ${SLYTHERIN_NEWCOMER_USERNAME} / testpass -> no LMS account -> REGISTRATION flow + +LMS Learner users (email / password): + ${GRYFFINDOR_LEARNER_EMAIL} / edx -> linked to ${GRYFFINDOR_ENTERPRISE_NAME} only + ${SLYTHERIN_LEARNER_EMAIL} / edx -> linked to ${SLYTHERIN_ENTERPRISE_NAME} only + ${DUAL_LEARNER}@example.com / edx -> linked to BOTH enterprises + +----------------------------------------------------------------------------- +Toggles used below +----------------------------------------------------------------------------- + +* Authn MFE: ENABLE_AUTHN_MICROFRONTEND in your devstack's + py_configuration_files/lms.py. Changing it requires an LMS + restart: docker restart edx.devstack.lms +* Provider: SAMLProviderConfig fields (skip_registration_form, + send_to_registration_first) via Django admin at + ${LMS_BASE}/admin/third_party_auth/samlproviderconfig/ . + It is a versioned ConfigurationModel: "add" clones the current + values into a new active version; no restart needed. + NOTE: re-running \`make dev.provision.keycloak\` recreates the + provider with the provisioned defaults, reverting any admin + toggle (skip_registration_form=True, send_to_registration_first=True). + +The enterprise logistration overrides live in the LEGACY logistration code +path. The legacy page renders when the Authn MFE is OFF (everyone) OR when the +MFE is ON and the request is in an enterprise context (the veto in Part B keeps +enterprise users on the legacy page). + +============================================================================= +PART A -- Authn MFE OFF (set ENABLE_AUTHN_MICROFRONTEND=False, restart LMS) +============================================================================= + + 1. Test LogistrationContextEnricher (via LogistrationContextRequested). + Injects enterprise branding into the logistration page context. + a. In a fresh/incognito session (logged out), open: + ${LMS_BASE}/login?tpa_hint=saml-${GRYFFINDOR_REALM} + Expected: the login page shows an enterprise welcome panel (sidebar) + branded for Gryffindor -- you should see the name "Gryffindor" and the + Gryffindor crest logo (the branding set by provisioning), with the + scarlet/gold house colors. To confirm the raw data, view page source: + the embedded context has "enable_enterprise_sidebar": true and + "enterprise_name": "Gryffindor". + + 2. [control] Without enterprise context, LogistrationContextEnricher does nothing. + a. In a fresh/incognito session (logged out), open the plain login page: + ${LMS_BASE}/login (no tpa_hint) + Expected: the standard login page renders with NO enterprise welcome panel + and no enterprise name/logo anywhere. Page source shows + "enable_enterprise_sidebar": false. + + 3. Test LogistrationCookieSetter (via LogistrationResponseRendered). + Sets the experiments_is_enterprise cookie from enable_enterprise_sidebar. + a. Open devtools (Application > Cookies > ${LMS_BASE}), then load: + ${LMS_BASE}/login?tpa_hint=saml-${GRYFFINDOR_REALM} + Expected: the experiments_is_enterprise cookie exists with value exactly + true (JSON). Optional secondary check: in the Network tab, the /login + response's Set-Cookie headers clear the enterprise_customer_uuid cookie + (an expired Set-Cookie), preventing a stale enterprise context from + persisting. + + 4. [control] Without enterprise context, the cookie value is false. + a. Open devtools (Application > Cookies > ${LMS_BASE}), then load the plain + login page: ${LMS_BASE}/login (no tpa_hint) + Expected: the experiments_is_enterprise cookie value is exactly false (JSON). + + 5. Test RegistrationFormEnterpriseOverrides (via RegistrationFormTPAOverridesRequested). + With skip_registration_form=True in an enterprise context, the SSO-prefilled + fields are hidden so only Terms of Service remains. (Provisioned defaults: + skip_registration_form=True, send_to_registration_first=True, + sync_learner_profile_data=False, so the hiding is attributable to the + enterprise step, not the platform's own path.) + a. Reset to a clean slate: make dev.provision.keycloak + b. Logged out, start SSO registration: + ${LMS_BASE}/auth/login/tpa-saml/?auth_entry=register&idp=${GRYFFINDOR_REALM} + c. Authenticate at Keycloak as ${GRYFFINDOR_NEWCOMER_USERNAME} (password "testpass"). + Expected: the registration form shows no editable inputs for full name, + public username, or email (they are hidden/pre-filled); effectively only + the Terms of Service agreement and the account-creation button remain. + + 6. [control] With skip_registration_form=False, the fields are not hidden. + a. In Django admin (${LMS_BASE}/admin/third_party_auth/samlproviderconfig/), + add a new provider version with skip_registration_form=False (no restart). + b. Logged out, start SSO registration again: + ${LMS_BASE}/auth/login/tpa-saml/?auth_entry=register&idp=${GRYFFINDOR_REALM} + c. Authenticate at Keycloak as ${GRYFFINDOR_NEWCOMER_USERNAME} (password "testpass"). + Expected: the full registration form renders with the full name, username, + and email fields VISIBLE (pre-filled from SSO but editable). + Cleanup: make dev.provision.keycloak (restores skip_registration_form=True). + + 7. Test LoginFormEnterpriseOverrides (via LoginFormTPAOverridesRequested). + For an enterprise SSO user landing on the login form, the email field is + pre-filled and made read-only. The login form only renders mid-pipeline + when the user is NOT sent to registration first, so toggle that off. + a. In Django admin (${LMS_BASE}/admin/third_party_auth/samlproviderconfig/), + add a new provider version with send_to_registration_first=False. + b. Logged out, start SSO login: + ${LMS_BASE}/auth/login/tpa-saml/?auth_entry=login&idp=${GRYFFINDOR_REALM} + c. Authenticate at Keycloak as ${GRYFFINDOR_NEWCOMER_USERNAME} (password "testpass"). + Expected: the login form's Email field is pre-filled with + ${GRYFFINDOR_NEWCOMER_USERNAME}@example.com and is read-only (greyed out; you cannot edit it). + Cleanup: make dev.provision.keycloak (restores send_to_registration_first=True). + NOTE: this test is the least settled -- the exact conditions under which the + enterprise login-form override renders depend on the SSO/association path. + Confirm in-browser and adjust these steps as needed. + +============================================================================= +PART B -- Authn MFE ON (set ENABLE_AUTHN_MICROFRONTEND=True, restart LMS) +============================================================================= + + 8. Test EnterpriseMFERedirectVeto (via LogistrationMFERedirectRequested). + In an enterprise context the authn-MFE redirect is vetoed, so the legacy + branded page renders instead. + a. Logged out, open the enterprise-context login: + ${LMS_BASE}/login?tpa_hint=saml-${GRYFFINDOR_REALM} + Expected: the browser STAYS on ${LMS_BASE}/login (the address bar host does + not change) and renders the legacy Gryffindor-branded page (the same + welcome panel/crest as test 1). It is NOT redirected to the authn MFE. + (With the MFE on, tests 1 and 3 also fire here, since the veto renders the + legacy page.) + + 9. [control] Without enterprise context, the request is redirected to the MFE. + a. Logged out, open the plain login page: ${LMS_BASE}/login (no tpa_hint) + Expected: the browser is redirected away from ${LMS_BASE}/login to the authn + micro-frontend (the app at AUTHN_MICROFRONTEND_URL, a different host/port + than ${LMS_BASE}). + +============================================================================= +PART C -- post-login redirect (independent of the Authn MFE toggle) +============================================================================= + + 10. Test PostLoginEnterpriseRedirect (via PostLoginRedirectURLRequested). + A learner belonging to more than one enterprise is redirected to the + enterprise-selection page after login. + a. Log in with email/password as ${DUAL_LEARNER}@example.com / edx + Expected: after login the browser lands on the enterprise selection page -- + the address bar shows /enterprise/select/active/?success_url=... -- and + the page prompts you to choose between Gryffindor and Slytherin instead + of loading the dashboard. + + 11. [control] A single-enterprise learner is not redirected to the selection page. + a. Log in with email/password as ${GRYFFINDOR_LEARNER_USERNAME}@example.com / edx + (the baseline's Gryffindor SSO learner, linked to Gryffindor only; use its + LMS password "edx" here, not its Keycloak password "testpass"). + Expected: no selection page; the learner proceeds straight to the normal + post-login destination (the ${LMS_BASE}/dashboard learner dashboard). + +EOF diff --git a/tests/test_enterprise/management/test_assign_system_wide_enterprise_role.py b/tests/test_enterprise/management/test_assign_system_wide_enterprise_role.py new file mode 100644 index 0000000000..917efb5277 --- /dev/null +++ b/tests/test_enterprise/management/test_assign_system_wide_enterprise_role.py @@ -0,0 +1,83 @@ +""" +Tests for the ``assign_system_wide_enterprise_role`` management command. +""" + +import ddt +import pytest + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_OPERATOR_ROLE +from enterprise.models import SystemWideEnterpriseUserRoleAssignment +from test_utils.factories import EnterpriseCustomerFactory, UserFactory + +COMMAND = 'assign_system_wide_enterprise_role' + + +@ddt.ddt +@pytest.mark.django_db +class TestAssignSystemWideEnterpriseRole(TestCase): + """Tests for the assign_system_wide_enterprise_role management command.""" + + def setUp(self): + self.user = UserFactory(username='enterprise_worker') + self.enterprise = EnterpriseCustomerFactory() + super().setUp() + + def test_all_contexts_assignment(self): + """``--all-contexts`` creates an all-contexts assignment for the user.""" + call_command(COMMAND, username='enterprise_worker', role=ENTERPRISE_OPERATOR_ROLE, all_contexts=True) + + assignment = SystemWideEnterpriseUserRoleAssignment.objects.get(user=self.user) + assert assignment.role.name == ENTERPRISE_OPERATOR_ROLE + assert assignment.applies_to_all_contexts is True + assert assignment.enterprise_customer is None + + @ddt.data( + {'identifier_attr': 'slug'}, + {'identifier_attr': 'uuid'}, + ) + @ddt.unpack + def test_customer_scoped(self, identifier_attr): + """``--enterprise-customer`` resolves the customer by slug or UUID and scopes the assignment.""" + call_command( + COMMAND, + username='enterprise_worker', + role=ENTERPRISE_ADMIN_ROLE, + enterprise_customer=str(getattr(self.enterprise, identifier_attr)), + ) + + assignment = SystemWideEnterpriseUserRoleAssignment.objects.get(user=self.user) + assert assignment.enterprise_customer == self.enterprise + assert assignment.applies_to_all_contexts is False + + def test_idempotent(self): + """Running twice does not create a duplicate assignment.""" + for _ in range(2): + call_command(COMMAND, username='enterprise_worker', role=ENTERPRISE_OPERATOR_ROLE, all_contexts=True) + + assert SystemWideEnterpriseUserRoleAssignment.objects.filter(user=self.user).count() == 1 + + def test_missing_user_raises(self): + """A nonexistent username raises CommandError and creates nothing.""" + with pytest.raises(CommandError, match="User 'ghost' does not exist"): + call_command(COMMAND, username='ghost', role=ENTERPRISE_OPERATOR_ROLE, all_contexts=True) + assert not SystemWideEnterpriseUserRoleAssignment.objects.exists() + + def test_missing_customer_raises(self): + """An unresolvable enterprise-customer identifier raises CommandError.""" + with pytest.raises(CommandError, match="does not exist"): + call_command( + COMMAND, + username='enterprise_worker', + role=ENTERPRISE_ADMIN_ROLE, + enterprise_customer='no-such-slug', + ) + + def test_unrecognised_role_raises(self): + """An unrecognised role name raises CommandError and creates nothing.""" + with pytest.raises(CommandError, match="not a recognised system-wide enterprise role"): + call_command(COMMAND, username='enterprise_worker', role='bogus_role', all_contexts=True) + assert not SystemWideEnterpriseUserRoleAssignment.objects.exists() diff --git a/tests/test_enterprise/management/test_seed_enterprise_devstack_data.py b/tests/test_enterprise/management/test_seed_enterprise_devstack_data.py new file mode 100644 index 0000000000..afb50a26be --- /dev/null +++ b/tests/test_enterprise/management/test_seed_enterprise_devstack_data.py @@ -0,0 +1,73 @@ +""" +Tests for the ``seed_enterprise_devstack_data`` management command. +""" + +from unittest.mock import patch + +import pytest + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import TestCase + +from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE +from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser + +User = get_user_model() + +GLOBAL_USERNAMES = { + 'enterprise_openedx_operator', +} + + +@patch('enterprise.devstack_api.UserProfile') +@pytest.mark.django_db +class TestSeedEnterpriseDevstackData(TestCase): + """Tests for the seed_enterprise_devstack_data management command.""" + + command = 'seed_enterprise_devstack_data' + enterprise_name = 'Acme Corp' + + def _tenant_usernames(self, slug): + """Return the set of tenant-scoped usernames the command creates for a slug.""" + return { + f'{ENTERPRISE_LEARNER_ROLE}_{slug}', + f'{ENTERPRISE_ADMIN_ROLE}_{slug}', + f'{slug}_learner_1', + f'{slug}_learner_2', + } + + def test_default_links_only_tenant_scoped_users(self, _MockUserProfile): + """Default run seeds global and tenant-scoped users, linking only the tenant-scoped ones.""" + call_command(self.command, enterprise_name=self.enterprise_name) + + enterprise_customer = EnterpriseCustomer.objects.get(name=self.enterprise_name) + tenant_usernames = self._tenant_usernames(enterprise_customer.slug) + + # Both global and tenant-scoped users are created. + assert User.objects.filter(username__in=GLOBAL_USERNAMES).count() == len(GLOBAL_USERNAMES) + assert User.objects.filter(username__in=tenant_usernames).count() == len(tenant_usernames) + + # Only the tenant-scoped users are linked to this enterprise (Option C). + linked_user_ids = set( + EnterpriseCustomerUser.objects.filter( + enterprise_customer=enterprise_customer, + ).values_list('user_id', flat=True) + ) + expected_user_ids = set( + User.objects.filter(username__in=tenant_usernames).values_list('id', flat=True) + ) + assert linked_user_ids == expected_user_ids + + def test_no_create_users_skips_all_users(self, _MockUserProfile): + """``--no-create-users`` seeds the enterprise but creates no users or links.""" + call_command(self.command, enterprise_name=self.enterprise_name, no_create_users=True) + + enterprise_customer = EnterpriseCustomer.objects.get(name=self.enterprise_name) + tenant_usernames = self._tenant_usernames(enterprise_customer.slug) + + assert not User.objects.filter(username__in=GLOBAL_USERNAMES).exists() + assert not User.objects.filter(username__in=tenant_usernames).exists() + assert not EnterpriseCustomerUser.objects.filter( + enterprise_customer=enterprise_customer, + ).exists() diff --git a/tests/test_enterprise/test_devstack_api.py b/tests/test_enterprise/test_devstack_api.py index dbac86161a..d1ab8c1e60 100644 --- a/tests/test_enterprise/test_devstack_api.py +++ b/tests/test_enterprise/test_devstack_api.py @@ -2,6 +2,9 @@ Tests for the devstack-only helpers in ``enterprise/devstack_api.py``. """ +import os +import shutil +import tempfile from unittest.mock import MagicMock, patch import ddt @@ -10,7 +13,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.contrib.sites.models import Site -from django.test import TestCase +from django.test import TestCase, override_settings from consent.models import DataSharingConsent from enterprise.constants import ( @@ -21,6 +24,8 @@ ENTERPRISE_OPERATOR_ROLE, ) from enterprise.devstack_api import ( + create_enterprise_saml_provider, + delete_user_and_enterprise_links, enroll_learner_in_course, ensure_enterprise_groups, get_or_create_enterprise_catalog, @@ -29,19 +34,31 @@ get_or_create_site, get_or_create_user, link_user_to_enterprise, + seed_global_operator_user, + update_or_create_enterprise_branding, ) from enterprise.models import ( EnterpriseCourseEnrollment, EnterpriseCustomer, + EnterpriseCustomerBrandingConfiguration, EnterpriseCustomerCatalog, + EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser, EnterpriseFeatureUserRoleAssignment, + PendingEnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment, ) -from test_utils.factories import EnterpriseCustomerFactory, UserFactory +from test_utils.factories import EnterpriseCustomerFactory, PendingEnterpriseCustomerUserFactory, UserFactory User = get_user_model() +# A minimal valid 1x1 PNG, used to exercise the logo-upload path. +_MINIMAL_PNG = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01' + b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' +) + @pytest.mark.django_db class TestGetOrCreateSite(TestCase): @@ -137,6 +154,11 @@ def test_creates_user(self, _MockUserProfile): assert user.email == 'testuser123@example.com' assert user.is_staff is False + def test_uses_explicit_email(self, _MockUserProfile): + """Uses the supplied email instead of the generated default.""" + user = get_or_create_user('testuser123', email='custom@corp.example') + assert user.email == 'custom@corp.example' + def test_creates_staff_user(self, _MockUserProfile): """Honors the ``is_staff`` flag when creating a new user.""" user = get_or_create_user('staffuser', is_staff=True) @@ -149,6 +171,24 @@ def test_idempotent_returns_existing(self, _MockUserProfile): assert user2.username == 'testuser123' assert User.objects.filter(username='testuser123').count() == 1 + def test_sets_first_and_last_name(self, _MockUserProfile): + """Sets the given/surname on the User when supplied.""" + user = get_or_create_user('godric1', first_name='Godric', last_name='Gryffindor') + assert user.first_name == 'Godric' + assert user.last_name == 'Gryffindor' + + def test_profile_name_from_first_last(self, MockUserProfile): + """Derives the profile name from the supplied first/last name.""" + get_or_create_user('godric1', first_name='Godric', last_name='Gryffindor') + _, kwargs = MockUserProfile.objects.update_or_create.call_args + assert kwargs['defaults']['name'] == 'Godric Gryffindor' + + def test_profile_name_defaults_without_names(self, MockUserProfile): + """Falls back to a generic profile name when no first/last is given.""" + get_or_create_user('nameless1') + _, kwargs = MockUserProfile.objects.update_or_create.call_args + assert kwargs['defaults']['name'] == 'Test Enterprise User' + @ddt.ddt @patch('enterprise.devstack_api.UserProfile') @@ -168,7 +208,7 @@ def setUp(self): ) @ddt.unpack def test_supported_role(self, _MockUserProfile, username, role, extra_kwargs): - """Returns a result dict echoing the role for every supported role.""" + """Returns the created user for every supported role.""" kwargs = {'username': username, 'role': role} if extra_kwargs.get('enterprise_customer'): kwargs['enterprise_customer'] = self.enterprise @@ -176,7 +216,7 @@ def test_supported_role(self, _MockUserProfile, username, role, extra_kwargs): kwargs['applies_to_all_contexts'] = True result = get_or_create_enterprise_user(**kwargs) assert result is not None - assert result['role'] == role + assert result.username == username def test_unknown_role_returns_none(self, _MockUserProfile): """Returns None when an unrecognised role string is passed.""" @@ -187,13 +227,13 @@ def test_operator_is_staff(self, _MockUserProfile): """Operator role provisions the underlying user as staff.""" result = get_or_create_enterprise_user( username='op_user', role=ENTERPRISE_OPERATOR_ROLE, applies_to_all_contexts=True) - assert result['user'].is_staff is True + assert result.is_staff is True def test_learner_is_not_staff(self, _MockUserProfile): """Learner role provisions the underlying user as non-staff.""" result = get_or_create_enterprise_user( username='learner_user', role=ENTERPRISE_LEARNER_ROLE, enterprise_customer=self.enterprise) - assert result['user'].is_staff is False + assert result.is_staff is False def test_creates_system_wide_role_assignment(self, _MockUserProfile): """Creates a SystemWideEnterpriseUserRoleAssignment for the new user.""" @@ -203,7 +243,7 @@ def test_creates_system_wide_role_assignment(self, _MockUserProfile): enterprise_customer=self.enterprise, ) assert SystemWideEnterpriseUserRoleAssignment.objects.filter( - user=result['user'], + user=result, ).exists() def test_applies_to_all_contexts(self, _MockUserProfile): @@ -213,20 +253,84 @@ def test_applies_to_all_contexts(self, _MockUserProfile): role=ENTERPRISE_OPERATOR_ROLE, applies_to_all_contexts=True, ) - assignment = SystemWideEnterpriseUserRoleAssignment.objects.get(user=result['user']) + assignment = SystemWideEnterpriseUserRoleAssignment.objects.get(user=result) assert assignment.applies_to_all_contexts is True - def test_admin_gets_feature_roles(self, _MockUserProfile): - """Admin role grants all four EnterpriseFeatureUserRoleAssignment rows.""" + def test_no_explicit_feature_role_assignments(self, _MockUserProfile): + """No explicit feature-role rows are created; feature access is implicit via the JWT mapping.""" result = get_or_create_enterprise_user( username='admin_user', role=ENTERPRISE_ADMIN_ROLE, enterprise_customer=self.enterprise) - assert EnterpriseFeatureUserRoleAssignment.objects.filter(user=result['user']).count() == 4 + assert EnterpriseFeatureUserRoleAssignment.objects.filter(user=result).count() == 0 - def test_learner_gets_no_feature_roles(self, _MockUserProfile): - """Learner role does not grant any EnterpriseFeatureUserRoleAssignment rows.""" + def test_email_and_name_passthrough(self, _MockUserProfile): + """email/first_name/last_name are passed through to the created User.""" result = get_or_create_enterprise_user( - username='learner_user', role=ENTERPRISE_LEARNER_ROLE, enterprise_customer=self.enterprise) - assert EnterpriseFeatureUserRoleAssignment.objects.filter(user=result['user']).count() == 0 + username='godric_learner', + role=ENTERPRISE_LEARNER_ROLE, + enterprise_customer=self.enterprise, + email='godric@corp.example', + first_name='Godric', + last_name='Gryffindor', + ) + assert result.email == 'godric@corp.example' + assert result.first_name == 'Godric' + assert result.last_name == 'Gryffindor' + + +@patch('enterprise.devstack_api.UserProfile') +@pytest.mark.django_db +class TestSeedGlobalOperatorUser(TestCase): + """Tests for ``seed_global_operator_user``.""" + + OPERATOR_USERNAME = 'enterprise_openedx_operator' + + def setUp(self): + ensure_enterprise_groups() + super().setUp() + + def test_creates_operator_user(self, _MockUserProfile): + """Creates and returns the shared global operator user.""" + result = seed_global_operator_user() + assert result.username == self.OPERATOR_USERNAME + assert result.is_staff is True + assert User.objects.filter(username=self.OPERATOR_USERNAME).count() == 1 + + def test_does_not_seed_admin_user(self, _MockUserProfile): + """No global admin user is seeded; tenant-scoped admins come from the seed command.""" + seed_global_operator_user() + assert not User.objects.filter(username='enterprise_admin').exists() + + def test_does_not_seed_ida_workers(self, _MockUserProfile): + """IDA service workers are provisioned by devstack, not seeded here.""" + seed_global_operator_user() + ida_workers = { + 'license-manager_worker', + 'enterprise-catalog_worker', + 'enterprise_worker', + 'ecommerce_worker', + } + assert not User.objects.filter(username__in=ida_workers).exists() + + def test_role_applies_to_all_contexts(self, _MockUserProfile): + """The operator's role assignment spans all enterprise contexts.""" + result = seed_global_operator_user() + assignment = SystemWideEnterpriseUserRoleAssignment.objects.get(user=result) + assert assignment.applies_to_all_contexts is True + assert assignment.enterprise_customer is None + + def test_not_linked_to_any_enterprise(self, _MockUserProfile): + """The operator is never linked to a specific enterprise.""" + result = seed_global_operator_user() + assert not EnterpriseCustomerUser.objects.filter(user_id=result.id).exists() + + def test_idempotent(self, _MockUserProfile): + """Calling twice creates no duplicate user or role assignment.""" + seed_global_operator_user() + seed_global_operator_user() + assert User.objects.filter(username=self.OPERATOR_USERNAME).count() == 1 + assert SystemWideEnterpriseUserRoleAssignment.objects.filter( + user__username=self.OPERATOR_USERNAME, + ).count() == 1 @pytest.mark.django_db @@ -319,3 +423,176 @@ def test_activates_inactive_enrollment(self, MockCourseEnrollment): enroll_learner_in_course(user, course_id, customer) mock_enrollment.activate.assert_called_once() + + +@pytest.mark.django_db +class TestUpdateOrCreateEnterpriseBranding(TestCase): + """Tests for ``update_or_create_enterprise_branding``.""" + + def setUp(self): + self.customer = EnterpriseCustomerFactory() + super().setUp() + + def _write_png(self, name): + """Write a minimal PNG to a fresh temp dir and return its path.""" + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir, ignore_errors=True) + path = os.path.join(tmp_dir, name) + with open(path, 'wb') as png_file: + png_file.write(_MINIMAL_PNG) + return path + + def _temp_media_root(self): + """Return a throwaway MEDIA_ROOT that is cleaned up after the test.""" + media_root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, media_root, ignore_errors=True) + return media_root + + def test_creates_branding_with_colors(self): + """Creates a branding config and applies every supplied accent color.""" + branding = update_or_create_enterprise_branding( + self.customer, + primary_color='#740001', + secondary_color='#D3A625', + tertiary_color='#EEBA30', + ) + assert branding.enterprise_customer == self.customer + assert branding.primary_color == '#740001' + assert branding.secondary_color == '#D3A625' + assert branding.tertiary_color == '#EEBA30' + assert not branding.logo + + def test_idempotent(self): + """Repeated calls update the single branding row instead of duplicating it.""" + update_or_create_enterprise_branding(self.customer, primary_color='#740001') + branding = update_or_create_enterprise_branding(self.customer, primary_color='#1A472A') + assert branding.primary_color == '#1A472A' + assert EnterpriseCustomerBrandingConfiguration.objects.filter( + enterprise_customer=self.customer, + ).count() == 1 + + def test_missing_logo_path_leaves_logo_unset(self): + """A logo_path with no file on disk is skipped, but colors are still applied.""" + branding = update_or_create_enterprise_branding( + self.customer, + logo_path='/nonexistent/does-not-exist.png', + primary_color='#740001', + ) + assert not branding.logo + assert branding.primary_color == '#740001' + + def test_sets_logo_from_path(self): + """A valid logo_path uploads the image and records it on the config.""" + logo_path = self._write_png('gryffindor.png') + with override_settings(MEDIA_ROOT=self._temp_media_root()): + branding = update_or_create_enterprise_branding(self.customer, logo_path=logo_path) + assert branding.logo + assert branding.logo.name.endswith('.png') + + def test_existing_logo_not_overwritten(self): + """A second call does not replace a logo the config already has.""" + first_path = self._write_png('first.png') + second_path = self._write_png('second.png') + with override_settings(MEDIA_ROOT=self._temp_media_root()): + first = update_or_create_enterprise_branding(self.customer, logo_path=first_path) + first_name = first.logo.name + second = update_or_create_enterprise_branding(self.customer, logo_path=second_path) + assert second.logo.name == first_name + + +@patch('enterprise.devstack_api.SAMLProviderConfig') +@pytest.mark.django_db +class TestCreateEnterpriseSamlProvider(TestCase): + """Tests for ``create_enterprise_saml_provider``.""" + + PROVIDER_KWARGS = { + 'slug': 'gryffindor', + 'name': 'Gryffindor IdP', + 'entity_id': 'https://keycloak.example/realms/gryffindor', + 'metadata_source': 'https://keycloak.example/realms/gryffindor/descriptor', + } + + def test_creates_provider_and_link(self, mock_saml_provider_config): + """Saves a SAMLProviderConfig version and links the IdP to the customer.""" + mock_saml_provider_config.return_value.provider_id = 'saml-gryffindor' + customer = EnterpriseCustomerFactory() + ecidp = create_enterprise_saml_provider(enterprise_customer=customer, **self.PROVIDER_KWARGS) + assert ecidp.enterprise_customer == customer + assert ecidp.provider_id == 'saml-gryffindor' + mock_saml_provider_config.return_value.save.assert_called_once() + + def test_applies_devstack_config(self, mock_saml_provider_config): + """The provider is created enabled/visible with the given SAML attributes.""" + mock_saml_provider_config.return_value.provider_id = 'saml-gryffindor' + customer = EnterpriseCustomerFactory() + create_enterprise_saml_provider( + enterprise_customer=customer, + attr_email='urn:oid:email', + **self.PROVIDER_KWARGS, + ) + _, kwargs = mock_saml_provider_config.call_args + assert kwargs['slug'] == 'gryffindor' + assert kwargs['enabled'] is True + assert kwargs['visible'] is True + assert kwargs['skip_registration_form'] is True + assert kwargs['skip_email_verification'] is True + assert kwargs['send_to_registration_first'] is True + assert kwargs['attr_email'] == 'urn:oid:email' + + def test_defaults_to_current_site(self, mock_saml_provider_config): + """When no site is given, the provider is attached to the current site.""" + mock_saml_provider_config.return_value.provider_id = 'saml-gryffindor' + customer = EnterpriseCustomerFactory() + create_enterprise_saml_provider(enterprise_customer=customer, **self.PROVIDER_KWARGS) + _, kwargs = mock_saml_provider_config.call_args + assert kwargs['site'] == Site.objects.get_current() + + def test_idempotent_link(self, mock_saml_provider_config): + """Repeated calls do not create a duplicate enterprise IdP link.""" + mock_saml_provider_config.return_value.provider_id = 'saml-gryffindor' + customer = EnterpriseCustomerFactory() + create_enterprise_saml_provider(enterprise_customer=customer, **self.PROVIDER_KWARGS) + create_enterprise_saml_provider(enterprise_customer=customer, **self.PROVIDER_KWARGS) + assert EnterpriseCustomerIdentityProvider.objects.filter( + provider_id='saml-gryffindor', + ).count() == 1 + + +@pytest.mark.django_db +class TestDeleteUserAndEnterpriseLinks(TestCase): + """Tests for ``delete_user_and_enterprise_links``.""" + + def test_deletes_user_and_returns_count(self): + """Deletes a matching user and reports one deletion.""" + UserFactory(email='newcomer@example.com') + deleted = delete_user_and_enterprise_links('newcomer@example.com') + assert deleted == 1 + assert not User.objects.filter(email='newcomer@example.com').exists() + + def test_deletes_enterprise_customer_user(self): + """Removes the non-cascading EnterpriseCustomerUser rows for the user.""" + customer = EnterpriseCustomerFactory() + user = UserFactory(email='newcomer@example.com') + link_user_to_enterprise(user, customer) + delete_user_and_enterprise_links('newcomer@example.com') + assert not EnterpriseCustomerUser.objects.filter(user_id=user.pk).exists() + + def test_deletes_pending_enterprise_customer_user(self): + """Clears the email-keyed PendingEnterpriseCustomerUser too.""" + PendingEnterpriseCustomerUserFactory(user_email='newcomer@example.com') + delete_user_and_enterprise_links('newcomer@example.com') + assert not PendingEnterpriseCustomerUser.objects.filter( + user_email='newcomer@example.com', + ).exists() + + def test_no_match_returns_zero(self): + """Returns 0 and does not error when no user has the given email.""" + deleted = delete_user_and_enterprise_links('absent@example.com') + assert deleted == 0 + + def test_leaves_other_users_untouched(self): + """Only deletes users whose email matches.""" + keep = UserFactory(email='keep@example.com') + UserFactory(email='remove@example.com') + delete_user_and_enterprise_links('remove@example.com') + assert User.objects.filter(pk=keep.pk).exists() diff --git a/tests/test_roles_api.py b/tests/test_roles_api.py index f5ab9b4c14..a3f08d89d8 100644 --- a/tests/test_roles_api.py +++ b/tests/test_roles_api.py @@ -1,6 +1,9 @@ """ Tests for the `roles_api` module. """ +import pytest + +from django.core.cache import cache from django.test import TestCase from enterprise import roles_api @@ -11,7 +14,8 @@ SYSTEM_ENTERPRISE_CATALOG_ADMIN_ROLE, SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, ) -from enterprise.models import SystemWideEnterpriseRole +from enterprise.models import SystemWideEnterpriseRole, SystemWideEnterpriseUserRoleAssignment +from test_utils.factories import EnterpriseCustomerFactory, UserFactory class TestUpdateRoleAssignmentsCommand(TestCase): @@ -41,3 +45,97 @@ def test_roles_by_name(self): for role_name in self.ALL_ROLE_NAMES: role_object = roles_api.roles_by_name().get(role_name) self.assertEqual(role_name, role_object.name) + + +@pytest.mark.django_db +class TestAssignRole(TestCase): + """Tests for ``roles_api.assign_role``.""" + + def setUp(self): + # The system-wide role getter is cache_memoize-cached; clear it so each + # test resolves roles against its own transaction rather than a stale + # (rolled-back) role object from a prior test. + cache.clear() + self.user = UserFactory() + self.enterprise = EnterpriseCustomerFactory() + super().setUp() + + def test_creates_all_contexts_assignment(self): + """Creates an all-contexts assignment and reports created=True.""" + assignment, created = roles_api.assign_role( + self.user, + ENTERPRISE_OPERATOR_ROLE, + applies_to_all_contexts=True, + ) + assert created is True + assert assignment.applies_to_all_contexts is True + assert assignment.enterprise_customer is None + assert assignment.role.name == ENTERPRISE_OPERATOR_ROLE + + def test_creates_customer_scoped_assignment(self): + """Scopes the assignment to the given enterprise customer.""" + assignment, created = roles_api.assign_role( + self.user, + ENTERPRISE_ADMIN_ROLE, + enterprise_customer=self.enterprise, + ) + assert created is True + assert assignment.enterprise_customer == self.enterprise + assert assignment.applies_to_all_contexts is False + + def test_idempotent(self): + """A repeat call returns the same row with created=False.""" + first, first_created = roles_api.assign_role( + self.user, + ENTERPRISE_OPERATOR_ROLE, + applies_to_all_contexts=True, + ) + second, second_created = roles_api.assign_role( + self.user, + ENTERPRISE_OPERATOR_ROLE, + applies_to_all_contexts=True, + ) + assert first_created is True + assert second_created is False + assert first.pk == second.pk + assert SystemWideEnterpriseUserRoleAssignment.objects.filter(user=self.user).count() == 1 + + def test_idempotent_ignores_applies_to_all_contexts_change(self): + """A repeat call with a different ``applies_to_all_contexts`` returns the existing row. + + ``applies_to_all_contexts`` is not part of the unique key, so it must not + participate in the lookup -- otherwise the second call would attempt a + duplicate insert and raise IntegrityError. + """ + first, first_created = roles_api.assign_role( + self.user, + ENTERPRISE_ADMIN_ROLE, + enterprise_customer=self.enterprise, + applies_to_all_contexts=False, + ) + second, second_created = roles_api.assign_role( + self.user, + ENTERPRISE_ADMIN_ROLE, + enterprise_customer=self.enterprise, + applies_to_all_contexts=True, + ) + assert first_created is True + assert second_created is False + assert first.pk == second.pk + assert SystemWideEnterpriseUserRoleAssignment.objects.filter(user=self.user).count() == 1 + + def test_creates_role_row_if_missing(self): + """Creates the SystemWideEnterpriseRole row when it does not yet exist.""" + SystemWideEnterpriseRole.objects.filter(name=ENTERPRISE_OPERATOR_ROLE).delete() + roles_api.assign_role( + self.user, + ENTERPRISE_OPERATOR_ROLE, + applies_to_all_contexts=True, + ) + assert SystemWideEnterpriseRole.objects.filter(name=ENTERPRISE_OPERATOR_ROLE).exists() + + def test_unknown_role_raises(self): + """An unrecognised role name raises ``UnknownSystemWideRoleError``.""" + with pytest.raises(roles_api.UnknownSystemWideRoleError): + roles_api.assign_role(self.user, 'bogus_role') + assert not SystemWideEnterpriseUserRoleAssignment.objects.filter(user=self.user).exists()