280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/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()
|