feat: implement Phase 4 enhancements
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Migrate frontend to Alpine.js for reactive state management
- Add source health dashboard in UI and /api/source-health endpoint
- Add event tagging (PATCH /api/events/{id}/tags) and commenting (POST /api/events/{id}/comments)
- Add CSV/JSON export from the UI
- Add rule-based alerting engine (rules.py) with CRUD endpoints (/api/rules)
- Add SIEM export via webhook (siem.py)
- Add AOC audit trail middleware logging all mutations to aoc_audit collection
- Update config with SIEM_ENABLED, SIEM_WEBHOOK_URL, ALERTS_ENABLED
- Add tests for rules engine, tags, comments, and source health
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from audit_trail import log_action
|
||||
from auth import require_auth
|
||||
from bson import ObjectId
|
||||
from database import events_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from models.api import FilterOptionsResponse, PaginatedEventResponse
|
||||
from models.api import (
|
||||
CommentAddRequest,
|
||||
FilterOptionsResponse,
|
||||
PaginatedEventResponse,
|
||||
TagsUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
@@ -34,6 +42,7 @@ def list_events(
|
||||
search: str | None = None,
|
||||
cursor: str | None = None,
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
filters = []
|
||||
|
||||
@@ -85,7 +94,7 @@ def list_events(
|
||||
{
|
||||
"$or": [
|
||||
{"timestamp": {"$lt": cursor_ts}},
|
||||
{"timestamp": cursor_ts, "_id": {"$lt": cursor_oid}},
|
||||
{"timestamp": cursor_ts, "_id": {"$lt": ObjectId(cursor_oid)}},
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -112,6 +121,12 @@ def list_events(
|
||||
|
||||
for e in events:
|
||||
e["_id"] = str(e["_id"])
|
||||
|
||||
log_action("list_events", "/api/events", {"filters": {k: v for k, v in {
|
||||
"service": service, "actor": actor, "operation": operation, "result": result,
|
||||
"start": start, "end": end, "search": search, "cursor": cursor, "page_size": page_size,
|
||||
}.items() if v is not None}}, user.get("sub", "anonymous"))
|
||||
|
||||
return {
|
||||
"items": events,
|
||||
"total": total,
|
||||
@@ -122,9 +137,6 @@ def list_events(
|
||||
|
||||
@router.get("/filter-options", response_model=FilterOptionsResponse)
|
||||
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
||||
"""
|
||||
Provide distinct values for UI filters (best-effort, capped).
|
||||
"""
|
||||
safe_limit = max(1, min(limit, 1000))
|
||||
try:
|
||||
services = sorted(events_collection.distinct("service"))[:safe_limit]
|
||||
@@ -144,3 +156,34 @@ def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
|
||||
"actor_upns": actor_upns,
|
||||
"devices": devices,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}/tags")
|
||||
def update_tags(
|
||||
event_id: str,
|
||||
body: TagsUpdateRequest,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
result = events_collection.update_one({"id": event_id}, {"$set": {"tags": body.tags}})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
log_action("update_tags", f"/api/events/{event_id}/tags", {"tags": body.tags}, user.get("sub", "anonymous"))
|
||||
return {"tags": body.tags}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/comments")
|
||||
def add_comment(
|
||||
event_id: str,
|
||||
body: CommentAddRequest,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
comment = {
|
||||
"text": body.text,
|
||||
"author": user.get("sub", "anonymous"),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
result = events_collection.update_one({"id": event_id}, {"$push": {"comments": comment}})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
log_action("add_comment", f"/api/events/{event_id}/comments", {"text": body.text}, user.get("sub", "anonymous"))
|
||||
return comment
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from audit_trail import log_action
|
||||
from auth import require_auth
|
||||
from config import ALERTS_ENABLED
|
||||
from database import events_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from graph.audit_logs import fetch_audit_logs
|
||||
@@ -8,6 +10,7 @@ from metrics import track_fetch, track_fetch_duration, track_fetch_error
|
||||
from models.api import FetchAuditLogsResponse
|
||||
from models.event_model import normalize_event
|
||||
from pymongo import UpdateOne
|
||||
from siem import forward_event
|
||||
from sources.intune_audit import fetch_intune_audit
|
||||
from sources.unified_audit import fetch_unified_audit
|
||||
from watermark import get_watermark, set_watermark
|
||||
@@ -52,12 +55,26 @@ def run_fetch(hours: int = 168):
|
||||
else:
|
||||
ops.append(UpdateOne({"id": doc.get("id"), "timestamp": doc.get("timestamp")}, {"$set": doc}, upsert=True))
|
||||
events_collection.bulk_write(ops, ordered=False)
|
||||
|
||||
if ALERTS_ENABLED:
|
||||
from rules import evaluate_event
|
||||
for doc in normalized:
|
||||
evaluate_event(doc)
|
||||
|
||||
for doc in normalized:
|
||||
forward_event(doc)
|
||||
|
||||
return {"stored_events": len(normalized), "errors": errors}
|
||||
|
||||
|
||||
@router.get("/fetch-audit-logs", response_model=FetchAuditLogsResponse)
|
||||
def fetch_logs(hours: int = Query(default=168, ge=1, le=720)):
|
||||
def fetch_logs(
|
||||
hours: int = Query(default=168, ge=1, le=720),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
try:
|
||||
return run_fetch(hours=hours)
|
||||
result = run_fetch(hours=hours)
|
||||
log_action("fetch_audit_logs", "/api/fetch-audit-logs", {"hours": hours, "stored": result["stored_events"]}, user.get("sub", "anonymous"))
|
||||
return result
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
30
backend/routes/health.py
Normal file
30
backend/routes/health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
from auth import require_auth
|
||||
from fastapi import APIRouter, Depends
|
||||
from models.api import SourceHealthResponse
|
||||
from watermark import watermarks_collection
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
SOURCES = ["directory", "unified", "intune"]
|
||||
|
||||
|
||||
@router.get("/source-health", response_model=list[SourceHealthResponse])
|
||||
def source_health():
|
||||
"""Return the last known fetch status for each ingestion source."""
|
||||
results = []
|
||||
for source in SOURCES:
|
||||
doc = watermarks_collection.find_one({"source": source})
|
||||
if doc and doc.get("last_fetch_time"):
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": doc["last_fetch_time"],
|
||||
"status": "healthy",
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"source": source,
|
||||
"last_fetch_time": None,
|
||||
"status": "unknown",
|
||||
})
|
||||
return results
|
||||
42
backend/routes/rules.py
Normal file
42
backend/routes/rules.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from auth import require_auth
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.api import AlertRuleResponse
|
||||
from rules import rules_collection
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
def _serialize(doc: dict) -> dict:
|
||||
doc["id"] = str(doc.pop("_id"))
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/rules", response_model=list[AlertRuleResponse])
|
||||
def list_rules():
|
||||
return [_serialize(doc) for doc in rules_collection.find()]
|
||||
|
||||
|
||||
@router.post("/rules", response_model=AlertRuleResponse)
|
||||
def create_rule(rule: AlertRuleResponse):
|
||||
payload = rule.model_dump(exclude={"id"})
|
||||
result = rules_collection.insert_one(payload)
|
||||
payload["id"] = str(result.inserted_id)
|
||||
return payload
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}", response_model=AlertRuleResponse)
|
||||
def update_rule(rule_id: str, rule: AlertRuleResponse):
|
||||
payload = rule.model_dump(exclude={"id"})
|
||||
result = rules_collection.update_one({"_id": ObjectId(rule_id)}, {"$set": payload})
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return {**payload, "id": rule_id}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
def delete_rule(rule_id: str):
|
||||
result = rules_collection.delete_one({"_id": ObjectId(rule_id)})
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return {"deleted": True}
|
||||
Reference in New Issue
Block a user