First version
This commit is contained in:
72
backend/graph/audit_logs.py
Normal file
72
backend/graph/audit_logs.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from graph.auth import get_access_token
|
||||
from graph.resolve import resolve_directory_object, resolve_service_principal_owners
|
||||
|
||||
|
||||
def fetch_audit_logs(hours=24, max_pages=50):
|
||||
"""Fetch paginated directory audit logs from Microsoft Graph and enrich with resolved names."""
|
||||
token = get_access_token()
|
||||
start_time = (datetime.utcnow() - timedelta(hours=hours)).isoformat() + "Z"
|
||||
next_url = (
|
||||
"https://graph.microsoft.com/v1.0/"
|
||||
f"auditLogs/directoryAudits?$filter=activityDateTime ge {start_time}"
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
events = []
|
||||
pages_fetched = 0
|
||||
|
||||
while next_url:
|
||||
if pages_fetched >= max_pages:
|
||||
raise RuntimeError(f"Aborting pagination after {max_pages} pages to avoid runaway fetch.")
|
||||
|
||||
try:
|
||||
res = requests.get(next_url, headers=headers, timeout=20)
|
||||
res.raise_for_status()
|
||||
body = res.json()
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"Failed to fetch audit logs page: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid JSON response from Graph: {exc}") from exc
|
||||
|
||||
events.extend(body.get("value", []))
|
||||
next_url = body.get("@odata.nextLink")
|
||||
pages_fetched += 1
|
||||
|
||||
return _enrich_events(events, token)
|
||||
|
||||
|
||||
def _enrich_events(events, token):
|
||||
"""
|
||||
Resolve actor/target IDs to readable names using Graph (requires Directory.Read.All).
|
||||
Adds _resolvedActor, _resolvedActorOwners, and per-target _resolved fields.
|
||||
"""
|
||||
cache = {}
|
||||
owner_cache = {}
|
||||
|
||||
for event in events:
|
||||
actor = event.get("initiatedBy", {}) or {}
|
||||
user = actor.get("user", {}) or {}
|
||||
sp = actor.get("servicePrincipal", {}) or {}
|
||||
app = actor.get("app", {}) or {}
|
||||
app_sp_id = app.get("servicePrincipalId") or app.get("servicePrincipalName")
|
||||
|
||||
actor_id = user.get("id") or sp.get("id") or app_sp_id
|
||||
|
||||
resolved_actor = resolve_directory_object(actor_id, token, cache) if actor_id else None
|
||||
actor_owners = []
|
||||
if resolved_actor and resolved_actor.get("type") == "servicePrincipal":
|
||||
actor_owners = resolve_service_principal_owners(resolved_actor.get("id"), token, owner_cache)
|
||||
|
||||
event["_resolvedActor"] = resolved_actor
|
||||
event["_resolvedActorOwners"] = actor_owners
|
||||
|
||||
for target in event.get("targetResources", []) or []:
|
||||
tid = target.get("id")
|
||||
if tid:
|
||||
resolved_target = resolve_directory_object(tid, token, cache)
|
||||
if resolved_target:
|
||||
target["_resolved"] = resolved_target
|
||||
|
||||
return events
|
||||
22
backend/graph/auth.py
Normal file
22
backend/graph/auth.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import requests
|
||||
from config import TENANT_ID, CLIENT_ID, CLIENT_SECRET
|
||||
|
||||
|
||||
def get_access_token(scope: str = "https://graph.microsoft.com/.default"):
|
||||
"""Request an application token from Microsoft identity platform."""
|
||||
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"scope": scope,
|
||||
}
|
||||
try:
|
||||
res = requests.post(url, data=data, timeout=15)
|
||||
res.raise_for_status()
|
||||
token = res.json().get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("Token endpoint returned no access_token")
|
||||
return token
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"Failed to obtain access token: {exc}") from exc
|
||||
96
backend/graph/resolve.py
Normal file
96
backend/graph/resolve.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _name_from_payload(payload: dict, kind: str) -> str:
|
||||
"""Pick a readable name for a directory object payload."""
|
||||
if kind == "user":
|
||||
upn = payload.get("userPrincipalName") or payload.get("mail")
|
||||
display = payload.get("displayName")
|
||||
if display and upn and display != upn:
|
||||
return f"{display} ({upn})"
|
||||
return display or upn or payload.get("id") or "Unknown user"
|
||||
if kind == "servicePrincipal":
|
||||
return (
|
||||
payload.get("displayName")
|
||||
or payload.get("appDisplayName")
|
||||
or payload.get("appId")
|
||||
or payload.get("id")
|
||||
or "Unknown app"
|
||||
)
|
||||
if kind == "group":
|
||||
return payload.get("displayName") or payload.get("mail") or payload.get("id") or "Unknown group"
|
||||
if kind == "device":
|
||||
return payload.get("displayName") or payload.get("id") or "Unknown device"
|
||||
return payload.get("displayName") or payload.get("id") or "Unknown"
|
||||
|
||||
|
||||
def _request_json(url: str, token: str) -> Optional[dict]:
|
||||
try:
|
||||
res = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_directory_object(object_id: str, token: str, cache: Dict[str, dict]) -> Optional[dict]:
|
||||
"""
|
||||
Resolve a directory object (user, servicePrincipal, group, device) to a readable name.
|
||||
Uses a simple multi-endpoint probe with caching to avoid extra Graph traffic.
|
||||
"""
|
||||
if not object_id:
|
||||
return None
|
||||
if object_id in cache:
|
||||
return cache[object_id]
|
||||
|
||||
probes = [
|
||||
("user", f"https://graph.microsoft.com/v1.0/users/{object_id}?$select=id,displayName,userPrincipalName,mail"),
|
||||
("servicePrincipal", f"https://graph.microsoft.com/v1.0/servicePrincipals/{object_id}?$select=id,displayName,appId,appDisplayName"),
|
||||
("group", f"https://graph.microsoft.com/v1.0/groups/{object_id}?$select=id,displayName,mail"),
|
||||
("device", f"https://graph.microsoft.com/v1.0/devices/{object_id}?$select=id,displayName"),
|
||||
]
|
||||
|
||||
for kind, url in probes:
|
||||
payload = _request_json(url, token)
|
||||
if payload:
|
||||
resolved = {
|
||||
"id": payload.get("id", object_id),
|
||||
"type": kind,
|
||||
"name": _name_from_payload(payload, kind),
|
||||
}
|
||||
cache[object_id] = resolved
|
||||
return resolved
|
||||
|
||||
cache[object_id] = None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_service_principal_owners(sp_id: str, token: str, cache: Dict[str, List[str]]) -> List[str]:
|
||||
"""Return a list of owner display names for a service principal."""
|
||||
if not sp_id:
|
||||
return []
|
||||
if sp_id in cache:
|
||||
return cache[sp_id]
|
||||
|
||||
owners = []
|
||||
url = (
|
||||
f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}"
|
||||
"/owners?$select=id,displayName,userPrincipalName,mail"
|
||||
)
|
||||
payload = _request_json(url, token)
|
||||
for owner in (payload or {}).get("value", []):
|
||||
name = (
|
||||
owner.get("displayName")
|
||||
or owner.get("userPrincipalName")
|
||||
or owner.get("mail")
|
||||
or owner.get("id")
|
||||
)
|
||||
if name:
|
||||
owners.append(name)
|
||||
|
||||
cache[sp_id] = owners
|
||||
return owners
|
||||
Reference in New Issue
Block a user