#!/usr/bin/env python3 """Collect per-user M365 detail for Cascades Tucson batch 3.""" import csv import json import sys import time import urllib.request import urllib.error import os _token_paths = [r"C:\Users\howar\AppData\Local\Temp\cascades_token.txt", "/tmp/cascades_token.txt"] TOKEN = "" for _p in _token_paths: if os.path.exists(_p): TOKEN = open(_p).read().strip() break if not TOKEN: raise SystemExit("token file not found") GRAPH_V1 = "https://graph.microsoft.com/v1.0" GRAPH_BETA = "https://graph.microsoft.com/beta" USERS = [ "crystal.rodriguez@cascadestucson.com", "dax.howard@cascadestucson.com", "frontdesk@cascadestucson.com", "hr@cascadestucson.com", "jd.martin@cascadestucson.com", ] OUT = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-3.csv" METHOD_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": "AuthenticatorPasswordless", } def req(url, extra_headers=None, max_retries=4): headers = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"} if extra_headers: headers.update(extra_headers) delay = 5.0 for attempt in range(max_retries): r = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") try: j = json.loads(body) except Exception: j = {"raw": body} if e.code == 429 or (e.code == 400 and "Too Many Requests" in body): retry_after = e.headers.get("Retry-After") if hasattr(e, "headers") else None wait = float(retry_after) if retry_after and retry_after.isdigit() else delay print(f"[RATE] {url[-80:]} -> {e.code}, sleeping {wait}s", file=sys.stderr) time.sleep(wait) delay = min(delay * 2, 30.0) continue if e.code in (500, 502, 503, 504): time.sleep(delay) delay = min(delay * 2, 30.0) continue return e.code, j except Exception as e: return 0, {"exception": str(e)} return 429, {"error": "max_retries"} def collect_user(upn): notes = [] row = { "UPN": upn, "DisplayName": "", "AccountEnabled": "", "MailboxType": "User", "Licenses": "", "MFARegistered": "false", "MFAMethods": "", "DefaultMFAMethod": "", "LastSignIn": "", "LastInteractiveSignIn": "", "AdminRoles": "", "JobTitle": "", "Department": "", "GroupCount": "", "Notes": "", } # 1a. Core profile (cheap) code, j = req(f"{GRAPH_V1}/users/{upn}?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department") if code == 404: row["Notes"] = "not_found" return row if code == 200: row["DisplayName"] = j.get("displayName") or "" row["AccountEnabled"] = str(j.get("accountEnabled", "")).lower() row["JobTitle"] = j.get("jobTitle") or "" row["Department"] = j.get("department") or "" elif code == 403: notes.append("profile:scope_unavailable") else: notes.append(f"profile:err{code}") time.sleep(0.15) # 1b. signInActivity (expensive, rate-limited) code, j = req(f"{GRAPH_V1}/users/{upn}?$select=signInActivity") if code == 200: sia = j.get("signInActivity") or {} # Per docs: lastSignInDateTime = interactive, lastNonInteractiveSignInDateTime = non-interactive row["LastInteractiveSignIn"] = sia.get("lastSignInDateTime") or "" last_non = sia.get("lastNonInteractiveSignInDateTime") or "" last_int = sia.get("lastSignInDateTime") or "" # LastSignIn = most recent of either candidates = [x for x in (last_int, last_non) if x] row["LastSignIn"] = max(candidates) if candidates else "" elif code == 403: notes.append("signin:scope_unavailable") elif code != 404: notes.append(f"signin:err{code}") time.sleep(0.15) # 2. Licenses code, j = req(f"{GRAPH_V1}/users/{upn}/licenseDetails") if code == 200: skus = [x.get("skuPartNumber", "") for x in j.get("value", [])] row["Licenses"] = ";".join([s for s in skus if s]) elif code == 403: notes.append("licenses:scope_unavailable") elif code != 404: notes.append(f"licenses:err{code}") time.sleep(0.15) # 3. MFA methods code, j = req(f"{GRAPH_V1}/users/{upn}/authentication/methods") if code == 200: types = [x.get("@odata.type", "") for x in j.get("value", [])] mapped = [] for t in types: mapped.append(METHOD_TYPE_MAP.get(t, t.replace("#microsoft.graph.", "").replace("AuthenticationMethod", ""))) # De-dup preserving order seen = set() uniq = [] for m in mapped: if m not in seen: seen.add(m) uniq.append(m) row["MFAMethods"] = ";".join(uniq) non_password = [m for m in uniq if m not in ("Password",)] row["MFARegistered"] = "true" if non_password else "false" elif code == 403: notes.append("mfa:scope_unavailable") elif code != 404: notes.append(f"mfa:err{code}") time.sleep(0.15) # 4. Default MFA method code, j = req(f"{GRAPH_BETA}/users/{upn}/authentication/signInPreferences") if code == 200: row["DefaultMFAMethod"] = j.get("userPreferredMethodForSecondaryAuthentication") or "" elif code == 403: notes.append("defaultmfa:scope_unavailable") elif code != 404: notes.append(f"defaultmfa:err{code}") time.sleep(0.15) # 5. Directory roles code, j = req(f"{GRAPH_V1}/users/{upn}/transitiveMemberOf/microsoft.graph.directoryRole") if code == 200: roles = [x.get("displayName", "") for x in j.get("value", [])] row["AdminRoles"] = ";".join([r for r in roles if r]) elif code == 403: notes.append("roles:scope_unavailable") elif code != 404: notes.append(f"roles:err{code}") time.sleep(0.15) # 6. Group count code, j = req(f"{GRAPH_V1}/users/{upn}/memberOf?$count=true&$top=1", extra_headers={"ConsistencyLevel": "eventual"}) if code == 200: row["GroupCount"] = str(j.get("@odata.count", "")) elif code == 403: notes.append("groups:scope_unavailable") elif code != 404: notes.append(f"groups:err{code}") if notes: row["Notes"] = ";".join(notes) return row def main(): rows = [] for upn in USERS: print(f"[INFO] {upn}", file=sys.stderr) rows.append(collect_user(upn)) time.sleep(0.25) header = ["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=header, quoting=csv.QUOTE_MINIMAL) w.writeheader() for r in rows: w.writerow(r) print(f"[OK] wrote {len(rows)} rows -> {OUT}") if __name__ == "__main__": main()