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