#!/usr/bin/env python3 """Collect M365 user detail for batch 7 (combined 7+8) of Cascades Tucson. Covers the remaining 8 users: veronica.feller, fax (shared), five Exchange Online Essentials (suspended SKU) accounts, and sysadmin@ (MSP admin). """ import csv import json import sys import time import urllib.parse import urllib.request import urllib.error TOKEN = open(r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token").read().strip() OUT = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-7.csv" # (UPN, MailboxType) — MailboxType is Shared for fax@, User for everyone else. USERS = [ ("veronica.feller@cascadestucson.com", "User"), ("fax@cascadestucson.com", "Shared"), ("medtech@cascadestucson.com", "User"), ("nurse@cascadestucson.com", "User"), ("transportation@cascadestucson.com", "User"), ("Britney.Thompson@cascadestucson.com", "User"), ("Shelby.Trozzi@cascadestucson.com", "User"), ("sysadmin@cascadestucson.com", "User"), ] # Users whose primary license is Exchange Online Essentials (the suspended SKU). ESSENTIALS_USERS = { "medtech@cascadestucson.com", "nurse@cascadestucson.com", "transportation@cascadestucson.com", "britney.thompson@cascadestucson.com", "shelby.trozzi@cascadestucson.com", } HEADERS = {"Authorization": f"Bearer {TOKEN}"} MFA_TYPE_MAP = { "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod": "Authenticator", "#microsoft.graph.phoneAuthenticationMethod": "SMS", "#microsoft.graph.fido2AuthenticationMethod": "FIDO2", "#microsoft.graph.temporaryAccessPassAuthenticationMethod": "TAP", "#microsoft.graph.softwareOathAuthenticationMethod": "OATH", "#microsoft.graph.passwordAuthenticationMethod": "Password", "#microsoft.graph.emailAuthenticationMethod": "Email", "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod": "WindowsHello", "#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod": "PasswordlessAuthenticator", } def _do(url, extra_headers=None): hdrs = dict(HEADERS) if extra_headers: hdrs.update(extra_headers) backoff = 2 for attempt in range(6): req = urllib.request.Request(url, headers=hdrs) try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: if e.code == 429 or e.code >= 500: retry_after = e.headers.get("Retry-After") wait = int(retry_after) if retry_after and retry_after.isdigit() else backoff print(f" [retry {attempt+1}] HTTP {e.code}, waiting {wait}s...", file=sys.stderr) time.sleep(wait) backoff = min(backoff * 2, 60) continue try: body = json.loads(e.read().decode("utf-8")) except Exception: body = {"error": str(e)} return e.code, body except Exception as e: print(f" [retry {attempt+1}] transport err: {e}", file=sys.stderr) time.sleep(backoff) backoff = min(backoff * 2, 60) continue return 0, {"error": "max_retries_exceeded"} def gget(url): return _do(url) def gget_consistency(url): return _do(url, {"ConsistencyLevel": "eventual"}) def _enc(upn): # Percent-encode just in case (most UPNs are safe, but be defensive). return urllib.parse.quote(upn, safe="@._-") def collect_user(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 = _enc(upn) # 1a. Basic profile by UPN (no signInActivity here — it requires GUID addressing). status, body = gget( f"https://graph.microsoft.com/v1.0/users/{upn_enc}" "?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department" ) if status == 404: row["Notes"] = "not_found" return row if status != 200: row["Notes"] = f"profile_error:{status}" return row user_id = body.get("id", "") row["DisplayName"] = body.get("displayName", "") or "" row["AccountEnabled"] = str(body.get("accountEnabled", "")).lower() row["JobTitle"] = body.get("jobTitle", "") or "" row["Department"] = body.get("department", "") or "" # 1b. signInActivity via GUID. if user_id: status, body = gget( f"https://graph.microsoft.com/v1.0/users/{user_id}?$select=signInActivity" ) if status == 200: sia = body.get("signInActivity") or {} row["LastInteractiveSignIn"] = sia.get("lastSignInDateTime", "") or "" li = sia.get("lastSignInDateTime", "") or "" lni = sia.get("lastNonInteractiveSignInDateTime", "") or "" row["LastSignIn"] = max(li, lni) if (li or lni) else "" elif status == 403: row["LastSignIn"] = "scope_unavailable" row["LastInteractiveSignIn"] = "scope_unavailable" else: notes.append(f"signInActivity:{status}") # 2. Licenses status, body = gget(f"https://graph.microsoft.com/v1.0/users/{upn_enc}/licenseDetails") if status == 200: skus = [x.get("skuPartNumber", "") for x in body.get("value", []) if x.get("skuPartNumber")] row["Licenses"] = ";".join(skus) elif status == 403: row["Licenses"] = "scope_unavailable" else: notes.append(f"licenses:{status}") # 3. MFA methods status, body = gget(f"https://graph.microsoft.com/v1.0/users/{upn_enc}/authentication/methods") methods = [] if status == 200: for m in body.get("value", []): t = m.get("@odata.type", "") methods.append(MFA_TYPE_MAP.get(t, t.replace("#microsoft.graph.", "").replace("AuthenticationMethod", ""))) row["MFAMethods"] = ";".join(methods) non_password = [m for m in methods if m != "Password"] row["MFARegistered"] = "true" if non_password else "false" elif status == 403: row["MFAMethods"] = "scope_unavailable" row["MFARegistered"] = "scope_unavailable" else: notes.append(f"methods:{status}") # 4. Default MFA method (beta) status, body = gget(f"https://graph.microsoft.com/beta/users/{upn_enc}/authentication/signInPreferences") if status == 200: row["DefaultMFAMethod"] = body.get("userPreferredMethodForSecondaryAuthentication", "") or "" elif status == 403: row["DefaultMFAMethod"] = "scope_unavailable" else: notes.append(f"signInPrefs:{status}") # 5. Directory roles (admin membership) status, body = gget( f"https://graph.microsoft.com/v1.0/users/{upn_enc}/transitiveMemberOf/microsoft.graph.directoryRole" ) if status == 200: roles = [r.get("displayName", "") for r in body.get("value", []) if r.get("displayName")] row["AdminRoles"] = ";".join(roles) elif status == 403: row["AdminRoles"] = "scope_unavailable" else: notes.append(f"roles:{status}") # 6. Group count status, body = gget_consistency( f"https://graph.microsoft.com/v1.0/users/{upn_enc}/memberOf?$count=true&$top=1" ) if status == 200: row["GroupCount"] = str(body.get("@odata.count", "")) elif status == 403: row["GroupCount"] = "scope_unavailable" else: notes.append(f"groups:{status}") # Callout: Essentials SKU (suspended) — flag if present. if upn.lower() in ESSENTIALS_USERS: lic_upper = (row["Licenses"] or "").upper() has_essentials = "EXCHANGE" in lic_upper and ("ESSENTIALS" in lic_upper or "EXCHANGESTANDARD" in lic_upper or "S_ESSENTIALS" in lic_upper) # Be generous — any variant containing "ESSENTIALS" counts. has_essentials = "ESSENTIALS" in lic_upper notes.append( "essentials_sku_suspended:" + ("licensed" if has_essentials else "no_essentials_sku_visible") ) if upn.lower() == "fax@cascadestucson.com": notes.append("shared_mailbox:fax_to_email_routing") if upn.lower() == "sysadmin@cascadestucson.com": notes.append("msp_admin_account") if notes: row["Notes"] = ";".join(notes) return row def main(): rows = [] for upn, mbxtype in USERS: print(f"Processing {upn}...", file=sys.stderr) row = collect_user(upn, mbxtype) rows.append(row) time.sleep(0.5) cols = ["UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", "GroupCount", "Notes"] with open(OUT, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=cols, quoting=csv.QUOTE_MINIMAL) w.writeheader() for r in rows: w.writerow(r) print(f"Wrote {len(rows)} rows to {OUT}", file=sys.stderr) # Print each row as JSON for visibility. for r in rows: print(json.dumps(r)) if __name__ == "__main__": main()