diff --git a/src/coldfront_plugin_cloud/config.py b/src/coldfront_plugin_cloud/config.py index 496aa8dc..54c7f3db 100644 --- a/src/coldfront_plugin_cloud/config.py +++ b/src/coldfront_plugin_cloud/config.py @@ -1,3 +1,5 @@ +import os + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV # noqa: F401 @@ -5,3 +7,5 @@ INSTALLED_APPS += [ "coldfront_plugin_cloud", ] + +PLUGIN_KEYCLOAK_ENABLED = False if os.getenv("KEYCLOAK_BASE_URL") is None else True diff --git a/src/coldfront_plugin_cloud/kc_client.py b/src/coldfront_plugin_cloud/kc_client.py deleted file mode 100644 index d4ed83de..00000000 --- a/src/coldfront_plugin_cloud/kc_client.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import functools - -import requests -from pydantic import BaseModel, ConfigDict, RootModel - - -class KeyCloakGroup(BaseModel): - """Keycloak group response model""" - - model_config = ConfigDict(extra="allow") - id: str - name: str - - -class GroupResponse(RootModel): - """Wrapper for group list responses""" - - root: list[KeyCloakGroup] - - -class KeyCloakUser(BaseModel): - """Keycloak user response model""" - - model_config = ConfigDict(extra="allow") - id: str - username: str - - -class UserResponse(RootModel): - """Wrapper for user list responses""" - - root: list[KeyCloakUser] - - -class KeyCloakAPIClient: - def __init__(self): - self.base_url = os.getenv("KEYCLOAK_BASE_URL") - self.realm = os.getenv("KEYCLOAK_REALM") - self.client_id = os.getenv("KEYCLOAK_CLIENT_ID") - self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET") - - self.token_url = ( - f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token" - ) - - @functools.cached_property - def api_client(self): - params = { - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret, - } - r = requests.post(self.token_url, data=params) - r.raise_for_status() - headers = { - "Authorization": ("Bearer %s" % r.json()["access_token"]), - "Content-Type": "application/json", - } - session = requests.session() - session.headers.update(headers) - return session - - def create_group(self, group_name): - url = f"{self.base_url}/admin/realms/{self.realm}/groups" - payload = {"name": group_name} - response = self.api_client.post(url, json=payload) - - # If group already exists, ignore and move on - if response.status_code not in (201, 409): - response.raise_for_status() - - def get_group_id(self, group_name) -> str | None: - """Return None if group not found""" - query = { - "search": group_name, - "exact": "true", - } - url = f"{self.base_url}/admin/realms/{self.realm}/groups" - r = self.api_client.get(url, params=query) - r.raise_for_status() - groups = GroupResponse.model_validate(r.json()) - return groups.root[0].id if groups.root else None - - def get_user_id(self, cf_username) -> str | None: - """Return None if user not found""" - # (Quan) Coldfront usernames map to Keycloak usernames - # https://github.com/nerc-project/coldfront-plugin-cloud/pull/249#discussion_r2953393852 - query = {"username": cf_username, "exact": "true"} - url = f"{self.base_url}/admin/realms/{self.realm}/users" - r = self.api_client.get(url, params=query) - r.raise_for_status() - users = UserResponse.model_validate(r.json()) - return users.root[0].id if users.root else None - - def add_user_to_group(self, user_id, group_id): - url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" - r = self.api_client.put(url) - r.raise_for_status() - - def remove_user_from_group(self, user_id, group_id): - url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" - r = self.api_client.delete(url) - r.raise_for_status() - - def get_user_groups(self, user_id) -> list[str]: - url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups" - r = self.api_client.get(url) - r.raise_for_status() - groups = GroupResponse.model_validate(r.json()) - return [group.name for group in groups.root] diff --git a/src/coldfront_plugin_cloud/keycloak.py b/src/coldfront_plugin_cloud/keycloak.py new file mode 100644 index 00000000..5ed3dd88 --- /dev/null +++ b/src/coldfront_plugin_cloud/keycloak.py @@ -0,0 +1,252 @@ +import os +import functools +import logging +from string import Template + + +import requests +from pydantic import BaseModel, ConfigDict, RootModel + +from coldfront_plugin_cloud import base, attributes + + +logger = logging.getLogger(__name__) + + +def _clean_template_string(template_string: str) -> str: + return template_string.replace(" ", "_").lower() + + +class MissingKeycloakTemplateError(Exception): + pass + + +class KeyCloakGroup(BaseModel): + """Keycloak group response model""" + + model_config = ConfigDict(extra="allow") + id: str + name: str + + +class GroupResponse(RootModel): + """Wrapper for group list responses""" + + root: list[KeyCloakGroup] + + +class KeyCloakUser(BaseModel): + """Keycloak user response model""" + + model_config = ConfigDict(extra="allow") + id: str + username: str + + +class UserResponse(RootModel): + """Wrapper for user list responses""" + + root: list[KeyCloakUser] + + +class KeyCloakResourceAllocator(base.ResourceAllocator): + def __init__(self, resource, allocation): + self.resource = resource + self.allocation = allocation + + self.base_url = os.getenv("KEYCLOAK_BASE_URL") + self.realm = os.getenv("KEYCLOAK_REALM") + self.client_id = os.getenv("KEYCLOAK_CLIENT_ID") + self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET") + + self.token_url = ( + f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token" + ) + + @functools.cached_property + def group_name_template(self) -> str | None: + return self.resource.get_attribute(attributes.RESOURCE_KEYCLOAK_GROUP_TEMPLATE) + + @functools.cached_property + def group_name(self): + """ + Acceptable variables for the group name template string is: + - $resource_name: the name of the resource (e.g. "OpenShift") + - Any allocation attribute defined for the allocation, with spaces replaced by underscores and + all lowercase (e.g. for `Project Name`, the variable would be `$project_name`) + """ + if self.group_name_template is None: + raise MissingKeycloakTemplateError( + f"Keycloak enabled but no group name template specified for resource {self.resource.name}" + ) + + resource_name = self.resource.name + allocation_attrs_list = self.allocation.allocationattribute_set.all() + + template_sub_dict = {"resource_name": resource_name} + for attr in allocation_attrs_list: + template_sub_dict[ + _clean_template_string(attr.allocation_attribute_type.name) + ] = attr.value + + return Template(self.group_name_template).substitute(**template_sub_dict) + + @functools.cached_property + def api_client(self): + params = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + r = requests.post(self.token_url, data=params) + r.raise_for_status() + headers = { + "Authorization": ("Bearer %s" % r.json()["access_token"]), + "Content-Type": "application/json", + } + session = requests.session() + session.headers.update(headers) + return session + + def create_federated_user(self, cf_username): + """For testing purposes""" + url = f"{self.base_url}/admin/realms/{self.realm}/users" + payload = { + "username": cf_username, + "enabled": True, + "email": cf_username, + } + r = self.api_client.post(url, json=payload) + r.raise_for_status() + + def create_group(self, group_name): + url = f"{self.base_url}/admin/realms/{self.realm}/groups" + payload = {"name": group_name} + response = self.api_client.post(url, json=payload) + + # If group already exists, ignore and move on + if response.status_code not in (201, 409): + response.raise_for_status() + + def get_group_id(self, group_name) -> str | None: + """Return None if group not found""" + query = { + "search": group_name, + "exact": "true", + } + url = f"{self.base_url}/admin/realms/{self.realm}/groups" + r = self.api_client.get(url, params=query) + r.raise_for_status() + groups = GroupResponse.model_validate(r.json()) + return groups.root[0].id if groups.root else None + + def get_user_id(self, cf_username) -> str | None: + """Return None if user not found""" + # (Quan) Coldfront usernames map to Keycloak usernames + # https://github.com/nerc-project/coldfront-plugin-cloud/pull/249#discussion_r2953393852 + query = {"username": cf_username, "exact": "true"} + url = f"{self.base_url}/admin/realms/{self.realm}/users" + r = self.api_client.get(url, params=query) + r.raise_for_status() + users = UserResponse.model_validate(r.json()) + return users.root[0].id if users.root else None + + def add_user_to_group(self, user_id, group_id): + url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" + r = self.api_client.put(url) + r.raise_for_status() + + def remove_user_from_group(self, user_id, group_id): + url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" + r = self.api_client.delete(url) + r.raise_for_status() + + def get_user_groups(self, user_id) -> list[str]: + url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups" + r = self.api_client.get(url) + r.raise_for_status() + groups = GroupResponse.model_validate(r.json()) + return [group.name for group in groups.root] + + def get_group_members(self, group_id) -> list[str]: + url = f"{self.base_url}/admin/realms/{self.realm}/groups/{group_id}/members" + page_size = 100 # Default KeyCloak page size https://www.keycloak.org/docs-api/latest/rest-api/index.html#_query_parameters_32 + page_offset = 0 + users = [] + + while True: + r = self.api_client.get( + url, params={"first": page_offset, "max": page_size} + ) + r.raise_for_status() + batch = UserResponse.model_validate(r.json()) + users.extend(user.username for user in batch.root) + if len(batch.root) < page_size: + break + page_offset += page_size + + return users + + def get_users(self, group_name) -> list[str]: + """Returns list of usernames (not uuids) belonging to group 'group_name' + If group hasn't been made yet, returns empty list""" + group_id = self.get_group_id(group_name) + return [] if group_id is None else self.get_group_members(group_id) + + def get_or_create_federated_user(self, username): + """Only returns uuid for username. If user not found in Keycloak, they should not be created""" + return self.get_user_id(username) + + def assign_role_on_user(self, username, group_name): + if (user_id := self.get_user_id(username)) is None: + logger.warning( + f"User {username} not found in Keycloak, cannot add to group." + ) + return + + self.create_group(group_name) + group_id = self.get_group_id(group_name) + self.add_user_to_group(user_id, group_id) + logger.info(f"User {username} added to Keycloak group {group_name}") + + def remove_role_from_user(self, username, group_name): + if (user_id := self.get_user_id(username)) is None: + logger.warning( + f"User {username} not found in Keycloak, cannot remove from group." + ) + return + + if (group_id := self.get_group_id(group_name)) is None: + logger.warning( + f"Group {group_name} not found in Keycloak, skipping removal for user {username}." + ) + return + self.remove_user_from_group(user_id, group_id) + logger.info(f"User {username} removed from Keycloak group {group_name}") + + def set_project_configuration(self, project_id, apply=True): + raise NotImplementedError + + def get_project(self, project_id): + raise NotImplementedError + + def create_project(self, suggested_project_name): + raise NotImplementedError + + def disable_project(self, project_id): + raise NotImplementedError + + def reactivate_project(self, project_id): + raise NotImplementedError + + def create_project_defaults(self, project_id): + raise NotImplementedError + + def set_quota(self, project_id): + raise NotImplementedError + + def get_quota(self, project_id): + raise NotImplementedError + + def get_federated_user(self, unique_id): + raise NotImplementedError diff --git a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py index 1edf04d9..906d0b0b 100644 --- a/src/coldfront_plugin_cloud/management/commands/validate_allocations.py +++ b/src/coldfront_plugin_cloud/management/commands/validate_allocations.py @@ -3,7 +3,9 @@ from coldfront_plugin_cloud import attributes from coldfront_plugin_cloud import utils from coldfront_plugin_cloud import tasks +from coldfront_plugin_cloud import keycloak +from django.conf import settings from django.core.management.base import BaseCommand from coldfront.core.resource.models import Resource from coldfront.core.allocation.models import ( @@ -70,6 +72,15 @@ def handle(self, *args, **options): ) continue + if settings.PLUGIN_KEYCLOAK_ENABLED: + try: + keycloak_allocator = tasks.get_kc_allocator(allocation) + keycloak_allocator.set_users( + keycloak_allocator.group_name, apply=options["apply"] + ) + except keycloak.MissingKeycloakTemplateError as e: + logging.warning(f"Skipping Keycloak validation: {e}") + # Check project exists in remote cluster try: allocator.get_project(project_id) diff --git a/src/coldfront_plugin_cloud/signals.py b/src/coldfront_plugin_cloud/signals.py index fa29407e..995cc943 100644 --- a/src/coldfront_plugin_cloud/signals.py +++ b/src/coldfront_plugin_cloud/signals.py @@ -1,6 +1,7 @@ import os from django.dispatch import receiver +from django.conf import settings from django_q.tasks import async_task from coldfront_plugin_cloud.tasks import ( @@ -27,10 +28,6 @@ def is_async(): return os.getenv("REDIS_HOST") -def is_keycloak_enabled(): - return os.getenv("KEYCLOAK_BASE_URL") - - @receiver(allocation_activate) @receiver(allocation_change_approved) def activate_allocation_receiver(sender, **kwargs): @@ -54,11 +51,11 @@ def activate_allocation_user_receiver(sender, **kwargs): allocation_user_pk = kwargs.get("allocation_user_pk") if is_async(): async_task(add_user_to_allocation, allocation_user_pk) - if is_keycloak_enabled(): + if settings.PLUGIN_KEYCLOAK_ENABLED: async_task(add_user_to_keycloak, allocation_user_pk) else: add_user_to_allocation(allocation_user_pk) - if is_keycloak_enabled(): + if settings.PLUGIN_KEYCLOAK_ENABLED: add_user_to_keycloak(allocation_user_pk) @@ -67,5 +64,5 @@ def allocation_remove_user_receiver(sender, **kwargs): allocation_user_pk = kwargs.get("allocation_user_pk") remove_user_from_allocation(allocation_user_pk) - if is_keycloak_enabled(): + if settings.PLUGIN_KEYCLOAK_ENABLED: remove_user_from_keycloak(allocation_user_pk) diff --git a/src/coldfront_plugin_cloud/tasks.py b/src/coldfront_plugin_cloud/tasks.py index b04aa0e8..5623f4c5 100644 --- a/src/coldfront_plugin_cloud/tasks.py +++ b/src/coldfront_plugin_cloud/tasks.py @@ -1,30 +1,29 @@ import datetime import logging import time -from string import Template from coldfront.core.allocation.models import ( Allocation, AllocationUser, - AllocationAttribute, ) from coldfront_plugin_cloud import ( attributes, base, + keycloak, openstack, openshift, esi, openshift_vm, utils, - kc_client, ) logger = logging.getLogger(__name__) -def get_kc_client(): - return kc_client.KeyCloakAPIClient() +def get_kc_allocator(allocation): + resource = allocation.resources.first() + return keycloak.KeyCloakResourceAllocator(resource, allocation) def find_allocator(allocation) -> base.ResourceAllocator: @@ -140,83 +139,23 @@ def remove_user_from_allocation(allocation_user_pk): logger.warning("No project has been created. Nothing to disable.") -def _clean_template_string(template_string: str) -> str: - return template_string.replace(" ", "_").lower() - - -def _get_keycloak_group_name(allocation: Allocation, template_string: str) -> str: - """ - Acceptable variables for the group name template string is: - - $resource_name: the name of the resource (e.g. "OpenShift") - - Any allocation attribute defined for the allocation, with spaces replaced by underscores and - all lowercase (e.g. for `Project Name`, the variable would be `$project_name`) - """ - resource_name = allocation.resources.first().name - allocation_attrs_list: list[AllocationAttribute] = ( - allocation.allocationattribute_set.all() - ) - - template_sub_dict = {"resource_name": resource_name} - for attr in allocation_attrs_list: - template_sub_dict[ - _clean_template_string(attr.allocation_attribute_type.name) - ] = attr.value - - return Template(template_string).substitute(**template_sub_dict) - - def add_user_to_keycloak(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) allocation = allocation_user.allocation - - kc_admin_client = get_kc_client() + kc_allocator = get_kc_allocator(allocation) username = allocation_user.user.username - - group_name_template = allocation.resources.first().get_attribute( - attributes.RESOURCE_KEYCLOAK_GROUP_TEMPLATE - ) - if group_name_template is None: - logger.info( - f"Keycloak enabled but no group name template specified for resource {allocation.resources.first().name}. Skipping addition to Keycloak group" - ) - return - - if (user_id := kc_admin_client.get_user_id(username)) is None: - logger.warning(f"User {username} not found in Keycloak, cannot add to group.") - return - - group_name = _get_keycloak_group_name(allocation, group_name_template) - kc_admin_client.create_group(group_name) - group_id = kc_admin_client.get_group_id(group_name) - kc_admin_client.add_user_to_group(user_id, group_id) + try: + kc_allocator.assign_role_on_user(username, kc_allocator.group_name) + except keycloak.MissingKeycloakTemplateError as e: + logger.info(f"Skipping adding user to Keycloak: {e}") def remove_user_from_keycloak(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) allocation = allocation_user.allocation - - kc_admin_client = get_kc_client() + kc_allocator = get_kc_allocator(allocation) username = allocation_user.user.username - - group_name_template = allocation.resources.first().get_attribute( - attributes.RESOURCE_KEYCLOAK_GROUP_TEMPLATE - ) - if group_name_template is None: - logger.info( - f"Keycloak enabled but no group name template specified for resource {allocation.resources.first().name}. Skipping removal from Keycloak group" - ) - return - - if (user_id := kc_admin_client.get_user_id(username)) is None: - logger.warning( - f"User {username} not found in Keycloak, cannot remove from group." - ) - return - - group_name = _get_keycloak_group_name(allocation, group_name_template) - if (group_id := kc_admin_client.get_group_id(group_name)) is None: - logger.warning( - f"Group {group_name} not found in Keycloak, skipping removal for user {username}." - ) - return - kc_admin_client.remove_user_from_group(user_id, group_id) + try: + kc_allocator.remove_role_from_user(username, kc_allocator.group_name) + except keycloak.MissingKeycloakTemplateError as e: + logger.info(f"Skipping removing user from Keycloak: {e}") diff --git a/src/coldfront_plugin_cloud/tests/functional/keycloak/test_keycloak.py b/src/coldfront_plugin_cloud/tests/functional/keycloak/test_keycloak.py index e515d82a..55b6c8a9 100644 --- a/src/coldfront_plugin_cloud/tests/functional/keycloak/test_keycloak.py +++ b/src/coldfront_plugin_cloud/tests/functional/keycloak/test_keycloak.py @@ -1,7 +1,10 @@ +from unittest import mock + from django.contrib.auth.models import User +from django.core.management import call_command from coldfront.core.resource.models import ResourceAttribute, ResourceAttributeType -from coldfront_plugin_cloud import tasks, kc_client, attributes, utils +from coldfront_plugin_cloud import keycloak, tasks, attributes, utils from coldfront_plugin_cloud.tests import base @@ -9,7 +12,6 @@ class TestKeyCloakUserManagement(base.TestBase): @classmethod def setUpTestData(cls) -> None: super().setUpTestData() - cls.kc_admin_client = kc_client.KeyCloakAPIClient() cls.resource = cls.new_openshift_resource( name="Test Resource", ) @@ -21,20 +23,26 @@ def setUpTestData(cls) -> None: value="$resource_name/$allocated_project_id", ) - def new_keycloak_user(self, cf_username): - url = f"{self.kc_admin_client.base_url}/admin/realms/{self.kc_admin_client.realm}/users" - payload = { - "username": cf_username, - "enabled": True, - "email": cf_username, - } - r = self.kc_admin_client.api_client.post(url, json=payload) - r.raise_for_status() + cls.kc_allocator = keycloak.KeyCloakResourceAllocator( + mock.MagicMock, mock.MagicMock + ) + + def setUp(self) -> None: + """To avoid internal validations that the base `Allocator` performs. Not relevant for these tests""" + mock_allocator = mock.MagicMock() + mock_allocator.allocation_str = "Test Allocation of project Test Project" + self.patcher = mock.patch( + "coldfront_plugin_cloud.tasks.find_allocator", return_value=mock_allocator + ) + self.mock_find_allocator = self.patcher.start() + + def tearDown(self) -> None: + self.patcher.stop() def new_user(self, username=None, add_to_keycloak=True) -> User: user = super().new_user(username) if add_to_keycloak: - self.new_keycloak_user(user.username) + self.kc_allocator.create_federated_user(user.username) return user def new_allocation( @@ -51,18 +59,19 @@ def test_user_added_to_allocation(self): user = self.new_user() project = self.new_project(pi=user) allocation = self.new_allocation(project, self.resource, 1) - allocation_user = self.new_allocation_user(allocation, user) + self.new_allocation_user(allocation, user) - # Simulate triggering the allocation activate signal - tasks.add_user_to_keycloak(allocation_user.pk) + # Validation should add user to Keycloak group + call_command("validate_allocations", apply=True) # Check that the user exists in Keycloak - user_id = self.kc_admin_client.get_user_id(user.username) + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) + user_id = kc_allocator.get_user_id(user.username) self.assertIsNotNone(user_id) # Check that the user is in the project group # Group name determined by the RESOURCE_KEYCLOAK_GROUP_TEMPLATE attribute, set to "$resource_name/$allocated_project_id" in tests - user_groups = self.kc_admin_client.get_user_groups(user_id) + user_groups = kc_allocator.get_user_groups(user_id) self.assertIn(f"{self.resource.name}/Test Value", user_groups) def test_user_removed_from_allocation(self): @@ -72,16 +81,18 @@ def test_user_removed_from_allocation(self): allocation = self.new_allocation(project, self.resource, 1) allocation_user = self.new_allocation_user(allocation, user) - tasks.add_user_to_keycloak(allocation_user.pk) + call_command("validate_allocations", apply=True) - user_id = self.kc_admin_client.get_user_id(user.username) - user_groups = self.kc_admin_client.get_user_groups(user_id) + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) + user_id = kc_allocator.get_user_id(user.username) + user_groups = kc_allocator.get_user_groups(user_id) self.assertIn(f"{self.resource.name}/Test Value", user_groups) - tasks.remove_user_from_keycloak(allocation_user.pk) + allocation_user.delete() + call_command("validate_allocations", apply=True) # Check that the user is no longer in the group - user_groups = self.kc_admin_client.get_user_groups(user_id) + user_groups = kc_allocator.get_user_groups(user_id) self.assertNotIn(f"{self.resource.name}/Test Value", user_groups) def test_user_not_in_keycloak_added_to_allocation(self): @@ -91,18 +102,17 @@ def test_user_not_in_keycloak_added_to_allocation(self): allocation = self.new_allocation( project, self.resource, 1, attr_value="Test Not Created" ) - allocation_user = self.new_allocation_user(allocation, user) + self.new_allocation_user(allocation, user) # Should not raise error - tasks.add_user_to_keycloak(allocation_user.pk) + call_command("validate_allocations", apply=True) - user_id = self.kc_admin_client.get_user_id(user.username) + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) + user_id = kc_allocator.get_user_id(user.username) self.assertIsNone(user_id) # Verify the group was not created at all - group_id = self.kc_admin_client.get_group_id( - f"{self.resource.name}/Test Not Created" - ) + group_id = kc_allocator.get_group_id(f"{self.resource.name}/Test Not Created") self.assertIsNone(group_id) def test_user_not_in_keycloak_removed_from_allocation(self): @@ -113,7 +123,8 @@ def test_user_not_in_keycloak_removed_from_allocation(self): allocation_user = self.new_allocation_user(allocation, user) # Verify the user doesn't exist in Keycloak - user_id = self.kc_admin_client.get_user_id(user.username) + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) + user_id = kc_allocator.get_user_id(user.username) self.assertIsNone(user_id) # Try to remove the user from the allocation (should not raise an error) @@ -127,17 +138,16 @@ def test_multiple_users_in_same_allocation(self): # Add multiple users to the allocation users = [self.new_user() for _ in range(3)] - allocation_users = [ - self.new_allocation_user(allocation, user) for user in users - ] + for user in users: + self.new_allocation_user(allocation, user) - for allocation_user in allocation_users: - tasks.add_user_to_keycloak(allocation_user.pk) + call_command("validate_allocations", apply=True) # Verify all users are in the group + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) for user in users: - user_id = self.kc_admin_client.get_user_id(user.username) - user_groups = self.kc_admin_client.get_user_groups(user_id) + user_id = kc_allocator.get_user_id(user.username) + user_groups = kc_allocator.get_user_groups(user_id) self.assertIn(f"{self.resource.name}/Test Value", user_groups) def test_remove_one_user_keeps_others_in_group(self): @@ -151,18 +161,18 @@ def test_remove_one_user_keeps_others_in_group(self): self.new_allocation_user(allocation, user) for user in users ] - for allocation_user in allocation_users: - tasks.add_user_to_keycloak(allocation_user.pk) + call_command("validate_allocations", apply=True) tasks.remove_user_from_keycloak(allocation_users[0].pk) # Verify all users except the removed one are still in the group - user1_id = self.kc_admin_client.get_user_id(users[0].username) - user1_groups = self.kc_admin_client.get_user_groups(user1_id) + kc_allocator = keycloak.KeyCloakResourceAllocator(self.resource, allocation) + user1_id = kc_allocator.get_user_id(users[0].username) + user1_groups = kc_allocator.get_user_groups(user1_id) self.assertNotIn(f"{self.resource.name}/Test Value", user1_groups) - user2_id = self.kc_admin_client.get_user_id(users[1].username) - user2_groups = self.kc_admin_client.get_user_groups(user2_id) + user2_id = kc_allocator.get_user_id(users[1].username) + user2_groups = kc_allocator.get_user_groups(user2_id) self.assertIn(f"{self.resource.name}/Test Value", user2_groups) def test_user_in_multiple_allocations_groups(self): @@ -181,14 +191,16 @@ def test_user_in_multiple_allocations_groups(self): # Add user to both allocations allocation_user1 = self.new_allocation_user(allocation1, user) - allocation_user2 = self.new_allocation_user(allocation2, user) + self.new_allocation_user(allocation2, user) - tasks.add_user_to_keycloak(allocation_user1.pk) - tasks.add_user_to_keycloak(allocation_user2.pk) + call_command("validate_allocations", apply=True) # Verify user is in both groups - user_id = self.kc_admin_client.get_user_id(user.username) - user_groups = self.kc_admin_client.get_user_groups(user_id) + kc_allocator = keycloak.KeyCloakResourceAllocator( + self.resource, allocation1 + ) # Shouldn't matter which allocation used here for purpose of this test + user_id = kc_allocator.get_user_id(user.username) + user_groups = kc_allocator.get_user_groups(user_id) self.assertIn(f"{self.resource.name}/Test Value 1", user_groups) self.assertIn(f"{self.resource.name}/Test Value 2", user_groups) @@ -196,7 +208,7 @@ def test_user_in_multiple_allocations_groups(self): tasks.remove_user_from_keycloak(allocation_user1.pk) # Verify user is now only in second group - user_groups = self.kc_admin_client.get_user_groups(user_id) + user_groups = kc_allocator.get_user_groups(user_id) self.assertNotIn(f"{self.resource.name}/Test Value 1", user_groups) self.assertIn(f"{self.resource.name}/Test Value 2", user_groups) @@ -219,13 +231,42 @@ def test_user_added_without_keycloak_group_template(self): # Verify the warning was logged self.assertEqual(len(log.records), 1) self.assertIn( - "Keycloak enabled but no group name template specified for resource Resource No Template", + "Skipping adding user to Keycloak: Keycloak enabled but no group name template specified for resource Resource No Template", log.records[0].getMessage(), ) self.assertIn(resource_no_template.name, log.records[0].getMessage()) # Verify the user exists in Keycloak but is not in any groups - user_id = self.kc_admin_client.get_user_id(user.username) + user_id = self.kc_allocator.get_user_id(user.username) self.assertIsNotNone(user_id) - user_groups = self.kc_admin_client.get_user_groups(user_id) + user_groups = self.kc_allocator.get_user_groups(user_id) self.assertEqual(user_groups, []) + + +class TestKeyCloakGetGroupMembersPagination(base.TestBase): + @classmethod + def setUpTestData(cls) -> None: + super().setUpTestData() + + def test_get_group_members_pagination(self): + resource = self.new_openshift_resource( + name="Test Resource", + ) + project = self.new_project() + allocation = self.new_allocation(project, resource, 2) + kc_allocator = keycloak.KeyCloakResourceAllocator(resource, allocation) + group_name = "Test Pagination Group" + kc_allocator.create_group(group_name) + group_id = kc_allocator.get_group_id(group_name) + + # Create 250 users and add them to the group (to ensure pagination is needed, as page size is 100) + for i in range(250): + username = f"pagination_user_{i}@example.com" + self.new_user(username=username) + kc_allocator.create_federated_user(username) + kc_allocator.add_user_to_group(kc_allocator.get_user_id(username), group_id) + + members = kc_allocator.get_group_members(group_id) + self.assertEqual(len(members), 250) + expected_usernames = {f"pagination_user_{i}@example.com" for i in range(250)} + self.assertEqual(set(members), expected_usernames)