First version

This commit is contained in:
2025-11-28 21:43:44 +01:00
commit 90f0e14f6e
22 changed files with 1674 additions and 0 deletions

View 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
View 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
View 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