sync: auto-sync from ACG-TECH03L at 2026-04-18 14:28:21
Author: Howard Enos Machine: ACG-TECH03L Timestamp: 2026-04-18 14:28:21
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,6 +10,13 @@ backups/
|
|||||||
*.log
|
*.log
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Live secrets / tokens — never commit
|
||||||
|
.token
|
||||||
|
.token_*
|
||||||
|
*.jwt
|
||||||
|
token.txt
|
||||||
|
.token.txt
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
@@ -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()
|
||||||
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -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()
|
||||||
@@ -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,
|
||||||
|
@@ -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()
|
||||||
@@ -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,
|
||||||
|
@@ -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}")
|
||||||
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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()
|
||||||
Reference in New Issue
Block a user