diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 99e3b66b0..15420ae3b 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 242fe8c07..7416d52c7 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 d8c869a01..0577bbf34 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 635868a1c..c9137a22d 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 381a1e693..a30fc9fd4 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 c72202f36..f6c4a0c51 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 000000000..bc95e7fbc --- /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 223b61f9a..0d8c4b4e3 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 f2b5c11ff..3fa2a4c77 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 34db583e1..2c13e2a88 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 fee97a33f..d281a9d57 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 e67a20c7c..ce5cbc294 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 000000000..0652e5186 --- /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 0da3a3d66..c90960ba1 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 2d3bb5bf5..7be4ef15e 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 000000000..e12490528 Binary files /dev/null and b/provision-tpa/gryffindor.png differ diff --git a/provision-tpa/slytherin.png b/provision-tpa/slytherin.png new file mode 100644 index 000000000..f76f330cc Binary files /dev/null and b/provision-tpa/slytherin.png differ diff --git a/scripts/provision-integration-test-ENT-11568.sh b/scripts/provision-integration-test-ENT-11568.sh new file mode 100755 index 000000000..90c3af01b --- /dev/null +++ b/scripts/provision-integration-test-ENT-11568.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# +# Provision a devstack environment for integration-testing the six new +# openedx-filter pipeline steps added in ticket ENT-11568 +# (Logistration Enterprise Context). +# +# Instructions: +# +# 1. Start relevant devstack services: +# +# make dev.up.lms+frontend-app-authn+frontend-app-learner-portal-enterprise +# +# 2. From the edx-enterprise directory, provision keycloak and run this script: +# +# make dev.provision.keycloak +# ./scripts/provision-integration-test-ENT-11568.sh + +set -eu -o pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# Per-tenant SSO usernames come from the single source of truth that the Keycloak +# realm import and provision-tpa.py also use: keycloak-devstack.env. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=/dev/null +source "${REPO_ROOT}/keycloak-devstack.env" + +set -x + +# Username for a learner linked to BOTH enterprises (Gryffindor + Slytherin) +# to trigger a multi-enterprise drop-down selection interstitial during login. +DUAL_LEARNER="dual_enterprise_learner" + +LMS_BASE="http://localhost:18000" + +# --------------------------------------------------------------------------- +# Helpers -- run a Django management command inside the LMS container +# --------------------------------------------------------------------------- + +lms_manage() { + docker exec -i edx.devstack.lms python manage.py lms --settings devstack "$@" +} + +# --------------------------------------------------------------------------- +# Step 1: Create a learner linked to two enterprises +# --------------------------------------------------------------------------- +# Create an enterprise learner linked with both enterprises. +# The first --enterprise-name occurrence is made into the "active" enterprise. +lms_manage create_enterprise_linked_learner \ + --username "$DUAL_LEARNER" \ + --enterprise-name "$GRYFFINDOR_ENTERPRISE_NAME" \ + --enterprise-name "$SLYTHERIN_ENTERPRISE_NAME" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +set +x +cat < 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 000000000..917efb527 --- /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 000000000..afb50a26b --- /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 dbac86161..d1ab8c1e6 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 f5ab9b4c1..a3f08d89d 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()