#!/usr/bin/env python3 """Agent for identity governance and lifecycle management operations.""" import os import json import argparse from datetime import datetime, timedelta import requests def get_graph_token(tenant_id, client_id, client_secret): """Authenticate to Microsoft Graph API via client credentials.""" url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" resp = requests.post(url, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": "https://graph.microsoft.com/.default", }, timeout=30) resp.raise_for_status() return resp.json()["access_token"] def list_users(token, filter_query=None): """List users from Microsoft Entra ID with optional filter.""" headers = {"Authorization": f"Bearer {token}"} url = "https://graph.microsoft.com/v1.0/users" params = {"$select": "id,displayName,userPrincipalName,accountEnabled,employeeId," "department,jobTitle,createdDateTime,signInActivity", "$top": "999"} if filter_query: params["$filter"] = filter_query users = [] while url: resp = requests.get(url, headers=headers, params=params, timeout=30) resp.raise_for_status() data = resp.json() users.extend(data.get("value", [])) url = data.get("@odata.nextLink") params = {} return users def detect_orphaned_accounts(token, days_inactive=90): """Find accounts with no sign-in activity for N days.""" users = list_users(token) cutoff = datetime.utcnow() - timedelta(days=days_inactive) orphaned = [] for u in users: if not u.get("accountEnabled"): continue sign_in = u.get("signInActivity", {}) last_sign_in = sign_in.get("lastSignInDateTime") if not last_sign_in: orphaned.append({ "user": u["userPrincipalName"], "department": u.get("department", ""), "reason": "No sign-in recorded", "risk": "HIGH", }) else: last_dt = datetime.fromisoformat(last_sign_in.rstrip("Z")) if last_dt < cutoff: orphaned.append({ "user": u["userPrincipalName"], "department": u.get("department", ""), "last_sign_in": last_sign_in, "days_inactive": (datetime.utcnow() - last_dt).days, "risk": "MEDIUM", }) return orphaned def detect_stale_guests(token, days_inactive=60): """Find guest accounts with no recent activity.""" guests = list_users(token, "userType eq 'Guest'") cutoff = datetime.utcnow() - timedelta(days=days_inactive) stale = [] for g in guests: sign_in = g.get("signInActivity", {}) last = sign_in.get("lastSignInDateTime") if not last or datetime.fromisoformat(last.rstrip("Z")) < cutoff: stale.append({ "user": g["userPrincipalName"], "created": g.get("createdDateTime", ""), "last_sign_in": last, }) return stale def get_access_reviews(token): """List active access reviews from Entra ID Governance.""" headers = {"Authorization": f"Bearer {token}"} url = "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions" resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() reviews = [] for r in resp.json().get("value", []): reviews.append({ "name": r["displayName"], "status": r["status"], "scope": r.get("scope", {}).get("query", ""), "created": r.get("createdDateTime"), }) return reviews def check_users_without_mfa(token): """Identify users without registered MFA methods.""" headers = {"Authorization": f"Bearer {token}"} url = "https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails" resp = requests.get(url, headers=headers, params={"$top": "999"}, timeout=30) resp.raise_for_status() no_mfa = [] for u in resp.json().get("value", []): if not u.get("isMfaRegistered"): no_mfa.append({ "user": u["userPrincipalName"], "mfa_registered": False, "methods": u.get("methodsRegistered", []), }) return no_mfa def generate_lifecycle_report(token): """Generate comprehensive identity governance report.""" report = {"generated_at": datetime.utcnow().isoformat(), "findings": {}} users = list_users(token) enabled = [u for u in users if u.get("accountEnabled")] disabled = [u for u in users if not u.get("accountEnabled")] report["summary"] = { "total_users": len(users), "enabled": len(enabled), "disabled": len(disabled), } report["findings"]["orphaned_accounts"] = detect_orphaned_accounts(token) report["findings"]["stale_guests"] = detect_stale_guests(token) report["findings"]["no_mfa"] = check_users_without_mfa(token) report["findings"]["access_reviews"] = get_access_reviews(token) return report def main(): parser = argparse.ArgumentParser(description="Identity Governance Lifecycle Agent") parser.add_argument("--tenant-id", default=os.getenv("AZURE_TENANT_ID")) parser.add_argument("--client-id", default=os.getenv("AZURE_CLIENT_ID")) parser.add_argument("--client-secret", default=os.getenv("AZURE_CLIENT_SECRET")) parser.add_argument("--output", default="iga_report.json") parser.add_argument("--action", choices=[ "orphaned", "guests", "mfa", "reviews", "full_report" ], default="full_report") args = parser.parse_args() token = get_graph_token(args.tenant_id, args.client_id, args.client_secret) report = {"scan_date": datetime.utcnow().isoformat(), "findings": {}} if args.action in ("orphaned", "full_report"): results = detect_orphaned_accounts(token) report["findings"]["orphaned_accounts"] = results print(f"[+] Orphaned accounts: {len(results)}") if args.action in ("guests", "full_report"): results = detect_stale_guests(token) report["findings"]["stale_guests"] = results print(f"[+] Stale guest accounts: {len(results)}") if args.action in ("mfa", "full_report"): results = check_users_without_mfa(token) report["findings"]["no_mfa"] = results print(f"[+] Users without MFA: {len(results)}") if args.action in ("reviews", "full_report"): results = get_access_reviews(token) report["findings"]["access_reviews"] = results print(f"[+] Active access reviews: {len(results)}") with open(args.output, "w") as f: json.dump(report, f, indent=2, default=str) print(f"[+] Report saved to {args.output}") if __name__ == "__main__": main()