gateway/modules/routes/routeAudit.py
2026-04-26 18:11:42 +02:00

410 lines
15 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Compliance & Audit API endpoints.
Provides three views:
- AI Data-Flow Log (Tab A)
- Security/GDPR Audit Log (Tab B)
- Aggregated Audit Statistics (Tab C)
RBAC: mandate-admin or compliance-viewer role required.
"""
import json
import logging
import re
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from starlette.requests import Request
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User
from modules.shared.i18nRegistry import apiRouteContext
logger = logging.getLogger(__name__)
routeApiMsg = apiRouteContext("routeAudit")
router = APIRouter(prefix="/api/audit", tags=["Audit"])
def _applySortFilterSearch(
items: List[Dict[str, Any]],
*,
sortJson: Optional[str] = None,
filtersJson: Optional[str] = None,
search: Optional[str] = None,
searchableKeys: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Apply sort, filter and search to a list of dicts in-memory.
Delegates to the shared ``applyFiltersAndSort`` from routeHelpers so that
date-range filters (``between`` operator) and null/empty filters work
consistently across all in-memory routes.
"""
from modules.routes.routeHelpers import applyFiltersAndSort
from modules.datamodels.datamodelPagination import PaginationParams, SortField
filtersDict: Optional[Dict[str, Any]] = None
if filtersJson:
try:
filtersDict = json.loads(filtersJson) if isinstance(filtersJson, str) else filtersJson
except (json.JSONDecodeError, TypeError):
pass
if search and searchableKeys:
if filtersDict is None:
filtersDict = {}
filtersDict["search"] = search
sortList = None
if sortJson:
try:
raw = json.loads(sortJson) if isinstance(sortJson, str) else sortJson
if isinstance(raw, list):
sortList = raw
except (json.JSONDecodeError, TypeError):
pass
if not filtersDict and not sortList:
return items
sortFields = [SortField(**s) for s in sortList] if sortList else []
params = PaginationParams.model_construct(
page=1,
pageSize=len(items) or 1,
filters=filtersDict or {},
sort=sortFields,
)
return applyFiltersAndSort(items, params)
def _distinctColumnValues(items: List[Dict[str, Any]], column: str) -> List[Optional[str]]:
"""Extract sorted distinct values for a column.
Includes ``None`` as the last entry when at least one row has a null/empty
value — this enables the "(Leer)" filter option in the frontend.
"""
vals = set()
hasEmpty = False
for r in items:
v = r.get(column)
if v is None or v == "":
hasEmpty = True
continue
vals.add(str(v))
result: List[Optional[str]] = sorted(vals)
if hasEmpty:
result.append(None)
return result
def _enrichUserAndInstanceLabels(
items: List[Dict[str, Any]],
context: "RequestContext",
userKey: str = "userId",
usernameKey: str = "username",
instanceKey: str = "featureInstanceId",
instanceLabelKey: str = "instanceLabel",
) -> None:
"""Resolve userId -> username and featureInstanceId -> label in-place.
Uses the central resolvers from routeHelpers. Returns None (not the raw ID)
for unresolvable entries so the frontend can distinguish "resolved" from
"missing".
"""
from modules.routes.routeHelpers import resolveUserLabels, resolveInstanceLabels
userIds = list({r.get(userKey) for r in items if r.get(userKey) and not r.get(usernameKey)})
instanceIds = list({r.get(instanceKey) for r in items if r.get(instanceKey)})
userMap: Dict[str, Optional[str]] = {}
instanceMap: Dict[str, Optional[str]] = {}
if userIds:
userMap = resolveUserLabels(userIds)
if instanceIds:
instanceMap = resolveInstanceLabels(instanceIds)
for r in items:
uid = r.get(userKey)
if uid and not r.get(usernameKey) and uid in userMap:
r[usernameKey] = userMap[uid]
iid = r.get(instanceKey)
if iid:
r[instanceLabelKey] = instanceMap.get(iid)
def _requireAuditAccess(context: RequestContext):
"""Raise 403 unless user has mandate-admin or compliance-viewer access."""
if context.isPlatformAdmin:
return
from modules.interfaces.interfaceDbApp import getInterface
appIf = getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
mandateId = str(context.mandateId) if context.mandateId else None
if not mandateId:
raise HTTPException(status_code=403, detail=routeApiMsg("Mandanten-Kontext erforderlich"))
from modules.datamodels.datamodelRbac import AccessRuleContext
permissions = appIf.rbac.getUserPermissions(
context.user, AccessRuleContext.UI, "ui.system.complianceAudit",
mandateId=mandateId,
)
if not permissions or not permissions.view:
raise HTTPException(status_code=403, detail=routeApiMsg("Kein Zugriff auf Audit-Daten"))
# ── Tab A: AI Data-Flow Log ──
@router.get("/ai-log")
@limiter.limit("120/minute")
async def getAiAuditLog(
request: Request,
context: RequestContext = Depends(getRequestContext),
userId: Optional[str] = Query(None),
featureInstanceId: Optional[str] = Query(None),
aiModel: Optional[str] = Query(None),
dateFrom: Optional[float] = Query(None, description="UTC epoch seconds"),
dateTo: Optional[float] = Query(None, description="UTC epoch seconds"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
sort: Optional[str] = Query(None, description='JSON array, e.g. [{"field":"timestamp","direction":"desc"}]'),
filters: Optional[str] = Query(None, description='JSON object, e.g. {"aiModel":"gpt-4o"}'),
search: Optional[str] = Query(None),
mode: Optional[str] = Query(None, description="'filterValues' to get distinct values for a column"),
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
if not mandateId:
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
from modules.shared.aiAuditLogger import aiAuditLogger
result = aiAuditLogger.getAiAuditLogs(
mandateId,
userId=userId,
featureInstanceId=featureInstanceId,
aiModel=aiModel,
fromTimestamp=dateFrom,
toTimestamp=dateTo,
limit=9999,
offset=0,
)
items = result.get("items", [])
_enrichUserAndInstanceLabels(items, context)
if mode == "filterValues" and column:
items = _applySortFilterSearch(items, filtersJson=filters)
return _distinctColumnValues(items, column)
items = _applySortFilterSearch(
items,
sortJson=sort,
filtersJson=filters,
search=search,
searchableKeys=["username", "aiModel", "instanceLabel", "aiProvider", "operationType"],
)
totalItems = len(items)
page = items[offset: offset + limit]
return {"items": page, "totalItems": totalItems}
@router.get("/ai-log/{entryId}/content")
@limiter.limit("60/minute")
async def getAiAuditEntryContent(
request: Request,
entryId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
from modules.shared.aiAuditLogger import aiAuditLogger
result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId)
if not result:
raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden"))
_phRx = re.compile(r"\[([a-z]+)\.([a-f0-9-]{36})\]")
combinedText = (result.get("contentInputFull") or "") + (result.get("contentOutputFull") or "")
phIds = set(m.group(2) for m in _phRx.finditer(combinedText))
mappings = []
if phIds:
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
for phId in phIds:
attr = neutIf.getAttributeById(phId)
if attr:
mappings.append({
"id": attr.get("id", ""),
"originalText": attr.get("originalText", ""),
"patternType": attr.get("patternType", ""),
})
except Exception as mapErr:
logger.warning(f"Could not resolve neutralization mappings for audit entry {entryId}: {mapErr}")
result["neutralizationMappings"] = mappings
return result
# ── Tab B: Security / GDPR Audit Log ──
@router.get("/log")
@limiter.limit("120/minute")
async def getAuditLog(
request: Request,
context: RequestContext = Depends(getRequestContext),
userId: Optional[str] = Query(None),
category: Optional[str] = Query(None),
action: Optional[str] = Query(None),
dateFrom: Optional[float] = Query(None),
dateTo: Optional[float] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
sort: Optional[str] = Query(None),
filters: Optional[str] = Query(None),
search: Optional[str] = Query(None),
mode: Optional[str] = Query(None),
column: Optional[str] = Query(None),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else None
from modules.shared.auditLogger import audit_logger
records = audit_logger.getAuditLogs(
userId=userId,
mandateId=mandateId,
category=category,
action=action,
fromTimestamp=dateFrom,
toTimestamp=dateTo,
limit=9999,
)
_enrichUserAndInstanceLabels(records, context)
if mode == "filterValues" and column:
records = _applySortFilterSearch(records, filtersJson=filters)
return _distinctColumnValues(records, column)
records = _applySortFilterSearch(
records,
sortJson=sort,
filtersJson=filters,
search=search,
searchableKeys=["username", "action", "resourceType", "category"],
)
totalItems = len(records)
page = records[offset: offset + limit]
return {"items": page, "totalItems": totalItems}
# ── Tab C: Audit Statistics ──
@router.get("/stats")
@limiter.limit("60/minute")
async def getAuditStats(
request: Request,
context: RequestContext = Depends(getRequestContext),
dateFrom: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
dateTo: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
groupBy: str = Query("model", pattern="^(model|user|feature|day)$",
description="Grouping: model, user, feature, day"),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
if not mandateId:
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
from modules.shared.aiAuditLogger import aiAuditLogger
from modules.shared.dateRange import isoDateRangeToLocalEpoch
fromTs, toTs = isoDateRangeToLocalEpoch(dateFrom, dateTo)
return aiAuditLogger.getAiAuditStats(
mandateId, fromTs=fromTs, toTs=toTs, groupBy=groupBy,
)
# ── Tab D: Neutralization Mappings ──
@router.get("/neutralization-mappings")
@limiter.limit("120/minute")
async def getNeutralizationMappings(
request: Request,
context: RequestContext = Depends(getRequestContext),
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
sort: Optional[str] = Query(None),
filters: Optional[str] = Query(None),
search: Optional[str] = Query(None),
mode: Optional[str] = Query(None),
column: Optional[str] = Query(None),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
if not mandateId:
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
attrs = neutIf.getNeutralizationAttributes()
items = [a.model_dump() if hasattr(a, "model_dump") else dict(a) for a in attrs]
for item in items:
pType = item.get("patternType", "")
uid = item.get("id", "")
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid
_enrichUserAndInstanceLabels(items, context)
if mode == "filterValues" and column:
items = _applySortFilterSearch(items, filtersJson=filters)
return _distinctColumnValues(items, column)
items = _applySortFilterSearch(
items,
sortJson=sort,
filtersJson=filters,
search=search,
searchableKeys=["placeholder", "originalText", "patternType"],
)
if not sort:
items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
totalItems = len(items)
page = items[offset: offset + limit]
return {"items": page, "totalItems": totalItems}
except Exception as e:
logger.error(f"Failed to load neutralization mappings: {e}")
raise HTTPException(status_code=500, detail=routeApiMsg("Fehler beim Laden der Neutralisierungs-Zuordnungen"))
@router.delete("/neutralization-mappings/{mappingId}")
@limiter.limit("60/minute")
async def deleteNeutralizationMapping(
request: Request,
mappingId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
if not mandateId:
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
deleted = neutIf.deleteAttributeById(mappingId)
if not deleted:
raise HTTPException(status_code=404, detail=routeApiMsg("Zuordnung nicht gefunden"))
return {"success": True, "id": mappingId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete neutralization mapping {mappingId}: {e}")
raise HTTPException(status_code=500, detail=routeApiMsg("Fehler beim Löschen der Zuordnung"))