Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-04-18 17:23:16 -07:00
34 changed files with 2364 additions and 0 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 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
4 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
5 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
6 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
7 ann.dery@cascadestucson.com Ann Dery true User O365_BUSINESS_PREMIUM false Password 2025-05-13T20:56:44Z 2025-05-13T20:56:44Z 0
8 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
9 boadmin@cascadestucson.com Bookkeeping Office true User O365_BUSINESS_PREMIUM false Password 2024-05-30T17:09:26Z 2024-05-30T17:09:26Z 0
10 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
11 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
12 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
13 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
14 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
15 hr@cascadestucson.com Human Resources true User O365_BUSINESS_PREMIUM false Password 2026-03-16T02:57:20Z 2026-03-16T02:57:20Z 0
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 matthew.brooks@cascadestucson.com Matthew Brooks true User O365_BUSINESS_PREMIUM false Password 2024-10-30T18:43:31Z 2024-10-30T18:43:17Z 0
24 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
25 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
26 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
27 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
28 security@cascadestucson.com Security Cascades True User M365 Business Standard No password 2024-01-24T21:28:04Z 2024-01-24T21:28:04Z 0
29 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
30 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
31 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
32 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
33 fax@cascadestucson.com Fax Cascades true Shared EXCHANGE_S_ESSENTIALS false Password 0 signInActivity:0;shared_mailbox:fax_to_email_routing
34 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
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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
47 karenrossini7@gmail.com karenrossini7 true Guest false Password 0 guest_user:karenrossini7@gmail.com;signin_err_429;no_mfa_registered
48 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
49 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
50 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
51 duprasc2002@yahoo.com duprasc2002 true Guest false Password 0 guest_user:duprasc2002@yahoo.com;signin_err_0;no_mfa_registered
52 eugenie.nicoud@helpany.com eugenie.nicoud true Guest false Password 0 guest_user:eugenie.nicoud@helpany.com;signin_err_0;no_mfa_registered
53 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
54 Kitchenipad@cascadestucson.com AppleID true User false Password 2025-08-20T18:34:39Z 2025-08-20T18:34:39Z 0 no_mfa_registered

View File

@@ -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()

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 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
4 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
5 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
6 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

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 ann.dery@cascadestucson.com Ann Dery true User O365_BUSINESS_PREMIUM false Password 2025-05-13T20:56:44Z 2025-05-13T20:56:44Z 0
3 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
4 boadmin@cascadestucson.com Bookkeeping Office true User O365_BUSINESS_PREMIUM false Password 2024-05-30T17:09:26Z 2024-05-30T17:09:26Z 0
5 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
6 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

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 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
4 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
5 hr@cascadestucson.com Human Resources true User O365_BUSINESS_PREMIUM false Password 2026-03-16T02:57:20Z 2026-03-16T02:57:20Z 0
6 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

View File

@@ -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()

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 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
4 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
5 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
6 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

View File

@@ -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()

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 matthew.brooks@cascadestucson.com Matthew Brooks true User O365_BUSINESS_PREMIUM false Password 2024-10-30T18:43:31Z 2024-10-30T18:43:17Z 0
4 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
5 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
6 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

View File

@@ -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}")

View File

@@ -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,
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
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
3 security@cascadestucson.com Security Cascades True User M365 Business Standard No password 2024-01-24T21:28:04Z 2024-01-24T21:28:04Z 0
4 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
5 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
6 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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
3 fax@cascadestucson.com Fax Cascades true Shared EXCHANGE_S_ESSENTIALS false Password 0 signInActivity:0;shared_mailbox:fax_to_email_routing
4 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
5 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
6 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
7 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
8 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
9 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 karenrossini7@gmail.com karenrossini7 true Guest false Password 0 guest_user:karenrossini7@gmail.com;signin_err_429;no_mfa_registered

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 duprasc2002@yahoo.com duprasc2002 true Guest false Password 0 guest_user:duprasc2002@yahoo.com;signin_err_0;no_mfa_registered

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 eugenie.nicoud@helpany.com eugenie.nicoud true Guest false Password 0 guest_user:eugenie.nicoud@helpany.com;signin_err_0;no_mfa_registered

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 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

View File

@@ -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
1 UPN DisplayName AccountEnabled MailboxType Licenses MFARegistered MFAMethods DefaultMFAMethod LastSignIn LastInteractiveSignIn AdminRoles JobTitle Department GroupCount Notes
2 Kitchenipad@cascadestucson.com AppleID true User false Password 2025-08-20T18:34:39Z 2025-08-20T18:34:39Z 0 no_mfa_registered

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Pull Graph detail for one user. Writes single-row CSV.
Usage: pull-single-user.py --id <objectId> --upn <upn> --mailbox-type <type> --out <path>
"""
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()