410 lines
15 KiB
Python
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"))
|