diff --git a/bin/mas-devops-create-initial-users-for-saas b/bin/mas-devops-create-initial-users-for-saas index 7372f495..1a05fbf2 100644 --- a/bin/mas-devops-create-initial-users-for-saas +++ b/bin/mas-devops-create-initial-users-for-saas @@ -34,6 +34,7 @@ if __name__ == "__main__": parser.add_argument("--coreapi-port", required=False, default=443) parser.add_argument("--admin-dashboard-port", required=False, default=443) parser.add_argument("--manage-api-port", required=False, default=443) + parser.add_argument("--mas-version", required=False, default="9.0") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--initial-users-yaml-file") @@ -56,6 +57,7 @@ if __name__ == "__main__": mas_instance_id = args.mas_instance_id mas_workspace_id = args.mas_workspace_id + mas_version = args.mas_version initial_users_yaml_file = args.initial_users_yaml_file initial_users_secret_name = args.initial_users_secret_name coreapi_port = args.coreapi_port @@ -66,6 +68,7 @@ if __name__ == "__main__": logger.info("--------------") logger.info(f"mas_instance_id: {mas_instance_id}") logger.info(f"mas_workspace_id: {mas_workspace_id}") + logger.info(f"mas_version: {mas_version}") logger.info(f"initial_users_yaml_file: {initial_users_yaml_file}") logger.info(f"initial_users_secret_name: {initial_users_secret_name}") logger.info(f"log_level: {log_level}") @@ -83,7 +86,7 @@ if __name__ == "__main__": config.load_kube_config() logger.debug("Loaded kubeconfig file") - user_utils = MASUserUtils(mas_instance_id, mas_workspace_id, client.api_client.ApiClient(), coreapi_port=coreapi_port, admin_dashboard_port=admin_dashboard_port, manage_api_port=manage_api_port) + user_utils = MASUserUtils(mas_instance_id, mas_workspace_id, client.api_client.ApiClient(), mas_version, coreapi_port=coreapi_port, admin_dashboard_port=admin_dashboard_port, manage_api_port=manage_api_port) if initial_users_secret_name is not None: diff --git a/setup.py b/setup.py index e914b8e5..58f99a2b 100644 --- a/setup.py +++ b/setup.py @@ -64,6 +64,7 @@ def get_version(rel_path): 'semver', # BSD License 'boto3', # Apache Software License 'slack_sdk', # MIT License + "packaging", # Apache Software License ], extras_require={ 'dev': [ diff --git a/src/mas/devops/users.py b/src/mas/devops/users.py index a4c6f997..f8275da6 100644 --- a/src/mas/devops/users.py +++ b/src/mas/devops/users.py @@ -18,6 +18,7 @@ import os import time import re +from packaging.version import Version class MASUserUtils(): @@ -43,7 +44,7 @@ class MASUserUtils(): MAXADMIN = "MAXADMIN" - def __init__(self, mas_instance_id: str, mas_workspace_id: str, k8s_client: client.api_client.ApiClient, coreapi_port: int = 443, admin_dashboard_port: int = 443, manage_api_port: int = 443): + def __init__(self, mas_instance_id: str, mas_workspace_id: str, k8s_client: client.api_client.ApiClient, mas_version: str = '9.0', coreapi_port: int = 443, admin_dashboard_port: int = 443, manage_api_port: int = 443): """ Initialize MASUserUtils for a specific MAS instance and workspace. @@ -57,6 +58,7 @@ def __init__(self, mas_instance_id: str, mas_workspace_id: str, k8s_client: clie """ self.mas_instance_id = mas_instance_id self.mas_workspace_id = mas_workspace_id + self.mas_version = mas_version self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") self.mas_core_namespace = f"mas-{self.mas_instance_id}-core" @@ -191,85 +193,272 @@ def mas_workspace_application_ids(self): def get_user(self, user_id): """ - Retrieve a user's details from MAS Core API. + Retrieve a user's details from MAS API. + + For MAS version >= 9.1, this method uses the Manage API masperuser endpoint. + For earlier versions, it uses the Core API v3/users endpoint. Args: user_id (str): The unique identifier of the user to retrieve. Returns: - dict: User details dictionary if found, None if user doesn't exist (404). + tuple: (resource_id, user_data) where: + - resource_id (str|None): Resource ID extracted from href for version >= 9.1, None for version < 9.1 + - user_data (dict|None): User details dictionary if found, None if user doesn't exist (404) Raises: Exception: If the API returns an unexpected status code. """ self.logger.debug(f"Getting user {user_id}") - url = f"{self.mas_api_url_internal}/v3/users/{user_id}" - headers = { - "Accept": "application/json", - "x-access-token": self.superuser_auth_token - } - response = requests.get( - url, - headers=headers, - verify=self.core_internal_ca_pem_file_path - ) + resource_id = None + + # For MAS version >= 9.1, use the Manage API masperuser endpoint + if Version(self.mas_version) >= Version('9.1'): + # Get MAXADMIN API key for authentication + maxadmin_manage_api_key = self.create_or_get_manage_api_key_for_user(MASUserUtils.MAXADMIN, temporary=True) + + # First request: Query to find user and get resource_id from href + url = f"{self.manage_api_url_internal}/maximo/api/os/masperuser" + querystring = { + "lean": 1, + "oslc.where": f"user.userid=\"{user_id}\"" + } + headers = { + "Accept": "application/json", + "apikey": maxadmin_manage_api_key["apikey"] + } + response = requests.get( + url, + headers=headers, + params=querystring, + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path + ) + + user_info = response.json() + + # Parse resource_id from user_info + if user_info and "member" in user_info and len(user_info["member"]) > 0: + href = user_info["member"][0].get("href", "") + # Extract resource_id from href (e.g., "api/os/masperuser/") + if href and "/" in href: + resource_id = href.split("/")[-1] + self.logger.info(f"Extracted resource_id: {resource_id} from user_info") + + # Second request: Get full user details + url = f"{self.manage_api_url_internal}/maximo/api/os/masperuser/" + querystring = { + "lean": 1, + "oslc.where": f"personid=\"{user_id}\"", + "oslc.select": "personid,displayname" + } + headers = { + "Accept": "application/json", + "apikey": maxadmin_manage_api_key["apikey"] + } + response = requests.get( + url, + headers=headers, + params=querystring, + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path + ) + else: + # For earlier versions, use the Core API v3/users endpoint + url = f"{self.mas_api_url_internal}/v3/users/{user_id}" + headers = { + "Accept": "application/json", + "x-access-token": self.superuser_auth_token + } + response = requests.get( + url, + headers=headers, + verify=self.core_internal_ca_pem_file_path + ) if response.status_code == 404: - return None + return resource_id, None - if response.status_code == 200: - return response.json() + if response.status_code != 200: + raise Exception(f"{response.status_code} {response.text}") - raise Exception(f"{response.status_code} {response.text}") + # Handle response based on version + if Version(self.mas_version) >= Version('9.1'): + # Manage API returns member array + user_data = response.json() + if "member" in user_data and len(user_data["member"]) > 0: + return resource_id, user_data["member"][0] + else: + # Empty member array means user not found + return resource_id, None + else: + # Core API returns user object directly + return resource_id, response.json() def get_or_create_user(self, payload): """ Get an existing user or create a new one if not found. - This method is idempotent - if the user already exists (identified by payload["id"]), - their existing record is returned without modification. If the user doesn't exist, - they are created with the provided payload. + This method is idempotent - if the user already exists (identified by payload["id"] + for version < 9.1 or payload["personid"] for version >= 9.1), their existing record + is returned without modification. If the user doesn't exist, they are created with + the provided payload. + + For MAS version >= 9.1, this method uses the Manage API masapiuser endpoint. + For earlier versions, it uses the Core API v3/users endpoint. Args: payload (dict): User definition dictionary containing user details. - Must include "id" field as the unique identifier. + Must include "id" field (version < 9.1) or "personid" field + (version >= 9.1) as the unique identifier. Returns: - dict: The user record (either existing or newly created). + tuple: (resource_id, user_data) where: + - resource_id (str|None): Resource ID extracted from href for version >= 9.1, None for version < 9.1 + - user_data (dict): The user record (either existing or newly created) Raises: Exception: If user creation fails with an unexpected status code. """ - existing_user = self.get_user(payload["id"]) + # Determine the user ID field based on version + user_id_field = "personid" if Version(self.mas_version) >= Version('9.1') else "id" + user_id = payload[user_id_field] + + resource_id, existing_user = self.get_user(user_id) if existing_user is not None: - self.logger.info(f"Existing user {existing_user['id']} found") - return existing_user + # Log using the appropriate field based on version + user_identifier = existing_user.get('personid') or existing_user.get('id') + self.logger.info(f"Existing user {user_identifier} found") + return resource_id, existing_user - self.logger.info(f"Creating new user {payload['id']}") + self.logger.info(f"Creating new user {user_id}") - url = f"{self.mas_api_url_internal}/v3/users" - querystring = {} + # For MAS version >= 9.1, use the Manage API masapiuser endpoint + if Version(self.mas_version) >= Version('9.1'): + # Get MAXADMIN API key for authentication + maxadmin_manage_api_key = self.create_or_get_manage_api_key_for_user(MASUserUtils.MAXADMIN, temporary=True) + + url = f"{self.manage_api_url_internal}/maximo/api/os/masperuser" + querystring = { + "lean": 1 + } + headers = { + "Content-Type": "application/json", + "apikey": maxadmin_manage_api_key["apikey"] + } + self.logger.debug(f"Creating new user {user_id} with Manage API with payload {payload}") + response = requests.post( + url, + json=payload, + headers=headers, + params=querystring, + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path + ) + if response.status_code == 201: + # Manage API returns empty response body on success, fetch the user + if response.text: + response_data = response.json() + # Parse resource_id from response if available + resource_id = None + if "member" in response_data and len(response_data["member"]) > 0: + href = response_data["member"][0].get("href", "") + if href and "/" in href: + resource_id = href.split("/")[-1] + self.logger.debug(f"Extracted resource_id: {resource_id} from create response") + return resource_id, response_data + else: + # Fetch the newly created user + return self.get_user(user_id) + else: + # For earlier versions, use the Core API v3/users endpoint + url = f"{self.mas_api_url_internal}/v3/users" + querystring = {} + headers = { + "Content-Type": "application/json", + "x-access-token": self.superuser_auth_token + } + response = requests.post( + url, + json=payload, + headers=headers, + params=querystring, + verify=self.core_internal_ca_pem_file_path + ) + if response.status_code == 201: + # For version < 9.1, resource_id is None + return None, response.json() + + # if response.status_code == 409: + # json = response.json() + # if "exception" in json and "message" in json["exception"] and json["exception"]["message"] == "AIUCO1005E": + # return None + + raise Exception(f"{response.status_code} {response.text}") + + def set_user_group_reassignment_auth(self, user_id, resource_id, groupreassign, manage_api_key): + """ + Set group reassignment authorization for a user via Manage API. + + This method updates the grpreassignauth field for a user's maxuser record, + which controls which security groups the user can reassign to other users. + + Args: + resource_id (str): The resource identifier of the user (extracted from href). + groupreassign (list): List of group objects in format [{"groupname": "GROUP1"}, {"groupname": "GROUP2"}, ...] + manage_api_key (dict): API key record with 'apikey' field for authentication. + + Returns: + dict: Updated user record. + + Raises: + Exception: If the update fails. + """ + if not groupreassign or len(groupreassign) == 0: + self.logger.debug(f"No group reassignment authorization to set for resource {resource_id}") + return + + self.logger.info(f"Setting group reassignment authorization for resource {resource_id} with {len(groupreassign)} groups") + + # Use Manage API to update the user's grpreassignauth + url = f"{self.manage_api_url_internal}/maximo/api/os/masperuser/{resource_id}" + querystring = { + "lean": 1, + "ccm": 1 + } headers = { "Content-Type": "application/json", - "x-access-token": self.superuser_auth_token + "apikey": manage_api_key["apikey"], + "x-method-override": "PATCH", + "patchtype": "MERGE" + } + + payload = { + "maxuser": { + "userid": user_id, + "grpreassignauth": groupreassign + } } + self.logger.debug(f"Sending PATCH request to {url} with payload: {payload}") + response = requests.post( url, json=payload, headers=headers, params=querystring, - verify=self.core_internal_ca_pem_file_path + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path ) - if response.status_code == 201: - return response.json() - # if response.status_code == 409: - # json = response.json() - # if "exception" in json and "message" in json["exception"] and json["exception"]["message"] == "AIUCO1005E": - # return None + if response.status_code in [200, 204]: + self.logger.info(f"Successfully set group reassignment authorization for resource {resource_id}") + # 204 No Content doesn't have a response body + if response.status_code == 200: + return response.json() + return None - raise Exception(f"{response.status_code} {response.text}") + raise Exception(f"Failed to set group reassignment authorization: {response.status_code} {response.text}") def update_user(self, payload): """ @@ -341,7 +530,7 @@ def update_user_display_name(self, user_id, display_name): raise Exception(f"{response.status_code} {response.text}") - def link_user_to_local_idp(self, user_id, email_password=False): + def link_user_to_local_idp(self, user_id, email_password=False, manage_api_key=None, resource_id=None): """ Link a user to the local identity provider (IDP). @@ -349,10 +538,17 @@ def link_user_to_local_idp(self, user_id, email_password=False): The method creates a local authentication identity for the user, enabling them to log in with username/password. + For MAS version < 9.1: Uses Core API PUT request + For MAS version >= 9.1: Uses Manage API PATCH request + Args: user_id (str): The unique identifier of the user to link. email_password (bool, optional): Whether to enable email/password authentication. Defaults to False. + manage_api_key (dict, optional): API key record with 'apikey' field for authentication. + Required for MAS version >= 9.1. + resource_id (str, optional): The resource identifier of the user (extracted from href). + Required for MAS version >= 9.1. Returns: None: Always returns None (authentication token is not exposed). @@ -365,40 +561,108 @@ def link_user_to_local_idp(self, user_id, email_password=False): or returned for security reasons. """ - # For the sake of idempotency, check if the user already has a local identity - user = self.get_user(user_id) - if user is None: - raise Exception(f"User {user_id} was not found") + # Check MAS version to determine which API to use + current_version = Version(self.mas_version) + version_9_1 = Version("9.1") - if "identities" in user and "_local" in user["identities"]: - self.logger.info(f"User {user_id} already has a local identity") - return None + if current_version >= version_9_1: + # Version >= 9.1: Use Manage API PATCH request + if manage_api_key is None: + raise Exception("manage_api_key is required for MAS version >= 9.1") + if resource_id is None: + raise Exception("resource_id is required for MAS version >= 9.1") - self.logger.info(f"Linking user {user_id} to local IDP (email_password: {email_password})") - url = f"{self.mas_api_url_internal}/v3/users/{user_id}/idps/local" - querystring = { - "emailPassword": email_password - } - payload = { - "idpUserId": user_id, - } - headers = { - "Content-Type": "application/json", - "x-access-token": self.superuser_auth_token - } - response = requests.put( - url, - json=payload, - headers=headers, - params=querystring, - verify=self.core_internal_ca_pem_file_path - ) - if response.status_code != 200: - raise Exception(response.text) + # For the sake of idempotency, check if the user already has a local identity + _, user = self.get_user(user_id) + if user is None: + raise Exception(f"User {user_id} was not found") + + if "identities" in user and "_local" in user["identities"]: + self.logger.info(f"User {user_id} already has a local identity") + return None - # Important: HTTP 200 output will contain generated user token; DO NOT LOG + self.logger.info(f"Linking user {user_id} to local IDP using Manage API (version {self.mas_version})") - return None + url = f"{self.manage_api_url_internal}/maximo/api/os/masperuser/{resource_id}" + querystring = { + "lean": 1, + "ccm": 1 + } + headers = { + "Content-Type": "application/json", + "apikey": manage_api_key["apikey"], + "x-method-override": "PATCH", + "patchtype": "MERGE" + } + + payload = { + "maxuser": { + "userid": user_id, + "masuseridp": [ + { + "emailpassword": email_password, + "idpid": "local", + "logintype": "0", + "idploginid": user_id, + "idptype": "local", + "enabled": True + } + ] + } + } + self.logger.debug(f"Sending PATCH request to {url} with payload: {payload}") + + response = requests.post( + url, + json=payload, + headers=headers, + params=querystring, + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path + ) + + if response.status_code in [200, 204]: + self.logger.info(f"Successfully linked user {user_id} to local IDP") + return None + + raise Exception(f"Failed to link user to local IDP: {response.status_code} {response.text}") + + else: + # Version < 9.1: Use Core API PUT request (existing implementation) + # For the sake of idempotency, check if the user already has a local identity + _, user = self.get_user(user_id) + if user is None: + raise Exception(f"User {user_id} was not found") + + if "identities" in user and "_local" in user["identities"]: + self.logger.info(f"User {user_id} already has a local identity") + return None + + self.logger.info(f"Linking user {user_id} to local IDP using Core API (version {self.mas_version}, email_password: {email_password})") + url = f"{self.mas_api_url_internal}/v3/users/{user_id}/idps/local" + querystring = { + "emailPassword": email_password + } + payload = { + "idpUserId": user_id, + } + headers = { + "Content-Type": "application/json", + "x-access-token": self.superuser_auth_token + } + response = requests.put( + url, + json=payload, + headers=headers, + params=querystring, + verify=self.core_internal_ca_pem_file_path + ) + if response.status_code != 200: + raise Exception(response.text) + + # Important: HTTP 200 output will contain generated user token; DO NOT LOG + + return None def get_user_workspaces(self, user_id): """ @@ -587,7 +851,7 @@ def check_user_sync(self, user_id, application_id, timeout_secs=60 * 10, retry_i t_end = time.time() + timeout_secs self.logger.info(f"Awaiting user {user_id} sync status \"SUCCESS\" for app {application_id}: {t_end - time.time():.2f} seconds remaining") while time.time() < t_end: - user = self.get_user(user_id) + resource_id, user = self.get_user(user_id) if "applications" not in user or application_id not in user["applications"] or "sync" not in user["applications"][application_id] or "state" not in user["applications"][application_id]["sync"]: self.logger.warning(f"User {user_id} does not have any sync state for application {application_id}, triggering resync") @@ -633,8 +897,12 @@ def resync_users(self, user_ids): # which reduces the impact of concurrent updates leading to race conditions) for user_id in user_ids: - user = self.get_user(user_id) - self.update_user_display_name(user_id, user["displayName"]) + resource_id, user = self.get_user(user_id) + # For version >= 9.1, Manage API uses "displayname" (lowercase) + # For version < 9.1, Core API uses "displayName" (camelCase) + display_name = user.get("displayname") if Version(self.mas_version) >= Version('9.1') else user.get("displayName") + if display_name: + self.update_user_display_name(user_id, display_name) def create_or_get_manage_api_key_for_user(self, user_id, temporary=False): """ @@ -725,7 +993,6 @@ def get_manage_api_key_for_user(self, user_id): Raises: Exception: If the API call fails. """ - self.logger.debug(f"Getting Manage API Key for user {user_id}") url = f"{self.manage_api_url_internal}/maximo/api/os/mxapiapikey" querystring = { "ccm": 1, @@ -940,6 +1207,52 @@ def add_user_to_manage_group(self, user_id, group_name, manage_api_key): raise Exception(f"{response.status_code} {response.text}") + def get_all_manage_groups(self): + """ + Get all security groups from Manage. + + Args: + manage_api_key (dict): API key record with 'apikey' field for authentication. + + Returns: + list: List of group names (strings). + + Raises: + Exception: If the API call fails. + """ + self.logger.debug("Getting all Manage security groups") + url = f"{self.manage_api_url_internal}/maximo/api/os/mxapigroup" + querystring = { + "ccm": 1, + "lean": 1, + "oslc.select": "groupname", + } + headers = { + "Accept": "application/json", + } + + response = requests.get( + url, + headers=headers, + params=querystring, + # verify=self.manage_internal_ca_pem_file_path, + cert=self.manage_internal_client_pem_file_path, + verify=self.manage_internal_ca_pem_file_path + ) + + if response.status_code != 200: + raise Exception(f"{response.status_code} {response.text}") + + json = response.json() + groups = [] + if "member" in json: + for member in json["member"]: + if "groupname" in member: + groups.append(member["groupname"]) + + self.logger.info(f"Found {len(groups)} security groups in Manage") + return groups + def get_mas_applications_in_workspace(self): """ Retrieve all MAS applications configured in the workspace. @@ -1130,11 +1443,14 @@ def create_initial_users_for_saas(self, initial_users): completed = [] failed = [] + all_security_groups = self.get_all_manage_groups() + groupreassign = [{"groupname": group} for group in all_security_groups] + for primary_user in primary_users: self.logger.info("") try: self.logger.info(f"Syncing primary user with email {primary_user['email']}") - self.create_initial_user_for_saas(primary_user, "PRIMARY") + self.create_initial_user_for_saas(primary_user, "PRIMARY", groupreassign) completed.append(primary_user) self.logger.info(f"Completed sync of primary user {primary_user['email']}") except Exception as e: @@ -1159,7 +1475,7 @@ def create_initial_users_for_saas(self, initial_users): "failed": failed } - def create_initial_user_for_saas(self, user, user_type): + def create_initial_user_for_saas(self, user, user_type, groupreassign=None): """ Create and fully configure a single initial user for SaaS. @@ -1186,6 +1502,7 @@ def create_initial_user_for_saas(self, user, user_type): Exception: If required fields are missing or user creation fails. Note: + For version < 9.1, PRIMARY users get: - userAdmin permission - PREMIUM application entitlement @@ -1193,6 +1510,14 @@ def create_initial_user_for_saas(self, user, user_type): - ADMIN role for most apps, MANAGEUSER for Manage - MAXADMIN security group membership + For version >= 9.1, + PRIMARY users get: + - apikeyAdmin permission (API Key Management) + - idpAdmin permission (IDP Management) + - Regular workspace access (not workspace admin) + - USERMANAGEMENT security group membership + - Group reassignment authorization for ALL security groups + SECONDARY users get: - No admin permissions - BASE application entitlement @@ -1222,84 +1547,154 @@ def create_initial_user_for_saas(self, user, user_type): display_name = f"{user_given_name} {user_family_name}" # Set user permissions and entitlements based on requested user_type - if user_type == "PRIMARY": - permissions = { - "systemAdmin": False, - "userAdmin": True, - "apikeyAdmin": False - } - entitlement = { - "application": "PREMIUM", - "admin": "ADMIN_BASE", - "alwaysReserveLicense": True - } - is_workspace_admin = True - application_role = "ADMIN" - facilities_role = "PREMIUM" - manage_role = "MANAGEUSER" - # TODO: check which security groups primary users should be members of - manage_security_groups = ["MAXADMIN"] - elif user_type == "SECONDARY": - permissions = { - "systemAdmin": False, - "userAdmin": False, - "apikeyAdmin": False - } - entitlement = { - "application": "BASE", - "admin": "NONE", - "alwaysReserveLicense": True + if Version(self.mas_version) < Version('9.1'): + if user_type == "PRIMARY": + permissions = { + "systemAdmin": False, + "userAdmin": True, + "apikeyAdmin": False + } + entitlement = { + "application": "PREMIUM", + "admin": "ADMIN_BASE", + "alwaysReserveLicense": True + } + is_workspace_admin = True + application_role = "ADMIN" + facilities_role = "PREMIUM" + manage_role = "MANAGEUSER" + manage_security_groups = ["MAXADMIN"] + elif user_type == "SECONDARY": + permissions = { + "systemAdmin": False, + "userAdmin": False, + "apikeyAdmin": False + } + entitlement = { + "application": "BASE", + "admin": "NONE", + "alwaysReserveLicense": True + } + is_workspace_admin = False + application_role = "USER" + facilities_role = "BASE" + manage_role = "MANAGEUSER" + # TODO: check which security groups secondary users should be members of + manage_security_groups = [] + else: + raise Exception(f"Unsupported user_type: {user_type}") + + user_def = { + "id": user_id, + "status": {"active": True}, + "username": username, + "owner": "local", + "emails": [ + { + "value": user_email, + "type": "Work", + "primary": True + } + ], + "phoneNumbers": [], + "addresses": [], + "displayName": display_name, + "issuer": "local", + "permissions": permissions, + "entitlement": entitlement, + "givenName": user_given_name, + "familyName": user_family_name, + } - is_workspace_admin = False - application_role = "USER" - facilities_role = "BASE" - manage_role = "MANAGEUSER" - # TODO: check which security groups secondary users should be members of - manage_security_groups = [] else: - raise Exception(f"Unsupported user_type: {user_type}") - - user_def = { - "id": user_id, - "status": {"active": True}, - "username": username, - "owner": "local", - "emails": [ - { - "value": user_email, - "type": "Work", - "primary": True + if user_type == "PRIMARY": + maxuser_def = { + "userid": user_id, + "personid": user_id, + "loginid": user_id, + "owner": "local", + "systemadmin": False, + "apikeyadmin": True, + "isauthorized": 1, + "idpadmin": True, + "status": "ACTIVE", + "groupuser": [ + { + "groupname": "USERMANAGEMENT" + } + ] } - ], - "phoneNumbers": [], - "addresses": [], - "displayName": display_name, - "issuer": "local", - "permissions": permissions, - "entitlement": entitlement, - "givenName": user_given_name, - "familyName": user_family_name - } + is_workspace_admin = True + application_role = "ADMIN" + facilities_role = "PREMIUM" + manage_role = "MANAGEUSER" + manage_security_groups = ["USERMANAGEMENT"] + elif user_type == "SECONDARY": + maxuser_def = { + "userid": user_id, + "personid": user_id, + "loginid": user_id, + "owner": "local", + "systemadmin": False, + "apikeyadmin": False, + "isauthorized": 0, + "idpadmin": False, + "status": "ACTIVE" + } + is_workspace_admin = False + application_role = "USER" + facilities_role = "BASE" + manage_role = "MANAGEUSER" + manage_security_groups = [] + else: + raise Exception(f"Unsupported user_type: {user_type}") + + user_def = { + "personid": user_id, + "primaryemailtype": "Work", + "primaryemail": user_email, + "primaryphone": "", + "addressline1": "", + "displayName": display_name, + "maxuser": maxuser_def, + } + + resource_id, _ = self.get_or_create_user(user_def) + + # For version >= 9.1, we always need a Manage API key and resource_id to link user to local IDP + # For version < 9.1, link user to local IDP first, then create API key only if needed for manage_security_groups + maxadmin_manage_api_key = None + if Version(self.mas_version) >= Version('9.1'): + maxadmin_manage_api_key = self.create_or_get_manage_api_key_for_user(MASUserUtils.MAXADMIN, temporary=True) + self.link_user_to_local_idp(user_id, email_password=True, manage_api_key=maxadmin_manage_api_key, resource_id=resource_id) + else: + # For version < 9.1, link user to local IDP without manage_api_key and resource_id + self.link_user_to_local_idp(user_id, email_password=True) - self.get_or_create_user(user_def) - self.link_user_to_local_idp(user_id, email_password=True) self.add_user_to_workspace(user_id, is_workspace_admin=is_workspace_admin) - for mas_application_id in self.mas_workspace_application_ids: - self.await_mas_application_availability(mas_application_id) - if mas_application_id == "manage": - role = manage_role - elif mas_application_id == "facilities": - role = facilities_role - else: - # otherwise grant the user the appropriate role for their user_type - role = application_role - self.set_user_application_permission(user_id, mas_application_id, role) + if Version(self.mas_version) < Version('9.1'): + for mas_application_id in self.mas_workspace_application_ids: + self.await_mas_application_availability(mas_application_id) + if mas_application_id == "manage": + role = manage_role + elif mas_application_id == "facilities": + role = facilities_role + else: + # otherwise grant the user the appropriate role for their user_type + role = application_role + self.set_user_application_permission(user_id, mas_application_id, role) - for mas_application_id in self.mas_workspace_application_ids: - self.check_user_sync(user_id, mas_application_id) + for mas_application_id in self.mas_workspace_application_ids: + self.check_user_sync(user_id, mas_application_id) if len(manage_security_groups) > 0 and "manage" in self.mas_workspace_application_ids: - maxadmin_manage_api_key = self.create_or_get_manage_api_key_for_user(MASUserUtils.MAXADMIN, temporary=True) - for manage_security_group in manage_security_groups: - self.add_user_to_manage_group(user_id, manage_security_group, maxadmin_manage_api_key) + if Version(self.mas_version) < Version('9.1'): + maxadmin_manage_api_key = self.create_or_get_manage_api_key_for_user(MASUserUtils.MAXADMIN, temporary=True) + for manage_security_group in manage_security_groups: + self.add_user_to_manage_group(user_id, manage_security_group, maxadmin_manage_api_key) + elif Version(self.mas_version) >= Version('9.1') and user_type == "PRIMARY" and groupreassign is not None: + if resource_id and maxadmin_manage_api_key: + self.set_user_group_reassignment_auth(user_id, resource_id, groupreassign, maxadmin_manage_api_key) + else: + self.logger.warning(f"Cannot set group reassignment auth: resource_id not found for user {user_id}") diff --git a/test/src/test_users.py b/test/src/test_users.py index 8b973e00..cf600834 100644 --- a/test/src/test_users.py +++ b/test/src/test_users.py @@ -12,7 +12,7 @@ import base64 from unittest.mock import MagicMock, patch, call from pytest import fixture - +from packaging.version import Version import os from mas.devops.users import MASUserUtils @@ -115,13 +115,15 @@ def mock_logininitial_endpoint(requests_mock): ) -@fixture -def user_utils(mock_v1_secrets, mock_logininitial_endpoint, mock_named_temporary_file, mock_atexit): +@fixture(params=['9.0', '9.1']) +def user_utils(request, mock_v1_secrets, mock_logininitial_endpoint, mock_named_temporary_file, mock_atexit): k8s_client = MagicMock() # DynamicClient is mocked out, no methods will be called on the k8s_client + mas_version = request.param user_utils = MASUserUtils( MAS_INSTANCE_ID, MAS_WORKSPACE_ID, k8s_client, + mas_version=mas_version, coreapi_port=COREAPI_PORT, admin_dashboard_port=ADMIN_DASHBOARD_PORT, manage_api_port=MANAGE_API_PORT @@ -136,16 +138,36 @@ def mock_manage_api_key(requests_mock): Setup mock Manage APIs for setting up an API Key ''' user_id = "user1" - apikey = {"userid": user_id, "href": f"https://{MANAGE_API_URL}/maximo/api/os/mxapikey/theapikeyid"} + apikey = {"userid": user_id, "apikey": "test-api-key-12345", "href": f"https://{MANAGE_API_URL}/maximo/api/os/mxapikey/theapikeyid"} # pragma: allowlist secret + + # Also setup for MAXADMIN user + maxadmin_apikey = {"userid": "MAXADMIN", "apikey": "maxadmin-api-key-67890", "href": f"https://{MANAGE_API_URL}/maximo/api/os/mxapikey/maxadminapikeyid"} # pragma: allowlist secret + + def maxadmin_matcher(req): + return req.json().get("userid") == "MAXADMIN" and req.verify == PEM_PATH and req.cert == PEM_PATH + + def user1_matcher(req): + return req.json().get("userid") == user_id and req.verify == PEM_PATH and req.cert == PEM_PATH + # Mock for MAXADMIN API key creation (returns 400 - key already exists) + requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/mxapiapikey?ccm=1&lean=1", + request_headers={"content-type": "application/json"}, + json={"Error": {"reasonCode": "BMXAA10051E", "message": "Only one API key allowed per user"}}, + status_code=400, + additional_matcher=maxadmin_matcher + ) + + # Mock for user1 API key creation requests_mock.post( f"{MANAGE_API_URL}/maximo/api/os/mxapiapikey?ccm=1&lean=1", request_headers={"content-type": "application/json"}, json={"id": user_id}, status_code=201, - additional_matcher=lambda req: additional_matcher(req, json={"expiration": -1, "userid": user_id}, cert=PEM_PATH) + additional_matcher=user1_matcher ) + # Mock for user1 API key retrieval requests_mock.get( f"{MANAGE_API_URL}/maximo/api/os/mxapiapikey?ccm=1&lean=1&oslc.select=*&oslc.where=userid=\"{user_id}\"", request_headers={"accept": "application/json"}, @@ -154,7 +176,16 @@ def mock_manage_api_key(requests_mock): additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) ) - yield apikey + # Mock for MAXADMIN API key retrieval (returns existing key) + requests_mock.get( + f"{MANAGE_API_URL}/maximo/api/os/mxapiapikey?ccm=1&lean=1&oslc.select=*&oslc.where=userid=\"MAXADMIN\"", + request_headers={"accept": "application/json"}, + json={"member": [maxadmin_apikey]}, + status_code=200, + additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) + ) + + yield maxadmin_apikey def test_admin_internal_ca_pem_file_path(user_utils, mock_named_temporary_file, mock_atexit): @@ -168,8 +199,9 @@ def test_admin_internal_ca_pem_file_path(user_utils, mock_named_temporary_file, assert mock_atexit.mock_calls == [call(os.remove, PEM_PATH)] -def mock_get_user(requests_mock, user_id, json, status_code): - return requests_mock.get( +def mock_get_user(requests_mock, user_id, json, status_code, mock_manage_api_key, json_manage=None): + # Mock Core API endpoint for version < 9.1 + core_mock = requests_mock.get( f"{MAS_API_URL}/v3/users/{user_id}", request_headers={"x-access-token": TOKEN}, json=json, @@ -177,22 +209,64 @@ def mock_get_user(requests_mock, user_id, json, status_code): additional_matcher=lambda req: additional_matcher(req) ) + # Use separate JSON for Manage API if provided, otherwise use the same + manage_json = json_manage if json_manage is not None else json + + # Mock Manage API endpoint for version >= 9.1 + # First request: Uses query parameter oslc.where with user.userid to get resource_id + manage_query_mock = requests_mock.get( + f"{MANAGE_API_URL}/maximo/api/os/masperuser?lean=1&oslc.where=user.userid%3D%22{user_id}%22", + request_headers={"apikey": mock_manage_api_key["apikey"]}, + json=manage_json, + status_code=status_code, + additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) + ) + + # Second request: Mock the query-based request with personid + # Always mock this for version >= 9.1, regardless of status_code + manage_personid_mock = requests_mock.get( + f"{MANAGE_API_URL}/maximo/api/os/masperuser/?lean=1&oslc.where=personid%3D%22{user_id}%22&oslc.select=personid%2Cdisplayname", + request_headers={"apikey": mock_manage_api_key["apikey"]}, + json=manage_json, + status_code=status_code, + additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) + ) + + return core_mock, manage_query_mock, manage_personid_mock + + +def mock_get_user_200(requests_mock, user_id, mock_manage_api_key): + # Core API response for version < 9.1 + core_json = { + "id": user_id, + "displayName": user_id + } + + # Manage API response for version >= 9.1 + # Include member array with href containing resource_id + resource_id = f"{user_id}_resource_id" + manage_json = { + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } -def mock_get_user_200(requests_mock, user_id): return mock_get_user( - requests_mock, user_id, {"id": user_id, "displayName": user_id}, 200 + requests_mock, user_id, core_json, 200, mock_manage_api_key, json_manage=manage_json ) -def mock_get_user_404(requests_mock, user_id): +def mock_get_user_404(requests_mock, user_id, mock_manage_api_key): return mock_get_user( - requests_mock, user_id, {"error": "notfound"}, 404 + requests_mock, user_id, {"error": "notfound"}, 404, mock_manage_api_key ) -def mock_get_user_500(requests_mock, user_id): +def mock_get_user_500(requests_mock, user_id, mock_manage_api_key): return mock_get_user( - requests_mock, user_id, {"error": "internal"}, 500 + requests_mock, user_id, {"error": "internal"}, 500, mock_manage_api_key ) @@ -295,33 +369,71 @@ def test_mas_workspace_application_ids(user_utils, requests_mock): assert get.call_count == 1 -def test_get_user_exists(user_utils, requests_mock): +def test_get_user_exists(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_200(requests_mock, user_id) - assert user_utils.get_user(user_id) == {"id": user_id, "displayName": user_id} - assert get.call_count == 1 + get_core, get_manage, get_manage_personid = mock_get_user_200(requests_mock, user_id, mock_manage_api_key) + resource_id, user_data = user_utils.get_user(user_id) + # For version >= 9.1, Manage API uses "personid" and "displayname" + # For version < 9.1, Core API uses "id" and "displayName" + if Version(user_utils.mas_version) >= Version('9.1'): + assert user_data["personid"] == user_id + assert user_data["displayname"] == user_id + else: + assert user_data["id"] == user_id + assert user_data["displayName"] == user_id + # For version >= 9.1, resource_id should be extracted; for < 9.1, it should be None + if Version(user_utils.mas_version) >= Version('9.1'): + assert resource_id is not None + assert resource_id == f"{user_id}_resource_id" + else: + assert resource_id is None + + # Check that the correct endpoint was called based on version + if user_utils.mas_version >= '9.1': + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 -def test_get_user_notfound(user_utils, requests_mock): +def test_get_user_notfound(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_404(requests_mock, user_id) - assert user_utils.get_user(user_id) is None - assert get.call_count == 1 + get_core, get_manage, get_manage_personid = mock_get_user_404(requests_mock, user_id, mock_manage_api_key) + resource_id, user_data = user_utils.get_user(user_id) + assert resource_id is None + assert user_data is None + + # Check that the correct endpoint was called based on version + if user_utils.mas_version >= '9.1': + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 -def test_get_user_error(user_utils, requests_mock): +def test_get_user_error(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_500(requests_mock, user_id) + get_core, get_manage, get_manage_personid = mock_get_user_500(requests_mock, user_id, mock_manage_api_key) with pytest.raises(Exception): user_utils.get_user(user_id) - assert get.call_count == 1 + + # Check that the correct endpoint was called based on version + if user_utils.mas_version >= '9.1': + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 -def test_get_or_create_user_exists(user_utils, requests_mock): +def test_get_or_create_user_exists(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_200(requests_mock, user_id) + get_core, get_manage, get_manage_personid = mock_get_user_200(requests_mock, user_id, mock_manage_api_key) - post = requests_mock.post( + # Mock Core API endpoint for version < 9.1 + post_core = requests_mock.post( f"{MAS_API_URL}/v3/users", request_headers={"x-access-token": TOKEN}, json={"id": user_id}, @@ -329,16 +441,53 @@ def test_get_or_create_user_exists(user_utils, requests_mock): additional_matcher=lambda req: additional_matcher(req, json={"id": user_id}) ) - assert user_utils.get_or_create_user({"id": user_id}) == {"id": user_id, "displayName": user_id} - assert get.call_count == 1 - assert post.call_count == 0 + # Mock Manage API endpoint for version >= 9.1 + post_manage = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser?lean=1", + request_headers={"apikey": mock_manage_api_key["apikey"]}, + json={"id": user_id}, + status_code=201, + additional_matcher=lambda req: additional_matcher(req, json={"personid": user_id}, cert=PEM_PATH) + ) + + # Use correct payload structure based on version + if Version(user_utils.mas_version) >= Version('9.1'): + payload = {"personid": user_id} + else: + payload = {"id": user_id} + + resource_id, user_data = user_utils.get_or_create_user(payload) + # For version >= 9.1, Manage API uses "personid" and "displayname" + # For version < 9.1, Core API uses "id" and "displayName" + if Version(user_utils.mas_version) >= Version('9.1'): + assert user_data["personid"] == user_id + assert user_data["displayname"] == user_id + else: + assert user_data["id"] == user_id + assert user_data["displayName"] == user_id + # For version >= 9.1, resource_id should be extracted; for < 9.1, it should be None + if Version(user_utils.mas_version) >= Version('9.1'): + assert resource_id is not None + assert resource_id == f"{user_id}_resource_id" + else: + assert resource_id is None + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 + assert post_core.call_count == 0 + assert post_manage.call_count == 0 -def test_get_or_create_user_notfound(user_utils, requests_mock): +def test_get_or_create_user_notfound(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_404(requests_mock, user_id) + get_core, get_manage, get_manage_personid = mock_get_user_404(requests_mock, user_id, mock_manage_api_key) - post = requests_mock.post( + # Mock Core API endpoint for version < 9.1 + post_core = requests_mock.post( f"{MAS_API_URL}/v3/users", request_headers={"x-access-token": TOKEN}, json={"id": user_id, "displayName": user_id}, @@ -346,15 +495,45 @@ def test_get_or_create_user_notfound(user_utils, requests_mock): additional_matcher=lambda req: additional_matcher(req, json={"id": user_id}) ) - assert user_utils.get_or_create_user({"id": user_id}) == {"id": user_id, "displayName": user_id} - assert get.call_count == 1 - assert post.call_count == 1 + # Mock Manage API endpoint for version >= 9.1 + post_manage = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser?lean=1", + request_headers={"apikey": mock_manage_api_key["apikey"]}, + json={"id": user_id, "displayName": user_id}, + status_code=201, + additional_matcher=lambda req: additional_matcher(req, json={"personid": user_id}, cert=PEM_PATH) + ) + # Use correct payload structure based on version + if Version(user_utils.mas_version) >= Version('9.1'): + payload = {"personid": user_id} + else: + payload = {"id": user_id} + + resource_id, user_data = user_utils.get_or_create_user(payload) + assert user_data == {"id": user_id, "displayName": user_id} + # For version >= 9.1, resource_id might be None if not in response; for < 9.1, it should be None + if Version(user_utils.mas_version) < Version('9.1'): + assert resource_id is None + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + assert post_core.call_count == 0 + assert post_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 + assert post_core.call_count == 1 + assert post_manage.call_count == 0 -def test_get_or_create_user_error(user_utils, requests_mock): + +def test_get_or_create_user_error(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_404(requests_mock, user_id) - post = requests_mock.post( + get_core, get_manage, get_manage_personid = mock_get_user_404(requests_mock, user_id, mock_manage_api_key) + + # Mock Core API endpoint for version < 9.1 + post_core = requests_mock.post( f"{MAS_API_URL}/v3/users", request_headers={"x-access-token": TOKEN}, json={"error": "unknown"}, @@ -362,10 +541,34 @@ def test_get_or_create_user_error(user_utils, requests_mock): additional_matcher=lambda req: additional_matcher(req, json={"id": user_id}) ) + # Mock Manage API endpoint for version >= 9.1 + post_manage = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser?lean=1", + request_headers={"apikey": mock_manage_api_key["apikey"]}, + json={"error": "unknown"}, + status_code=500, + additional_matcher=lambda req: additional_matcher(req, json={"personid": user_id}, cert=PEM_PATH) + ) + + # Use correct payload structure based on version + if Version(user_utils.mas_version) >= Version('9.1'): + payload = {"personid": user_id} + else: + payload = {"id": user_id} + with pytest.raises(Exception): - user_utils.get_or_create_user({"id": user_id}) - assert get.call_count == 1 - assert post.call_count == 1 + user_utils.get_or_create_user(payload) + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + assert post_core.call_count == 0 + assert post_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 + assert post_core.call_count == 1 + assert post_manage.call_count == 0 def test_update_user(user_utils, requests_mock): @@ -422,11 +625,13 @@ def test_update_user_display_name_error(user_utils, requests_mock): assert patche.call_count == 1 -def test_link_user_to_local_idp(user_utils, requests_mock): +def test_link_user_to_local_idp(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" email_password = True - get = mock_get_user_200(requests_mock, user_id) + resource_id = f"{user_id}_resource_id" + get_core, get_manage, get_manage_personid = mock_get_user_200(requests_mock, user_id, mock_manage_api_key) + # Mock Core API PUT request for version < 9.1 put = requests_mock.put( f"{MAS_API_URL}/v3/users/{user_id}/idps/local?emailPassword={email_password}", request_headers={"x-access-token": TOKEN}, @@ -435,32 +640,105 @@ def test_link_user_to_local_idp(user_utils, requests_mock): additional_matcher=lambda req: additional_matcher(req, json={"idpUserId": user_id}) ) - user_utils.link_user_to_local_idp(user_id, email_password=email_password) + # Mock Manage API PATCH request for version >= 9.1 + patch = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser/{resource_id}?lean=1&ccm=1", + request_headers={ + "Content-Type": "application/json", + "apikey": mock_manage_api_key["apikey"], + "x-method-override": "PATCH", + "patchtype": "MERGE" + }, + json={"id": user_id}, + status_code=200, + additional_matcher=lambda req: additional_matcher( + req, + json={ + "maxuser": { + "userid": user_id, + "masuseridp": [{ + "emailpassword": True, + "idpid": "local", + "logintype": "0", + "idploginid": user_id, + "idptype": "local", + "enabled": True + }] + } + }, + cert=PEM_PATH + ) + ) - assert get.call_count == 1 - assert put.call_count == 1 + # Call the function with appropriate parameters based on version + if Version(user_utils.mas_version) >= Version('9.1'): + user_utils.link_user_to_local_idp(user_id, email_password=email_password, manage_api_key=mock_manage_api_key, resource_id=resource_id) + else: + user_utils.link_user_to_local_idp(user_id, email_password=email_password) + + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + assert put.call_count == 0 + assert patch.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 + assert put.call_count == 1 + assert patch.call_count == 0 -def test_link_user_to_local_idp_usernotfound(user_utils, requests_mock): +def test_link_user_to_local_idp_usernotfound(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" - get = mock_get_user_404(requests_mock, user_id) + resource_id = f"{user_id}_resource_id" + get_core, get_manage, get_manage_personid = mock_get_user_404(requests_mock, user_id, mock_manage_api_key) + put = requests_mock.put( f"{MAS_API_URL}/v3/users/{user_id}/idps/local", additional_matcher=lambda req: additional_matcher(req, json={"idpUserId": user_id}) ) + patch = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser/{resource_id}?lean=1&ccm=1", + additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) + ) + with pytest.raises(Exception): - user_utils.link_user_to_local_idp(user_id) + if Version(user_utils.mas_version) >= Version('9.1'): + user_utils.link_user_to_local_idp(user_id, manage_api_key=mock_manage_api_key, resource_id=resource_id) + else: + user_utils.link_user_to_local_idp(user_id) - assert get.call_count == 1 + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 assert put.call_count == 0 + assert patch.call_count == 0 -def test_link_user_to_local_idp_already_linked(user_utils, requests_mock): +def test_link_user_to_local_idp_already_linked(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" email_password = True - get = mock_get_user( - requests_mock, user_id, {"id": user_id, "identities": {"_local": {}}}, 200 + resource_id = f"{user_id}_resource_id" + get_core, get_manage, get_manage_personid = mock_get_user( + requests_mock, + user_id, + {"id": user_id, "identities": {"_local": {}}}, + 200, + mock_manage_api_key, + json_manage={ + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id, + "identities": {"_local": {}} + }] + } ) put = requests_mock.put( @@ -471,10 +749,26 @@ def test_link_user_to_local_idp_already_linked(user_utils, requests_mock): additional_matcher=lambda req: additional_matcher(req, json={"idpUserId": user_id}) ) - user_utils.link_user_to_local_idp(user_id, email_password=email_password) + patch = requests_mock.post( + f"{MANAGE_API_URL}/maximo/api/os/masperuser/{resource_id}?lean=1&ccm=1", + additional_matcher=lambda req: additional_matcher(req, cert=PEM_PATH) + ) + + # Call the function with appropriate parameters based on version + if Version(user_utils.mas_version) >= Version('9.1'): + user_utils.link_user_to_local_idp(user_id, email_password=email_password, manage_api_key=mock_manage_api_key, resource_id=resource_id) + else: + user_utils.link_user_to_local_idp(user_id, email_password=email_password) - assert get.call_count == 1 + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count == 1 + else: + assert get_core.call_count == 1 + assert get_manage.call_count == 0 assert put.call_count == 0 + assert patch.call_count == 0 def test_get_user_workspaces(user_utils, requests_mock): @@ -682,13 +976,16 @@ def test_set_user_application_permissions_alreadyset(user_utils, requests_mock): assert put.call_count == 0 -def test_resync_users(user_utils, requests_mock): +def test_resync_users(user_utils, requests_mock, mock_manage_api_key): user_ids = ["user1", "user2"] - gets = [] + gets_core = [] + gets_manage = [] patches = [] for user_id in user_ids: - gets.append(mock_get_user_200(requests_mock, user_id)) + get_core, get_manage, get_manage_personid = mock_get_user_200(requests_mock, user_id, mock_manage_api_key) + gets_core.append(get_core) + gets_manage.append(get_manage) patches.append( requests_mock.patch( @@ -703,22 +1000,36 @@ def test_resync_users(user_utils, requests_mock): user_utils.resync_users(user_ids) - for get in gets: - assert get.call_count == 1 + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + for get_core in gets_core: + assert get_core.call_count == 0 + for get_manage in gets_manage: + assert get_manage.call_count == 1 + else: + for get_core in gets_core: + assert get_core.call_count == 1 + for get_manage in gets_manage: + assert get_manage.call_count == 0 for patche in patches: assert patche.call_count == 1 -def test_check_user_sync(user_utils, requests_mock): +def test_check_user_sync(user_utils, requests_mock, mock_manage_api_key): + # Skip for version >= 9.1 as Manage API doesn't return applications field + if Version(user_utils.mas_version) >= Version('9.1'): + pytest.skip("check_user_sync not applicable for version >= 9.1 (Manage API doesn't return applications field)") + user_id = "user1" application_id = "manage" # transitions from PENDING -> SUCCESS on the third call attempts = 0 - def json_callback(request, context): + def json_callback_core(request, context): nonlocal attempts + # For version < 9.1, each get_user call makes 1 request if attempts >= 2: state = "SUCCESS" else: @@ -740,22 +1051,53 @@ def json_callback(request, context): } } - get = mock_get_user( + def json_callback_manage(request, context): + nonlocal attempts + # For version >= 9.1, each get_user call makes 2 requests + attempts = attempts + 1 + resource_id = f"{user_id}_resource_id" + # Manage API doesn't return applications field for version >= 9.1 + return { + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } + + get_core, get_manage, get_manage_personid = mock_get_user( requests_mock, user_id, - json_callback, - 200 + json_callback_core, + 200, + mock_manage_api_key, + json_manage=json_callback_manage ) user_utils.check_user_sync(user_id, application_id, timeout_secs=8, retry_interval_secs=0) - assert get.call_count == 3 + # Check that the correct endpoint was called based on version + # Note: For version >= 9.1, get_user makes 2 requests (query + resource_id GET) + # but we only track the first query request in get_manage mock + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + # Each get_user call makes 2 requests, but we only count the query request + assert get_manage.call_count == 3 + else: + assert get_core.call_count == 3 + assert get_manage.call_count == 0 + + +def test_check_user_sync_timeout(user_utils, requests_mock, mock_manage_api_key): + # Skip for version >= 9.1 as Manage API doesn't return applications field + if Version(user_utils.mas_version) >= Version('9.1'): + pytest.skip("check_user_sync not applicable for version >= 9.1 (Manage API doesn't return applications field)") -def test_check_user_sync_timeout(user_utils, requests_mock): user_id = "user1" application_id = "manage" - get = mock_get_user( + resource_id = f"{user_id}_resource_id" + get_core, get_manage, get_manage_personid = mock_get_user( requests_mock, user_id, { @@ -773,15 +1115,34 @@ def test_check_user_sync_timeout(user_utils, requests_mock): } } }, - 200 + 200, + mock_manage_api_key, + json_manage={ + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } ) with pytest.raises(Exception) as excinfo: user_utils.check_user_sync(user_id, application_id, timeout_secs=0.3, retry_interval_secs=0.05) assert str(excinfo.value) == f"User {user_id} sync failed to complete for app within {0.3} seconds" - assert get.call_count > 1 + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count > 1 + else: + assert get_core.call_count > 1 + assert get_manage.call_count == 0 + + +def test_check_user_sync_appstate_notfound(user_utils, requests_mock, mock_manage_api_key): + # Skip for version >= 9.1 as Manage API doesn't return applications field + if Version(user_utils.mas_version) >= Version('9.1'): + pytest.skip("check_user_sync not applicable for version >= 9.1 (Manage API doesn't return applications field)") -def test_check_user_sync_appstate_notfound(user_utils, requests_mock): user_id = "user1" application_id = "manage" @@ -790,7 +1151,7 @@ def test_check_user_sync_appstate_notfound(user_utils, requests_mock): # a single resync should have been triggered attempts = 0 - def json_callback(request, context): + def json_callback_core(request, context): nonlocal attempts if attempts >= 1: ret = { @@ -824,6 +1185,20 @@ def json_callback(request, context): attempts = attempts + 1 return ret + def json_callback_manage(request, context): + nonlocal attempts + resource_id = f"{user_id}_resource_id" + # Manage API doesn't return applications field for version >= 9.1 + ret = { + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } + attempts = attempts + 1 + return ret + patche = requests_mock.patch( f"{MAS_API_URL}/v3/users/{user_id}", request_headers={"x-access-token": TOKEN}, @@ -831,21 +1206,36 @@ def json_callback(request, context): status_code=200 ) - get = mock_get_user( + get_core, get_manage, get_manage_personid = mock_get_user( requests_mock, user_id, - json_callback, - 200 + json_callback_core, + 200, + mock_manage_api_key, + json_manage=json_callback_manage ) user_utils.check_user_sync(user_id, application_id, timeout_secs=8, retry_interval_secs=0) - assert get.call_count == 3 + + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + # For version >= 9.1, each get_user call makes 2 requests + assert get_manage.call_count == 3 + assert get_manage_personid.call_count == 3 + else: + assert get_core.call_count == 3 + assert get_manage.call_count == 0 # a single resync should have been triggered assert patche.call_count == 1 -def test_check_user_sync_appstate_transient_error(user_utils, requests_mock): +def test_check_user_sync_appstate_transient_error(user_utils, requests_mock, mock_manage_api_key): + # Skip for version >= 9.1 as Manage API doesn't return applications field + if Version(user_utils.mas_version) >= Version('9.1'): + pytest.skip("check_user_sync not applicable for version >= 9.1 (Manage API doesn't return applications field)") + user_id = "user1" application_id = "manage" @@ -854,7 +1244,7 @@ def test_check_user_sync_appstate_transient_error(user_utils, requests_mock): # a single resync should have been triggered attempts = 0 - def json_callback(request, context): + def json_callback_core(request, context): nonlocal attempts if attempts >= 1: ret = { @@ -883,6 +1273,20 @@ def json_callback(request, context): attempts = attempts + 1 return ret + def json_callback_manage(request, context): + nonlocal attempts + resource_id = f"{user_id}_resource_id" + # Manage API doesn't return applications field for version >= 9.1 + ret = { + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } + attempts = attempts + 1 + return ret + patche = requests_mock.patch( f"{MAS_API_URL}/v3/users/{user_id}", request_headers={"x-access-token": TOKEN}, @@ -890,21 +1294,32 @@ def json_callback(request, context): status_code=200 ) - get = mock_get_user( + get_core, get_manage, get_manage_personid = mock_get_user( requests_mock, user_id, - json_callback, - 200 + json_callback_core, + 200, + mock_manage_api_key, + json_manage=json_callback_manage ) user_utils.check_user_sync(user_id, application_id, timeout_secs=8, retry_interval_secs=0) - assert get.call_count == 3 + + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + # For version >= 9.1, each get_user call makes 2 requests + assert get_manage.call_count == 3 + assert get_manage_personid.call_count == 3 + else: + assert get_core.call_count == 3 + assert get_manage.call_count == 0 # a single resync should have been triggered assert patche.call_count == 1 -def test_check_user_sync_appstate_persistent_error(user_utils, requests_mock): +def test_check_user_sync_appstate_persistent_error(user_utils, requests_mock, mock_manage_api_key): user_id = "user1" application_id = "manage" @@ -915,7 +1330,8 @@ def test_check_user_sync_appstate_persistent_error(user_utils, requests_mock): status_code=200 ) - get = mock_get_user( + resource_id = f"{user_id}_resource_id" + get_core, get_manage, get_manage_personid = mock_get_user( requests_mock, user_id, { @@ -929,16 +1345,32 @@ def test_check_user_sync_appstate_persistent_error(user_utils, requests_mock): } } }, - 200 + 200, + mock_manage_api_key, + json_manage={ + "member": [{ + "href": f"api/os/masperuser/{resource_id}", + "personid": user_id, + "displayname": user_id + }] + } ) with pytest.raises(Exception) as excinfo: user_utils.check_user_sync(user_id, application_id, timeout_secs=0.3, retry_interval_secs=0.05) assert str(excinfo.value) == f"User {user_id} sync failed to complete for app within {0.3} seconds" - assert get.call_count > 1 - # an "update_user_display_name" should have been triggered for every 2 get calls (1 call by check_user_sync, 1 by resync) - assert patche.call_count == get.call_count / 2 + # Check that the correct endpoint was called based on version + if Version(user_utils.mas_version) >= Version('9.1'): + assert get_core.call_count == 0 + assert get_manage.call_count > 1 + # an "update_user_display_name" should have been triggered for every 2 get calls (1 call by check_user_sync, 1 by resync) + assert patche.call_count == get_manage.call_count / 2 + else: + assert get_core.call_count > 1 + assert get_manage.call_count == 0 + # an "update_user_display_name" should have been triggered for every 2 get calls (1 call by check_user_sync, 1 by resync) + assert patche.call_count == get_core.call_count / 2 def test_get_manage_api_key_for_user_exists(user_utils, requests_mock): @@ -1652,71 +2084,97 @@ def test_create_initial_user_for_saas_unsupported_type(user_utils): # Assisted by watsonx Code Assistant -@pytest.mark.parametrize("user_type, user_id, user_email, permissions, entitlement, is_workspace_admin, application_role, manage_role, facilities_role, manage_security_groups", [ +@pytest.mark.parametrize("user_type, user_id, user_email, is_workspace_admin, application_role, manage_role, facilities_role, manage_security_groups_90, manage_security_groups_91", [ ( "PRIMARY", None, "bill.bob@acme.com", - {"systemAdmin": False, "userAdmin": True, "apikeyAdmin": False}, - {"application": "PREMIUM", "admin": "ADMIN_BASE", "alwaysReserveLicense": True}, True, "ADMIN", "MANAGEUSER", "PREMIUM", - ["MAXADMIN"] + ["MAXADMIN"], + ["USERMANAGEMENT"] ), ( "PRIMARY", "billbob", "bill.bob@acme.com", - {"systemAdmin": False, "userAdmin": True, "apikeyAdmin": False}, - {"application": "PREMIUM", "admin": "ADMIN_BASE", "alwaysReserveLicense": True}, True, "ADMIN", "MANAGEUSER", "PREMIUM", - ["MAXADMIN"] + ["MAXADMIN"], + ["USERMANAGEMENT"] ), ( "SECONDARY", None, "bab.bon@acme.com", - {"systemAdmin": False, "userAdmin": False, "apikeyAdmin": False}, - {"application": "BASE", "admin": "NONE", "alwaysReserveLicense": True}, False, "USER", "MANAGEUSER", "BASE", + [], [] ), ( "SECONDARY", "babbon", "bab.bon@acme.com", - {"systemAdmin": False, "userAdmin": False, "apikeyAdmin": False}, - {"application": "BASE", "admin": "NONE", "alwaysReserveLicense": True}, False, "USER", "MANAGEUSER", "BASE", + [], [] ) ]) def test_create_initial_user_for_saas( - user_type, user_id, user_email, permissions, entitlement, is_workspace_admin, application_role, manage_role, facilities_role, manage_security_groups, + user_type, user_id, user_email, is_workspace_admin, application_role, manage_role, facilities_role, manage_security_groups_90, manage_security_groups_91, user_utils, requests_mock ): - user_utils.get_or_create_user = MagicMock() + # Determine expected values based on MAS version + mas_version = user_utils.mas_version + if mas_version == '9.0': + manage_security_groups = manage_security_groups_90 + if user_type == "PRIMARY": + permissions = {"systemAdmin": False, "userAdmin": True, "apikeyAdmin": False} + entitlement = {"application": "PREMIUM", "admin": "ADMIN_BASE", "alwaysReserveLicense": True} + else: # SECONDARY + permissions = {"systemAdmin": False, "userAdmin": False, "apikeyAdmin": False} + entitlement = {"application": "BASE", "admin": "NONE", "alwaysReserveLicense": True} + else: # 9.1 + manage_security_groups = manage_security_groups_91 + permissions = None # Not used in 9.1 + entitlement = None # Not used in 9.1 + # Mock get_or_create_user to return appropriate response based on version + # Note: user_id might be None at this point, it gets set to user_email later + actual_user_id = user_id if user_id is not None else user_email + if Version(mas_version) >= Version('9.1'): + # For 9.1, return tuple (resource_id, user_data) with member array containing href + resource_id = f"_{actual_user_id.replace('@', '_').replace('.', '_')}_resource_id" + user_utils.get_or_create_user = MagicMock(return_value=( + resource_id, + { + "member": [{"href": f"api/os/masperuser/{resource_id}"}], + "id": actual_user_id + } + )) + else: + # For version < 9.1, return tuple (None, user_data) + user_utils.get_or_create_user = MagicMock(return_value=(None, {"id": actual_user_id})) user_utils.link_user_to_local_idp = MagicMock() user_utils.add_user_to_workspace = MagicMock() mas_workspace_application_ids = ["manage", "iot", "facilities"] - user_utils.get_mas_applications_in_workspace = MagicMock(return_value=map(lambda x: {"id": x}, mas_workspace_application_ids)) + user_utils.get_mas_applications_in_workspace = MagicMock(return_value=list(map(lambda x: {"id": x}, mas_workspace_application_ids))) user_utils.await_mas_application_availability = MagicMock() user_utils.set_user_application_permission = MagicMock() user_utils.check_user_sync = MagicMock() manage_api_key = "manage_api_key" # pragma: allowlist secret user_utils.create_or_get_manage_api_key_for_user = MagicMock(return_value=manage_api_key) user_utils.add_user_to_manage_group = MagicMock() + user_utils.set_user_group_reassignment_auth = MagicMock() user_given_name = "billy" user_family_name = "bobby" @@ -1735,51 +2193,136 @@ def test_create_initial_user_for_saas( username = user_id - user_utils.create_initial_user_for_saas(initial_users, user_type) + # For version 9.1 PRIMARY users, pass groupreassign parameter + if Version(mas_version) >= Version('9.1') and user_type == "PRIMARY": + groupreassign = [{"groupname": "USERMANAGEMENT"}] + user_utils.create_initial_user_for_saas(initial_users, user_type, groupreassign) + else: + user_utils.create_initial_user_for_saas(initial_users, user_type) - user_utils.get_or_create_user.assert_called_once_with({ - "id": user_id, - "status": {"active": True}, - "username": username, - "owner": "local", - "emails": [ - { - "value": user_email, - "type": "Work", - "primary": True + # Build expected user_def based on version + if mas_version == '9.0': + expected_user_def = { + "id": user_id, + "status": {"active": True}, + "username": username, + "owner": "local", + "emails": [ + { + "value": user_email, + "type": "Work", + "primary": True + } + ], + "phoneNumbers": [], + "addresses": [], + "displayName": display_name, + "issuer": "local", + "permissions": permissions, + "entitlement": entitlement, + "givenName": user_given_name, + "familyName": user_family_name + } + else: # >=9.1 + if user_type == "PRIMARY": + maxuser_def = { + "userid": user_id, + "personid": user_id, + "loginid": user_id, + "owner": "local", + "systemadmin": False, + "apikeyadmin": True, + "isauthorized": 1, + "idpadmin": True, + "status": "ACTIVE", + "groupuser": [ + { + "groupname": "USERMANAGEMENT" + } + ] } - ], - "phoneNumbers": [], - "addresses": [], - "displayName": display_name, - "issuer": "local", - "permissions": permissions, - "entitlement": entitlement, - "givenName": user_given_name, - "familyName": user_family_name - }) - user_utils.link_user_to_local_idp.assert_called_once_with(user_id, email_password=True) + else: # SECONDARY + maxuser_def = { + "userid": user_id, + "personid": user_id, + "loginid": user_id, + "owner": "local", + "systemadmin": False, + "apikeyadmin": False, + "isauthorized": 0, + "idpadmin": False, + "status": "ACTIVE" + } + + expected_user_def = { + "personid": user_id, + "primaryemailtype": "Work", + "primaryemail": user_email, + "primaryphone": "", + "addressline1": "", + "displayName": display_name, + "maxuser": maxuser_def + } + + user_utils.get_or_create_user.assert_called_once_with(expected_user_def) + + # Check link_user_to_local_idp call based on version + if Version(mas_version) >= Version('9.1'): + resource_id = f"_{actual_user_id.replace('@', '_').replace('.', '_')}_resource_id" + user_utils.link_user_to_local_idp.assert_called_once_with(user_id, email_password=True, manage_api_key=manage_api_key, resource_id=resource_id) + else: + user_utils.link_user_to_local_idp.assert_called_once_with(user_id, email_password=True) + user_utils.add_user_to_workspace.assert_called_once_with(user_id, is_workspace_admin=is_workspace_admin) - user_utils.await_mas_application_availability.assert_has_calls([call("manage"), call("iot")]) - user_utils.set_user_application_permission.assert_has_calls([ - call(user_id, "manage", manage_role), - call(user_id, "iot", application_role), - call(user_id, "facilities", facilities_role), - ]) - user_utils.check_user_sync.assert_has_calls([ - call(user_id, "manage"), - call(user_id, "iot"), - call(user_id, "facilities") - ]) - if len(manage_security_groups) > 0: + # For version < 9.1, await_mas_application_availability and set_user_application_permission are called + # For version >= 9.1, they are NOT called + if mas_version == '9.0': + user_utils.await_mas_application_availability.assert_has_calls([call("manage"), call("iot")]) + user_utils.set_user_application_permission.assert_has_calls([ + call(user_id, "manage", manage_role), + call(user_id, "iot", application_role), + call(user_id, "facilities", facilities_role), + ]) + else: # >=9.1 + user_utils.await_mas_application_availability.assert_not_called() + user_utils.set_user_application_permission.assert_not_called() + + # check_user_sync is only called for version < 9.1 + # For version >= 9.1, Manage API doesn't return applications field, so sync check is not performed + if mas_version == '9.0': + user_utils.check_user_sync.assert_has_calls([ + call(user_id, "manage"), + call(user_id, "iot"), + call(user_id, "facilities") + ]) + else: # 9.1 + user_utils.check_user_sync.assert_not_called() + + # For version >= 9.1, API key is always created (needed for link_user_to_local_idp) + # For version < 9.1, API key is only created if there are manage_security_groups + if Version(mas_version) >= Version('9.1') or len(manage_security_groups) > 0: user_utils.create_or_get_manage_api_key_for_user.assert_called_once_with("MAXADMIN", temporary=True) else: user_utils.create_or_get_manage_api_key_for_user.assert_not_called() - user_utils.add_user_to_manage_group.assert_has_calls( - map(lambda sg: call(user_id, sg, manage_api_key), manage_security_groups) - ) + if len(manage_security_groups) > 0: + # For version < 9.1, add_user_to_manage_group is called + # For version >= 9.1, set_user_group_reassignment_auth is called for PRIMARY users + if mas_version == '9.0': + user_utils.add_user_to_manage_group.assert_has_calls( + list(map(lambda sg: call(user_id, sg, manage_api_key), manage_security_groups)) + ) + user_utils.set_user_group_reassignment_auth.assert_not_called() + else: # >=9.1 + user_utils.add_user_to_manage_group.assert_not_called() + if user_type == "PRIMARY": + # For versions >= 9.1, both user_id and resource_id are passed + actual_user_id = user_id if user_id is not None else user_email + resource_id = f"_{actual_user_id.replace('@', '_').replace('.', '_')}_resource_id" + user_utils.set_user_group_reassignment_auth.assert_called_once_with(actual_user_id, resource_id, [{"groupname": "USERMANAGEMENT"}], manage_api_key) + else: + user_utils.set_user_group_reassignment_auth.assert_not_called() def test_create_initial_users_for_saas_invalid_inputs(user_utils): @@ -1813,9 +2356,10 @@ def test_create_initial_users_for_saas(user_utils): mas_workspace_application_ids = ["manage", "iot"] user_utils.get_mas_applications_in_workspace = MagicMock(return_value=map(lambda x: {"id": x}, mas_workspace_application_ids)) user_utils.await_mas_application_availability = MagicMock() + user_utils.get_all_manage_groups = MagicMock(return_value=["MAXADMIN", "MAXUSER"]) user_utils.create_initial_user_for_saas = MagicMock() - def fail_for_users_b_and_e(user, user_type): + def fail_for_users_b_and_e(user, user_type, groupreassign=None): if user["email"] in ["b", "e"]: raise Exception(f"{user['email']} should fail") user_utils.create_initial_user_for_saas.side_effect = fail_for_users_b_and_e