#!/usr/bin/env python3 """ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Python Script Name = create_entra_id_users.py ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Purpose = Create Azure Entra users from CSV, auto-generate passwords when missing, ensure a security group named 'internalprogrammatic' exists, and add users to it. Explanation1 = New Entra ID Users get created, they can request a Password Reset to the HelpDesk IT Admin and then onboard themselves via Microsoft Authenticator. Explanation2 = Every time users need to login, MS Authenticator APP will be used for MFA. Login Location will be shown in the Authenticator and mysignins.microsoft.com Explanation3 = By design, the new Entra ID users created by this script will need to reset their password at first login and always use MFA with MS Authenticator. Explanation4 = This script uses MS Graph API and MSAL for authentication. Each New Entra ID Account Password can be either autogenerated & exported or set in the users.CSV file. Explanation5 = Get YOUR_CLIENT_ID (the new APP registration APP ID), YOUR_TENANT_ID, and YOUR_CLIENT_SECRET based on the below steps, in order to plug this Python script to Entra ID. ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + Step-by-step : Obtain authentication token (App Registration + Admin consent) + Assumption: Azure Entra ID Tenant does not need to an active Azure subscription. You can still register an Application and grant it Graph permissions from the Microsoft Entra Admin Center. - Sign in to Microsoft Entra Admin Center - Open https://entra.microsoft.com and sign in with a tenant administrator account for the directory (e.g.: Global Administrator Account). - Register an Application - Navigate to "Entra ID (Azure Active Directory)" > "App registrations" > "New registration". - Give it a name like "programmatic-bulk-user-creator". - Supported account types : "Accounts in this organizational directory only". - Redirect URI: It is not required for client credentials flow. Leave it blank. - Click Register and note the Application (client) ID and Directory (tenant) ID. - Create a client secret for the App Registration : - In the app's "Certificates & secrets" section, create a new client secret. - Copy the secret value now and store it securely since you will not be able to view it again. - Grant API permissions for Microsoft Graph (Application permissions) - In the app's "API permissions", click "Add a permission" > Microsoft Graph > Application permissions. - Add User.ReadWrite.All and Directory.Read.All as well as Group.ReadWrite.All. All alone for creating and managing users. - After adding, click "Grant admin consent for " and confirm. The action requires a tenant administrator account (e.g.: Global Administrator Account). - Verify permissions and admin consent. - Ensure the Permission status shows "Granted for " ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + Important notes and troubleshooting : ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ - Ensure the app registration effectively has User.ReadWrite.All and Group.ReadWrite.All as application permissions and that an administrator granted consent. - Without these, user creation or group modification will return 403. - If you see 400/409 when adding to group, the user might already be a member or the request format could be wrong; the script logs the Graph response for troubleshooting. - The Graph endpoint to add a member requires sending the user reference to /groups/{id}/members/$ref with @odata.id set to the user URL; the script uses that API pattern. - Use the tenant's onmicrosoft.com domain if no custom domain is verified in the tenant. ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + Dependencies and install command ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ - Python 3.8 or later - Install required packages: python -m pip install msal requests ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Usage -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- python create_entra_id_users.py entra_id_users.csv --client-id --tenant-id --client-secret python create_entra_id_users.py entra_id_users.csv --client-id --tenant-id --client-secret [--dry-run] [--output-password-file out.csv] -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Sources and basis: - This script main logic was authored by COPILOT from scratch using general knowledge of Microsoft Graph and MSAL programming patterns. - It was not copied from any single external repository or individual author ( according to COPILOT on WIN 1.25093.144.0 ). + Primary references the code is based on: - Microsoft Graph REST API concepts for creating users and for adding group members (POST /users, POST /groups/{id}/members/$ref). - OAuth2 client credentials flow and MSAL for Python usage to obtain an application access token. - Typical password-composition best practices for generating complex passwords programmatically. + Common public examples and community guidance that informed the approach - Microsoft Learn and Microsoft Graph documentation examples showing JSON payloads and endpoints for user and group management. - MSAL (Microsoft Authentication Library) examples for the client credentials flow in Python. - Community snippets and Stack Overflow answers illustrating how to call Graph from Python and how to add members to a group. ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ """ import argparse import csv import json import sys import time import secrets import string import requests from msal import ConfidentialClientApplication GRAPH_SCOPE = ["https://graph.microsoft.com/.default"] GRAPH_ENDPOINT = "https://graph.microsoft.com/v1.0" GROUP_NAME = "internalprogrammatic" def generate_password(length=16, require_symbols=True): alphabet = string.ascii_letters + string.digits symbols = "!@#$%^&*()-_=+[]{}<>?/" while True: pool = alphabet + (symbols if require_symbols else "") pwd = ''.join(secrets.choice(pool) for _ in range(length)) if (any(c.islower() for c in pwd) and any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd) and (not require_symbols or any(c in symbols for c in pwd))): return pwd def get_access_token(client_id, tenant_id, client_secret): app = ConfidentialClientApplication( client_id=client_id, client_credential=client_secret, authority=f"https://login.microsoftonline.com/{tenant_id}" ) result = app.acquire_token_silent(GRAPH_SCOPE, account=None) if not result: result = app.acquire_token_for_client(scopes=GRAPH_SCOPE) if "access_token" not in result: raise RuntimeError(f"Failed to obtain access token: {result}") return result["access_token"] def find_group(session, display_name): url = f"{GRAPH_ENDPOINT}/groups?$filter=displayName eq '{display_name}'&$select=id,displayName" resp = session.get(url) resp.raise_for_status() data = resp.json() if data.get("value"): return data["value"][0]["id"] return None def create_group(session, display_name): url = f"{GRAPH_ENDPOINT}/groups" payload = { "displayName": display_name, "mailEnabled": False, "mailNickname": display_name.replace(" ", "").lower(), "securityEnabled": True } resp = session.post(url, json=payload) resp.raise_for_status() return resp.json()["id"] def add_member_to_group(session, group_id, user_id): # POST /groups/{id}/members/$ref with @odata.id to the user url = f"{GRAPH_ENDPOINT}/groups/{group_id}/members/$ref" body = {"@odata.id": f"https://graph.microsoft.com/v1.0/users/{user_id}"} resp = session.post(url, json=body) return resp def create_user(session, user_obj): url = f"{GRAPH_ENDPOINT}/users" resp = session.post(url, json=user_obj) return resp def csv_row_to_user(row, gen_password_if_missing=True): displayName = row.get("displayName", "").strip() userPrincipalName = row.get("userPrincipalName", "").strip() mailNickname = (row.get("mailNickname") or userPrincipalName.split("@")[0]).strip() password_raw = (row.get("password") or "").strip() force_change = row.get("forceChangePasswordNextSignIn", "true").strip().lower() in ("true", "1", "yes") if not password_raw and gen_password_if_missing: password_raw = generate_password() user_obj = { "accountEnabled": True, "displayName": displayName, "mailNickname": mailNickname, "userPrincipalName": userPrincipalName, "passwordProfile": { "forceChangePasswordNextSignIn": force_change, "password": password_raw } } return user_obj, password_raw def main(): parser = argparse.ArgumentParser(description="Create Azure Entra users from CSV and add them to a security group.") parser.add_argument("csvfile", help="CSV file path") parser.add_argument("--client-id", required=True, help="App (client) ID") parser.add_argument("--tenant-id", required=True, help="Tenant ID (directory ID)") parser.add_argument("--client-secret", required=True, help="Client secret") parser.add_argument("--dry-run", action="store_true", help="Print actions without calling Graph") parser.add_argument("--output-password-file", help="Optional CSV path to write generated passwords") args = parser.parse_args() try: token = get_access_token(args.client_id, args.tenant_id, args.client_secret) except Exception as e: print(f"ERROR acquiring token: {e}", file=sys.stderr) sys.exit(2) session = requests.Session() session.headers.update({"Authorization": f"Bearer {token}", "Content-Type": "application/json"}) # Ensure group exists (or create it) group_id = None if args.dry_run: print(f"[DRY-RUN] Would look for or create group '{GROUP_NAME}'") else: group_id = find_group(session, GROUP_NAME) if group_id: print(f"Found existing group '{GROUP_NAME}' id={group_id}") else: group_id = create_group(session, GROUP_NAME) print(f"Created group '{GROUP_NAME}' id={group_id}") success = 0 failures = 0 generated_passwords = [] with open(args.csvfile, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for line_num, row in enumerate(reader, start=2): user_obj, used_password = csv_row_to_user(row, gen_password_if_missing=True) generated_passwords.append({ "displayName": user_obj.get("displayName"), "userPrincipalName": user_obj.get("userPrincipalName"), "password": used_password }) if args.dry_run: print(json.dumps({"createUser": user_obj, "addToGroup": GROUP_NAME}, ensure_ascii=False)) continue # Create the user resp = create_user(session, user_obj) if resp.status_code in (200, 201): created = resp.json() user_id = created["id"] upn = created.get("userPrincipalName") print(f"[OK] Created {upn} id={user_id}") success += 1 # Add to group add_resp = add_member_to_group(session, group_id, user_id) if add_resp.status_code in (204, 201): print(f"[OK] Added {upn} to group {GROUP_NAME}") else: # 400 if already member or bad payload; log and continue print(f"[ERR] Failed adding {upn} to group {GROUP_NAME} -> {add_resp.status_code} {add_resp.text}", file=sys.stderr) failures += 1 else: print(f"[ERR] Line {line_num} {user_obj.get('userPrincipalName')} -> {resp.status_code} {resp.text}", file=sys.stderr) failures += 1 time.sleep(0.1) if args.output_password_file: fieldnames = ["displayName", "userPrincipalName", "password"] with open(args.output_password_file, "w", newline="", encoding="utf-8") as out: writer = csv.DictWriter(out, fieldnames=fieldnames) writer.writeheader() for entry in generated_passwords: writer.writerow(entry) print(f"Wrote passwords to {args.output_password_file}") print(f"Summary: success={success} failures={failures}") if __name__ == "__main__": main()