feat: implement Phase 2 stabilization
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Cache Graph API tokens with expiry-aware reuse in graph/auth.py - Add tenacity-based retry/backoff wrapper (utils/http.py) and apply to all Graph/source API calls - Add Pydantic request/response models (models/api.py) and FastAPI query constraints - Add unit tests for event_model, auth and integration tests for API endpoints - Configure ruff linter/formatter in pyproject.toml - Add GitHub Actions CI pipeline (.github/workflows/ci.yml) - Add requirements-dev.txt with pytest, mongomock, httpx, ruff - Clean up typing imports and fix ruff linting across codebase
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from graph.auth import get_access_token
|
||||
from utils.http import get_with_retry
|
||||
|
||||
|
||||
def fetch_intune_audit(hours: int = 24, max_pages: int = 50) -> List[dict]:
|
||||
def fetch_intune_audit(hours: int = 24, max_pages: int = 50) -> list[dict]:
|
||||
"""
|
||||
Fetch Intune audit events via Microsoft Graph.
|
||||
Requires Intune audit permissions (e.g., DeviceManagementConfiguration.Read.All).
|
||||
@@ -24,13 +23,13 @@ def fetch_intune_audit(hours: int = 24, max_pages: int = 50) -> List[dict]:
|
||||
if pages >= max_pages:
|
||||
raise RuntimeError(f"Aborting Intune pagination after {max_pages} pages.")
|
||||
try:
|
||||
res = requests.get(url, headers=headers, timeout=20)
|
||||
res = get_with_retry(url, headers=headers, timeout=20)
|
||||
res.raise_for_status()
|
||||
body = res.json()
|
||||
except requests.RequestException as exc:
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to fetch Intune audit logs: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid Intune response JSON: {exc}") from exc
|
||||
|
||||
events.extend(body.get("value", []))
|
||||
url = body.get("@odata.nextLink")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import requests
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from graph.auth import get_access_token
|
||||
|
||||
from utils.http import get_with_retry, post_with_retry
|
||||
|
||||
AUDIT_CONTENT_TYPES = {
|
||||
"Audit.Exchange": "Exchange admin audit",
|
||||
@@ -23,40 +22,41 @@ def _ensure_subscription(content_type: str, token: str, tenant_id: str):
|
||||
url = f"https://manage.office.com/api/v1.0/{tenant_id}/activity/feed/subscriptions/start"
|
||||
params = {"contentType": content_type}
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
requests.post(url, params=params, headers=headers, timeout=10)
|
||||
except requests.RequestException:
|
||||
pass # best-effort
|
||||
with suppress(Exception):
|
||||
post_with_retry(url, params=params, headers=headers, timeout=10)
|
||||
|
||||
|
||||
def _list_content(content_type: str, token: str, tenant_id: str, hours: int) -> List[dict]:
|
||||
def _list_content(content_type: str, token: str, tenant_id: str, hours: int) -> list[dict]:
|
||||
start, end = _time_window(hours)
|
||||
url = f"https://manage.office.com/api/v1.0/{tenant_id}/activity/feed/subscriptions/content"
|
||||
params = {"contentType": content_type, "startTime": start, "endTime": end}
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
res = requests.get(url, params=params, headers=headers, timeout=20)
|
||||
res = get_with_retry(url, params=params, headers=headers, timeout=20)
|
||||
if res.status_code in (400, 401, 403, 404):
|
||||
# Likely not enabled or insufficient perms; surface the text to the caller.
|
||||
raise RuntimeError(f"{content_type} content listing failed ({res.status_code}): {res.text}")
|
||||
return []
|
||||
res.raise_for_status()
|
||||
return res.json() or []
|
||||
except requests.RequestException as exc:
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to list {content_type} content: {exc}") from exc
|
||||
|
||||
|
||||
def _download_content(content_uri: str, token: str) -> List[dict]:
|
||||
def _download_content(content_uri: str, token: str) -> list[dict]:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
res = requests.get(content_uri, headers=headers, timeout=30)
|
||||
res = get_with_retry(content_uri, headers=headers, timeout=30)
|
||||
res.raise_for_status()
|
||||
return res.json() or []
|
||||
except requests.RequestException as exc:
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to download audit content: {exc}") from exc
|
||||
|
||||
|
||||
def fetch_unified_audit(hours: int = 24, max_files: int = 50) -> List[dict]:
|
||||
def fetch_unified_audit(hours: int = 24, max_files: int = 50) -> list[dict]:
|
||||
"""
|
||||
Fetch unified audit logs (Exchange, SharePoint, Teams policy changes via Audit.General)
|
||||
using the Office 365 Management Activity API.
|
||||
@@ -67,7 +67,7 @@ def fetch_unified_audit(hours: int = 24, max_files: int = 50) -> List[dict]:
|
||||
|
||||
events = []
|
||||
|
||||
for content_type in AUDIT_CONTENT_TYPES.keys():
|
||||
for content_type in AUDIT_CONTENT_TYPES:
|
||||
_ensure_subscription(content_type, token, TENANT_ID)
|
||||
contents = _list_content(content_type, token, TENANT_ID, hours)
|
||||
for item in contents[:max_files]:
|
||||
|
||||
Reference in New Issue
Block a user