├── src ├── __init__.py ├── setup.py ├── main.py ├── utils.py ├── cognito_migration.py ├── firebase_migration.py ├── auth0_migration.py └── ping_migration.py ├── requirements.txt ├── password-hash.txt.example ├── .gitignore ├── .env.example ├── LICENSE └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | python-dotenv 3 | descope 4 | boto3 5 | bcrypt 6 | firebase-admin -------------------------------------------------------------------------------- /password-hash.txt.example: -------------------------------------------------------------------------------- 1 | hash_config { 2 | algorithm: SCRYPT, 3 | base64_signer_key: eD..., 4 | base64_salt_separator: ..., 5 | rounds: 1, 6 | mem_cost: 18, 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore all json files 2 | *.json 3 | 4 | __pycache__/ 5 | 6 | # ignore all .env files 7 | *.env 8 | 9 | /venv 10 | 11 | # ignore everything in creds and logs 12 | creds/* 13 | logs/* 14 | 15 | .env 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DESCOPE_PROJECT_ID= 2 | DESCOPE_MANAGEMENT_KEY= 3 | 4 | #If Auth0 Migrations 5 | AUTH0_TOKEN= 6 | AUTH0_TENANT_ID= 7 | AUTH0_REGION= 8 | 9 | #If Cognito migration 10 | AWS_ACCESS_KEY_ID= 11 | AWS_SECRET_ACCESS_KEY= 12 | COGNITO_USER_POOL_ID= 13 | COGNITO_REGION= 14 | 15 | #If Firebase miration 16 | FIREBASE_DB_URL= 17 | 18 | #If Ping migration 19 | PING_CLIENT_ID= 20 | PING_CLIENT_SECRET= 21 | PING_ENVIRONMENT_ID= 22 | PING_API_PATH= -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Descope 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | from datetime import datetime 5 | 6 | from descope import DescopeClient, AuthException 7 | 8 | def setup_logging(provider=""): 9 | log_directory = "logs" 10 | if not os.path.exists(log_directory): 11 | os.makedirs(log_directory) 12 | 13 | # datetime object containing current date and time 14 | now = datetime.now() 15 | 16 | dt_string = now.strftime("%d_%m_%Y_%H:%M:%S") 17 | logging_file_name = os.path.join(log_directory, f"migration_log_{provider}{provider and '_'}{dt_string}.log") 18 | logging.basicConfig( 19 | filename=logging_file_name, 20 | level=logging.INFO, 21 | format="%(asctime)s - %(levelname)s - %(message)s", 22 | ) 23 | 24 | def initialize_descope(): 25 | DESCOPE_PROJECT_ID = os.getenv("DESCOPE_PROJECT_ID") 26 | DESCOPE_MANAGEMENT_KEY = os.getenv("DESCOPE_MANAGEMENT_KEY") 27 | 28 | try: 29 | descope_client = DescopeClient( 30 | project_id=DESCOPE_PROJECT_ID, management_key=DESCOPE_MANAGEMENT_KEY 31 | ) 32 | except AuthException as error: 33 | logging.error(f"Failed to initialize Descope Client: {error}") 34 | sys.exit() 35 | 36 | return descope_client 37 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from setup import setup_logging 4 | 5 | 6 | def main(): 7 | dry_run = False 8 | verbose = False 9 | with_passwords = False 10 | passwords_file_path = "" 11 | from_json = False 12 | json_file_path = "" 13 | 14 | # General Tool Flags 15 | parser = argparse.ArgumentParser( 16 | description="This program assists you in migrating your users and user groups from various services to Descope." 17 | ) 18 | parser.add_argument( 19 | "provider", 20 | choices=["firebase", "auth0", "cognito", "ping"], 21 | help="Specify the service to migrate from", 22 | ) 23 | parser.add_argument("--dry-run", action="store_true", help="Enable dry run mode") 24 | parser.add_argument( 25 | "--verbose", 26 | "-v", 27 | action="store_true", 28 | help="Enable verbose printing for live runs and dry runs", 29 | ) 30 | 31 | # Provider Specific Flags 32 | parser.add_argument( 33 | "--with-passwords", 34 | nargs=1, 35 | metavar="file-path", 36 | help="Run the script with passwords from the specified file", 37 | ) 38 | parser.add_argument( 39 | "--from-json", 40 | nargs=1, 41 | metavar="file-path", 42 | help="Run the script with users from the specified file rather than API", 43 | ) 44 | 45 | args = parser.parse_args() 46 | 47 | provider = args.provider 48 | # General Flags 49 | if args.dry_run: 50 | dry_run = True 51 | 52 | if args.verbose: 53 | verbose = True 54 | 55 | # Auth0 Flags 56 | if args.with_passwords: 57 | passwords_file_path = args.with_passwords[0] 58 | with_passwords = True 59 | # print(f"Running with passwords from file: {passwords_file_path}") 60 | 61 | if args.from_json: 62 | json_file_path = args.from_json[0] 63 | from_json = True 64 | 65 | setup_logging(provider) 66 | 67 | if provider == "firebase": 68 | from firebase_migration import migrate_firebase 69 | 70 | migrate_firebase(dry_run, verbose) 71 | elif provider == "auth0": 72 | from auth0_migration import migrate_auth0 73 | 74 | migrate_auth0(dry_run, verbose, passwords_file_path, json_file_path) 75 | elif provider == "cognito": 76 | from cognito_migration import migrate_cognito 77 | 78 | migrate_cognito(dry_run, verbose) 79 | elif provider == "ping": 80 | from ping_migration import migrate_pingone 81 | 82 | migrate_pingone(dry_run, verbose) 83 | else: 84 | print("Invalid service specified.") 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Descope Third-Party Migration Tool 2 | This repository includes a Python utility for migrating from a third-party service to Descope. 3 | 4 | This currently tool supports the following third-party services: 5 | - Auth0 6 | - AWS Cognito 7 | - Firebase 8 | - Ping 9 | 10 | 11 | >Migrations can differ wildly depending on the specific identity implementation and provider. However, this tool serves as a template that you can edit if it doesn't fully meet your needs. 12 | 13 | ## Setup 💿 14 | 15 | 1. Clone the Repo: 16 | 17 | ``` 18 | git clone git@github.com:descope/descope-migration.git 19 | ``` 20 | 21 | 2. Create a Virtual Environment 22 | 23 | ``` 24 | python3 -m venv venv 25 | source venv/bin/activate 26 | ``` 27 | 28 | 3. Install the Necessary Python libraries 29 | 30 | ``` 31 | pip3 install -r requirements.txt 32 | ``` 33 | 34 | 4. Follow the guide for your specific third-party 35 | - [Auth0](https://docs.descope.com/migrate/auth0) 36 | - [AWS Cognito](https://docs.descope.com/migrate/cognito) 37 | - [Firebase](https://docs.descope.com/migrate/firebase) 38 | - [Ping](https://docs.descope.com/migrate/ping) 39 | - If you're using a Custom data store please follow this guide instead [Custom Data Store](https://docs.descope.com/migrate/custom) 40 | 41 | ## Running the Migration Script 🚀 42 | 43 | The tool will handle migrations differently for each third-party service. However, there are some general commands that remain consistent across all third parties. 44 | 45 | ### Third-Party Providers 46 | 47 | To pick the the Third-Party service to migrate from you must pass the `provider` flag 48 | 49 | The following are supported: 50 | - `auth0` for Auth0 by Okta 51 | - `cognito` for AWS Cognito 52 | - `firebase` for Firebase 53 | - `ping` for Ping 54 | 55 | Use: 56 | ``` 57 | python3 src/main.py auth0 58 | ``` 59 | 60 | #### Guides for each provider 61 | 62 | Pick the third-party you are migrating from and follow the corrosponding guide 63 | - [Auth0](https://docs.descope.com/migrate/auth0) 64 | - [AWS Cognito](https://docs.descope.com/migrate/cognito) 65 | - [Firebase](https://docs.descope.com/migrate/firebase) 66 | - [Ping](https://docs.descope.com/migrate/ping) 67 | 68 | ### Dry Run vs Live Run 69 | 70 | If you want to see what users and other information will be migrated before actually migrating you can dry run the migration for all thrid-parties. Live run will actually perform the migration 71 | 72 | #### Dry Run 73 | 74 | The `--dry-run` flag can be used by all third-parties: 75 | 76 | ``` 77 | python3 src/main.py provider --dry-run 78 | ``` 79 | 80 | #### Live Run 81 | 82 | To live run exclude the `--dry-run` flag: 83 | 84 | ``` 85 | python3 src/main.py provider 86 | ``` 87 | ### Verbose 88 | 89 | You can add the `-v` or `--verbose` flag to any dry run or live run by any provider to get more information on which users or objects are being migrated. 90 | Exclude the flag to get a more compact printout. 91 | 92 | Use: 93 | ``` 94 | Dry Run Verbose: python3 src/main.py --dry-run -v 95 | Live Run Verbose: python3 src/main.py -v 96 | ``` 97 | ## Issue Reporting ⚠️ 98 | 99 | For any issues or suggestions, feel free to open an issue in the GitHub repository. 100 | 101 | ## License 📜 102 | 103 | This project is licensed under the MIT License - see the LICENSE file for details. 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import requests 3 | import os 4 | from dotenv import load_dotenv 5 | import logging 6 | import time 7 | import json 8 | from collections.abc import MutableMapping 9 | 10 | load_dotenv() 11 | DESCOPE_PROJECT_ID = os.getenv("DESCOPE_PROJECT_ID") 12 | DESCOPE_MANAGEMENT_KEY = os.getenv("DESCOPE_MANAGEMENT_KEY") 13 | 14 | def api_request_with_retry(action, url, headers, data=None, max_retries=4, timeout=10): 15 | """ 16 | Handles API requests with additional retry on timeout and rate limit. 17 | 18 | Args: 19 | - action (string): 'get' or 'post' 20 | - url (string): The URL of the path for the api request 21 | - headers (dict): Headers to be sent with the request 22 | - data (json): Optional and used only for post, but the payload to post 23 | - max_retries (int): The max number of retries 24 | - timeout (int): The timeout for the request in seconds 25 | Returns: 26 | - API Response 27 | - Or None 28 | """ 29 | retries = 0 30 | while retries < max_retries: 31 | try: 32 | if action == "get": 33 | response = requests.get(url, headers=headers, timeout=timeout) 34 | else: 35 | response = requests.post( 36 | url, headers=headers, data=data, timeout=timeout 37 | ) 38 | 39 | if ( 40 | response.status_code != 429 41 | ): # Not a rate limit error, proceed with response 42 | return response 43 | 44 | # If rate limit error, prepare for retry 45 | retries += 1 46 | wait_time = 5**retries 47 | logging.info(f"Rate limit reached. Retrying in {wait_time} seconds...") 48 | time.sleep(wait_time) 49 | 50 | except requests.exceptions.ReadTimeout as e: 51 | # Handle read timeout exception 52 | logging.warning(f"Read timed out. (read timeout={timeout}): {e}") 53 | retries += 1 54 | wait_time = 5**retries 55 | logging.info(f"Retrying attempt {retries}/{max_retries}...") 56 | time.sleep( 57 | wait_time 58 | ) # Wait for 5 seconds before retrying or use a backoff strategy 59 | 60 | except requests.exceptions.RequestException as e: 61 | # Handle other request exceptions 62 | logging.error(f"A request exception occurred: {e}") 63 | break # In case of other exceptions, you may want to break the loop 64 | 65 | logging.error("Max retries reached. Giving up.") 66 | return None 67 | 68 | 69 | def create_custom_attributes_in_descope(custom_attr_dict): 70 | """ 71 | Creates custom attributes in Descope 72 | 73 | Args: 74 | - custom_attr_dict: Dictionary of custom attribute names and assosciated data types {"name" : dataType, ...} 75 | """ 76 | 77 | type_mapping = { 78 | 'String': 1, 79 | 'Number': 2, 80 | 'Boolean': 3 81 | } 82 | 83 | # Takes indivdual custom attribute and makes a json body for create attribute post request 84 | custom_attr_post_body = [] 85 | for custom_attr_name, custom_attr_type in custom_attr_dict.items(): 86 | custom_attr_body = { 87 | "name": custom_attr_name, 88 | "type": type_mapping.get(custom_attr_type, 1), # Defualt to 0 if type not found 89 | "options": [], 90 | "displayName": custom_attr_name, 91 | "defaultValue": {}, 92 | "viewPermissions": [], 93 | "editPermissions": [], 94 | "editable": True 95 | } 96 | custom_attr_post_body.append(custom_attr_body) 97 | 98 | #Combine all custom attribute post request bodies into one 99 | #Request for custom attributes to be created using a post request 100 | try: 101 | endpoint = "https://api.descope.com/v1/mgmt/user/customattribute/create" 102 | data = {"attributes":custom_attr_post_body} 103 | # print(data) #MYPRINT 104 | headers = { 105 | "Authorization": f"Bearer {DESCOPE_PROJECT_ID}:{DESCOPE_MANAGEMENT_KEY}", 106 | "Content-Type": "application/json" 107 | } 108 | response = api_request_with_retry( 109 | action="post", 110 | url=endpoint, 111 | headers=headers, 112 | data=json.dumps(data) 113 | ) 114 | 115 | if response.ok: 116 | logging.info(f"Custom attributes successfully created in Descope") 117 | else: 118 | response.raise_for_status() 119 | 120 | except requests.HTTPError as e: 121 | error_dict = { 122 | "status_code":e.response.status_code, 123 | "error_reason":e.response.reason, 124 | "error_message":e.response.text 125 | } 126 | logging.error(f"Failed to create custom Attributes: {str(error_dict)}") 127 | 128 | 129 | def flatten_dict(dictionary, parent_key='', separator='_' ): 130 | """ 131 | Takes a dictonary and flattens it if it has nested attributes. 132 | Nested attribute names will be Root.Parents.AttributeName 133 | 134 | Args: 135 | - dictionary: dictionary of attributes some of which may be nested 136 | - parent_key: used for recursion and defines the root key for attribute names 137 | - separator: will be the seperating delimiter between root,parents, and attribute name 138 | """ 139 | items = [] 140 | for key, value in dictionary.items(): 141 | new_key = parent_key + separator + key if parent_key else key 142 | if isinstance(value, MutableMapping): 143 | items.extend(flatten_dict(value,new_key,separator=separator).items()) 144 | else: 145 | items.append((new_key,value)) 146 | return dict(items) 147 | 148 | 149 | def parse_hash_params(hash_params_file_path): 150 | """ 151 | Parse the hash parameters from the given password-hash.txt file. 152 | """ 153 | hash_params = {} 154 | try: 155 | with open(hash_params_file_path, "r") as file: 156 | for line in file: 157 | line = line.strip() 158 | if line.startswith("algorithm:"): 159 | hash_params["algorithm"] = line.split(":", 1)[1].strip().strip(",") 160 | elif line.startswith("base64_signer_key:"): 161 | hash_params["signer_key"] = line.split(":", 1)[1].strip().strip(",") 162 | elif line.startswith("base64_salt_separator:"): 163 | hash_params["salt_separator"] = ( 164 | line.split(":", 1)[1].strip().strip(",") 165 | ) 166 | elif line.startswith("rounds:"): 167 | # Added strip(',') to remove any trailing commas 168 | hash_params["rounds"] = int( 169 | line.split(":", 1)[1].strip().strip(",") 170 | ) 171 | elif line.startswith("mem_cost:"): 172 | # Added strip(',') to remove any trailing commas 173 | hash_params["mem_cost"] = int( 174 | line.split(":", 1)[1].strip().strip(",") 175 | ) 176 | except FileNotFoundError: 177 | print(f"File not found: {hash_params_file_path}") 178 | exit(1) 179 | except ValueError as e: 180 | print(f"Error parsing hash parameters: {e}") 181 | exit(1) 182 | return hash_params 183 | 184 | class AnonLoginId: 185 | def __init__(self): 186 | self.anon_counter = 0 187 | 188 | def make_anon_login_id(self): 189 | login_id = f"anon_user_{self.anon_counter}@anonymous.com" 190 | self.anon_counter += 1 191 | return login_id -------------------------------------------------------------------------------- /src/cognito_migration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import requests 4 | from dotenv import load_dotenv 5 | import logging 6 | import time 7 | import boto3 8 | from botocore.exceptions import NoCredentialsError, ClientError 9 | import bcrypt 10 | 11 | from setup import initialize_descope 12 | 13 | from utils import create_custom_attributes_in_descope 14 | 15 | DESCOPE_API_URL = "https://api.descope.com" 16 | 17 | # Load and read environment variables from .env file 18 | load_dotenv() 19 | AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") 20 | AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") 21 | COGNITO_USER_POOL_ID = os.getenv("COGNITO_USER_POOL_ID") 22 | COGNITO_REGION = os.getenv("COGNITO_REGION") 23 | 24 | # Initialize the Descope client 25 | descope_client = initialize_descope() 26 | 27 | 28 | def get_cognito_user_pool_schema(): 29 | try: 30 | client = boto3.client("cognito-idp", region_name=COGNITO_REGION) 31 | response = client.describe_user_pool(UserPoolId=COGNITO_USER_POOL_ID) 32 | 33 | if "UserPool" in response: 34 | return response["UserPool"].get("SchemaAttributes", []) 35 | else: 36 | return [] 37 | except NoCredentialsError: 38 | print("Credentials not available") 39 | return [] 40 | except ClientError as e: 41 | print(f"An error occurred: {e}") 42 | return [] 43 | 44 | 45 | def fetch_cognito_users(): 46 | """ 47 | Fetch and parse Cognito users from the provided endpoint. 48 | 49 | Returns: 50 | - all_users (List): A list of parsed Cognito users if successful, empty list otherwise. 51 | """ 52 | client = boto3.client("cognito-idp", region_name=COGNITO_REGION) 53 | user_pool_id = COGNITO_USER_POOL_ID 54 | 55 | all_users = [] 56 | pagination_token = None 57 | 58 | while True: 59 | if pagination_token: 60 | response = client.list_users( 61 | UserPoolId=user_pool_id, PaginationToken=pagination_token 62 | ) 63 | else: 64 | response = client.list_users(UserPoolId=user_pool_id) 65 | 66 | all_users.extend(response["Users"]) 67 | 68 | if "PaginationToken" in response: 69 | pagination_token = response["PaginationToken"] 70 | else: 71 | break 72 | 73 | return all_users 74 | 75 | 76 | def fetch_cognito_user_groups(): 77 | """ 78 | Fetch and parse Cognito user groups from the provided endpoint. 79 | 80 | Returns: 81 | - all_groups (List): A list of parsed Cognito user groups if successful, empty list otherwise. 82 | """ 83 | client = boto3.client("cognito-idp", region_name=COGNITO_REGION) 84 | user_pool_id = COGNITO_USER_POOL_ID 85 | 86 | all_groups = [] 87 | pagination_token = None 88 | 89 | while True: 90 | if pagination_token: 91 | response = client.list_groups( 92 | UserPoolId=user_pool_id, NextToken=pagination_token 93 | ) 94 | else: 95 | response = client.list_groups(UserPoolId=user_pool_id) 96 | 97 | all_groups.extend(response["Groups"]) 98 | 99 | if "NextToken" in response: 100 | pagination_token = response["NextToken"] 101 | else: 102 | break 103 | 104 | return all_groups 105 | 106 | 107 | def get_users_in_group(group_name): 108 | """ 109 | Get and parse Cognito users associated with the provided group. 110 | 111 | Args: 112 | - group_name (string): The group name to get the associated members 113 | 114 | Returns: 115 | - all_users (List): A list of users in the given group. 116 | """ 117 | client = boto3.client("cognito-idp", region_name=COGNITO_REGION) 118 | user_pool_id = COGNITO_USER_POOL_ID 119 | 120 | all_users = [] 121 | pagination_token = None 122 | 123 | while True: 124 | if pagination_token: 125 | response = client.list_users_in_group( 126 | UserPoolId=user_pool_id, 127 | GroupName=group_name, 128 | NextToken=pagination_token, 129 | ) 130 | else: 131 | response = client.list_users_in_group( 132 | UserPoolId=user_pool_id, GroupName=group_name 133 | ) 134 | 135 | all_users.extend(response["Users"]) 136 | 137 | if "NextToken" in response: 138 | pagination_token = response["NextToken"] 139 | else: 140 | break 141 | 142 | return all_users 143 | 144 | 145 | ### Begin Process Functions 146 | 147 | 148 | def generate_hashed_password(password): 149 | salt = bcrypt.gensalt() 150 | hashed = bcrypt.hashpw(password.encode(), salt) 151 | return hashed, salt 152 | 153 | 154 | def process_users(api_response_users, schema_attributes, dry_run): 155 | """ 156 | Process the list of users from Cognito by dynamically mapping and creating them in Descope 157 | based on the user pool schema. 158 | 159 | Args: 160 | - api_response_users (list): A list of users fetched from Cognito API. 161 | - schema_attributes (list): Schema attributes from Cognito user pool. 162 | - dry_run (bool): Flag for dry run mode. 163 | """ 164 | total_users_migrated = [] 165 | 166 | schema_attr_names = {attr["Name"] for attr in schema_attributes} 167 | #Gets the custom Attributes from Schema and makes a Dict {custom_attr_name:custom_attr_datatype} 168 | custom_attr_dict = {attr["Name"]:attr["AttributeDataType"] for attr in schema_attributes if attr["Name"].startswith("custom:")} 169 | 170 | # Create custom attributes in Descope if they exist 171 | if custom_attr_dict: 172 | create_custom_attributes_in_descope(custom_attr_dict) 173 | 174 | for user in api_response_users: 175 | # Extract the 'sub' attribute (unique identifier in Cognito) 176 | cognito_user_id = next( 177 | ( 178 | attr["Value"] 179 | for attr in user.get("Attributes", []) 180 | if attr["Name"] == "sub" 181 | ), 182 | None, 183 | ) 184 | 185 | # Cognito username 186 | username = user.get("Username") 187 | 188 | descope_user_data = { 189 | "loginId": None, 190 | "customAttributes": {"username": username, "sub": cognito_user_id} 191 | if cognito_user_id and username 192 | else {}, 193 | "test": False, 194 | } 195 | 196 | # Dynamically set other attributes based on the Cognito schema 197 | for attribute in user.get("Attributes", []): 198 | attr_name = attribute["Name"] 199 | attr_value = attribute["Value"] 200 | 201 | if attr_name in schema_attr_names: 202 | if attr_name == "email": 203 | descope_user_data["loginId"] = attr_value 204 | descope_user_data[attr_name] = attr_value 205 | if attr_name in ["phone_number"]: 206 | descope_user_data[attr_name] = attr_value 207 | elif attr_name in ["email_verified", "phone_number_verified"]: 208 | descope_user_data[attr_name] = attr_value == "true" 209 | elif attr_name != "sub" and not (attr_name in custom_attr_dict): 210 | # Handle other custom attributes 211 | descope_user_data["customAttributes"][attr_name] = attr_value 212 | elif attr_name != "sub" and (attr_name in custom_attr_dict): 213 | # Handle custom attributes from cognito marked with "custom:" 214 | descope_user_data["customAttributes"][attr_name] = attr_value if custom_attr_dict[attr_name] == 'String' else float(attr_value) 215 | 216 | 217 | if dry_run: 218 | # if verbose: 219 | # print( 220 | # f"Dry run: Would create user {username} with Cognito User ID {cognito_user_id}" 221 | # ) 222 | 223 | total_users_migrated.append(username) 224 | continue 225 | 226 | user_email = descope_user_data["email"] 227 | 228 | try: 229 | # Add additional attributes if necessary 230 | descope_client.mgmt.user.create( 231 | login_id=descope_user_data["loginId"], 232 | email=descope_user_data["email"], 233 | phone=descope_user_data.get("phone_number"), 234 | custom_attributes=descope_user_data["customAttributes"], 235 | verified_email=descope_user_data.get("email_verified"), 236 | verified_phone=descope_user_data.get("phone_number_verified"), 237 | ) 238 | 239 | descope_client.mgmt.user.activate(login_id=descope_user_data["loginId"]) 240 | logging.info(f"User {user_email} successfully created in Descope") 241 | 242 | # if verbose: 243 | # print(f"User {user_email} successfully created in Descope") 244 | total_users_migrated.append(username) 245 | except Exception as e: 246 | logging.error(f"Failed to create user {user_email}: {str(e)}") 247 | 248 | return total_users_migrated 249 | 250 | 251 | def process_user_groups(cognito_groups, dry_run): 252 | """ 253 | Process the Cognito user groups - creating roles in Descope and associating users 254 | 255 | Args: 256 | - cognito_groups (list): List of groups fetched from Cognito 257 | """ 258 | total_user_groups_migrated = [] 259 | for group in cognito_groups: 260 | group_name = group.get("GroupName") 261 | descope_role_data = { 262 | "name": group.get("GroupName"), 263 | # Add other necessary mappings or attributes 264 | } 265 | 266 | if dry_run: 267 | logging.info(f"Dry run: Would create role {descope_role_data['name']}") 268 | continue 269 | 270 | try: 271 | descope_client.mgmt.role.create(name=group_name) 272 | # if verbose: 273 | # print(f"Role {group_name} successfully created in Descope") 274 | total_user_groups_migrated.append(group_name) 275 | 276 | except Exception as e: 277 | logging.error(f"Failed to create role {group_name}: {str(e)}") 278 | # Fetch users in this group from Cognito 279 | cognito_users_in_group = get_users_in_group(group_name) 280 | 281 | # Associate these users with the role in Descope 282 | if not dry_run: 283 | associate_users_with_role_in_descope( 284 | cognito_users_in_group, descope_role_data["name"] 285 | ) 286 | return total_user_groups_migrated 287 | 288 | 289 | def associate_users_with_role_in_descope(users, role_name): 290 | """ 291 | Associate a list of users with a role in Descope. 292 | 293 | Args: 294 | - users (list): List of user identifiers (e.g., email or username). 295 | - role_name (string): The name of the role in Descope. 296 | """ 297 | for user in users: 298 | descope_login_id = next( 299 | (attr["Value"] for attr in user["Attributes"] if attr["Name"] == "email"), 300 | None, 301 | ) 302 | 303 | try: 304 | descope_client.mgmt.user.add_roles( 305 | login_id=descope_login_id, role_names=[role_name] 306 | ) 307 | logging.info( 308 | f"User {descope_login_id} successfully associated with role {role_name}" 309 | ) 310 | except Exception as e: 311 | logging.error( 312 | f"Failed to associate user {descope_login_id} with role {role_name}: {str(e)}" 313 | ) 314 | 315 | ### End Process Functions 316 | 317 | ### Begin Main Migration Function 318 | 319 | def migrate_cognito(dry_run,verbose): 320 | 321 | schema_attributes = get_cognito_user_pool_schema() 322 | 323 | # Fetch and Create Users from Cognito 324 | cognito_users = fetch_cognito_users() 325 | num_users_found = len(cognito_users) 326 | 327 | # Fetch and Process User Groups (Roles) from Cognito 328 | cognito_groups = fetch_cognito_user_groups() 329 | num_groups_found = len(cognito_groups) 330 | 331 | logging.info("Migration process completed.") 332 | 333 | if dry_run == False: 334 | total_users_migrated = process_users(cognito_users, schema_attributes, dry_run) 335 | total_user_groups_migrated = process_user_groups(cognito_groups, dry_run) 336 | print("=================== User Migration =============================") 337 | print(f"Cognito Users found {num_users_found}") 338 | print(f"Successfully migrated {len(total_users_migrated)} users") 339 | if verbose: 340 | for user in total_users_migrated: 341 | print(f"\tUser: {user}") 342 | 343 | print("=================== User Group Migration =============================") 344 | print(f"Cognito User Groups found {num_groups_found}") 345 | print(f"Succesfully migrated {len(total_user_groups_migrated)} groups") 346 | if verbose: 347 | for user_group in total_user_groups_migrated: 348 | print(f"\tUser Group: {user_group}") 349 | else: 350 | print("=================== User Migration =============================") 351 | print(f"Cognito Users found {num_users_found}") 352 | print(f"Would try to migrate {num_users_found} Users") 353 | if verbose: 354 | for user in cognito_users: 355 | print(f"\tUser: {user['Username']}") 356 | 357 | print("=================== User Group Migration =============================") 358 | print(f"Cognito User Groups found {num_groups_found}") 359 | print(f"Would try to migrate {num_groups_found} User Groups") 360 | if verbose: 361 | for group in cognito_groups: 362 | print(f"\tUser Group: {group['GroupName']}") 363 | 364 | 365 | 366 | 367 | ### End Main Migration Function -------------------------------------------------------------------------------- /src/firebase_migration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | import logging 4 | import bcrypt 5 | import sys 6 | import time 7 | 8 | from setup import initialize_descope 9 | 10 | from utils import ( 11 | flatten_dict, 12 | create_custom_attributes_in_descope, 13 | AnonLoginId, 14 | parse_hash_params 15 | ) 16 | 17 | from descope import ( 18 | AuthException, 19 | UserPasswordBcrypt, 20 | UserPassword, 21 | UserPasswordFirebase, 22 | UserObj, 23 | RateLimitException 24 | ) 25 | 26 | import firebase_admin 27 | from firebase_admin import credentials 28 | from firebase_admin import auth 29 | from firebase_admin import db 30 | from firebase_admin import firestore 31 | 32 | 33 | """Load and read environment variables from .env file""" 34 | load_dotenv() 35 | FIREBASE_DB_URL = os.getenv("FIREBASE_DB_URL") 36 | 37 | descope_client = initialize_descope() 38 | 39 | anon = AnonLoginId() 40 | 41 | attribute_source = None 42 | 43 | cred = credentials.Certificate( 44 | os.getcwd() + "/creds/firebase-certs.json" 45 | ) 46 | if FIREBASE_DB_URL: 47 | firebase_admin.initialize_app(cred, {"databaseURL": FIREBASE_DB_URL}) 48 | else: 49 | firebase_admin.initialize_app(cred) 50 | 51 | 52 | def fetch_firebase_users(): 53 | """ 54 | Fetch and parse Firebase users. 55 | 56 | Returns: 57 | - all_users (Dict): A list of parsed Firebase users if successful, empty list otherwise. 58 | """ 59 | all_users = [] 60 | page_token = None 61 | 62 | while True: 63 | try: 64 | page = auth.list_users(page_token=page_token) 65 | for user in page.users: 66 | user_dict = user.__dict__ 67 | 68 | # Fetch custom attributes from Firebase Database 69 | # if FIREBASE_DB_URL: 70 | # custom_attributes = db.reference( 71 | # f"path/to/user/{user.uid}/customAttributes" 72 | # ).get() 73 | # user_dict["customAttributes"] = custom_attributes or {} 74 | all_users.append(user_dict) 75 | 76 | if not page.has_next_page: 77 | break 78 | 79 | page_token = page.next_page_token 80 | 81 | except firebase_admin.exceptions.FirebaseError as error: 82 | logging.error(f"Error fetching Firebase users. Error: {error}") 83 | break 84 | 85 | return all_users 86 | 87 | 88 | def fetch_custom_attributes(user_id): 89 | """ 90 | Fetch custom attributes for a given user ID from either Realtime Database or Firestore 91 | 92 | Args: 93 | - user_id (str): The user's ID in Firebase. 94 | 95 | Returns: 96 | - dict: A dictionary of custom attributes. 97 | """ 98 | if attribute_source == "firestore": 99 | firestore_db = firestore.client() 100 | doc_ref = firestore_db.collection("users").document(user_id) 101 | doc_snapshot = doc_ref.get() 102 | if doc_snapshot.exists: 103 | return doc_snapshot.to_dict() or {} 104 | return {} 105 | elif attribute_source == "realtime": 106 | ref = db.reference(f"users/{user_id}") 107 | return ref.get() or {} 108 | return {} 109 | 110 | 111 | def set_custom_attribute_source(source): 112 | global attribute_source 113 | attribute_source = source 114 | 115 | 116 | ### End Firebase Actions 117 | 118 | ### Begin Descope Actions 119 | 120 | 121 | def build_user_object_with_passwords(extracted_user, hash_params): 122 | 123 | if extracted_user["password_hash"]: 124 | userPasswordToCreate = UserPassword( 125 | hashed=UserPasswordFirebase( 126 | hash=extracted_user["password_hash"], 127 | salt=extracted_user["salt"], 128 | salt_separator=hash_params["salt_separator"], 129 | signer_key=hash_params["signer_key"], 130 | memory=hash_params["mem_cost"], 131 | rounds=hash_params["rounds"], 132 | ) 133 | ) 134 | 135 | user_object = [ 136 | UserObj( 137 | login_id=extracted_user["login_id"], 138 | email=extracted_user["email"], 139 | display_name=extracted_user["display_name"], 140 | given_name=extracted_user["given_name"], 141 | family_name=extracted_user["family_name"], 142 | phone=extracted_user["phone"], 143 | picture=extracted_user["picture"], 144 | verified_email=extracted_user["verified_email"], 145 | verified_phone=extracted_user["verified_phone"], 146 | password=userPasswordToCreate, 147 | custom_attributes=extracted_user["custom_attributes"], 148 | ) 149 | ] 150 | return user_object 151 | 152 | # Create temporary password if anonymous user 153 | elif (not extracted_user["email"]) and (not extracted_user["phone"]): 154 | result = os.urandom(12) 155 | hash = bcrypt.hashpw(result, bcrypt.gensalt()) 156 | 157 | userPasswordToCreate = UserPassword( 158 | hashed=UserPasswordBcrypt( 159 | hash=hash.decode('utf-8') 160 | ) 161 | ) 162 | 163 | user_object = [ 164 | UserObj( 165 | login_id=extracted_user["login_id"], 166 | email=extracted_user["email"], 167 | display_name=extracted_user["display_name"], 168 | given_name=extracted_user["given_name"], 169 | family_name=extracted_user["family_name"], 170 | phone=extracted_user["phone"], 171 | picture=extracted_user["picture"], 172 | verified_email=extracted_user["verified_email"], 173 | verified_phone=extracted_user["verified_phone"], 174 | password=userPasswordToCreate, 175 | custom_attributes=extracted_user["custom_attributes"], 176 | ) 177 | ] 178 | 179 | return user_object 180 | else: 181 | user_object = [ 182 | UserObj( 183 | login_id=extracted_user["login_id"], 184 | email=extracted_user["email"], 185 | display_name=extracted_user["display_name"], 186 | given_name=extracted_user["given_name"], 187 | family_name=extracted_user["family_name"], 188 | phone=extracted_user["phone"], 189 | picture=extracted_user["picture"], 190 | verified_email=extracted_user["verified_email"], 191 | verified_phone=extracted_user["verified_phone"], 192 | custom_attributes=extracted_user["custom_attributes"], 193 | ) 194 | ] 195 | return user_object 196 | 197 | 198 | def invite_batch(user_objects, login_id, is_disabled): 199 | """ 200 | Invites a batch of users with retry logic for rate limiting. 201 | 202 | Args: 203 | - user_objects: List of UserObj to create 204 | - login_id: Login ID for the user 205 | - is_disabled: Boolean indicating if user should be disabled 206 | 207 | Returns: 208 | - Boolean indicating success/failure 209 | """ 210 | max_retries = 5 211 | retry_delay = 1 # Initial delay in seconds if Retry-After is not provided 212 | 213 | for attempt in range(max_retries): 214 | try: 215 | # Create the user 216 | resp = descope_client.mgmt.user.invite_batch( 217 | users=user_objects, 218 | invite_url="https://localhost", 219 | send_mail=False, 220 | send_sms=False, 221 | ) 222 | 223 | # Update user status in Descope based on Firebase status 224 | if is_disabled: 225 | descope_client.mgmt.user.deactivate(login_id=login_id) 226 | logging.info(f"User {login_id} deactivated in Descope.") 227 | else: 228 | descope_client.mgmt.user.activate(login_id=login_id) 229 | logging.info(f"User {login_id} activated in Descope.") 230 | 231 | return True # Success, exit the loop and function 232 | 233 | except RateLimitException as e: 234 | print(f"WARNING: Rate limit hit for user {login_id}. Attempt {attempt + 1}/{max_retries}. Error: {e}") 235 | if attempt == max_retries - 1: 236 | print(f"ERROR: Max retries reached for user {login_id}. Skipping.") 237 | return False # Max retries exceeded 238 | 239 | try: 240 | # Extract Retry-After header, default to exponential backoff 241 | retry_after = int(e.rate_limit_parameters.get('Retry-After', retry_delay)) 242 | print(f"INFO: Waiting for {retry_after} seconds before retrying...") 243 | time.sleep(retry_after) 244 | # Optional: Increase default delay for next potential failure without Retry-After 245 | retry_delay = min(retry_delay * 2, 60) # Double delay, cap at 60 seconds 246 | except ValueError: 247 | print(f"WARNING: Could not parse Retry-After value. Waiting for default {retry_delay} seconds.") 248 | time.sleep(retry_delay) 249 | retry_delay = min(retry_delay * 2, 60) 250 | 251 | except AuthException as error: 252 | print( 253 | f"ERROR: Unable to invite user {login_id}. Error: {error.error_message}" 254 | ) 255 | logging.error( 256 | f"Unable to create users with password. Error: {error.error_message}" 257 | ) 258 | return False # Non-rate-limit error, fail immediately 259 | 260 | return False # Should not be reached if logic is correct, but safety return 261 | 262 | 263 | def create_descope_user(user, hash_params): 264 | """ 265 | Create a Descope user based on matched Firebase user data using Descope Python SDK. 266 | 267 | Args: 268 | - user (dict): A dictionary containing user details fetched from Firebase Admin SDK. 269 | """ 270 | try: 271 | # Extracting user data from the nested '_data' structure 272 | user_data = user.get("_data", {}) 273 | 274 | custom_attributes = {"freshlyMigrated": True} 275 | is_disabled = user_data.get("disabled", False) 276 | # Use Email if exists, otherwise phone, otherwise is anon user create anon login email 277 | login_id = user_data.get("email") if user_data.get("email") else user_data.get("phoneNumber") if user_data.get("phoneNumber") else anon.make_anon_login_id() 278 | 279 | password_hash = user_data.get("passwordHash") or "" 280 | salt = user_data.get("salt") or "" 281 | 282 | # Default Firebase user attributes 283 | extracted_user = { 284 | "login_id": login_id, 285 | "email": user_data.get("email"), #login_id if (not user_data.get("email")) and (not user_data.get("phoneNumber")) else user_data.get("email"), # Uses email if it exists else uses anon_email 286 | "phone": user_data.get("phoneNumber"), 287 | "display_name": user_data.get("displayName"), 288 | "given_name": user_data.get("givenName"), 289 | "family_name": user_data.get("familyName"), 290 | "picture": user_data.get("photoUrl"), 291 | "verified_email": user_data.get("emailVerified", False), 292 | "verified_phone": ( 293 | user_data.get("phoneVerified", False) 294 | if user_data.get("phoneNumber") 295 | else False 296 | ), 297 | "custom_attributes": custom_attributes, 298 | "is_disabled": is_disabled, 299 | "password_hash": password_hash, 300 | "salt": salt, 301 | } 302 | 303 | #Put the UUID in the UUID custom attribute per user 304 | user_id = user_data.get("localId") 305 | if user_id: 306 | custom_attributes.update({"UUID":user_id}) 307 | 308 | # Fetch custom attributes from Firebase Realtime Database, if URL is provided 309 | if FIREBASE_DB_URL: 310 | user_id = user_data.get("localId") 311 | if user_id: 312 | additional_attributes = fetch_custom_attributes( 313 | user_data.get("localId") 314 | ) 315 | 316 | if additional_attributes: 317 | flattend_attributes = flatten_dict(additional_attributes) 318 | mapped_dict = { 319 | key: ( 320 | "String" if isinstance(value, str) else 321 | "Boolean" if isinstance(value, bool) else 322 | "Number" if isinstance(value, (int, float)) else 323 | "String" 324 | ) 325 | for key, value in flattend_attributes.items() 326 | } 327 | 328 | # Create the custom attributes will not make duplicates 329 | create_custom_attributes_in_descope(mapped_dict) 330 | custom_attributes.update(flattend_attributes) 331 | 332 | # Create the Descope user 333 | user_object = build_user_object_with_passwords(extracted_user, hash_params) 334 | success = invite_batch(user_object, login_id, is_disabled) 335 | 336 | return success, False, False, login_id 337 | 338 | except AuthException as error: 339 | logging.error(f"Unable to create user. {user}") 340 | logging.error(f"Error: {error.error_message}") 341 | return ( 342 | False, 343 | False, 344 | False, 345 | user.get("user_id") + " Reason: " + error.error_message, 346 | ) 347 | 348 | 349 | ### End Descope Actions: 350 | 351 | ### Begin Process Functions 352 | 353 | 354 | def process_users(api_response_users, hash_params, dry_run): 355 | """ 356 | Process the list of users from Firebase by mapping and creating them in Descope. 357 | 358 | Args: 359 | - api_response_users (list): A list of users fetched from Firebase Admin SDK. 360 | """ 361 | failed_users = [] 362 | successful_migrated_users = [] 363 | merged_users = 0 364 | disabled_users_mismatch = [] 365 | if dry_run: 366 | print(f"Would migrate {len(api_response_users)} users from Firebase to Descope") 367 | else: 368 | print( 369 | f"Starting migration of {len(api_response_users)} users found via Firebase Admin SDK" 370 | ) 371 | # create freshly migrated custom attribute 372 | freshly_migrated = {"freshlyMigrated":"Boolean"} 373 | uuid_attribute = {"UUID":"String"} 374 | create_custom_attributes_in_descope(freshly_migrated) 375 | create_custom_attributes_in_descope(uuid_attribute) 376 | 377 | 378 | for user in api_response_users: 379 | success, merged, disabled_mismatch, user_id_error = create_descope_user( 380 | user, hash_params 381 | ) 382 | if success: 383 | 384 | if merged: 385 | merged_users += 1 386 | if success and disabled_mismatch: 387 | disabled_users_mismatch.append(user_id_error) 388 | else: 389 | user_data = user.get("_data", {}) 390 | login_id = user_data.get("email") if user_data.get("email") else user_data.get("phoneNumber") if user_data.get("phoneNumber") else "Anon User" 391 | successful_migrated_users.append(login_id) 392 | else: 393 | failed_users.append(user_id_error) 394 | if len(successful_migrated_users) > 0 and (len(successful_migrated_users) % 10 == 0): 395 | print(f"Still working, migrated {len(successful_migrated_users)} users.") 396 | return ( 397 | failed_users, 398 | successful_migrated_users, 399 | merged_users, 400 | disabled_users_mismatch, 401 | ) 402 | 403 | ### End Process Functions 404 | 405 | ### Begin Main Migration Function 406 | 407 | def migrate_firebase(dry_run,verbose): 408 | 409 | # Check if the password-hash.txt file exists 410 | if not os.path.isfile("creds/password-hash.txt"): 411 | print( 412 | f"Required file 'creds/password-hash.txt' not found. Please ensure it is placed in the correct location." 413 | ) 414 | sys.exit(1) 415 | # If the file exists, proceed to parse the hash parameters 416 | hash_params = parse_hash_params("creds/password-hash.txt") 417 | # Ask the user if they want to import custom attributes 418 | import_custom_attributes = ( 419 | input("Do you want to import custom user attributes? (y/n): ").strip().lower() 420 | ) 421 | attribute_source = None 422 | 423 | if import_custom_attributes == "y": 424 | while attribute_source not in ["firestore", "realtime"]: 425 | attribute_source = ( 426 | input("Enter the source of custom attributes (firestore or realtime): ") 427 | .strip() 428 | .lower() 429 | ) 430 | set_custom_attribute_source(attribute_source) 431 | 432 | firebase_users = fetch_firebase_users() 433 | ( 434 | failed_users, 435 | successful_migrated_users, 436 | merged_users, 437 | disabled_users_mismatch, 438 | ) = process_users(firebase_users, hash_params, dry_run) 439 | 440 | if dry_run == False: 441 | print("=================== User Migration =============================") 442 | print(f"Firebase Users found via Admin SDK {len(firebase_users)}") 443 | print(f"Successfully migrated {len(successful_migrated_users)} users") 444 | if verbose: 445 | for user in successful_migrated_users: 446 | print(f"\tUser: {user}") 447 | print(f"Successfully merged {merged_users} users") 448 | if len(disabled_users_mismatch) != 0: 449 | print( 450 | f"Users migrated, but disabled due to one of the merged accounts being disabled {len(disabled_users_mismatch)}" 451 | ) 452 | print( 453 | f"Users disabled due to one of the merged accounts being disabled {disabled_users_mismatch}" 454 | ) 455 | if len(failed_users) != 0: 456 | print(f"Failed to migrate {len(failed_users)}") 457 | print(f"Users which failed to migrate:") 458 | for failed_user in failed_users: 459 | print(failed_user) 460 | print( 461 | f"Created users within Descope {len(successful_migrated_users) - merged_users}" 462 | ) 463 | else: 464 | print("=================== User Migration =============================") 465 | print(f"Firebase Users found via Admin SDK {len(firebase_users)}") 466 | if verbose: 467 | for user in firebase_users: 468 | print(f"\tUser: {user['_data']['localId']}") 469 | 470 | 471 | ### End Main Migration Function 472 | 473 | 474 | 475 | 476 | 477 | -------------------------------------------------------------------------------- /src/auth0_migration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from dotenv import load_dotenv 4 | import logging 5 | 6 | from descope import ( 7 | AuthException, 8 | DescopeClient, 9 | AssociatedTenant, 10 | RoleMapping, 11 | AttributeMapping, 12 | UserPassword, 13 | UserPasswordBcrypt, 14 | UserObj 15 | ) 16 | 17 | from setup import initialize_descope 18 | 19 | from utils import ( 20 | api_request_with_retry, 21 | create_custom_attributes_in_descope 22 | ) 23 | 24 | """Load and read environment variables from .env file""" 25 | load_dotenv() 26 | AUTH0_TOKEN = os.getenv("AUTH0_TOKEN") 27 | AUTH0_TENANT_ID = os.getenv("AUTH0_TENANT_ID") 28 | AUTH0_REGION = os.getenv("AUTH0_REGION", "us") # Default to 'us' if not specified 29 | 30 | # Update the Auth0 domain construction to use the region 31 | AUTH0_DOMAIN = f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com" 32 | 33 | descope_client = initialize_descope() 34 | 35 | ### Begin Auth0 Actions 36 | 37 | def fetch_auth0_users_from_file(file_path): 38 | """ 39 | Fetch and parse Auth0 users from the provided file. 40 | 41 | Returns: 42 | - all_users (list): A list of parsed Auth0 users if successful, empty list otherwise. 43 | """ 44 | file_users = [] # Renamed to avoid confusion with API users 45 | all_users = [] 46 | with open(file_path, "r") as file: 47 | for line in file: 48 | file_users.append(json.loads(line)) 49 | 50 | for user in file_users: 51 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 52 | page = 0 53 | per_page = 20 54 | 55 | while True: 56 | response = api_request_with_retry( 57 | "get", 58 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/users?page={page}&per_page={per_page}&q=user_id:\"{user['user_id']}\"", 59 | headers=headers, 60 | ) 61 | if response.status_code != 200: 62 | logging.error(f"Error fetching Auth0 users. Status code: {response.status_code}") 63 | break # Consider breaking instead of returning to continue with the next user 64 | users_from_api = response.json() 65 | if not users_from_api: 66 | break 67 | all_users.extend(users_from_api) 68 | page += 1 69 | return all_users 70 | 71 | def fetch_auth0_users(): 72 | """ 73 | Fetch and parse Auth0 users from the provided endpoint. 74 | 75 | Returns: 76 | - all_users (Dict): A list of parsed Auth0 users if successful, empty list otherwise. 77 | """ 78 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 79 | page = 0 80 | per_page = 20 81 | all_users = [] 82 | while True: 83 | response = api_request_with_retry( 84 | "get", 85 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/users?page={page}&per_page={per_page}", 86 | headers=headers, 87 | ) 88 | if response.status_code != 200: 89 | logging.error( 90 | f"Error fetching Auth0 users. Status code: {response.status_code}" 91 | ) 92 | return all_users 93 | users = response.json() 94 | if not users: 95 | break 96 | all_users.extend(users) 97 | page += 1 98 | return all_users 99 | 100 | 101 | def fetch_auth0_roles(): 102 | """ 103 | Fetch and parse Auth0 roles from the provided endpoint. 104 | 105 | Returns: 106 | - all_roles (Dict): A list of parsed Auth0 roles if successful, empty list otherwise. 107 | """ 108 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 109 | page = 0 110 | per_page = 20 111 | all_roles = [] 112 | while True: 113 | response = api_request_with_retry( 114 | "get", 115 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/roles?page={page}&per_page={per_page}", 116 | headers=headers, 117 | ) 118 | if response.status_code != 200: 119 | logging.error( 120 | f"Error fetching Auth0 roles. Status code: {response.status_code}" 121 | ) 122 | return all_roles 123 | roles = response.json() 124 | if not roles: 125 | break 126 | all_roles.extend(roles) 127 | page += 1 128 | return all_roles 129 | 130 | 131 | def get_users_in_role(role): 132 | """ 133 | Get and parse Auth0 users associated with the provided role. 134 | 135 | Returns: 136 | - role (string): The role ID to get the associated members 137 | """ 138 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 139 | page = 0 140 | per_page = 20 141 | all_users = [] 142 | 143 | while True: 144 | response = api_request_with_retry( 145 | "get", 146 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/roles/{role}/users?page={page}&per_page={per_page}", 147 | headers=headers, 148 | ) 149 | if response.status_code != 200: 150 | logging.error( 151 | f"Error fetching Auth0 users in roles. Status code: {response.status_code}" 152 | ) 153 | return all_users 154 | users = response.json() 155 | if not users: 156 | break 157 | all_users.extend(users) 158 | page += 1 159 | return all_users 160 | 161 | 162 | def get_permissions_for_role(role): 163 | """ 164 | Get and parse Auth0 permissions for a role 165 | 166 | Args: 167 | - role (string): The id of the role to query for permissions 168 | Returns: 169 | - all_permissions (string): Dictionary of all permissions associated to the role. 170 | """ 171 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 172 | page = 0 173 | per_page = 20 174 | all_permissions = [] 175 | 176 | while True: 177 | response = api_request_with_retry( 178 | "get", 179 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/roles/{role}/permissions?per_page={per_page}&page={page}", 180 | headers=headers, 181 | ) 182 | if response.status_code != 200: 183 | logging.error( 184 | f"Error fetching Auth0 permissions in roles. Status code: {response.status_code}" 185 | ) 186 | return all_permissions 187 | permissions = response.json() 188 | if not permissions: 189 | break 190 | all_permissions.extend(permissions) 191 | page += 1 192 | return all_permissions 193 | 194 | 195 | def fetch_auth0_organizations(): 196 | """ 197 | Fetch and parse Auth0 organization members from the provided endpoint. 198 | 199 | Returns: 200 | - all_organizations (string): Dictionary of all organizations within the Auth0 tenant. 201 | """ 202 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 203 | page = 0 204 | per_page = 20 205 | all_organizations = [] 206 | 207 | while True: 208 | response = api_request_with_retry( 209 | "get", 210 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/organizations?per_page={per_page}&page={page}", 211 | headers=headers, 212 | ) 213 | if response.status_code != 200: 214 | logging.error( 215 | f"Error fetching Auth0 organizations. Status code: {response.status_code}" 216 | ) 217 | return all_organizations 218 | organizations = response.json() 219 | if not organizations: 220 | break 221 | all_organizations.extend(organizations) 222 | page += 1 223 | return all_organizations 224 | 225 | 226 | def fetch_auth0_organization_members(organization): 227 | """ 228 | Fetch and parse Auth0 organization members from the provided endpoint. 229 | 230 | Args: 231 | - organization (string): Auth0 organization ID to fetch the members 232 | Returns: 233 | - all_members (dict): Dictionary of all members within the organization. 234 | """ 235 | headers = {"Authorization": f"Bearer {AUTH0_TOKEN}"} 236 | page = 0 237 | per_page = 20 238 | all_members = [] 239 | 240 | while True: 241 | response = api_request_with_retry( 242 | "get", 243 | f"https://{AUTH0_TENANT_ID}.{AUTH0_REGION}.auth0.com/api/v2/organizations/{organization}/members?per_page={per_page}&page={page}", 244 | headers=headers, 245 | ) 246 | if response.status_code != 200: 247 | logging.error( 248 | f"Error fetching Auth0 organization members. Status code: {response.status_code}" 249 | ) 250 | return all_members 251 | members = response.json() 252 | if not members: 253 | break 254 | all_members.extend(members) 255 | page += 1 256 | return all_members 257 | 258 | 259 | ### End Auth0 Actions 260 | 261 | ### Begin Descope Actions 262 | 263 | 264 | def create_descope_role_and_permissions(role, permissions): 265 | """ 266 | Create a Descope role and its associated permissions using the Descope Python SDK. 267 | 268 | Args: 269 | - role (dict): A dictionary containing role details from Auth0. 270 | - permissions (dict): A dictionary containing permissions details from Auth0. 271 | """ 272 | permissionNames = [] 273 | success_permissions = 0 274 | existing_permissions_descope = [] 275 | failed_permissions = [] 276 | for permission in permissions: 277 | name = permission["permission_name"] 278 | description = permission.get("description", "") 279 | try: 280 | descope_client.mgmt.permission.create(name=name, description=description) 281 | permissionNames.append(name) 282 | success_permissions += 1 283 | except AuthException as error: 284 | error_message_dict = json.loads(error.error_message) 285 | if error_message_dict["errorCode"] == "E024104": 286 | existing_permissions_descope.append(name) 287 | permissionNames.append(name) 288 | logging.error(f"Unable to create permission: {name}.") 289 | logging.error(f"Status Code: {error.status_code}") 290 | logging.error(f"Error: {error.error_message}") 291 | else: 292 | failed_permissions.append(f"{name}, Reason: {error.error_message}") 293 | logging.error(f"Unable to create permission: {name}.") 294 | logging.error(f"Status Code: {error.status_code}") 295 | logging.error(f"Error: {error.error_message}") 296 | 297 | 298 | role_name = role["name"] 299 | if not check_role_exists_descope(role_name): 300 | role_description = role.get("description", "") 301 | try: 302 | descope_client.mgmt.role.create( 303 | name=role_name, 304 | description=role_description, 305 | permission_names=permissionNames, 306 | ) 307 | return True, False, success_permissions, existing_permissions_descope, failed_permissions, "" 308 | except AuthException as error: 309 | logging.error(f"Unable to create role: {role_name}.") 310 | logging.error(f"Status Code: {error.status_code}") 311 | logging.error(f"Error: {error.error_message}") 312 | return ( 313 | False, 314 | False, 315 | success_permissions, 316 | existing_permissions_descope, 317 | failed_permissions, 318 | f"{role_name} Reason: {error.error_message}", 319 | ) 320 | else: 321 | return False, True, success_permissions, existing_permissions_descope, failed_permissions, "" 322 | 323 | 324 | def create_descope_user(user): 325 | """ 326 | Create a Descope user based on matched Auth0 user data using Descope Python SDK. 327 | 328 | Args: 329 | - user (dict): A dictionary containing user details fetched from Auth0 API. 330 | """ 331 | try: 332 | login_ids = [] 333 | connections = [] 334 | for identity in user.get("identities", []): 335 | if "Username" in identity["connection"]: 336 | login_ids.append(user.get("email")) 337 | connections.append(identity["connection"]) 338 | elif "sms" in identity["connection"]: 339 | login_ids.append(user.get("phone_number")) 340 | connections.append(identity["connection"]) 341 | elif "-" in identity["connection"]: 342 | login_ids.append( 343 | identity["connection"].split("-")[0] + "-" + identity["user_id"] 344 | ) 345 | connections.append(identity["connection"]) 346 | else: 347 | login_ids.append(identity["connection"] + "-" + identity["user_id"]) 348 | connections.append(identity["connection"]) 349 | 350 | emails = [user.get("email")] 351 | 352 | users = [] 353 | try: 354 | resp = descope_client.mgmt.user.search_all(emails=emails) 355 | users = resp["users"] 356 | except AuthException as error: 357 | pass 358 | 359 | if len(users) == 0: 360 | login_id = login_ids[0] 361 | email = user.get("email") 362 | phone = ( 363 | user.get("phone_number") if identity.get("provider") == "sms" else None 364 | ) 365 | display_name = user.get("name") 366 | given_name = user.get("given_name") 367 | family_name = user.get("family_name") 368 | picture = user.get("picture") 369 | verified_email = user.get("email_verified", False) 370 | verified_phone = user.get("phone_verified", False) if phone else False 371 | custom_attributes = { 372 | "connection": ",".join(map(str, connections)), 373 | "freshlyMigrated": True, 374 | } 375 | additional_login_ids = login_ids[1 : len(login_ids)] 376 | 377 | # Create the user 378 | resp = descope_client.mgmt.user.create( 379 | login_id=login_id, 380 | email=email, 381 | display_name=display_name, 382 | given_name=given_name, 383 | family_name=family_name, 384 | phone=phone, 385 | picture=picture, 386 | custom_attributes=custom_attributes, 387 | verified_email=verified_email, 388 | verified_phone=verified_phone, 389 | additional_login_ids=additional_login_ids, 390 | ) 391 | 392 | # Update user status if necessary 393 | status = "disabled" if user.get("blocked", False) else "enabled" 394 | if status == "disabled": 395 | try: 396 | resp = descope_client.mgmt.user.deactivate(login_id=login_id) 397 | except AuthException as error: 398 | logging.error(f"Unable to deactivate user.") 399 | logging.error(f"Status Code: {error.status_code}") 400 | logging.error(f"Error: {error.error_message}") 401 | elif status == "enabled": 402 | try: 403 | resp = descope_client.mgmt.user.activate(login_id=login_id) 404 | except AuthException as error: 405 | logging.error(f"Unable to activate user.") 406 | logging.error(f"Status Code: {error.status_code}") 407 | logging.error(f"Error: {error.error_message}") 408 | return True, "", False, "" 409 | else: 410 | user_to_update = users[0] 411 | if user.get("picture"): 412 | picture = user.get("picture") 413 | else: 414 | picture = user_to_update["picture"] 415 | 416 | if user.get("given_name"): 417 | given_name = user.get("given_name") 418 | else: 419 | given_name = user_to_update["givenName"] 420 | 421 | if user.get("family_name"): 422 | family_name = user.get("family_name") 423 | else: 424 | family_name = user_to_update["familyName"] 425 | 426 | custom_attributes = user_to_update["customAttributes"] 427 | if "connection" in user_to_update["customAttributes"]: 428 | for connection in custom_attributes["connection"].split(","): 429 | if connection in connections: 430 | connections.remove(connection) 431 | if len(connections) == 0: 432 | login_id = user_to_update["loginIds"][0] 433 | status = "disabled" if user.get("blocked", False) else "enabled" 434 | if status == "disabled" or user_to_update["status"] == "disabled": 435 | try: 436 | resp = descope_client.mgmt.user.deactivate(login_id=login_id) 437 | except AuthException as error: 438 | logging.error(f"Unable to deactivate user.") 439 | logging.error(f"Status Code: {error.status_code}") 440 | logging.error(f"Error: {error.error_message}") 441 | return None, "", True, user.get("user_id") 442 | return None, "", None, "" 443 | additional_connections = ",".join(map(str, connections)) 444 | if "connection" in user_to_update["customAttributes"] and additional_connections: 445 | custom_attributes["connection"] += "," + additional_connections 446 | else: 447 | custom_attributes["connection"] = additional_connections 448 | 449 | try: 450 | login_ids.pop(login_ids.index(user_to_update["loginIds"][0])) 451 | except Exception as e: 452 | pass 453 | login_id = user_to_update["loginIds"][0] 454 | resp = descope_client.mgmt.user.update( 455 | login_id=login_id, 456 | email=user_to_update["email"], 457 | display_name=user_to_update["name"], 458 | given_name=given_name, 459 | family_name=family_name, 460 | phone=user_to_update["phone"], 461 | picture=picture, 462 | custom_attributes=custom_attributes, 463 | verified_email=user_to_update["verifiedEmail"], 464 | verified_phone=user_to_update["verifiedPhone"], 465 | additional_login_ids=login_ids, 466 | ) 467 | # TODO: Handle user statuses? Yea, that's my thinking, if either are disabled, merge them, disable the merged one, print the disabled accounts that hit this scenario in the completion? 468 | status = "disabled" if user.get("blocked", False) else "enabled" 469 | if status == "disabled" or user_to_update["status"] == "disabled": 470 | try: 471 | resp = descope_client.mgmt.user.deactivate(login_id=login_id) 472 | 473 | except AuthException as error: 474 | logging.error(f"Unable to deactivate user.") 475 | logging.error(f"Status Code: {error.status_code}") 476 | logging.error(f"Error: {error.error_message}") 477 | return True, user.get("name"), True, user.get("user_id") 478 | return True, user.get("name"), False, "" 479 | except AuthException as error: 480 | logging.error(f"Unable to create user. {user}") 481 | logging.error(f"Error: {error.error_message}") 482 | return ( 483 | False, 484 | "", 485 | False, 486 | user.get("user_id") + " Reason: " + error.error_message, 487 | ) 488 | 489 | 490 | def add_user_to_descope_role(user, role): 491 | """ 492 | Add a Descope user based on matched Auth0 user data. 493 | 494 | Args: 495 | - user (str): Login ID of the user you wish to add to role 496 | - role (str): The name of the role which you want to add the user to 497 | """ 498 | role_names = [role] 499 | 500 | try: 501 | resp = descope_client.mgmt.user.add_roles(login_id=user, role_names=role_names) 502 | logging.info("User role successfully added") 503 | return True, "" 504 | except AuthException as error: 505 | logging.error( 506 | f"Unable to add role to user. Status code: {error.error_message}" 507 | ) 508 | return False, f"{user} Reason: {error.error_message}" 509 | 510 | 511 | def create_descope_tenant(organization): 512 | """ 513 | Create a Descope create_descope_tenant based on matched Auth0 organization data. 514 | 515 | Args: 516 | - organization (dict): A dictionary containing organization details fetched from Auth0 API. 517 | """ 518 | name = organization["display_name"] 519 | tenant_id = organization["id"] 520 | 521 | try: 522 | resp = descope_client.mgmt.tenant.create(name=name, id=tenant_id) 523 | return True, "" 524 | except AuthException as error: 525 | logging.error("Unable to create tenant.") 526 | logging.error(f"Error:, {error.error_message}") 527 | return False, f"Tenant {name} failed to create Reason: {error.error_message}" 528 | 529 | 530 | def add_descope_user_to_tenant(tenant, loginId): 531 | """ 532 | Map a descope user to a tenant based on Auth0 data using Descope SDK. 533 | 534 | Args: 535 | - tenant (string): The tenant ID of the tenant to associate the user. 536 | - loginId (string): the loginId of the user to associate to the tenant. 537 | """ 538 | try: 539 | resp = descope_client.mgmt.user.add_tenant(login_id=loginId, tenant_id=tenant) 540 | return True, "" 541 | except AuthException as error: 542 | logging.error("Unable to add user to tenant.") 543 | logging.error(f"Error:, {error.error_message}") 544 | return False, error.error_message 545 | 546 | def check_tenant_exists_descope(tenant_id): 547 | 548 | try: 549 | tenant_resp = descope_client.mgmt.tenant.load(tenant_id) 550 | return True 551 | except: 552 | return False 553 | 554 | def check_role_exists_descope(role_name): 555 | 556 | try: 557 | roles_resp = descope_client.mgmt.role.search(role_names=[role_name]) 558 | if roles_resp["roles"]: 559 | return True 560 | else: 561 | return False 562 | except: 563 | return False 564 | 565 | 566 | ### End Descope Actions: 567 | 568 | ### Begin Process Functions 569 | 570 | 571 | def process_users(api_response_users, dry_run, from_json, verbose): 572 | """ 573 | Process the list of users from Auth0 by mapping and creating them in Descope. 574 | 575 | Args: 576 | - api_response_users (list): A list of users fetched from Auth0 API. 577 | """ 578 | failed_users = [] 579 | successful_migrated_users = 0 580 | merged_users = [] 581 | disabled_users_mismatch = [] 582 | 583 | inital_custom_attributes = {"connection": "String","freshlyMigrated":"Boolean"} 584 | create_custom_attributes_in_descope(inital_custom_attributes) 585 | 586 | if dry_run: 587 | print(f"Would migrate {len(api_response_users)} users from Auth0 to Descope") 588 | if verbose: 589 | for user in api_response_users: 590 | print(f"\tUser: {user['name']}") 591 | 592 | else: 593 | if from_json: 594 | print( 595 | f"Starting migration of {len(api_response_users)} users found via Auth0 user Export" 596 | ) 597 | else: 598 | print( 599 | f"Starting migration of {len(api_response_users)} users found via Auth0 API" 600 | ) 601 | for user in api_response_users: 602 | if verbose: 603 | print(f"\tUser: {user['name']}") 604 | 605 | success, merged, disabled_mismatch, user_id_error = create_descope_user( 606 | user 607 | ) 608 | if success: 609 | successful_migrated_users += 1 610 | if merged: 611 | merged_users.append(merged) 612 | if success and disabled_mismatch: 613 | disabled_users_mismatch.append(user_id_error) 614 | elif success == None: 615 | if success == None and disabled_mismatch: 616 | disabled_users_mismatch.append(user_id_error) 617 | else: 618 | failed_users.append(user_id_error) 619 | if successful_migrated_users % 10 == 0 and successful_migrated_users > 0 and not verbose: 620 | print(f"Still working, migrated {successful_migrated_users} users.") 621 | return ( 622 | failed_users, 623 | successful_migrated_users, 624 | merged_users, 625 | disabled_users_mismatch, 626 | ) 627 | 628 | 629 | def process_roles(auth0_roles, dry_run, verbose): 630 | """ 631 | Process the Auth0 organizations - creating roles, permissions, and associating users 632 | 633 | Args: 634 | - auth0_roles (dict): Dictionary of roles fetched from Auth0 635 | """ 636 | failed_roles = [] 637 | successful_migrated_roles = 0 638 | roles_exist_descope = 0 639 | total_existing_permissions_descope = [] 640 | total_failed_permissions = [] 641 | successful_migrated_permissions = 0 642 | roles_and_users = [] 643 | failed_roles_and_users = [] 644 | if dry_run: 645 | print(f"Would migrate {len(auth0_roles)} roles from Auth0 to Descope") 646 | if verbose: 647 | for role in auth0_roles: 648 | permissions = get_permissions_for_role(role["id"]) 649 | print( 650 | f"\tRole: {role['name']} with {len(permissions)} associated permissions" 651 | ) 652 | else: 653 | print(f"Starting migration of {len(auth0_roles)} roles found via Auth0 API") 654 | for role in auth0_roles: 655 | permissions = get_permissions_for_role(role["id"]) 656 | if verbose: 657 | print( 658 | f"\tRole: {role['name']} with {len(permissions)} associated permissions" 659 | ) 660 | ( 661 | success, 662 | role_exists, 663 | success_permissions, 664 | existing_permissions_descope, 665 | failed_permissions, 666 | error, 667 | ) = create_descope_role_and_permissions(role, permissions) 668 | if success: 669 | successful_migrated_roles += 1 670 | successful_migrated_permissions += success_permissions 671 | elif role_exists: 672 | roles_exist_descope += 1 673 | successful_migrated_permissions += success_permissions 674 | else: 675 | failed_roles.append(error) 676 | successful_migrated_permissions += success_permissions 677 | if len(failed_permissions) != 0: 678 | for item in failed_permissions: 679 | total_failed_permissions.append(item) 680 | if len(existing_permissions_descope) != 0: 681 | for item in existing_permissions_descope: 682 | if item not in total_existing_permissions_descope: 683 | total_existing_permissions_descope.append(item) 684 | users = get_users_in_role(role["id"]) 685 | 686 | users_added = 0 687 | for user in users: 688 | success, error = add_user_to_descope_role(user["email"], role["name"]) 689 | if success: 690 | users_added += 1 691 | else: 692 | failed_roles_and_users.append( 693 | f"{user['user_id']} failed to be added to {role['name']} Reason: {error}" 694 | ) 695 | roles_and_users.append(f"Mapped {users_added} user to {role['name']}") 696 | if successful_migrated_roles % 10 == 0 and successful_migrated_roles > 0 and not verbose: 697 | print(f"Still working, migrated {successful_migrated_roles} roles.") 698 | 699 | return ( 700 | failed_roles, 701 | successful_migrated_roles, 702 | roles_exist_descope, 703 | total_failed_permissions, 704 | successful_migrated_permissions, 705 | total_existing_permissions_descope, 706 | roles_and_users, 707 | failed_roles_and_users, 708 | ) 709 | 710 | 711 | def process_auth0_organizations(auth0_organizations, dry_run, verbose): 712 | """ 713 | Process the Auth0 organizations - creating tenants and associating users 714 | 715 | Args: 716 | - auth0_organizations (dict): Dictionary of organizations fetched from Auth0 717 | """ 718 | successful_tenant_creation = 0 719 | tenant_exists_descope = 0 720 | failed_tenant_creation = [] 721 | failed_users_added_tenants = [] 722 | tenant_users = [] 723 | if dry_run: 724 | print( 725 | f"Would migrate {len(auth0_organizations)} organizations from Auth0 to Descope" 726 | ) 727 | if verbose: 728 | for organization in auth0_organizations: 729 | org_members = fetch_auth0_organization_members(organization["id"]) 730 | print( 731 | f"\tOrganization: {organization['display_name']} with {len(org_members)} associated users" 732 | ) 733 | else: 734 | print(f"Starting migration of {len(auth0_organizations)} organizations found via Auth0 API") 735 | for organization in auth0_organizations: 736 | 737 | if not check_tenant_exists_descope(organization["id"]): 738 | success, error = create_descope_tenant(organization) 739 | if success: 740 | successful_tenant_creation += 1 741 | else: 742 | failed_tenant_creation.append(error) 743 | else: 744 | tenant_exists_descope += 1 745 | 746 | 747 | org_members = fetch_auth0_organization_members(organization["id"]) 748 | if verbose: 749 | print(f"\tOrganization: {organization['display_name']} with {len(org_members)} associated users") 750 | users_added = 0 751 | for user in org_members: 752 | success, error = add_descope_user_to_tenant( 753 | organization["id"], user["email"] 754 | ) 755 | if success: 756 | users_added += 1 757 | else: 758 | failed_users_added_tenants.append( 759 | f"User {user['email']} failed to be added to tenant {organization['display_name']} Reason: {error}" 760 | ) 761 | tenant_users.append( 762 | f"Associated {users_added} users with tenant: {organization['display_name']} " 763 | ) 764 | if successful_tenant_creation % 10 == 0 and successful_tenant_creation > 0 and not verbose: 765 | print(f"Still working, migrated {successful_tenant_creation} organizations.") 766 | return ( 767 | successful_tenant_creation, 768 | tenant_exists_descope, 769 | failed_tenant_creation, 770 | failed_users_added_tenants, 771 | tenant_users, 772 | ) 773 | 774 | ### End Process Functions 775 | 776 | ### Password Functions 777 | 778 | 779 | def read_auth0_export(file_path): 780 | """ 781 | Read and parse the Auth0 export file formatted as NDJSON. 782 | 783 | Args: 784 | - file_path (str): The path to the Auth0 export file. 785 | 786 | Returns: 787 | - list: A list of parsed Auth0 user data. 788 | """ 789 | with open(file_path, "r") as file: 790 | data = [json.loads(line) for line in file] 791 | return data 792 | 793 | def process_users_with_passwords(file_path, dry_run, verbose): 794 | users = read_auth0_export(file_path) 795 | successful_password_users = 0 796 | failed_password_users = [] 797 | 798 | if dry_run: 799 | print( 800 | f"Would migrate {len(users)} users from Auth0 with Passwords to Descope" 801 | ) 802 | if verbose: 803 | for user in users: 804 | print(f"\tuser: {user['name']}") 805 | 806 | else: 807 | print( 808 | f"Starting migration of {len(users)} users from Auth0 password file" 809 | ) 810 | for user in users: 811 | extracted_user = { 812 | 'email_verified': user['email_verified'], 813 | 'email': user['email'], 814 | 'connection': user['connection'], 815 | 'passwordHash': user['passwordHash'] 816 | } 817 | user_object = build_user_object_with_passwords(extracted_user) 818 | success = create_users_with_passwords(user_object) 819 | #user = fetch_auth0_password_user(user['email']) 820 | if success: 821 | successful_password_users += 1 822 | else: 823 | failed_password_users += 1 824 | failed_password_users.append(user['email']) 825 | return len(users), successful_password_users, failed_password_users 826 | 827 | 828 | def build_user_object_with_passwords(extracted_user): 829 | userPasswordToCreate=UserPassword( 830 | hashed=UserPasswordBcrypt( 831 | hash=extracted_user['passwordHash'] 832 | ) 833 | ) 834 | user_object=[ 835 | UserObj( 836 | login_id=extracted_user['email'], 837 | email=extracted_user['email'], 838 | verified_email=True,#extracted_user['email_verified'], 839 | password=userPasswordToCreate, 840 | custom_attributes = { 841 | "connection": "Username-Password-Authentication", 842 | "freshlyMigrated": True, 843 | } 844 | ) 845 | ] 846 | return user_object 847 | 848 | def create_users_with_passwords(user_object): 849 | # Create the user 850 | try: 851 | resp = descope_client.mgmt.user.invite_batch( 852 | users=user_object, 853 | invite_url="https://localhost", 854 | send_mail=False, 855 | send_sms=False 856 | ) 857 | return True 858 | except AuthException as error: 859 | logging.error("Unable to create user with password.") 860 | logging.error(f"Error:, {error.error_message}") 861 | return False 862 | 863 | ### End Password Functions 864 | 865 | ### Begin Main Migration Function 866 | def migrate_auth0(dry_run,verbose,passwords_file_path,json_file_path): 867 | """ 868 | Main function to process Auth0 users, roles, permissions, and organizations, creating and mapping them together within your Descope project. 869 | """ 870 | from_json=False 871 | if passwords_file_path: 872 | print(f"Running with passwords from file: {passwords_file_path}") 873 | found_password_users, successful_password_users, failed_password_users = process_users_with_passwords(passwords_file_path, dry_run, verbose) 874 | 875 | if json_file_path: 876 | print(f"Running with users from file: {json_file_path}") 877 | auth0_users = fetch_auth0_users_from_file(json_file_path) 878 | from_json=True 879 | else: 880 | auth0_users = fetch_auth0_users() 881 | 882 | failed_users, successful_migrated_users, merged_users, disabled_users_mismatch = process_users(auth0_users, dry_run, from_json, verbose) 883 | 884 | # Fetch, create, and associate users with roles and permissions 885 | auth0_roles = fetch_auth0_roles() 886 | failed_roles, successful_migrated_roles, roles_exist_descope, failed_permissions, successful_migrated_permissions, total_existing_permissions_descope, roles_and_users, failed_roles_and_users = process_roles(auth0_roles, dry_run, verbose) 887 | 888 | # Fetch, create, and associate users with Organizations 889 | auth0_organizations = fetch_auth0_organizations() 890 | successful_tenant_creation, tenant_exists_descope, failed_tenant_creation, failed_users_added_tenants, tenant_users = process_auth0_organizations(auth0_organizations, dry_run, verbose) 891 | if dry_run == False: 892 | if passwords_file_path: 893 | print("=================== Password User Migration ====================") 894 | print(f"Auth0 Users password users in file {found_password_users}") 895 | print(f"Successfully migrated {successful_password_users} users") 896 | if len(failed_password_users) !=0: 897 | print(f"Failed to migrate {len(failed_password_users)}") 898 | print(f"Users which failed to migrate:") 899 | for failed_user in failed_password_users: 900 | print(failed_password_users) 901 | print(f"Created users within Descope {successful_password_users}") 902 | 903 | print("=================== User Migration =============================") 904 | print(f"Auth0 Users found via API {len(auth0_users)}") 905 | print(f"Successfully migrated {successful_migrated_users} users") 906 | print(f"Successfully merged {len(merged_users)} users") 907 | if verbose: 908 | for merged_user in merged_users: 909 | print(f"Merged user: {merged_user}") 910 | if len(disabled_users_mismatch) !=0: 911 | print(f"Users migrated, but disabled due to one of the merged accounts being disabled {len(disabled_users_mismatch)}") 912 | print(f"Users disabled due to one of the merged accounts being disabled {disabled_users_mismatch}") 913 | if len(failed_users) !=0: 914 | print(f"Failed to migrate {len(failed_users)}") 915 | print(f"Users which failed to migrate:") 916 | for failed_user in failed_users: 917 | print(failed_user) 918 | print(f"Created users within Descope {successful_migrated_users - len(merged_users)}") 919 | 920 | print("=================== Role Migration =============================") 921 | print(f"Auth0 Roles found via API {len(auth0_roles)}") 922 | print(f"Existing roles found in Descope {roles_exist_descope}") 923 | print(f"Created roles within Descope {successful_migrated_roles}") 924 | if len(failed_roles) !=0: 925 | print(f"Failed to migrate {len(failed_roles)}") 926 | print(f"Roles which failed to migrate:") 927 | for failed_role in failed_roles: 928 | print(failed_role) 929 | 930 | print("=================== Permission Migration =======================") 931 | print(f"Auth0 Permissions found via API {len(failed_permissions) + successful_migrated_permissions + len(total_existing_permissions_descope)}") 932 | print(f"Existing permissions found in Descope {len(total_existing_permissions_descope)}") 933 | print(f"Created permissions within Descope {successful_migrated_permissions}") 934 | if len(failed_permissions) !=0: 935 | print(f"Failed to migrate {len(failed_permissions)}") 936 | print(f"Permissions which failed to migrate:") 937 | for failed_permission in failed_permissions: 938 | print(failed_permission) 939 | 940 | print("=================== User/Role Mapping ==========================") 941 | print(f"Successfully role and user mapping") 942 | for success_role_user in roles_and_users: 943 | print(success_role_user) 944 | if len(failed_roles_and_users) !=0: 945 | print(f"Failed role and user mapping") 946 | for failed_role_user in failed_roles_and_users: 947 | print(failed_role_user) 948 | 949 | print("=================== Tenant Migration ===========================") 950 | print(f"Auth0 Tenants found via API {len(auth0_organizations)}") 951 | print(f"Existing tenants found in Descope {tenant_exists_descope}") 952 | print(f"Created tenants within Descope {successful_tenant_creation}") 953 | if len(failed_tenant_creation) !=0: 954 | print(f"Failed to migrate {len(failed_tenant_creation)}") 955 | print(f"Tenants which failed to migrate:") 956 | for failed_tenant in failed_tenant_creation: 957 | print(failed_tenant) 958 | 959 | print("=================== User/Tenant Mapping ========================") 960 | print(f"Successfully tenant and user mapping") 961 | for tenant_user in tenant_users: 962 | print(tenant_user) 963 | if len(failed_users_added_tenants) !=0: 964 | print(f"Failed tenant and user mapping") 965 | for failed_users_added_tenant in failed_users_added_tenants: 966 | print(failed_users_added_tenant) 967 | 968 | 969 | ### End Main Migration Function 970 | -------------------------------------------------------------------------------- /src/ping_migration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import requests 4 | import base64 5 | import json 6 | from dotenv import load_dotenv 7 | import time 8 | 9 | from descope.descope_client import DescopeClient 10 | from descope.exceptions import AuthException 11 | from descope.management.user import UserObj 12 | 13 | from setup import initialize_descope 14 | 15 | from utils import ( 16 | api_request_with_retry, 17 | create_custom_attributes_in_descope 18 | ) 19 | 20 | _access_token = None 21 | _token_expiry = 0 22 | 23 | """ 24 | Load and read environment variables from .env file 25 | """ 26 | load_dotenv() 27 | PING_CLIENT_ID = os.getenv("PING_CLIENT_ID") 28 | PING_CLIENT_SECRET = os.getenv("PING_CLIENT_SECRET") 29 | PING_ENVIRONMENT_ID = os.getenv("PING_ENVIRONMENT_ID") 30 | PING_API_PATH = os.getenv("PING_API_PATH") 31 | 32 | descope_client = initialize_descope() 33 | 34 | 35 | ### Begin PingOne Actions 36 | 37 | # --- PingOne API Authentication --- 38 | def get_pingone_access_token(): 39 | """ 40 | Retrieve and manage access token for PingOne API authentication. 41 | 42 | This function implements token caching to avoid unnecessary API calls. 43 | If a valid token exists, it returns the cached token. Otherwise, it 44 | fetches a new token using client credentials flow. 45 | 46 | Returns: 47 | str or None: The access token if successful, None if failed 48 | 49 | Global Variables: 50 | _access_token (str): Cached access token 51 | _token_expiry (float): Timestamp when token expires 52 | """ 53 | global _access_token, _token_expiry 54 | 55 | # If token is still valid, return it 56 | if _access_token and time.time() < _token_expiry: 57 | return _access_token 58 | 59 | # Otherwise, fetch a new token 60 | token_url = f"https://auth.pingone.com/{PING_ENVIRONMENT_ID}/as/token" 61 | payload = { 62 | "grant_type": "client_credentials", 63 | } 64 | credentials = f"{PING_CLIENT_ID}:{PING_CLIENT_SECRET}" 65 | encoded_credentials = base64.b64encode(credentials.encode()).decode() 66 | headers = { 67 | 'Authorization': f"Basic {encoded_credentials}", 68 | 'Content-Type': 'application/x-www-form-urlencoded' 69 | } 70 | response = requests.post(token_url, data=payload, headers=headers) 71 | if response.status_code == 200: 72 | token_data = response.json() 73 | _access_token = token_data.get("access_token") 74 | expires_in = token_data.get("expires_in", 3600) # seconds 75 | _token_expiry = time.time() + expires_in - 60 # refresh 1 min before expiry 76 | return _access_token 77 | else: 78 | print(f"Failed to get access token: {response.text}") 79 | return None 80 | 81 | # --- Fetch All Users from your Organization from PingOne API --- 82 | def fetch_pingone_users(): 83 | """ 84 | Fetch all users from all PingOne environments. 85 | 86 | This function aggregates users from all environments by first fetching 87 | all environments, then fetching users from each environment individually. 88 | 89 | Returns: 90 | list: A list of all users across all environments 91 | 92 | Dependencies: 93 | fetch_pingone_environments(): Gets all environments 94 | fetch_pingone_environment_members(): Gets users for a specific environment 95 | """ 96 | 97 | all_envs = fetch_pingone_environments() 98 | all_users = [] 99 | for env in all_envs: 100 | all_users.extend(fetch_pingone_environment_members(env["id"])) 101 | 102 | print(f"Total users fetched: {len(all_users)}") 103 | return all_users 104 | 105 | # --- Fetch Environments ( == Descope Tenants) from PingOne --- 106 | def fetch_pingone_environments(): 107 | """ 108 | Fetch and return all environments from PingOne. 109 | 110 | This function retrieves all environments from the PingOne API with pagination 111 | support. It handles the API response structure and continues fetching until 112 | all environments are retrieved. 113 | 114 | Returns: 115 | list: A list of environment dictionaries containing environment details 116 | 117 | Environment Variables: 118 | PING_API_PATH (str): Base URL for PingOne API 119 | """ 120 | envs_url = f"{PING_API_PATH}/environments" 121 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 122 | all_envs = [] 123 | limit = 100 124 | offset = 0 125 | 126 | while True: 127 | response = requests.get(envs_url, headers=headers, params={"limit": limit, "offset": offset}) 128 | if response.status_code != 200: 129 | break 130 | 131 | response_data = response.json() 132 | 133 | envs = response_data.get("_embedded", {}).get("environments", []) 134 | if not envs: 135 | break 136 | 137 | all_envs.extend(envs) 138 | 139 | if len(envs) < limit: 140 | break 141 | 142 | offset += limit 143 | 144 | # Safety check to prevent infinite loops 145 | if offset > 10000: # Arbitrary limit 146 | break 147 | 148 | return all_envs 149 | 150 | def fetch_pingone_builtin_roles(): 151 | """ 152 | Fetch and return all built-in roles from PingOne with pagination support. 153 | 154 | This function retrieves all built-in roles from the PingOne API. Built-in 155 | roles are system-defined roles that are available across all environments. 156 | 157 | Returns: 158 | list: A list of built-in role dictionaries containing role details 159 | 160 | Environment Variables: 161 | PING_API_PATH (str): Base URL for PingOne API 162 | """ 163 | roles_url = f"{PING_API_PATH}/roles" 164 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 165 | all_roles = [] 166 | limit = 100 # Number of users per page 167 | offset = 0 # Starting offset 168 | 169 | while True: 170 | response = requests.get(roles_url, headers=headers, params={"limit": limit, "offset": offset}) 171 | 172 | if response.status_code != 200: 173 | break 174 | 175 | response_data = response.json() 176 | 177 | roles = response_data.get("_embedded", {}).get("roles", []) 178 | if not roles: 179 | break 180 | 181 | all_roles.extend(roles) 182 | 183 | if len(roles) < limit: 184 | break 185 | 186 | offset += limit 187 | 188 | # Safety check to prevent infinite loops 189 | if offset > 10000: # Arbitrary limit 190 | break 191 | 192 | print(f"Total unique built-in roles fetched: {len(all_roles)}") 193 | return all_roles 194 | 195 | def fetch_pingone_custom_roles(environment_id): 196 | """ 197 | Fetch and return all custom roles from a specific PingOne environment. 198 | 199 | This function retrieves all custom roles for a given environment with pagination 200 | support. Custom roles are environment-specific roles created by administrators. 201 | 202 | Args: 203 | environment_id (str): The ID of the environment to fetch custom roles from 204 | 205 | Returns: 206 | list: A list of custom role dictionaries containing role details 207 | 208 | Environment Variables: 209 | PING_API_PATH (str): Base URL for PingOne API 210 | """ 211 | custom_roles_url = f"{PING_API_PATH}/environments/{environment_id}/roles?filter=%28type+eq+%22CUSTOM%22%29" 212 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 213 | all_roles = [] 214 | limit = 100 # Number of users per page 215 | offset = 0 # Starting offset 216 | 217 | while True: 218 | response = requests.get(custom_roles_url, headers=headers, params={"limit": limit, "offset": offset}) 219 | 220 | if response.status_code != 200: 221 | break 222 | 223 | response_data = response.json() 224 | 225 | roles = response_data.get("_embedded", {}).get("roles", []) 226 | if not roles: 227 | break 228 | 229 | all_roles.extend(roles) 230 | 231 | if len(roles) < limit: 232 | break 233 | 234 | offset += limit 235 | 236 | # Safety check to prevent infinite loops 237 | if offset > 10000: # Arbitrary limit 238 | break 239 | 240 | return all_roles 241 | 242 | def fetch_pingone_environment_members(environment_id): 243 | """ 244 | Fetch and return all users from a specific PingOne environment. 245 | 246 | This function retrieves all users for a given environment with pagination 247 | support. It handles the API response structure and continues fetching until 248 | all users in the environment are retrieved. 249 | 250 | Args: 251 | environment_id (str): The ID of the environment to fetch users from 252 | 253 | Returns: 254 | list: A list of user dictionaries containing user details 255 | 256 | Environment Variables: 257 | PING_API_PATH (str): Base URL for PingOne API 258 | """ 259 | envs_url = f"{PING_API_PATH}/environments/{environment_id}/users" 260 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 261 | all_users = [] 262 | limit = 100 # Number of users per page 263 | offset = 0 # Starting offset 264 | 265 | while True: 266 | response = requests.get(envs_url, headers=headers, params={"limit": limit, "offset": offset}) 267 | 268 | if response.status_code != 200: 269 | break 270 | 271 | response_data = response.json() 272 | 273 | users = response_data.get("_embedded", {}).get("users", []) 274 | if not users: 275 | break 276 | 277 | all_users.extend(users) 278 | 279 | if len(users) < limit: 280 | break 281 | 282 | offset += limit 283 | 284 | # Safety check to prevent infinite loops 285 | if offset > 10000: # Arbitrary limit 286 | break 287 | 288 | return all_users 289 | 290 | def fetch_pingone_user_roles(user_id, environment_id): 291 | """ 292 | Fetch and return all roles assigned to a specific user from PingOne. 293 | 294 | This function retrieves all role assignments for a user and filters them based 295 | on scope. Only roles whose assignment scope matches the user's environment 296 | or is organization-wide are returned. 297 | 298 | Args: 299 | user_id (str): The ID of the user to get roles for 300 | environment_id (str): The environment ID where the user exists 301 | 302 | Returns: 303 | list: A list of roles assigned to the user in the given environment 304 | 305 | Environment Variables: 306 | PING_API_PATH (str): Base URL for PingOne API 307 | """ 308 | roles_url = f"{PING_API_PATH}/environments/{environment_id}/users/{user_id}/roleAssignments" 309 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 310 | all_roles = [] 311 | limit = 100 312 | offset = 0 313 | while True: 314 | response = requests.get(roles_url, headers=headers, params={"limit": limit, "offset": offset}) 315 | if response.status_code != 200: 316 | logging.error(f"Failed to fetch user roles for user {user_id}: {response.status_code} - {response.text}") 317 | break 318 | response_data = response.json() 319 | role_assignments = response_data.get("_embedded", {}).get("roleAssignments", []) 320 | if not role_assignments: 321 | break 322 | # Extract role information from assignments, only if scope.id matches environment_id 323 | for assignment in role_assignments: 324 | role = assignment.get("role", {}) 325 | scope = assignment.get("scope") 326 | if not role: 327 | continue 328 | if not scope: 329 | all_roles.append(role) 330 | continue 331 | scope_type = scope.get("type") 332 | scope_id = scope.get("id") 333 | if scope_type == "ORGANIZATION": 334 | all_roles.append(role) 335 | elif scope_type == "ENVIRONMENT" and scope_id == environment_id: 336 | all_roles.append(role) 337 | 338 | if len(role_assignments) < limit: 339 | break 340 | offset += limit 341 | if offset > 10000: 342 | break 343 | return all_roles 344 | 345 | def fetch_pingone_role_name_by_role_id(role_id): 346 | """ 347 | Fetch and return the name of a role by its ID from PingOne. 348 | 349 | This function retrieves the role details for a specific role ID and 350 | returns the role name. It's used to resolve role names from role IDs 351 | when processing user role assignments. 352 | 353 | Args: 354 | role_id (str): The ID of the role to fetch the name for 355 | 356 | Returns: 357 | str: The name of the role, or None if not found 358 | 359 | Environment Variables: 360 | PING_API_PATH (str): Base URL for PingOne API 361 | """ 362 | roles_url = f"{PING_API_PATH}/roles/{role_id}" 363 | headers = {"Authorization": f"Bearer {get_pingone_access_token()}"} 364 | role_info = [] 365 | limit = 100 # Number of users per page 366 | offset = 0 # Starting offset 367 | 368 | while True: 369 | response = requests.get(roles_url, headers=headers, params={"limit": limit, "offset": offset}) 370 | 371 | if response.status_code != 200: 372 | break 373 | 374 | response_data = response.json() 375 | 376 | role = response_data.get("name") 377 | if not role: 378 | break 379 | 380 | role_info.append(role) 381 | 382 | if len(role) < limit: 383 | break 384 | 385 | offset += limit 386 | 387 | # Safety check to prevent infinite loops 388 | if offset > 10000: # Arbitrary limit 389 | break 390 | 391 | return role_info[0] 392 | 393 | 394 | ### End PingOne Actions 395 | 396 | ### Begin Descope Actions 397 | 398 | def create_descope_user(user): 399 | """ 400 | Create a Descope user based on PingOne user data using Descope Python SDK. 401 | 402 | This function creates or updates a user in Descope based on PingOne user data. 403 | It handles both new user creation and existing user updates. The function 404 | also manages user status (enabled/disabled) and custom attributes. 405 | 406 | Args: 407 | user (dict): A dictionary containing user details fetched from PingOne API 408 | 409 | Returns: 410 | tuple: (success (bool), merged_user_name (str), disabled_mismatch (bool), error_message (str)) 411 | - success: Whether the operation was successful 412 | - merged_user_name: Name of user if merged, empty string otherwise 413 | - disabled_mismatch: Whether there was a disabled status mismatch 414 | - error_message: Error message if operation failed 415 | 416 | Dependencies: 417 | descope_client: Initialized Descope client 418 | """ 419 | try: 420 | login_ids = [] 421 | connections = [] 422 | if user.get("email"): 423 | login_ids.append(user["email"]) 424 | connections.append("email") 425 | if user.get("username"): 426 | login_ids.append(user["username"].lower()) 427 | connections.append("username") 428 | if user.get("email"): 429 | login_id = user["email"] 430 | else: 431 | login_id = user.get("username").lower() 432 | additional_login_ids = login_ids[1 : len(login_ids)] 433 | email = user.get("email") 434 | phone = user.get("primaryPhone") 435 | display_name = user.get("username") 436 | given_name = user.get("name").get("given") 437 | family_name = user.get("name").get("family") 438 | mfa_enabled = user.get("mfaEnabled", False) 439 | user_id = user.get("id") 440 | population_id = user.get("population").get("id") 441 | environment_id = user.get("environment").get("id") 442 | custom_attributes = { 443 | "mfaEnabled": mfa_enabled, 444 | "userId": user_id, 445 | "populationId": population_id, 446 | "environmentId": environment_id, 447 | "freshlyMigrated": 'true', 448 | } 449 | users = [] 450 | try: 451 | resp = descope_client.mgmt.user.search_all(login_ids=[login_id]) 452 | users = resp.get("users", []) 453 | except Exception: 454 | pass 455 | if not users: 456 | try: 457 | resp = descope_client.mgmt.user.create( 458 | login_id=login_id, 459 | email=email, 460 | display_name=display_name, 461 | given_name=given_name, 462 | family_name=family_name, 463 | phone=phone, 464 | custom_attributes=custom_attributes, 465 | additional_login_ids=additional_login_ids, 466 | ) 467 | print(f"Created Descope user: {login_id}") 468 | # Do not add user to tenant here 469 | status = "disabled" if user.get("blocked", False) else "enabled" 470 | if status == "disabled": 471 | try: 472 | resp = descope_client.mgmt.user.deactivate(login_id=login_id) 473 | except AuthException as error: 474 | logging.error(f"Unable to deactivate user.") 475 | logging.error(f"Status Code: {error.status_code}") 476 | logging.error(f"Error: {error.error_message}") 477 | elif status == "enabled": 478 | try: 479 | resp = descope_client.mgmt.user.activate(login_id=login_id) 480 | except AuthException as error: 481 | logging.error(f"Unable to activate user.") 482 | logging.error(f"Status Code: {error.status_code}") 483 | logging.error(f"Error: {error.error_message}") 484 | return True, "", False, "" 485 | except AuthException as error: 486 | logging.error(f"Unable to create user. {user}") 487 | logging.error(f"Error: {error.error_message}") 488 | return ( 489 | False, 490 | "", 491 | False, 492 | user.get("userId", "") + " Reason: " + str(error.error_message), 493 | ) 494 | except Exception as e: 495 | logging.error(f"Unexpected error creating/updating user: {user}") 496 | logging.error(str(e)) 497 | return ( 498 | False, 499 | "", 500 | False, 501 | user.get("userId", "") + " Reason: " + str(e), 502 | ) 503 | else: 504 | user_to_update = users[0] 505 | 506 | descope_login_id = user_to_update["loginIds"][0] if "loginIds" in user_to_update and user_to_update["loginIds"] else login_id 507 | try: 508 | resp = descope_client.mgmt.user.update( 509 | login_id=descope_login_id.lower(), 510 | email=email, 511 | display_name=display_name, 512 | given_name=given_name, 513 | family_name=family_name, 514 | phone=phone, 515 | custom_attributes=custom_attributes, 516 | additional_login_ids=additional_login_ids, 517 | ) 518 | logging.info(f"Updated Descope user: {login_id}") 519 | status = "disabled" if user.get("blocked", False) else "enabled" 520 | if status == "disabled": 521 | try: 522 | resp = descope_client.mgmt.user.deactivate(login_id=descope_login_id) 523 | except AuthException as error: 524 | logging.error(f"Unable to deactivate user.") 525 | logging.error(f"Status Code: {error.status_code}") 526 | logging.error(f"Error: {error.error_message}") 527 | elif status == "enabled": 528 | try: 529 | resp = descope_client.mgmt.user.activate(login_id=descope_login_id) 530 | except AuthException as error: 531 | logging.error(f"Unable to activate user.") 532 | logging.error(f"Status Code: {error.status_code}") 533 | logging.error(f"Error: {error.error_message}") 534 | if status == "disabled" or user_to_update.get("status") == "disabled": 535 | return True, user.get("name"), True, user.get("user_id") 536 | return True, user.get("name"), False, "" 537 | except Exception as e: 538 | logging.error(f"Unexpected error updating user: {user}") 539 | logging.error(str(e)) 540 | return ( 541 | False, 542 | "", 543 | False, 544 | user.get("userId", "") + " Reason: " + str(e), 545 | ) 546 | except Exception as e: 547 | logging.error(f"Unexpected error in create_descope_user: {user}") 548 | logging.error(str(e)) 549 | return ( 550 | False, 551 | "", 552 | False, 553 | user.get("userId", "") + " Reason: " + str(e), 554 | ) 555 | 556 | def create_descope_tenant(organization): 557 | """ 558 | Create a Descope tenant based on PingOne environment data. 559 | 560 | This function creates a new tenant in Descope using the environment 561 | information from PingOne. The tenant ID and name are derived from 562 | the PingOne environment data. 563 | 564 | Args: 565 | organization (dict): A dictionary containing environment details fetched from PingOne API 566 | 567 | Returns: 568 | tuple: (success (bool), error_message (str)) 569 | - success: Whether the tenant creation was successful 570 | - error_message: Error message if creation failed 571 | 572 | Dependencies: 573 | descope_client: Initialized Descope client 574 | """ 575 | name = organization["name"] 576 | tenant_id = organization["id"] 577 | 578 | try: 579 | resp = descope_client.mgmt.tenant.create(name=name, id=tenant_id) 580 | return True, "" 581 | except AuthException as error: 582 | logging.error("Unable to create tenant.") 583 | logging.error(f"Error:, {error.error_message}") 584 | return False, f"Tenant {name} failed to create Reason: {error.error_message}" 585 | 586 | def add_descope_user_to_tenant(tenantId, loginId): 587 | """ 588 | Map a Descope user to a tenant based on PingOne data using Descope SDK. 589 | 590 | This function associates a user with a tenant in Descope. It first checks 591 | if the user is already associated with the tenant to avoid duplicates. 592 | 593 | Args: 594 | tenantId (str): The tenant ID of the tenant to associate the user with 595 | loginId (str): The login ID of the user to associate with the tenant 596 | 597 | Returns: 598 | tuple: (success (bool), error_message (str)) 599 | - success: Whether the association was successful 600 | - error_message: Error message if association failed 601 | 602 | Dependencies: 603 | descope_client: Initialized Descope client 604 | check_user_in_tenant_descope(): Checks if user is already in tenant 605 | """ 606 | if not check_user_in_tenant_descope(loginId, tenantId): 607 | try: 608 | resp = descope_client.mgmt.user.add_tenant(login_id=loginId, tenant_id=tenantId) 609 | return True, "" 610 | except AuthException as error: 611 | logging.error("Unable to add user to tenant.") 612 | logging.error(f"Error:, {error.error_message}") 613 | return False, error.error_message 614 | 615 | return False, "User already exists in tenant" 616 | 617 | def check_tenant_exists_descope(tenant_id): 618 | """ 619 | Check if a tenant exists in Descope. 620 | 621 | This function attempts to load a tenant by ID to determine if it 622 | already exists in the Descope system. 623 | 624 | Args: 625 | tenant_id (str): The ID of the tenant to check 626 | 627 | Returns: 628 | bool: True if tenant exists, False otherwise 629 | 630 | Dependencies: 631 | descope_client: Initialized Descope client 632 | """ 633 | 634 | try: 635 | tenant_resp = descope_client.mgmt.tenant.load(tenant_id) 636 | return True 637 | except: 638 | return False 639 | 640 | def check_user_in_tenant_descope(user_login_id, tenant_id): 641 | """ 642 | Check if a user is already in a tenant in Descope. 643 | 644 | This function loads a user and checks if they are already associated 645 | with the specified tenant by examining their tenant associations. 646 | 647 | Args: 648 | user_login_id (str): The login ID of the user to check 649 | tenant_id (str): The tenant ID to check for the user 650 | 651 | Returns: 652 | bool: True if user is in the tenant, False otherwise 653 | 654 | Dependencies: 655 | descope_client: Initialized Descope client 656 | """ 657 | 658 | try: 659 | resp = descope_client.mgmt.user.load(login_id=user_login_id) 660 | user_tenants = resp.get("user", {}).get("userTenants", []) 661 | 662 | for tenant in user_tenants: 663 | if tenant.get("tenantId") == tenant_id: 664 | return True 665 | return False 666 | except: 667 | return False 668 | 669 | def check_role_exists_descope(role_name,tenant_id): 670 | """ 671 | Check if a role exists in Descope. 672 | 673 | This function searches for a role by name, optionally scoped to a specific 674 | tenant. It handles both global roles (tenant_id=None) and tenant-scoped roles. 675 | 676 | Args: 677 | role_name (str): The name of the role to check 678 | tenant_id (str or None): The tenant ID to scope the search to, or None for global roles 679 | 680 | Returns: 681 | bool: True if role exists, False otherwise 682 | 683 | Dependencies: 684 | descope_client: Initialized Descope client 685 | """ 686 | try: 687 | if tenant_id is not None: 688 | roles_resp = descope_client.mgmt.role.search(role_names=[role_name],tenant_ids=[tenant_id]) 689 | else: 690 | roles_resp = descope_client.mgmt.role.search(role_names=[role_name]) 691 | 692 | if roles_resp["roles"]: 693 | return True 694 | else: 695 | return False 696 | except: 697 | return False 698 | 699 | def create_descope_role_and_permissions(role, permissions, tenant_id): 700 | """ 701 | Create a Descope role and its associated permissions using the Descope Python SDK. 702 | 703 | This function creates permissions first, then creates a role with those permissions. 704 | It handles both new permission creation and existing permission updates. The role 705 | can be scoped to a specific tenant or be global. 706 | 707 | Args: 708 | role (dict): A dictionary containing role details from PingOne 709 | permissions (list): A list of permission dictionaries from PingOne 710 | tenant_id (str or None): The tenant ID to scope the role to, or None for global 711 | 712 | Returns: 713 | tuple: (success (bool), role_exists (bool), success_permissions (int), 714 | existing_permissions_descope (list), failed_permissions (list), error_message (str)) 715 | - success: Whether the role creation was successful 716 | - role_exists: Whether the role already existed (always False in current implementation) 717 | - success_permissions: Number of permissions successfully created/updated 718 | - existing_permissions_descope: List of permissions that already existed 719 | - failed_permissions: List of permissions that failed to create 720 | - error_message: Error message if role creation failed 721 | 722 | Dependencies: 723 | descope_client: Initialized Descope client 724 | """ 725 | permissionNames = [] 726 | success_permissions = 0 727 | existing_permissions_descope = [] 728 | failed_permissions = [] 729 | permissions_already_in_descope = descope_client.mgmt.permission.load_all().get("permissions", []) 730 | permission_names_already_in_descope = [permission["name"] for permission in permissions_already_in_descope] 731 | for permission in permissions: 732 | name = permission["id"] 733 | description = permission.get("description", "") 734 | try: 735 | if name in permission_names_already_in_descope: 736 | descope_client.mgmt.permission.update(name=name,new_name=name,description=description) 737 | existing_permissions_descope.append(name) 738 | success_permissions += 1 739 | break 740 | 741 | descope_client.mgmt.permission.create(name=name, description=description) 742 | permissionNames.append(name) 743 | success_permissions += 1 744 | except AuthException as error: 745 | if error.error_message: 746 | error_message_dict = json.loads(error.error_message) 747 | if error_message_dict["errorCode"] == "E024104": 748 | existing_permissions_descope.append(name) 749 | permissionNames.append(name) 750 | logging.error(f"Unable to create permission: {name}.") 751 | logging.error(f"Status Code: {error.status_code}") 752 | logging.error(f"Error: {error.error_message}") 753 | else: 754 | failed_permissions.append(f"{name}, Reason: {error.error_message}") 755 | logging.error(f"Unable to create permission: {name}.") 756 | logging.error(f"Status Code: {error.status_code}") 757 | logging.error(f"Error: {error.error_message}") 758 | else: 759 | failed_permissions.append(f"{name}, Reason: Unknown error") 760 | logging.error(f"Unable to create permission: {name}.") 761 | logging.error(f"Status Code: {error.status_code}") 762 | logging.error(f"Error: {error.error_message}") 763 | 764 | role_name = role["name"] 765 | role_description = role.get("description", "") 766 | try: 767 | descope_client.mgmt.role.create( 768 | name=role_name, 769 | description=role_description, 770 | permission_names=permissionNames, 771 | tenant_id=tenant_id, 772 | ) 773 | return True, False, success_permissions, existing_permissions_descope, failed_permissions, "" 774 | except AuthException as error: 775 | logging.error(f"Unable to create role: {role_name}.") 776 | logging.error(f"Status Code: {error.status_code}") 777 | logging.error(f"Error: {error.error_message}") 778 | return ( 779 | False, 780 | False, 781 | success_permissions, 782 | existing_permissions_descope, 783 | failed_permissions, 784 | f"{role_name} Reason: {error.error_message}", 785 | ) 786 | 787 | ### End Descope Actions 788 | 789 | ### Begin Process Functions 790 | 791 | def process_users(all_users, dry_run, verbose): 792 | """ 793 | Process the list of users from PingOne by mapping and creating them in Descope. 794 | 795 | This function iterates through all users from PingOne and creates or updates 796 | them in Descope. It handles both dry run mode (for testing) and verbose 797 | output for detailed logging. 798 | 799 | Args: 800 | all_users (list): A list of users fetched from PingOne API 801 | dry_run (bool): If True, only simulate the migration without making changes 802 | verbose (bool): If True, print detailed information about each user 803 | 804 | Returns: 805 | tuple: (failed_users (list), successful_migrated_users (int), 806 | merged_users (list), disabled_users_mismatch (list)) 807 | - failed_users: List of user IDs that failed to migrate 808 | - successful_migrated_users: Number of users successfully migrated 809 | - merged_users: List of users that were merged with existing users 810 | - disabled_users_mismatch: List of users with disabled status mismatches 811 | 812 | Dependencies: 813 | create_descope_user(): Creates or updates individual users 814 | """ 815 | failed_users = [] 816 | successful_migrated_users = 0 817 | merged_users = [] 818 | disabled_users_mismatch = [] 819 | if dry_run: 820 | print(f"Would migrate {len(all_users)} users from PingOne to Descope") 821 | if verbose: 822 | for user in all_users: 823 | print(f"\tUser: {user['username']}") 824 | else: 825 | print(f"Starting migration of {len(all_users)} users found via PingOne API") 826 | for user in all_users: 827 | if verbose: 828 | print(f"\tUser: {user['username']}") 829 | success, merged, disabled_mismatch, user_id_error = create_descope_user(user) 830 | if success: 831 | successful_migrated_users += 1 832 | if merged: 833 | merged_users.append(merged) 834 | if success and disabled_mismatch: 835 | disabled_users_mismatch.append(user_id_error) 836 | elif success == None: 837 | if success == None and disabled_mismatch: 838 | disabled_users_mismatch.append(user_id_error) 839 | else: 840 | failed_users.append(user_id_error) 841 | if successful_migrated_users % 10 == 0 and successful_migrated_users > 0 and not verbose: 842 | print(f"Still working, migrated {successful_migrated_users} users.") 843 | return ( 844 | failed_users, 845 | successful_migrated_users, 846 | merged_users, 847 | disabled_users_mismatch, 848 | ) 849 | 850 | def process_pingone_environments(pingone_envs, dry_run, verbose): 851 | """ 852 | Process the PingOne environments - creating tenants and associating users to tenants. 853 | 854 | This function creates tenants in Descope based on PingOne environments and 855 | associates users with those tenants. It handles both dry run mode and 856 | verbose output for detailed logging. 857 | 858 | Args: 859 | pingone_envs (list): List of environments fetched from PingOne 860 | dry_run (bool): If True, only simulate the migration without making changes 861 | verbose (bool): If True, print detailed information about each environment 862 | 863 | Returns: 864 | tuple: (successful_tenant_creation (int), tenant_exists_descope (int), 865 | failed_tenant_creation (list), failed_users_added_tenants (list), 866 | tenant_users (list)) 867 | - successful_tenant_creation: Number of tenants successfully created 868 | - tenant_exists_descope: Number of tenants that already existed 869 | - failed_tenant_creation: List of tenant creation errors 870 | - failed_users_added_tenants: List of user-tenant association errors 871 | - tenant_users: List of successful user-tenant associations 872 | 873 | Dependencies: 874 | create_descope_tenant(): Creates individual tenants 875 | add_descope_user_to_tenant(): Associates users with tenants 876 | fetch_pingone_environment_members(): Gets users for a specific environment 877 | """ 878 | successful_tenant_creation = 0 879 | tenant_exists_descope = 0 880 | failed_tenant_creation = [] 881 | failed_users_added_tenants = [] 882 | tenant_users = [] 883 | if dry_run: 884 | print( 885 | f"Would migrate {len(pingone_envs)} environments from PingOne to Descope tenants" 886 | ) 887 | if verbose: 888 | for environment in pingone_envs: 889 | env_members = fetch_pingone_environment_members(environment["id"]) 890 | print( 891 | f"\tEnvironment: {environment['name']} with {len(env_members)} associated users" 892 | ) 893 | else: 894 | print(f"Starting migration of {len(pingone_envs)} environments found via PingOne API") 895 | for environment in pingone_envs: 896 | if not check_tenant_exists_descope(environment["id"]): 897 | success, error = create_descope_tenant(environment) 898 | if success: 899 | successful_tenant_creation += 1 900 | else: 901 | failed_tenant_creation.append(error) 902 | else: 903 | tenant_exists_descope += 1 904 | # Use fetch_pingone_environment_members to get users for this environment 905 | env_members = fetch_pingone_environment_members(environment["id"]) 906 | users_added = 0 907 | for user in env_members: 908 | login_id = user.get("email") or user.get("username") 909 | success, error = add_descope_user_to_tenant(environment["id"], login_id) 910 | if success: 911 | users_added += 1 912 | else: 913 | failed_users_added_tenants.append( 914 | f"User {login_id} failed to be added to tenant {environment['name']} Reason: {error}" 915 | ) 916 | tenant_users.append( 917 | f"Associated {users_added} users with tenant: {environment['name']} " 918 | ) 919 | if successful_tenant_creation % 10 == 0 and successful_tenant_creation > 0 and not verbose: 920 | print(f"Still working, migrated {successful_tenant_creation} environments.") 921 | return ( 922 | successful_tenant_creation, 923 | tenant_exists_descope, 924 | failed_tenant_creation, 925 | failed_users_added_tenants, 926 | tenant_users, 927 | ) 928 | 929 | def process_roles(pingone_roles, pingone_environments, dry_run, verbose, ping_users=None): 930 | """ 931 | Process creating roles, permissions, and associating users in Descope. 932 | 933 | This function creates roles and permissions in Descope based on PingOne data, 934 | then assigns roles to users. It handles both global roles and tenant-scoped 935 | roles, and manages role assignments to users. 936 | 937 | Args: 938 | pingone_roles (list): List of roles fetched from PingOne 939 | pingone_environments (list): List of environments (tenants) fetched from PingOne 940 | dry_run (bool): If True, only simulate the migration without making changes 941 | verbose (bool): If True, print detailed information about each role 942 | ping_users (list, optional): List of users fetched from PingOne API for role assignment 943 | 944 | Returns: 945 | tuple: (failed_roles (list), successful_migrated_roles (int), roles_exist_descope (int), 946 | total_failed_permissions (list), successful_migrated_permissions (int), 947 | total_existing_permissions_descope (list), roles_and_users (list), 948 | failed_roles_and_users (list), total_roles_assigned (int), failed_role_assignments (list)) 949 | - failed_roles: List of roles that failed to migrate 950 | - successful_migrated_roles: Number of roles successfully migrated 951 | - roles_exist_descope: Number of roles that already existed (always 0 in current implementation) 952 | - total_failed_permissions: List of permissions that failed to create 953 | - successful_migrated_permissions: Number of permissions successfully migrated 954 | - total_existing_permissions_descope: List of permissions that already existed 955 | - roles_and_users: List of successful role-user associations 956 | - failed_roles_and_users: List of failed role-user associations 957 | - total_roles_assigned: Number of roles successfully assigned to users 958 | - failed_role_assignments: List of failed role assignments 959 | 960 | Dependencies: 961 | create_descope_role_and_permissions(): Creates roles and permissions 962 | fetch_pingone_user_roles(): Gets roles for a specific user 963 | fetch_pingone_role_name_by_role_id(): Gets role name by role ID 964 | """ 965 | descope_roles_to_create = [] 966 | for role in pingone_roles: 967 | applicable_to = role.get("applicableTo", []) 968 | role_name = role["name"] 969 | if "ORGANIZATION" in applicable_to: 970 | if not check_role_exists_descope(role_name, None): 971 | descope_roles_to_create.append((role, None)) 972 | elif "ENVIRONMENT" in applicable_to: 973 | for env in pingone_environments: 974 | if not check_role_exists_descope(role_name, env["id"]): 975 | descope_roles_to_create.append((role, env["id"])) 976 | # else: skip roles not applicable to org or environment 977 | 978 | failed_roles = [] 979 | successful_migrated_roles = 0 980 | roles_exist_descope = 0 981 | total_existing_permissions_descope = [] 982 | total_failed_permissions = [] 983 | successful_migrated_permissions = 0 984 | roles_and_users = [] 985 | failed_roles_and_users = [] 986 | total_roles_assigned = 0 987 | failed_role_assignments = [] 988 | if dry_run: 989 | print(f"Would migrate {len(descope_roles_to_create)} roles from PingOne to Descope") 990 | if verbose: 991 | for role, tenant_id in descope_roles_to_create: 992 | permissions = role["permissions"] 993 | print(f"\tRole: {role['name']} (tenant_id={tenant_id}) with {len(permissions)} associated permissions") 994 | else: 995 | for role, tenant_id in descope_roles_to_create: 996 | permissions = role["permissions"] 997 | ( 998 | success, 999 | role_exists, # will always be False now 1000 | success_permissions, 1001 | existing_permissions_descope, 1002 | failed_permissions, 1003 | error, 1004 | ) = create_descope_role_and_permissions(role, permissions, tenant_id) 1005 | if success: 1006 | successful_migrated_roles += 1 1007 | successful_migrated_permissions += success_permissions 1008 | else: 1009 | failed_roles.append(error) 1010 | successful_migrated_permissions += success_permissions 1011 | if len(failed_permissions) != 0: 1012 | for item in failed_permissions: 1013 | total_failed_permissions.append(item) 1014 | if len(existing_permissions_descope) != 0: 1015 | for item in existing_permissions_descope: 1016 | if item not in total_existing_permissions_descope: 1017 | total_existing_permissions_descope.append(item) 1018 | # --- Assign roles to users after all roles are created --- 1019 | if ping_users is not None: 1020 | # Build a mapping: {(login_id, tenant_id): set(role_names)} 1021 | user_tenant_roles = {} 1022 | for user in ping_users: 1023 | user_id = user.get("id") 1024 | environment_id = user.get("environment", {}).get("id") 1025 | login_id = user.get("email") or user.get("username") 1026 | user_roles = fetch_pingone_user_roles(user_id, environment_id) 1027 | for role in user_roles: 1028 | role_name = fetch_pingone_role_name_by_role_id(role.get("id")) 1029 | key = (login_id, environment_id) 1030 | if key not in user_tenant_roles: 1031 | user_tenant_roles[key] = set() 1032 | user_tenant_roles[key].add(role_name) 1033 | # Now set all roles for each user in each tenant 1034 | for (login_id, tenant_id), role_names in user_tenant_roles.items(): 1035 | try: 1036 | resp = descope_client.mgmt.user.set_tenant_roles( 1037 | login_id=login_id, 1038 | tenant_id=tenant_id, 1039 | role_names=list(role_names) 1040 | ) 1041 | total_roles_assigned += len(role_names) 1042 | except AuthException as error: 1043 | failed_role_assignments.append(f"Roles {role_names} to user {login_id} in tenant {tenant_id}: {error.error_message}") 1044 | logging.error(f"Failed to set roles {role_names} to user {login_id} in tenant {tenant_id}: {error.error_message}") 1045 | except Exception as e: 1046 | failed_role_assignments.append(f"Roles {role_names} to user {login_id} in tenant {tenant_id}: {str(e)}") 1047 | logging.error(f"Error setting roles {role_names} to user {login_id} in tenant {tenant_id}: {str(e)}") 1048 | return ( 1049 | failed_roles, 1050 | successful_migrated_roles, 1051 | roles_exist_descope, # will always be 0 now 1052 | total_failed_permissions, 1053 | successful_migrated_permissions, 1054 | total_existing_permissions_descope, 1055 | roles_and_users, 1056 | failed_roles_and_users, 1057 | total_roles_assigned, 1058 | failed_role_assignments, 1059 | ) 1060 | 1061 | # --- Main Migration Function --- 1062 | def migrate_pingone(dry_run, verbose): 1063 | """ 1064 | Main function to orchestrate migration from PingOne to Descope. 1065 | 1066 | This function coordinates the entire migration process from PingOne to Descope. 1067 | It performs the following steps in order: 1068 | 1. Authenticates with PingOne API 1069 | 2. Fetches and creates users in Descope 1070 | 3. Fetches and creates environments as tenants in Descope 1071 | 4. Associates users with their respective tenants 1072 | 5. Fetches and creates roles and permissions in Descope 1073 | 6. Assigns roles to users based on their PingOne assignments 1074 | 7. Prints comprehensive migration statistics 1075 | 1076 | Args: 1077 | dry_run (bool): If True, only simulate the migration without making changes 1078 | verbose (bool): If True, print detailed information during migration 1079 | 1080 | Returns: 1081 | None: This function prints results to console and doesn't return values 1082 | 1083 | Dependencies: 1084 | get_pingone_access_token(): Authenticates with PingOne 1085 | fetch_pingone_users(): Gets all users from PingOne 1086 | fetch_pingone_environments(): Gets all environments from PingOne 1087 | fetch_pingone_builtin_roles(): Gets built-in roles from PingOne 1088 | fetch_pingone_custom_roles(): Gets custom roles from PingOne 1089 | process_users(): Processes user migration 1090 | process_pingone_environments(): Processes environment/tenant migration 1091 | process_roles(): Processes role and permission migration 1092 | """ 1093 | access_token = get_pingone_access_token() 1094 | if not access_token: 1095 | logging.error("Failed to obtain access token. Exiting.") 1096 | return 1097 | 1098 | 1099 | 1100 | url = f"{PING_API_PATH}/environments/{{envID}}/v2/Users/.search" 1101 | 1102 | payload = "{\n \"filter\": \"emails ew \\\"@example.com\\\"\",\n \"count\": 10\n}" 1103 | headers = { 1104 | 'Authorization': 'Bearer {{accessToken}}', 1105 | 'Content-Type': 'application/json' 1106 | } 1107 | 1108 | response = requests.request("POST", url, headers=headers, data = payload) 1109 | 1110 | print(response.text.encode('utf8')) 1111 | 1112 | 1113 | # 1. Fetch and create users 1114 | ping_users = fetch_pingone_users() 1115 | failed_users, successful_migrated_users, merged_users, disabled_users_mismatch = process_users(ping_users, dry_run, verbose) 1116 | # 2. Fetch and create environments (tenants) and associate users to tenants 1117 | pingone_environments = fetch_pingone_environments() 1118 | successful_tenant_creation, tenant_exists_descope, failed_tenant_creation, failed_users_added_tenants, tenant_users = process_pingone_environments(pingone_environments, dry_run, verbose) 1119 | # 3. Fetch and create roles/permissions for all tenants, and assign roles to users 1120 | pingone_roles = fetch_pingone_builtin_roles() 1121 | for environment in pingone_environments: 1122 | pingone_roles.extend(fetch_pingone_custom_roles(environment["id"])) 1123 | failed_roles, successful_migrated_roles, roles_exist_descope, failed_permissions, successful_migrated_permissions, total_existing_permissions_descope, roles_and_users, failed_roles_and_users, total_roles_assigned, failed_role_assignments = process_roles( 1124 | pingone_roles, pingone_environments, dry_run, verbose, ping_users) 1125 | 1126 | if dry_run == False: 1127 | print("=================== User Migration =============================") 1128 | print(f"PingOne Users found via API {len(ping_users)}") 1129 | print(f"Successfully migrated {successful_migrated_users} users") 1130 | print(f"Successfully merged {len(merged_users)} users") 1131 | if verbose: 1132 | for merged_user in merged_users: 1133 | print(f"Merged user: {merged_user}") 1134 | if len(disabled_users_mismatch) !=0: 1135 | print(f"Users migrated, but disabled due to one of the merged accounts being disabled {len(disabled_users_mismatch)}") 1136 | print(f"Users disabled due to one of the merged accounts being disabled {disabled_users_mismatch}") 1137 | if len(failed_users) !=0: 1138 | print(f"Failed to migrate {len(failed_users)}") 1139 | print(f"Users which failed to migrate:") 1140 | for failed_user in failed_users: 1141 | print(failed_user) 1142 | print(f"Created users within Descope {successful_migrated_users - len(merged_users)}") 1143 | 1144 | print("=================== Role Migration =============================") 1145 | print(f"PingOne Roles found via API {len(pingone_roles)}") 1146 | print(f"Existing roles found in Descope {roles_exist_descope}") 1147 | print(f"Created roles within Descope {successful_migrated_roles}") 1148 | if len(failed_roles) !=0: 1149 | print(f"Failed to migrate {len(failed_roles)}") 1150 | print(f"Roles which failed to migrate:") 1151 | for failed_role in failed_roles: 1152 | print(failed_role) 1153 | 1154 | print("=================== Permission Migration =======================") 1155 | print(f"PingOne Permissions found via API {len(failed_permissions) + successful_migrated_permissions + len(total_existing_permissions_descope)}") 1156 | print(f"Existing permissions found in Descope {len(total_existing_permissions_descope)}") 1157 | print(f"Created permissions within Descope {successful_migrated_permissions}") 1158 | if len(failed_permissions) !=0: 1159 | print(f"Failed to migrate {len(failed_permissions)}") 1160 | print(f"Permissions which failed to migrate:") 1161 | for failed_permission in failed_permissions: 1162 | print(failed_permission) 1163 | 1164 | print("=================== User/Role Mapping ==========================") 1165 | print(f"Total roles assigned to users: {total_roles_assigned}") 1166 | if len(failed_role_assignments) !=0: 1167 | print(f"Failed role assignments:") 1168 | for failed_assignment in failed_role_assignments: 1169 | print(failed_assignment) 1170 | 1171 | print("=================== Tenant Migration ===========================") 1172 | print(f"PingOne environments found via API {len(pingone_environments)}") 1173 | print(f"Existing tenants found in Descope {tenant_exists_descope}") 1174 | print(f"Created tenants within Descope {successful_tenant_creation}") 1175 | if len(failed_tenant_creation) !=0: 1176 | print(f"Failed to migrate {len(failed_tenant_creation)}") 1177 | print(f"Tenants which failed to migrate:") 1178 | for failed_tenant in failed_tenant_creation: 1179 | print(failed_tenant) 1180 | 1181 | print("=================== User/Tenant Mapping ========================") 1182 | print(f"Successful tenant and user mapping") 1183 | for tenant_user in tenant_users: 1184 | print(tenant_user) 1185 | if len(failed_users_added_tenants) !=0: 1186 | print(f"Failed tenant and user mapping") 1187 | for failed_users_added_tenant in failed_users_added_tenants: 1188 | print(failed_users_added_tenant) 1189 | 1190 | --------------------------------------------------------------------------------