#!/usr/bin/env python3 """Batch 1 user detail puller for Cascades Tucson M365 tenant.""" import csv import json import subprocess import sys import time import urllib.parse import urllib.request TENANT_ID = "207fa277-e9d8-4eb7-ada1-1064d2221498" USERS = [ ("Allison.Reibschied@cascadestucson.com", "User"), ("Training@cascadestucson.com", "User"), ("accounting@cascadestucson.com", "Shared"), # flagged shared but still licensed ("accountingassistant@cascadestucson.com", "User"), ("alyssa.brooks@cascadestucson.com", "User"), ] OUTPUT_CSV = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-1.csv" def get_token(): # Token is cached by get-token.sh at this path (TTL 55 min) cache_path = f"/tmp/remediation-tool/{TENANT_ID}/graph.jwt" # Try Windows-style path first since Python may not resolve /tmp the same way import os candidates = [ cache_path, f"C:/Users/howar/AppData/Local/Temp/remediation-tool/{TENANT_ID}/graph.jwt", os.path.expandvars(rf"%TEMP%\remediation-tool\{TENANT_ID}\graph.jwt"), ] for p in candidates: try: with open(p, "r") as f: tok = f.read().strip() if tok.startswith("eyJ"): return tok except FileNotFoundError: continue raise RuntimeError(f"No token found in any of {candidates}") def graph_get(token, path, extra_headers=None, max_retries=5): if path.startswith("http"): url = path else: url = f"https://graph.microsoft.com{path}" for attempt in range(max_retries): req = urllib.request.Request(url) req.add_header("Authorization", f"Bearer {token}") if extra_headers: for k, v in extra_headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: try: body = json.loads(e.read().decode("utf-8")) except Exception: body = {"error_raw": str(e)} # Retry on 429 and 5xx if e.code == 429 or (500 <= e.code < 600): # Honor Retry-After if present retry_after = 0 try: retry_after = int(e.headers.get("Retry-After", "0")) except Exception: retry_after = 0 delay = max(retry_after, 2 ** attempt * 5) # 5,10,20,40,80 print(f" [retry {attempt+1}/{max_retries}] HTTP {e.code}, sleeping {delay}s", file=sys.stderr) time.sleep(delay) continue return e.code, body except Exception as e: if attempt < max_retries - 1: time.sleep(5) continue return 0, {"error_exc": str(e)} return 429, {"error": "max_retries_exceeded"} # Map OData auth method types to short names METHOD_SHORT = { "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod": "Authenticator", "#microsoft.graph.phoneAuthenticationMethod": "Phone", "#microsoft.graph.passwordAuthenticationMethod": "Password", "#microsoft.graph.fido2AuthenticationMethod": "FIDO2", "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod": "WindowsHello", "#microsoft.graph.temporaryAccessPassAuthenticationMethod": "TAP", "#microsoft.graph.softwareOathAuthenticationMethod": "OATH", "#microsoft.graph.emailAuthenticationMethod": "Email", "#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod": "PasswordlessAuthenticator", "#microsoft.graph.platformCredentialAuthenticationMethod": "PlatformCredential", "#microsoft.graph.hardwareOathAuthenticationMethod": "HardwareOATH", } def short_method(odata_type): if odata_type in METHOD_SHORT: return METHOD_SHORT[odata_type] # Strip the prefix and AuthenticationMethod suffix s = odata_type.replace("#microsoft.graph.", "").replace("AuthenticationMethod", "") return s[:1].upper() + s[1:] if s else odata_type def process_user(token, upn, mailbox_type): row = { "UPN": upn, "DisplayName": "", "AccountEnabled": "", "MailboxType": mailbox_type, "Licenses": "", "MFARegistered": "", "MFAMethods": "", "DefaultMFAMethod": "", "LastSignIn": "", "LastInteractiveSignIn": "", "AdminRoles": "", "JobTitle": "", "Department": "", "GroupCount": "", "Notes": "", } notes = [] upn_enc = urllib.parse.quote(upn) # 1a. Core profile (without signInActivity — that query is heavily throttled separately) status, data = graph_get( token, f"/v1.0/users/{upn_enc}?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department", ) if status == 404: row["Notes"] = "not_found" return row if status != 200: notes.append(f"profile_err_{status}") else: row["DisplayName"] = data.get("displayName") or "" row["AccountEnabled"] = str(data.get("accountEnabled", "")).lower() row["JobTitle"] = data.get("jobTitle") or "" row["Department"] = data.get("department") or "" # 1b. signInActivity (separate call — subject to distinct throttling) status, data = graph_get( token, f"/v1.0/users/{upn_enc}?$select=signInActivity", ) if status == 200: sia = data.get("signInActivity") or {} row["LastSignIn"] = sia.get("lastSignInDateTime") or "" row["LastInteractiveSignIn"] = ( sia.get("lastSignInDateTime") or sia.get("lastNonInteractiveSignInDateTime") or "" ) elif status == 403: row["LastSignIn"] = "scope_unavailable" row["LastInteractiveSignIn"] = "scope_unavailable" else: notes.append(f"signin_err_{status}") # 2. Licenses status, data = graph_get(token, f"/v1.0/users/{upn_enc}/licenseDetails") if status == 200: skus = [v.get("skuPartNumber", "") for v in data.get("value", [])] row["Licenses"] = ";".join([s for s in skus if s]) else: notes.append(f"lic_err_{status}") # 3. MFA methods status, data = graph_get(token, f"/v1.0/users/{upn_enc}/authentication/methods") if status == 200: methods = [] non_pw = False for m in data.get("value", []): t = m.get("@odata.type", "") short = short_method(t) methods.append(short) if t != "#microsoft.graph.passwordAuthenticationMethod": non_pw = True row["MFAMethods"] = ";".join(methods) row["MFARegistered"] = "true" if non_pw else "false" elif status == 403: row["MFAMethods"] = "scope_unavailable" row["MFARegistered"] = "scope_unavailable" else: notes.append(f"mfa_err_{status}") # 4. Default MFA method (beta) status, data = graph_get(token, f"/beta/users/{upn_enc}/authentication/signInPreferences") if status == 200: row["DefaultMFAMethod"] = data.get("userPreferredMethodForSecondaryAuthentication") or "" elif status == 403: row["DefaultMFAMethod"] = "scope_unavailable" else: notes.append(f"defmfa_err_{status}") # 5. Directory roles status, data = graph_get( token, f"/v1.0/users/{upn_enc}/transitiveMemberOf/microsoft.graph.directoryRole" ) if status == 200: roles = [r.get("displayName", "") for r in data.get("value", [])] row["AdminRoles"] = ";".join([r for r in roles if r]) elif status == 403: row["AdminRoles"] = "scope_unavailable" else: notes.append(f"roles_err_{status}") # 6. Group count status, data = graph_get( token, f"/v1.0/users/{upn_enc}/memberOf?$count=true&$top=1", extra_headers={"ConsistencyLevel": "eventual"}, ) if status == 200: count = data.get("@odata.count") row["GroupCount"] = str(count) if count is not None else "" else: notes.append(f"groups_err_{status}") # Notes auto_notes = [] if mailbox_type == "Shared" and row["Licenses"]: auto_notes.append("shared_mailbox_still_licensed") if row["AccountEnabled"] == "false" and row["Licenses"]: auto_notes.append("disabled_account_still_licensed") if row["AccountEnabled"] == "false": auto_notes.append("account_disabled") if row["MFARegistered"] == "false": auto_notes.append("no_mfa_registered") if row["MFAMethods"] and "Authenticator" not in row["MFAMethods"] and "FIDO2" not in row["MFAMethods"]: # Flag weak/SMS-only setups methods_set = set(row["MFAMethods"].split(";")) non_pw_methods = methods_set - {"Password"} if non_pw_methods and non_pw_methods.issubset({"Phone", "Email", "OATH"}): if "Phone" in non_pw_methods and len(non_pw_methods) == 1: auto_notes.append("sms_only_mfa") if row["AdminRoles"] and row["AdminRoles"] != "scope_unavailable": auto_notes.append("has_admin_role") if auto_notes: notes.extend(auto_notes) row["Notes"] = ";".join(notes) return row def main(): token = get_token() print(f"Token length: {len(token)}", file=sys.stderr) fieldnames = [ "UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", "GroupCount", "Notes", ] rows = [] for upn, mbox in USERS: print(f"Processing {upn}...", file=sys.stderr) row = process_user(token, upn, mbox) rows.append(row) print(f" -> {row}", file=sys.stderr) time.sleep(1.0) # slightly more polite; 4 batches running in parallel with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL) writer.writeheader() for r in rows: writer.writerow(r) print(f"\nWrote {len(rows)} rows to {OUTPUT_CSV}") if __name__ == "__main__": main()