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:
@@ -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()
|
||||
Reference in New Issue
Block a user