From 74890d51ec850ef5f0d020d652044f9ae108a4a6 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Sat, 18 Apr 2026 14:28:22 -0700 Subject: [PATCH] sync: auto-sync from ACG-TECH03L at 2026-04-18 14:28:21 Author: Howard Enos Machine: ACG-TECH03L Timestamp: 2026-04-18 14:28:21 --- .gitignore | 7 + .../user-detail-batches/_batch2_collect.py | 247 ++++++++++++++++ .../user-detail-batches/_batch3_collect.py | 218 ++++++++++++++ .../user-detail-batches/_batch7_collect.py | 247 ++++++++++++++++ .../reports/user-detail-batches/_merge-all.py | 38 +++ .../user-detail-batches/_refresh_b1_b3.py | 248 ++++++++++++++++ .../reports/user-detail-batches/all-users.csv | 54 ++++ .../user-detail-batches/batch-1-pull.py | 279 ++++++++++++++++++ .../reports/user-detail-batches/batch-1.csv | 6 + .../reports/user-detail-batches/batch-2.csv | 6 + .../reports/user-detail-batches/batch-3.csv | 6 + .../user-detail-batches/batch-4-collect.py | 216 ++++++++++++++ .../reports/user-detail-batches/batch-4.csv | 6 + .../user-detail-batches/batch-5-collect.py | 235 +++++++++++++++ .../reports/user-detail-batches/batch-5.csv | 6 + .../user-detail-batches/batch-6-collect.py | 259 ++++++++++++++++ .../reports/user-detail-batches/batch-6.csv | 6 + .../reports/user-detail-batches/batch-7.csv | 9 + .../user-detail-batches/batch-8/user-01.csv | 2 + .../user-detail-batches/batch-8/user-02.csv | 2 + .../user-detail-batches/batch-8/user-03.csv | 2 + .../user-detail-batches/batch-8/user-04.csv | 2 + .../user-detail-batches/batch-8/user-05.csv | 2 + .../user-detail-batches/batch-8/user-06.csv | 2 + .../user-detail-batches/batch-8/user-07.csv | 2 + .../user-detail-batches/batch-8/user-08.csv | 2 + .../user-detail-batches/batch-8/user-09.csv | 2 + .../user-detail-batches/batch-8/user-10.csv | 2 + .../user-detail-batches/batch-8/user-11.csv | 2 + .../user-detail-batches/batch-8/user-12.csv | 2 + .../user-detail-batches/batch-8/user-13.csv | 2 + .../user-detail-batches/batch-8/user-14.csv | 2 + .../user-detail-batches/batch-8/user-15.csv | 2 + .../user-detail-batches/pull-single-user.py | 241 +++++++++++++++ 34 files changed, 2364 insertions(+) create mode 100644 clients/cascades-tucson/reports/user-detail-batches/_batch2_collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/_batch3_collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/_batch7_collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/_merge-all.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/_refresh_b1_b3.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/all-users.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-1-pull.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-1.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-2.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-3.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-4-collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-4.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-5-collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-5.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-6-collect.py create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-6.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-7.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-01.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-02.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-03.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-04.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-05.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-06.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-07.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-08.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-09.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-10.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-11.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-12.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-13.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-14.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/batch-8/user-15.csv create mode 100644 clients/cascades-tucson/reports/user-detail-batches/pull-single-user.py diff --git a/.gitignore b/.gitignore index f537e98..b5bc6c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ backups/ *.log *.bak +# Live secrets / tokens — never commit +.token +.token_* +*.jwt +token.txt +.token.txt + # OS files .DS_Store Thumbs.db diff --git a/clients/cascades-tucson/reports/user-detail-batches/_batch2_collect.py b/clients/cascades-tucson/reports/user-detail-batches/_batch2_collect.py new file mode 100644 index 0000000..2e88bdb --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/_batch2_collect.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Collect per-user M365 detail for Cascades Tucson batch 2.""" +import csv +import json +import sys +import time +import urllib.parse +import urllib.request + +TOKEN_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token_batch2" +OUT_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-2.csv" + +USERS = [ + "ann.dery@cascadestucson.com", + "ashley.jensen@cascadestucson.com", + "boadmin@cascadestucson.com", + "christina.dupras@cascadestucson.com", + "christine.nyanzunda@cascadestucson.com", +] + +with open(TOKEN_FILE, "r") as f: + TOKEN = f.read().strip() + +HEADERS_BASE = { + "Authorization": f"Bearer {TOKEN}", + "Accept": "application/json", +} + +# Map Graph method @odata.type -> short name +METHOD_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 graph_get(url, extra_headers=None, max_retries=4): + headers = dict(HEADERS_BASE) + if extra_headers: + headers.update(extra_headers) + attempt = 0 + while True: + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + status = resp.status + data = resp.read().decode("utf-8") + return status, json.loads(data) if data else {} + 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 and attempt < max_retries: + retry_after = int(e.headers.get("Retry-After", "2")) + time.sleep(max(retry_after, 2)) + attempt += 1 + continue + if e.code >= 500 and attempt < max_retries: + time.sleep(2 ** attempt) + attempt += 1 + continue + return e.code, j + except Exception as e: + if attempt < max_retries: + time.sleep(2 ** attempt) + attempt += 1 + continue + return 0, {"error": str(e)} + + +def collect_user(upn): + row = { + "UPN": upn, + "DisplayName": "", + "AccountEnabled": "", + "MailboxType": "User", + "Licenses": "", + "MFARegistered": "false", + "MFAMethods": "", + "DefaultMFAMethod": "", + "LastSignIn": "", + "LastInteractiveSignIn": "", + "AdminRoles": "", + "JobTitle": "", + "Department": "", + "GroupCount": "", + "Notes": "", + } + notes = [] + + enc_upn = urllib.parse.quote(upn) + + # 1a. Core profile (without signInActivity - Graph requires GUID key for that) + url = (f"https://graph.microsoft.com/v1.0/users/{enc_upn}" + "?$select=id,userPrincipalName,displayName,accountEnabled," + "jobTitle,department") + status, data = graph_get(url) + if status == 404: + row["Notes"] = "not_found" + return row + user_id = "" + if status == 200: + user_id = data.get("id", "") + 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 "" + elif status in (401, 403): + notes.append("profile:scope_unavailable") + else: + notes.append(f"profile:http_{status}") + + # 1b. signInActivity (requires GUID) + if user_id: + url = (f"https://graph.microsoft.com/v1.0/users/{user_id}" + "?$select=signInActivity") + status, data = graph_get(url) + 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 in (401, 403): + row["LastSignIn"] = "scope_unavailable" + row["LastInteractiveSignIn"] = "scope_unavailable" + else: + notes.append(f"signin:http_{status}") + + # 2. Licenses + url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/licenseDetails" + status, data = graph_get(url) + if status == 200: + skus = [x.get("skuPartNumber", "") for x in data.get("value", [])] + row["Licenses"] = ";".join(s for s in skus if s) + elif status == 404: + notes.append("licenses:not_found") + elif status in (401, 403): + row["Licenses"] = "scope_unavailable" + else: + notes.append(f"licenses:http_{status}") + + # 3. MFA methods + url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/authentication/methods" + status, data = graph_get(url) + if status == 200: + methods_raw = data.get("value", []) + short_names = [] + non_password_count = 0 + for m in methods_raw: + t = m.get("@odata.type", "") + short = METHOD_MAP.get(t, t.replace("#microsoft.graph.", "").replace("AuthenticationMethod", "")) + short_names.append(short) + if t != "#microsoft.graph.passwordAuthenticationMethod": + non_password_count += 1 + row["MFAMethods"] = ";".join(short_names) + row["MFARegistered"] = "true" if non_password_count > 0 else "false" + elif status in (401, 403): + row["MFAMethods"] = "scope_unavailable" + row["MFARegistered"] = "scope_unavailable" + elif status == 404: + notes.append("mfa:not_found") + else: + notes.append(f"mfa:http_{status}") + + # 4. Default MFA method + url = f"https://graph.microsoft.com/beta/users/{enc_upn}/authentication/signInPreferences" + status, data = graph_get(url) + if status == 200: + row["DefaultMFAMethod"] = data.get("userPreferredMethodForSecondaryAuthentication") or "" + elif status in (401, 403): + row["DefaultMFAMethod"] = "scope_unavailable" + elif status == 404: + notes.append("default_mfa:not_found") + else: + notes.append(f"default_mfa:http_{status}") + + # 5. Directory roles (transitive) + url = (f"https://graph.microsoft.com/v1.0/users/{enc_upn}/transitiveMemberOf/" + "microsoft.graph.directoryRole") + status, data = graph_get(url) + 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 in (401, 403): + row["AdminRoles"] = "scope_unavailable" + elif status == 404: + pass + else: + notes.append(f"roles:http_{status}") + + # 6. Group count + url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/memberOf?$count=true&$top=1" + status, data = graph_get(url, extra_headers={"ConsistencyLevel": "eventual"}) + if status == 200: + cnt = data.get("@odata.count") + row["GroupCount"] = str(cnt) if cnt is not None else "" + elif status in (401, 403): + row["GroupCount"] = "scope_unavailable" + elif status == 404: + pass + else: + notes.append(f"groups:http_{status}") + + if notes and not row["Notes"]: + row["Notes"] = ";".join(notes) + return row + + +def main(): + rows = [] + for i, upn in enumerate(USERS): + print(f"[{i+1}/{len(USERS)}] {upn}", file=sys.stderr) + row = collect_user(upn) + rows.append(row) + if i < len(USERS) - 1: + time.sleep(0.25) + + fieldnames = [ + "UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", + "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", + "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", + "GroupCount", "Notes", + ] + with open(OUT_FILE, "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"Wrote {len(rows)} rows to {OUT_FILE}", file=sys.stderr) + # Also print summary to stdout + for r in rows: + print(json.dumps(r)) + + +if __name__ == "__main__": + main() diff --git a/clients/cascades-tucson/reports/user-detail-batches/_batch3_collect.py b/clients/cascades-tucson/reports/user-detail-batches/_batch3_collect.py new file mode 100644 index 0000000..78aa41f --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/_batch3_collect.py @@ -0,0 +1,218 @@ +#!/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() diff --git a/clients/cascades-tucson/reports/user-detail-batches/_batch7_collect.py b/clients/cascades-tucson/reports/user-detail-batches/_batch7_collect.py new file mode 100644 index 0000000..1ba697c --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/_batch7_collect.py @@ -0,0 +1,247 @@ +#!/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() diff --git a/clients/cascades-tucson/reports/user-detail-batches/_merge-all.py b/clients/cascades-tucson/reports/user-detail-batches/_merge-all.py new file mode 100644 index 0000000..9024acb --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/_merge-all.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Merge batches 1-7 + batch-8/user-*.csv into all-users.csv.""" +import csv +import glob +import os + +BASE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches" +OUT = os.path.join(BASE, "all-users.csv") + +sources = [] +for i in range(1, 8): + sources.append(os.path.join(BASE, f"batch-{i}.csv")) +sources.extend(sorted(glob.glob(os.path.join(BASE, "batch-8", "user-*.csv")))) + +fieldnames = None +rows = [] +seen_upns = set() + +for src in sources: + with open(src, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + if fieldnames is None: + fieldnames = reader.fieldnames + for row in reader: + upn_key = (row.get("UPN") or "").lower() + if upn_key in seen_upns: + print(f" SKIP duplicate: {upn_key} (from {os.path.basename(src)})") + continue + seen_upns.add(upn_key) + rows.append(row) + +with open(OUT, "w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL) + w.writeheader() + for r in rows: + w.writerow(r) + +print(f"\nWrote {len(rows)} rows from {len(sources)} source files -> {OUT}") diff --git a/clients/cascades-tucson/reports/user-detail-batches/_refresh_b1_b3.py b/clients/cascades-tucson/reports/user-detail-batches/_refresh_b1_b3.py new file mode 100644 index 0000000..f9c35e5 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/_refresh_b1_b3.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Refresh profile fields (DisplayName, AccountEnabled, LastSignIn, +LastInteractiveSignIn, JobTitle, Department) for batch-1 and batch-3 rows +that previously got profile_err_400. Uses the split-query pattern from batch-2: +query core profile fields separately from signInActivity. + +Preserves all other columns (License/MFA/AdminRoles/GroupCount) and row order. +Cleans profile_err_400 / profile:err400 from the Notes column when the refresh +succeeds. +""" +import csv +import json +import os +import sys +import time +import urllib.parse +import urllib.request + +TOKEN_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token_refresh" +BASE_DIR = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches" +BATCH_FILES = { + "batch-1": os.path.join(BASE_DIR, "batch-1.csv"), + "batch-3": os.path.join(BASE_DIR, "batch-3.csv"), +} + +with open(TOKEN_FILE, "r") as f: + TOKEN = f.read().strip() + +HEADERS_BASE = { + "Authorization": f"Bearer {TOKEN}", + "Accept": "application/json", +} + +FIELDNAMES = [ + "UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", + "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", + "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", + "GroupCount", "Notes", +] + +PROFILE_ERR_TOKENS = {"profile_err_400", "profile:err400"} + + +def graph_get(url, extra_headers=None, max_retries=4): + headers = dict(HEADERS_BASE) + if extra_headers: + headers.update(extra_headers) + attempt = 0 + while True: + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read().decode("utf-8") + return resp.status, json.loads(data) if data else {} + 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 and attempt < max_retries: + retry_after = int(e.headers.get("Retry-After", "2")) + time.sleep(max(retry_after, 2)) + attempt += 1 + continue + if e.code >= 500 and attempt < max_retries: + time.sleep(2 ** attempt) + attempt += 1 + continue + return e.code, j + except Exception as e: + if attempt < max_retries: + time.sleep(2 ** attempt) + attempt += 1 + continue + return 0, {"error": str(e)} + + +def refresh_profile(upn): + """Fetch profile + signInActivity using split-query pattern. + Returns (fields_dict, extra_notes_list, fail_reason_or_None).""" + out = { + "DisplayName": "", + "AccountEnabled": "", + "LastSignIn": "", + "LastInteractiveSignIn": "", + "JobTitle": "", + "Department": "", + } + notes = [] + enc = urllib.parse.quote(upn) + + # Step 1: core profile + url = ( + f"https://graph.microsoft.com/v1.0/users/{enc}" + "?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department" + ) + status, data = graph_get(url) + if status == 404: + return out, ["not_found"], "not_found" + if status != 200: + reason = f"profile_http_{status}" + notes.append(reason) + return out, notes, reason + + user_id = data.get("id", "") + out["DisplayName"] = data.get("displayName") or "" + out["AccountEnabled"] = str(data.get("accountEnabled", "")).lower() + out["JobTitle"] = data.get("jobTitle") or "" + out["Department"] = data.get("department") or "" + + # Step 2: signInActivity via GUID + if user_id: + url2 = ( + f"https://graph.microsoft.com/v1.0/users/{user_id}" + "?$select=signInActivity" + ) + status2, data2 = graph_get(url2) + if status2 == 200: + sia = data2.get("signInActivity") or {} + out["LastSignIn"] = sia.get("lastSignInDateTime") or "" + out["LastInteractiveSignIn"] = ( + sia.get("lastSignInDateTime") + or sia.get("lastNonInteractiveSignInDateTime") + or "" + ) + elif status2 in (401, 403): + out["LastSignIn"] = "scope_unavailable" + out["LastInteractiveSignIn"] = "scope_unavailable" + notes.append("signin:scope_unavailable") + elif status2 == 400: + out["LastSignIn"] = "scope_unavailable" + out["LastInteractiveSignIn"] = "scope_unavailable" + notes.append("signin:err400") + else: + notes.append(f"signin:http_{status2}") + + return out, notes, None + + +def clean_notes(existing_notes, succeeded, extra_notes): + """Strip profile_err tokens if refresh succeeded; merge extra notes.""" + tokens = [t.strip() for t in (existing_notes or "").split(";") if t.strip()] + if succeeded: + tokens = [t for t in tokens if t not in PROFILE_ERR_TOKENS] + for e in extra_notes: + if e and e not in tokens: + tokens.append(e) + return ";".join(tokens) + + +def process_file(path): + with open(path, "r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + updated = 0 + failures = [] + + for i, row in enumerate(rows): + upn = row.get("UPN", "").strip() + if not upn: + continue + print(f"[{os.path.basename(path)}] {i+1}/{len(rows)} {upn}", file=sys.stderr) + fields, extra_notes, fail_reason = refresh_profile(upn) + + if fail_reason is None: + # Profile core succeeded + for k, v in fields.items(): + row[k] = v + row["Notes"] = clean_notes(row.get("Notes", ""), succeeded=True, + extra_notes=extra_notes) + updated += 1 + else: + # Profile still failed - leave blank fields and record + row["Notes"] = clean_notes(row.get("Notes", ""), succeeded=False, + extra_notes=extra_notes) + failures.append((upn, fail_reason)) + + time.sleep(0.5) + + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDNAMES, quoting=csv.QUOTE_MINIMAL) + writer.writeheader() + for r in rows: + # Ensure all fields are present in order + clean_row = {k: r.get(k, "") for k in FIELDNAMES} + writer.writerow(clean_row) + + return updated, len(rows), failures + + +def main(): + summary = {} + all_failures = [] + stale_findings = [] + + for label, path in BATCH_FILES.items(): + updated, total, failures = process_file(path) + summary[label] = (updated, total) + all_failures.extend([(label, u, r) for (u, r) in failures]) + + # Post-scan for stale findings + for label, path in BATCH_FILES.items(): + with open(path, "r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + upn = row.get("UPN", "") + if row.get("AccountEnabled", "").lower() == "false": + stale_findings.append(f"{label}: {upn} account DISABLED") + last = row.get("LastSignIn", "") + if last and last not in ("scope_unavailable", "") and "T" in last: + year_part = last.split("-")[0] + try: + y = int(year_part) + if y < 2025: + stale_findings.append( + f"{label}: {upn} last sign-in {last}" + ) + except ValueError: + pass + elif last == "" and row.get("AccountEnabled", "").lower() == "true": + stale_findings.append(f"{label}: {upn} never signed in (no LastSignIn)") + + print("\n=== SUMMARY ===", file=sys.stderr) + for label, (u, t) in summary.items(): + print(f" {label}: {u}/{t} rows updated", file=sys.stderr) + if all_failures: + print(" Failures:", file=sys.stderr) + for label, upn, reason in all_failures: + print(f" {label} {upn}: {reason}", file=sys.stderr) + else: + print(" No failures.", file=sys.stderr) + if stale_findings: + print(" Stale findings:", file=sys.stderr) + for s in stale_findings: + print(f" {s}", file=sys.stderr) + + # machine-readable output + print(json.dumps({ + "summary": {k: {"updated": v[0], "total": v[1]} for k, v in summary.items()}, + "failures": [{"batch": b, "upn": u, "reason": r} for (b, u, r) in all_failures], + "stale": stale_findings, + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/clients/cascades-tucson/reports/user-detail-batches/all-users.csv b/clients/cascades-tucson/reports/user-detail-batches/all-users.csv new file mode 100644 index 0000000..c2db52a --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/all-users.csv @@ -0,0 +1,54 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +Allison.Reibschied@cascadestucson.com,Allison Reibschied,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-13T19:59:52Z,2026-03-13T19:59:52Z,,,,0, +Training@cascadestucson.com,Training,true,User,O365_BUSINESS_PREMIUM,false,Password,,2026-04-17T15:05:37Z,2026-04-17T15:05:37Z,,,,0,no_mfa_registered +accounting@cascadestucson.com,Accounting Dept.,true,Shared,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-25T17:31:04Z,2026-03-25T17:31:04Z,,,,1,shared_mailbox_still_licensed +accountingassistant@cascadestucson.com,Accounting Assistant,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-03-04T16:11:11Z,2024-03-04T16:11:11Z,,,,0,no_mfa_registered +alyssa.brooks@cascadestucson.com,Alyssa Brooks,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-12T21:19:46Z,2026-04-12T21:19:46Z,,,,1, +ann.dery@cascadestucson.com,Ann Dery,true,User,O365_BUSINESS_PREMIUM,false,Password,,2025-05-13T20:56:44Z,2025-05-13T20:56:44Z,,,,0, +ashley.jensen@cascadestucson.com,Ashley Jensen,true,User,FLOW_FREE;O365_BUSINESS_PREMIUM,true,Password;SMS,sms,2026-04-17T16:19:15Z,2026-04-17T16:19:15Z,,,,1, +boadmin@cascadestucson.com,Bookkeeping Office,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-05-30T17:09:26Z,2024-05-30T17:09:26Z,,,,0, +christina.dupras@cascadestucson.com,Christina Dupras,true,User,O365_BUSINESS_PREMIUM,true,Password;SMS;Authenticator,sms,2026-04-17T19:39:28Z,2026-04-17T19:39:28Z,,,,1, +christine.nyanzunda@cascadestucson.com,Christine Nyanzuda,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-16T16:32:07Z,2026-02-16T16:32:07Z,,,,0, +crystal.rodriguez@cascadestucson.com,Crystal Rodriguez,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-17T20:31:30Z,2026-02-17T20:31:30Z,,,,2, +dax.howard@cascadestucson.com,Dax Howard,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-07T04:33:59Z,2026-03-07T04:33:59Z,,,,0, +frontdesk@cascadestucson.com,Front Desk,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-12T16:57:37Z,2026-02-12T16:57:37Z,,,,1, +hr@cascadestucson.com,Human Resources,true,User,O365_BUSINESS_PREMIUM,false,Password,,2026-03-16T02:57:20Z,2026-03-16T02:57:20Z,,,,0, +jd.martin@cascadestucson.com,JD Martin,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-16T14:20:46Z,2026-02-16T14:20:46Z,,,,0, +jodi.ramstack@cascadestucson.com,Jodi Ramstack,true,User,O365_BUSINESS_PREMIUM,false,Password,,2025-03-28T23:49:51Z,2025-03-20T20:44:25Z,,,,0,flagged_for_delete_2026-04-13:enabled=true;licensed=yes +john.trozzi@cascadestucson.com,John Trozzi,true,User,O365_BUSINESS_PREMIUM,true,Password;SMS;Authenticator;Authenticator;FIDO2,push,2026-04-18T08:40:02Z,2026-04-16T16:28:40Z,,,,1, +karen.rossini@cascadestucson.com,Karen Rossini,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T04:56:56Z,2026-03-22T02:30:14Z,,,,0, +lauren.hasselman@cascadestucson.com,Lauren Hasselman,true,User,FLOW_FREE;O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T08:44:39Z,2026-04-17T20:00:51Z,,,,0, +lois.lane@cascadestucson.com,Lois Lane,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T05:57:48Z,2026-04-11T04:37:10Z,,,,1, +lupe.sanchez@cascadestucson.com,Lupe Sanchez,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-17T21:55:22Z,2026-02-12T16:46:52Z,,,,1, +matthew.brooks@cascadestucson.com,Matthew Brooks,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-10-30T18:43:31Z,2024-10-30T18:43:17Z,,,,0, +megan.hiatt@cascadestucson.com,Megan Hiatt,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T09:02:26Z,2026-04-17T20:23:11Z,,,,3,CREDENTIAL_STUFFING_ACTIVE;MFA_POSTURE=AUTHENTICATOR_ONLY;DEFAULT=push +memcarereceptionist@cascadestucson.com,MemCare Receptionist,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T07:48:01Z,2026-04-13T05:03:44Z,,,,1, +meredith.kuhn@cascadestucson.com,Meredith Kuhn,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T09:00:07Z,2026-03-19T19:27:02Z,,,,2, +ramon.castaneda@cascadestucson.com,Ramon Castaneda,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-15T14:10:31Z,2026-04-01T21:48:00Z,,,,0, +security@cascadestucson.com,Security Cascades,True,User,M365 Business Standard,No,password,,2024-01-24T21:28:04Z,2024-01-24T21:28:04Z,,,,0, +sharon.edwards@cascadestucson.com,Sharon Edwards,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-18T08:29:20Z,2026-04-13T19:57:22Z,,,,0, +susan.hicks@cascadestucson.com,Susan Hicks,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-17T22:05:34Z,2026-04-13T21:34:53Z,,,,1, +tamra.matthews@cascadestucson.com,Tamra Matthews,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-18T04:34:13Z,2026-03-01T15:34:26Z,,,,3, +veronica.feller@cascadestucson.com,Veronica Feller,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-17T20:24:15Z,2026-04-15T02:52:59Z,,,,1, +fax@cascadestucson.com,Fax Cascades,true,Shared,EXCHANGE_S_ESSENTIALS,false,Password,,,,,,,0,signInActivity:0;shared_mailbox:fax_to_email_routing +medtech@cascadestucson.com,medtech,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2025-10-01T22:33:57Z,2025-10-01T22:33:57Z,,,,0,essentials_sku_suspended:licensed +nurse@cascadestucson.com,Nurse,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2026-04-11T05:57:04Z,2026-04-11T05:57:04Z,,,,0,essentials_sku_suspended:licensed +transportation@cascadestucson.com,transportation,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2026-04-17T18:58:52Z,2025-05-07T16:51:42Z,,transportation,transportation,0,essentials_sku_suspended:licensed +Britney.Thompson@cascadestucson.com,Britney Thompson,true,User,O365_BUSINESS_PREMIUM;EXCHANGE_S_ESSENTIALS,false,Password,,2026-02-12T01:33:24Z,2026-02-12T01:33:24Z,,,,0,essentials_sku_suspended:licensed +Shelby.Trozzi@cascadestucson.com,Shelby Trozzi,true,User,O365_BUSINESS_PREMIUM;EXCHANGE_S_ESSENTIALS,true,Password;Authenticator,push,2026-04-18T08:50:56Z,2026-04-01T16:35:55Z,,,,0,essentials_sku_suspended:licensed +sysadmin@cascadestucson.com,Computer Guru Support,true,User,FLOW_FREE,true,Password;SMS;Authenticator,sms,2026-04-17T23:06:58Z,2026-04-17T21:12:47Z,Global Administrator,,,1,msp_admin_account +admin@NETORGFT4257522.onmicrosoft.com,cascadestucson.com,false,User,,true,Password;Email;Phone;Authenticator,push,2026-03-31T16:53:03Z,2026-03-31T16:53:03Z,,,,0,account_disabled +nela.durut-azizi@cascadestucson.com,Nela Durut-Azizi,false,Shared,,false,Password,,2025-03-17T20:04:31Z,2025-03-17T20:04:31Z,,,,1,account_disabled;no_mfa_registered +anna.pitzlin@cascadestucson.com,Anna Pitzlin,false,Shared,,false,Password,,2024-11-14T02:50:13Z,2024-11-14T02:50:13Z,,,,1,account_disabled;no_mfa_registered +kristiana.dowse@cascadestucson.com,Kristiana Dowse (Shared),false,Shared,,false,Password,,2024-01-08T19:04:04Z,2024-01-08T19:04:04Z,,,,0,account_disabled;no_mfa_registered +jeff.bristol@cascadestucson.com,Jeff Bristol,false,Shared,,false,Password,,2026-03-08T02:31:50Z,2026-03-08T02:31:50Z,,,,0,account_disabled;no_mfa_registered +Stephanie.Devin@cascadestucson.com,Stephanie Devin,false,User,,true,Password;Phone,sms,2026-02-20T19:19:51Z,2026-02-20T19:19:51Z,,,,0,account_disabled +nick.pavloff@cascadestucson.com,nick pavloff,false,User,,true,Password;Authenticator,push,2026-03-07T18:23:41Z,2026-03-07T18:23:41Z,,,,0,account_disabled +karenrossini7@gmail.com,karenrossini7,true,Guest,,false,Password,,,,,,,0,guest_user:karenrossini7@gmail.com;signin_err_429;no_mfa_registered +a.r.jensen018@gmail.com,a.r.jensen018,true,Guest,,false,Password,,,,,,,0,guest_user:a.r.jensen018@gmail.com;signin_err_0;no_mfa_registered +howard@azcomputerguru.com,howard,true,Guest,,false,Password,,2025-12-30T19:18:38Z,2025-12-30T19:18:38Z,,,,0,guest_user:howard@azcomputerguru.com;no_mfa_registered +deboram@teepasnow.com,Debora Morris,true,Guest,,false,Password,,2026-01-07T17:20:11Z,2026-01-07T17:20:11Z,,,,0,guest_user:deboram@teepasnow.com;no_mfa_registered +duprasc2002@yahoo.com,duprasc2002,true,Guest,,false,Password,,,,,,,0,guest_user:duprasc2002@yahoo.com;signin_err_0;no_mfa_registered +eugenie.nicoud@helpany.com,eugenie.nicoud,true,Guest,,false,Password,,,,,,,0,guest_user:eugenie.nicoud@helpany.com;signin_err_0;no_mfa_registered +dunedolly21@gmail.com,dunedolly21,true,Guest,,true,Password;Authenticator,push,2026-04-15T01:10:01Z,2026-04-15T01:10:01Z,,,,0,guest_user:dunedolly21@gmail.com +Kitchenipad@cascadestucson.com,AppleID,true,User,,false,Password,,2025-08-20T18:34:39Z,2025-08-20T18:34:39Z,,,,0,no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-1-pull.py b/clients/cascades-tucson/reports/user-detail-batches/batch-1-pull.py new file mode 100644 index 0000000..dafa55f --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-1-pull.py @@ -0,0 +1,279 @@ +#!/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() diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-1.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-1.csv new file mode 100644 index 0000000..f8314ba --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-1.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +Allison.Reibschied@cascadestucson.com,Allison Reibschied,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-13T19:59:52Z,2026-03-13T19:59:52Z,,,,0, +Training@cascadestucson.com,Training,true,User,O365_BUSINESS_PREMIUM,false,Password,,2026-04-17T15:05:37Z,2026-04-17T15:05:37Z,,,,0,no_mfa_registered +accounting@cascadestucson.com,Accounting Dept.,true,Shared,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-25T17:31:04Z,2026-03-25T17:31:04Z,,,,1,shared_mailbox_still_licensed +accountingassistant@cascadestucson.com,Accounting Assistant,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-03-04T16:11:11Z,2024-03-04T16:11:11Z,,,,0,no_mfa_registered +alyssa.brooks@cascadestucson.com,Alyssa Brooks,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-12T21:19:46Z,2026-04-12T21:19:46Z,,,,1, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-2.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-2.csv new file mode 100644 index 0000000..54c4809 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-2.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +ann.dery@cascadestucson.com,Ann Dery,true,User,O365_BUSINESS_PREMIUM,false,Password,,2025-05-13T20:56:44Z,2025-05-13T20:56:44Z,,,,0, +ashley.jensen@cascadestucson.com,Ashley Jensen,true,User,FLOW_FREE;O365_BUSINESS_PREMIUM,true,Password;SMS,sms,2026-04-17T16:19:15Z,2026-04-17T16:19:15Z,,,,1, +boadmin@cascadestucson.com,Bookkeeping Office,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-05-30T17:09:26Z,2024-05-30T17:09:26Z,,,,0, +christina.dupras@cascadestucson.com,Christina Dupras,true,User,O365_BUSINESS_PREMIUM,true,Password;SMS;Authenticator,sms,2026-04-17T19:39:28Z,2026-04-17T19:39:28Z,,,,1, +christine.nyanzunda@cascadestucson.com,Christine Nyanzuda,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-16T16:32:07Z,2026-02-16T16:32:07Z,,,,0, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-3.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-3.csv new file mode 100644 index 0000000..ad5a5c1 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-3.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +crystal.rodriguez@cascadestucson.com,Crystal Rodriguez,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-17T20:31:30Z,2026-02-17T20:31:30Z,,,,2, +dax.howard@cascadestucson.com,Dax Howard,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-03-07T04:33:59Z,2026-03-07T04:33:59Z,,,,0, +frontdesk@cascadestucson.com,Front Desk,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-12T16:57:37Z,2026-02-12T16:57:37Z,,,,1, +hr@cascadestucson.com,Human Resources,true,User,O365_BUSINESS_PREMIUM,false,Password,,2026-03-16T02:57:20Z,2026-03-16T02:57:20Z,,,,0, +jd.martin@cascadestucson.com,JD Martin,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-02-16T14:20:46Z,2026-02-16T14:20:46Z,,,,0, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-4-collect.py b/clients/cascades-tucson/reports/user-detail-batches/batch-4-collect.py new file mode 100644 index 0000000..9293055 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-4-collect.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Collect M365 user detail for batch 4 of Cascades Tucson.""" +import csv +import json +import sys +import time +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-4.csv" + +USERS = [ + "jodi.ramstack@cascadestucson.com", + "john.trozzi@cascadestucson.com", + "karen.rossini@cascadestucson.com", + "lauren.hasselman@cascadestucson.com", + "lois.lane@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 collect_user(upn): + row = { + "UPN": upn, "DisplayName": "", "AccountEnabled": "", + "MailboxType": "User", "Licenses": "", "MFARegistered": "", + "MFAMethods": "", "DefaultMFAMethod": "", "LastSignIn": "", + "LastInteractiveSignIn": "", "AdminRoles": "", "JobTitle": "", + "Department": "", "GroupCount": "", "Notes": "", + } + notes = [] + + # 1a. Basic profile by UPN (signInActivity requires GUID addressing, fetched below) + status, body = gget( + f"https://graph.microsoft.com/v1.0/users/{upn}" + "?$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 - requires GUID addressing + 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 {} + # lastSignInDateTime = last interactive sign-in per MS docs + row["LastInteractiveSignIn"] = sia.get("lastSignInDateTime", "") or "" + # LastSignIn = most recent of either (interactive or non-interactive) + 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}/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}/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 + status, body = gget(f"https://graph.microsoft.com/beta/users/{upn}/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 + status, body = gget( + f"https://graph.microsoft.com/v1.0/users/{upn}/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}/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}") + + # Special note for jodi.ramstack + if upn.lower().startswith("jodi.ramstack"): + jodi_state = [] + jodi_state.append(f"flagged_for_delete_2026-04-13:enabled={row['AccountEnabled']}") + jodi_state.append(f"licensed={'yes' if row['Licenses'] and row['Licenses'] != 'scope_unavailable' else 'no'}") + notes.extend(jodi_state) + + if notes: + row["Notes"] = ";".join(notes) + + return row + + +def main(): + rows = [] + for upn in USERS: + print(f"Processing {upn}...", file=sys.stderr) + row = collect_user(upn) + 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 to stdout for visibility + for r in rows: + print(json.dumps(r)) + + +if __name__ == "__main__": + main() diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-4.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-4.csv new file mode 100644 index 0000000..cf477b4 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-4.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +jodi.ramstack@cascadestucson.com,Jodi Ramstack,true,User,O365_BUSINESS_PREMIUM,false,Password,,2025-03-28T23:49:51Z,2025-03-20T20:44:25Z,,,,0,flagged_for_delete_2026-04-13:enabled=true;licensed=yes +john.trozzi@cascadestucson.com,John Trozzi,true,User,O365_BUSINESS_PREMIUM,true,Password;SMS;Authenticator;Authenticator;FIDO2,push,2026-04-18T08:40:02Z,2026-04-16T16:28:40Z,,,,1, +karen.rossini@cascadestucson.com,Karen Rossini,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T04:56:56Z,2026-03-22T02:30:14Z,,,,0, +lauren.hasselman@cascadestucson.com,Lauren Hasselman,true,User,FLOW_FREE;O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T08:44:39Z,2026-04-17T20:00:51Z,,,,0, +lois.lane@cascadestucson.com,Lois Lane,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T05:57:48Z,2026-04-11T04:37:10Z,,,,1, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-5-collect.py b/clients/cascades-tucson/reports/user-detail-batches/batch-5-collect.py new file mode 100644 index 0000000..76e75b6 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-5-collect.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Collect M365 user detail for batch 5 of Cascades Tucson. + +Batch 5 of 8 for HIPAA license planning. +IMPORTANT: megan.hiatt is under active credential-stuffing attack — +her MFA posture is flagged explicitly in Notes. +""" +import csv +import json +import sys +import time +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-5.csv" + +USERS = [ + "lupe.sanchez@cascadestucson.com", + "matthew.brooks@cascadestucson.com", + "megan.hiatt@cascadestucson.com", + "memcarereceptionist@cascadestucson.com", + "meredith.kuhn@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 collect_user(upn): + row = { + "UPN": upn, "DisplayName": "", "AccountEnabled": "", + "MailboxType": "User", "Licenses": "", "MFARegistered": "", + "MFAMethods": "", "DefaultMFAMethod": "", "LastSignIn": "", + "LastInteractiveSignIn": "", "AdminRoles": "", "JobTitle": "", + "Department": "", "GroupCount": "", "Notes": "", + } + notes = [] + + # 1a. Basic profile by UPN + status, body = gget( + f"https://graph.microsoft.com/v1.0/users/{upn}" + "?$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 - requires GUID addressing + 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}/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}/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 + status, body = gget(f"https://graph.microsoft.com/beta/users/{upn}/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 + status, body = gget( + f"https://graph.microsoft.com/v1.0/users/{upn}/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}/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}") + + # Special handling for megan.hiatt — ACTIVE CREDENTIAL-STUFFING ATTACK + if upn.lower().startswith("megan.hiatt"): + mfa_posture = [] + mfa_posture.append("CREDENTIAL_STUFFING_ACTIVE") + mm = row["MFAMethods"] or "" + non_password = [m for m in mm.split(";") if m and m != "Password"] + has_sms = "SMS" in non_password + has_authenticator = any(x in non_password for x in ("Authenticator", "PasswordlessAuthenticator")) + has_fido2 = "FIDO2" in non_password + if not non_password: + mfa_posture.append("MFA_POSTURE=NONE_REGISTERED") + elif has_sms and not (has_authenticator or has_fido2): + mfa_posture.append("MFA_POSTURE=SMS_ONLY") + elif has_sms and has_authenticator: + mfa_posture.append("MFA_POSTURE=AUTHENTICATOR_AND_SMS") + elif has_authenticator and not has_sms: + mfa_posture.append("MFA_POSTURE=AUTHENTICATOR_ONLY") + elif has_fido2: + mfa_posture.append("MFA_POSTURE=FIDO2") + else: + mfa_posture.append(f"MFA_POSTURE=OTHER({';'.join(non_password)})") + mfa_posture.append(f"DEFAULT={row['DefaultMFAMethod'] or 'unset'}") + notes.extend(mfa_posture) + + if notes: + row["Notes"] = ";".join(notes) + + return row + + +def main(): + rows = [] + for upn in USERS: + print(f"Processing {upn}...", file=sys.stderr) + row = collect_user(upn) + 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) + for r in rows: + print(json.dumps(r)) + + +if __name__ == "__main__": + main() diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-5.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-5.csv new file mode 100644 index 0000000..fd533d1 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-5.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +lupe.sanchez@cascadestucson.com,Lupe Sanchez,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-17T21:55:22Z,2026-02-12T16:46:52Z,,,,1, +matthew.brooks@cascadestucson.com,Matthew Brooks,true,User,O365_BUSINESS_PREMIUM,false,Password,,2024-10-30T18:43:31Z,2024-10-30T18:43:17Z,,,,0, +megan.hiatt@cascadestucson.com,Megan Hiatt,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T09:02:26Z,2026-04-17T20:23:11Z,,,,3,CREDENTIAL_STUFFING_ACTIVE;MFA_POSTURE=AUTHENTICATOR_ONLY;DEFAULT=push +memcarereceptionist@cascadestucson.com,MemCare Receptionist,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T07:48:01Z,2026-04-13T05:03:44Z,,,,1, +meredith.kuhn@cascadestucson.com,Meredith Kuhn,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-18T09:00:07Z,2026-03-19T19:27:02Z,,,,2, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-6-collect.py b/clients/cascades-tucson/reports/user-detail-batches/batch-6-collect.py new file mode 100644 index 0000000..2c9b9fe --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-6-collect.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Batch 6 - Cascades Tucson per-user M365 detail for HIPAA planning.""" +import csv +import json +import time +import urllib.request +import urllib.parse +import urllib.error + +with open(r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token.txt", "r") as f: + TOKEN = f.read().strip() + +USERS = [ + "ramon.castaneda@cascadestucson.com", + "security@cascadestucson.com", + "sharon.edwards@cascadestucson.com", + "susan.hicks@cascadestucson.com", + "tamra.matthews@cascadestucson.com", +] + +OUT_CSV = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-6.csv" + +HEADERS_BASE = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"} + + +def graph_get(url, extra_headers=None, max_retries=4): + headers = dict(HEADERS_BASE) + if extra_headers: + headers.update(extra_headers) + for attempt in range(max_retries): + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + if e.code == 429: + wait = int(e.headers.get("Retry-After", "5")) + time.sleep(wait) + continue + if e.code in (500, 502, 503, 504): + time.sleep(2 ** attempt) + continue + return {"_error": f"HTTP {e.code}", "_body": body[:500]} + except Exception as e: + if attempt == max_retries - 1: + return {"_error": str(e)[:200]} + time.sleep(2 ** attempt) + return {"_error": "max retries"} + + +# SKU map - common ones +SKU_MAP = { + "05e9a617-0261-4cee-bb44-138d3ef5d965": "M365 E3", + "06ebc4ee-1bb5-47dd-8120-11324bc54e06": "M365 E5", + "c42b9cae-ea4f-4ab7-9717-81576235ccac": "M365 Dev E5", + "f245ecc8-75af-4f8e-b61f-27d8114de5f3": "M365 Business Standard", + "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46": "M365 Business Premium", + "3b555118-da6a-4418-894f-7df1e2096870": "M365 Business Basic", + "4b9405b0-7788-4568-add1-99614e613b69": "Exchange Online (Plan 1)", + "19ec0d23-8335-4cbd-94ac-6050e30712fa": "Exchange Online (Plan 2)", + "c5928f49-12ba-48f7-ada3-0d743a3601d5": "Visio Plan 2", + "f30db892-07e9-47e9-837c-80727f46fd3d": "Power Automate Free", + "f8a1db68-be16-40ed-86d5-cb42ce701560": "Power BI Pro", + "a403ebcc-fae0-4ca2-8c8c-7a907fd6c235": "Power BI (free)", + "1f2f344a-700d-42c9-9427-5cea1d5d7ba6": "Stream", + "6470687e-a428-4b7a-bef2-8a291cdf4c05": "Windows Store for Business", + "6fd2c87f-b296-42f0-b197-1e91e994b900": "Office 365 E3", + "c7df2760-2c81-4ef7-b578-5b5392b571df": "Office 365 E5", + "18181a46-0d4e-45cd-891e-60aabd171b4e": "Office 365 E1", + "bd09678e-b83c-4d3f-aaba-3dad4abd128b": "Teams EEA", + "52ea0e27-ae73-4983-a08f-13561ebdb823": "Teams Exploratory Dept", + "710779e8-3d4a-4c88-adb9-386c958d1fdf": "Teams Exploratory", + "3f4babde-90ec-47c6-995d-d223749065d1": "Teams Commercial Cloud", + "66b55226-6b4f-492c-910c-a3b7a3c9d993": "M365 F3", + "44575a31-1921-4b6b-b11c-ca05f2450d6f": "M365 F1", + "dcb1a3ae-b33f-4487-846a-a640262fadf4": "M365 Apps for Business", + "c2273bd0-dff7-4215-9ef5-2c7bcfb06425": "M365 Apps for Enterprise", + "4b585984-651b-448a-9e53-3b10f069cf7f": "F3 Defender", + "26d45bd9-adf1-46cd-a9e1-51e9a5524128": "EOP (Enterprise Mobility E3)", + "b05e124f-c7cc-45a0-a6aa-8cf78c946968": "EMS E5", + "84a661c4-e949-4bd2-a560-ed7766fcaf2b": "AAD Premium P2", + "078d2b04-f1bd-4111-bbd4-b4b1b354cef4": "AAD Premium P1", + "efccb6f7-5641-4e0e-bd10-b4976e1bf68e": "EMS E3", + "53818b1b-4a27-454b-8896-0dba576410e6": "Project Plan 3", + "09015f9f-377f-4538-bbb5-f75ceb09358a": "Project Plan 5", + "a403ebcc-fae0-4ca2-8c8c-7a907fd6c235": "Power BI Free", + "e43b5b99-8dfb-405f-9987-dc307f34bcbd": "Phone System", + "3ab6abff-666f-4424-bfb7-f0bc274ec7bc": "Teams Essentials", + "dcf0408c-aaec-446c-afd4-43e3683943ea": "Teams Premium", +} + + +def sku_name(sku_id, sku_part): + if sku_id in SKU_MAP: + return SKU_MAP[sku_id] + return sku_part or sku_id[:8] + + +def csv_quote(s): + s = "" if s is None else str(s) + if "," in s or '"' in s or "\n" in s: + return '"' + s.replace('"', '""') + '"' + return s + + +rows = [] +failures = [] +stats = {"no_mfa": [], "sms_only": [], "stale": [], "admin": []} + +for i, upn in enumerate(USERS): + print(f"[{i+1}/{len(USERS)}] {upn}") + notes = [] + upn_enc = urllib.parse.quote(upn, safe="@") + + # 1a. Profile + prof = graph_get(f"https://graph.microsoft.com/v1.0/users/{upn_enc}?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department") + if "_error" in prof: + failures.append(f"{upn}: profile {prof['_error']}") + rows.append([upn, "", "", "User", "", "", "", "", "", "", "", "", "", "", f"profile-error:{prof['_error']}"]) + time.sleep(0.5) + continue + + user_id = prof.get("id", "") + display = prof.get("displayName", "") or "" + enabled = prof.get("accountEnabled", "") + job = prof.get("jobTitle", "") or "" + dept = prof.get("department", "") or "" + + # 1b. signInActivity (requires GUID, not UPN) + sia = graph_get(f"https://graph.microsoft.com/v1.0/users/{user_id}?$select=signInActivity") + last_signin = "" + last_interactive = "" + if "_error" not in sia: + s = sia.get("signInActivity") or {} + last_signin = s.get("lastSignInDateTime", "") or "" + last_interactive = s.get("lastNonInteractiveSignInDateTime", "") or s.get("lastSignInDateTime", "") or "" + # Actually: lastSignIn = lastInteractive, lastNonInteractive = noninteractive + last_signin = s.get("lastNonInteractiveSignInDateTime", "") or s.get("lastSignInDateTime", "") or "" + last_interactive = s.get("lastSignInDateTime", "") or "" + else: + notes.append(f"signIn-err") + + # Stale detection + if last_interactive: + try: + from datetime import datetime, timezone + dt = datetime.fromisoformat(last_interactive.replace("Z", "+00:00")) + days = (datetime.now(timezone.utc) - dt).days + if days > 90: + stats["stale"].append(f"{upn} ({days}d)") + except Exception: + pass + elif enabled: + stats["stale"].append(f"{upn} (never)") + + # 2. Licenses + lic_resp = graph_get(f"https://graph.microsoft.com/v1.0/users/{upn_enc}/licenseDetails") + lic_names = [] + if "_error" not in lic_resp: + for l in lic_resp.get("value", []): + lic_names.append(sku_name(l.get("skuId", ""), l.get("skuPartNumber", ""))) + else: + notes.append("lic-err") + licenses = ";".join(lic_names) + + # 3. MFA methods + mfa_resp = graph_get(f"https://graph.microsoft.com/v1.0/users/{upn_enc}/authentication/methods") + mfa_methods = [] + if "_error" not in mfa_resp: + for m in mfa_resp.get("value", []): + t = m.get("@odata.type", "") + short = t.split(".")[-1].replace("AuthenticationMethod", "") + mfa_methods.append(short) + else: + notes.append("mfa-err") + + # Filter out passwordAuth as "MFA" + non_password = [m for m in mfa_methods if m.lower() not in ("password",)] + mfa_registered = "Yes" if non_password else "No" + mfa_methods_str = ";".join(mfa_methods) + + if not non_password and enabled: + stats["no_mfa"].append(upn) + + # SMS-only detection (only method besides password is phone/sms) + strong = [m for m in non_password if m.lower() not in ("phone", "sms", "email")] + if non_password and not strong: + if any(m.lower() in ("phone", "sms") for m in non_password): + stats["sms_only"].append(upn) + + # 4. Default MFA + pref_resp = graph_get(f"https://graph.microsoft.com/beta/users/{upn_enc}/authentication/signInPreferences") + default_mfa = "" + if "_error" not in pref_resp: + default_mfa = pref_resp.get("userPreferredMethodForSecondaryAuthentication", "") or "" + else: + notes.append("pref-err") + + # 5. Roles + roles_resp = graph_get(f"https://graph.microsoft.com/v1.0/users/{upn_enc}/transitiveMemberOf/microsoft.graph.directoryRole") + roles = [] + if "_error" not in roles_resp: + for r in roles_resp.get("value", []): + roles.append(r.get("displayName", "")) + else: + notes.append("roles-err") + admin_roles = ";".join(roles) + if roles: + stats["admin"].append(f"{upn} [{admin_roles}]") + + # 6. Group count + grp_resp = graph_get( + f"https://graph.microsoft.com/v1.0/users/{upn_enc}/memberOf?$count=true&$top=1", + extra_headers={"ConsistencyLevel": "eventual"}, + ) + group_count = "" + if "_error" not in grp_resp: + group_count = grp_resp.get("@odata.count", "") + else: + notes.append("grp-err") + + rows.append([ + upn, display, str(enabled), "User", licenses, + mfa_registered, mfa_methods_str, default_mfa, + last_signin, last_interactive, admin_roles, + job, dept, str(group_count), ";".join(notes) + ]) + + time.sleep(0.5) + +# Write CSV +header = ["UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", + "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", + "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", + "GroupCount", "Notes"] + +with open(OUT_CSV, "w", encoding="utf-8", newline="") as f: + f.write(",".join(header) + "\n") + for r in rows: + f.write(",".join(csv_quote(c) for c in r) + "\n") + +print(f"\nRows written: {len(rows)}") +print(f"Failures: {len(failures)}") +for fa in failures: + print(f" - {fa}") +print(f"\nNo-MFA: {len(stats['no_mfa'])}") +for u in stats["no_mfa"]: + print(f" - {u}") +print(f"SMS-only: {len(stats['sms_only'])}") +for u in stats["sms_only"]: + print(f" - {u}") +print(f"Stale (>90d or never): {len(stats['stale'])}") +for u in stats["stale"]: + print(f" - {u}") +print(f"Admin roles: {len(stats['admin'])}") +for u in stats["admin"]: + print(f" - {u}") diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-6.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-6.csv new file mode 100644 index 0000000..d713c51 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-6.csv @@ -0,0 +1,6 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +ramon.castaneda@cascadestucson.com,Ramon Castaneda,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-15T14:10:31Z,2026-04-01T21:48:00Z,,,,0, +security@cascadestucson.com,Security Cascades,True,User,M365 Business Standard,No,password,,2024-01-24T21:28:04Z,2024-01-24T21:28:04Z,,,,0, +sharon.edwards@cascadestucson.com,Sharon Edwards,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-18T08:29:20Z,2026-04-13T19:57:22Z,,,,0, +susan.hicks@cascadestucson.com,Susan Hicks,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-17T22:05:34Z,2026-04-13T21:34:53Z,,,,1, +tamra.matthews@cascadestucson.com,Tamra Matthews,True,User,M365 Business Standard,Yes,password;microsoftAuthenticator,push,2026-04-18T04:34:13Z,2026-03-01T15:34:26Z,,,,3, diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-7.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-7.csv new file mode 100644 index 0000000..68d62d7 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-7.csv @@ -0,0 +1,9 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +veronica.feller@cascadestucson.com,Veronica Feller,true,User,O365_BUSINESS_PREMIUM,true,Password;Authenticator,push,2026-04-17T20:24:15Z,2026-04-15T02:52:59Z,,,,1, +fax@cascadestucson.com,Fax Cascades,true,Shared,EXCHANGE_S_ESSENTIALS,false,Password,,,,,,,0,signInActivity:0;shared_mailbox:fax_to_email_routing +medtech@cascadestucson.com,medtech,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2025-10-01T22:33:57Z,2025-10-01T22:33:57Z,,,,0,essentials_sku_suspended:licensed +nurse@cascadestucson.com,Nurse,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2026-04-11T05:57:04Z,2026-04-11T05:57:04Z,,,,0,essentials_sku_suspended:licensed +transportation@cascadestucson.com,transportation,true,User,EXCHANGE_S_ESSENTIALS,false,Password,,2026-04-17T18:58:52Z,2025-05-07T16:51:42Z,,transportation,transportation,0,essentials_sku_suspended:licensed +Britney.Thompson@cascadestucson.com,Britney Thompson,true,User,O365_BUSINESS_PREMIUM;EXCHANGE_S_ESSENTIALS,false,Password,,2026-02-12T01:33:24Z,2026-02-12T01:33:24Z,,,,0,essentials_sku_suspended:licensed +Shelby.Trozzi@cascadestucson.com,Shelby Trozzi,true,User,O365_BUSINESS_PREMIUM;EXCHANGE_S_ESSENTIALS,true,Password;Authenticator,push,2026-04-18T08:50:56Z,2026-04-01T16:35:55Z,,,,0,essentials_sku_suspended:licensed +sysadmin@cascadestucson.com,Computer Guru Support,true,User,FLOW_FREE,true,Password;SMS;Authenticator,sms,2026-04-17T23:06:58Z,2026-04-17T21:12:47Z,Global Administrator,,,1,msp_admin_account diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-01.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-01.csv new file mode 100644 index 0000000..a5bda41 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-01.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +admin@NETORGFT4257522.onmicrosoft.com,cascadestucson.com,false,User,,true,Password;Email;Phone;Authenticator,push,2026-03-31T16:53:03Z,2026-03-31T16:53:03Z,,,,0,account_disabled diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-02.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-02.csv new file mode 100644 index 0000000..9d3d790 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-02.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +nela.durut-azizi@cascadestucson.com,Nela Durut-Azizi,false,Shared,,false,Password,,2025-03-17T20:04:31Z,2025-03-17T20:04:31Z,,,,1,account_disabled;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-03.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-03.csv new file mode 100644 index 0000000..e82c13d --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-03.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +anna.pitzlin@cascadestucson.com,Anna Pitzlin,false,Shared,,false,Password,,2024-11-14T02:50:13Z,2024-11-14T02:50:13Z,,,,1,account_disabled;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-04.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-04.csv new file mode 100644 index 0000000..7a59e55 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-04.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +kristiana.dowse@cascadestucson.com,Kristiana Dowse (Shared),false,Shared,,false,Password,,2024-01-08T19:04:04Z,2024-01-08T19:04:04Z,,,,0,account_disabled;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-05.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-05.csv new file mode 100644 index 0000000..1349cb2 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-05.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +jeff.bristol@cascadestucson.com,Jeff Bristol,false,Shared,,false,Password,,2026-03-08T02:31:50Z,2026-03-08T02:31:50Z,,,,0,account_disabled;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-06.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-06.csv new file mode 100644 index 0000000..ea49614 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-06.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +Stephanie.Devin@cascadestucson.com,Stephanie Devin,false,User,,true,Password;Phone,sms,2026-02-20T19:19:51Z,2026-02-20T19:19:51Z,,,,0,account_disabled diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-07.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-07.csv new file mode 100644 index 0000000..8f0707d --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-07.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +nick.pavloff@cascadestucson.com,nick pavloff,false,User,,true,Password;Authenticator,push,2026-03-07T18:23:41Z,2026-03-07T18:23:41Z,,,,0,account_disabled diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-08.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-08.csv new file mode 100644 index 0000000..894ec22 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-08.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +karenrossini7@gmail.com,karenrossini7,true,Guest,,false,Password,,,,,,,0,guest_user:karenrossini7@gmail.com;signin_err_429;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-09.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-09.csv new file mode 100644 index 0000000..5a29812 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-09.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +a.r.jensen018@gmail.com,a.r.jensen018,true,Guest,,false,Password,,,,,,,0,guest_user:a.r.jensen018@gmail.com;signin_err_0;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-10.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-10.csv new file mode 100644 index 0000000..aa52cb4 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-10.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +howard@azcomputerguru.com,howard,true,Guest,,false,Password,,2025-12-30T19:18:38Z,2025-12-30T19:18:38Z,,,,0,guest_user:howard@azcomputerguru.com;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-11.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-11.csv new file mode 100644 index 0000000..e4fc0bb --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-11.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +deboram@teepasnow.com,Debora Morris,true,Guest,,false,Password,,2026-01-07T17:20:11Z,2026-01-07T17:20:11Z,,,,0,guest_user:deboram@teepasnow.com;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-12.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-12.csv new file mode 100644 index 0000000..d570f12 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-12.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +duprasc2002@yahoo.com,duprasc2002,true,Guest,,false,Password,,,,,,,0,guest_user:duprasc2002@yahoo.com;signin_err_0;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-13.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-13.csv new file mode 100644 index 0000000..ca90a98 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-13.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +eugenie.nicoud@helpany.com,eugenie.nicoud,true,Guest,,false,Password,,,,,,,0,guest_user:eugenie.nicoud@helpany.com;signin_err_0;no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-14.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-14.csv new file mode 100644 index 0000000..afc20c2 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-14.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +dunedolly21@gmail.com,dunedolly21,true,Guest,,true,Password;Authenticator,push,2026-04-15T01:10:01Z,2026-04-15T01:10:01Z,,,,0,guest_user:dunedolly21@gmail.com diff --git a/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-15.csv b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-15.csv new file mode 100644 index 0000000..953dbc6 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/batch-8/user-15.csv @@ -0,0 +1,2 @@ +UPN,DisplayName,AccountEnabled,MailboxType,Licenses,MFARegistered,MFAMethods,DefaultMFAMethod,LastSignIn,LastInteractiveSignIn,AdminRoles,JobTitle,Department,GroupCount,Notes +Kitchenipad@cascadestucson.com,AppleID,true,User,,false,Password,,2025-08-20T18:34:39Z,2025-08-20T18:34:39Z,,,,0,no_mfa_registered diff --git a/clients/cascades-tucson/reports/user-detail-batches/pull-single-user.py b/clients/cascades-tucson/reports/user-detail-batches/pull-single-user.py new file mode 100644 index 0000000..27325c9 --- /dev/null +++ b/clients/cascades-tucson/reports/user-detail-batches/pull-single-user.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Pull Graph detail for one user. Writes single-row CSV. + +Usage: pull-single-user.py --id --upn --mailbox-type --out +""" +import argparse +import csv +import json +import sys +import time +import urllib.parse +import urllib.request +import urllib.error + +TENANT_ID = "207fa277-e9d8-4eb7-ada1-1064d2221498" +TOKEN_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token" + +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", + "#microsoft.graph.smsAuthenticationMethod": "SMS", +} + + +def short_method(odata_type): + if odata_type in METHOD_SHORT: + return METHOD_SHORT[odata_type] + s = odata_type.replace("#microsoft.graph.", "").replace("AuthenticationMethod", "") + return s[:1].upper() + s[1:] if s else odata_type + + +def get_token(): + with open(TOKEN_FILE, "r") as f: + return f.read().strip() + + +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)} + if e.code == 429 or (500 <= e.code < 600): + 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) + 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"} + + +def process_user(token, user_id, upn_display, mailbox_type): + """user_id is the Graph objectId (works for both members and guests).""" + row = { + "UPN": upn_display, + "DisplayName": "", + "AccountEnabled": "", + "MailboxType": mailbox_type, + "Licenses": "", + "MFARegistered": "", + "MFAMethods": "", + "DefaultMFAMethod": "", + "LastSignIn": "", + "LastInteractiveSignIn": "", + "AdminRoles": "", + "JobTitle": "", + "Department": "", + "GroupCount": "", + "Notes": "", + } + notes = [] + uid = urllib.parse.quote(user_id) + + # 1a. Core profile + status, data = graph_get( + token, + f"/v1.0/users/{uid}?$select=id,userPrincipalName,displayName,accountEnabled,jobTitle,department,userType,mail", + ) + 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 "" + utype = data.get("userType") or "" + if utype == "Guest": + notes.append(f"guest_user:{data.get('mail','')}") + + # 1b. signInActivity + status, data = graph_get(token, f"/v1.0/users/{uid}?$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/{uid}/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/{uid}/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/{uid}/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/{uid}/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/{uid}/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}") + + # Auto-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["AdminRoles"] and row["AdminRoles"] != "scope_unavailable": + auto_notes.append("has_admin_role") + notes.extend(auto_notes) + row["Notes"] = ";".join(notes) + return row + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--id", required=True, help="Graph objectId") + ap.add_argument("--upn", required=True, help="UPN for display in CSV") + ap.add_argument("--mailbox-type", default="User") + ap.add_argument("--out", required=True, help="Output CSV path (single row)") + args = ap.parse_args() + + token = get_token() + row = process_user(token, args.id, args.upn, args.mailbox_type) + + fieldnames = [ + "UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses", + "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn", + "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department", + "GroupCount", "Notes", + ] + with open(args.out, "w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL) + w.writeheader() + w.writerow(row) + print(f"OK {args.upn} -> {args.out}") + + +if __name__ == "__main__": + main()