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,248 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user