Files
claudetools/clients/cascades-tucson/reports/user-detail-batches/batch-6-collect.py
Howard Enos 74890d51ec 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
2026-04-18 14:34:04 -07:00

260 lines
9.9 KiB
Python

#!/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}")