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,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}")
|
||||
Reference in New Issue
Block a user