Files
leocrm/app/routes/compliance.py
T
Agent Zero e59db34a6d feat(K): Phase K EU Compliance — AI Registry, DPIA, Incident Register, Retention Admin, Tests, Doku
- K-REG: GET /api/v1/compliance/ai-registry — lists all agents with ai_use_case_metadata
- K-DPIA: GET /api/v1/compliance/dpia-template — pre-filled DPIA template export
- K-INC: ComplianceIncident model, Migration 0133 (RLS), CRUD routes (admin-only)
- K-RET: GET/PATCH /api/v1/compliance/retention-policies — 5 policies editable
- K-COMP-TEST: 12/12 integration tests pass
- K-DOC: docs/compliance.md — Betriebsdoku
- Frontend: ComplianceTab.tsx in SettingsAI.tsx (new tab)
- 13 files created/modified
2026-08-21 01:58:46 +02:00

503 lines
17 KiB
Python

"""Compliance routes — AI registry, DPIA template, incident register, retention policies.
All endpoints are admin-only (require_permission('system:admin')).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.ai_use_case import AIUseCaseMetadata, validate_ai_use_case
from app.core.db import get_db
from app.deps import require_permission
from app.models.audit import AuditLog
from app.models.compliance import ComplianceIncident
from app.plugins.builtins.automation.models import AgentDefinition
router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"])
# ─── Schemas ───
class AIRegistryEntry(BaseModel):
agent_id: str
name: str
description: str
is_active: bool
llm_model: str
ai_use_case_metadata: dict[str, Any]
validation_warnings: list[str]
class AIRegistryResponse(BaseModel):
items: list[AIRegistryEntry]
total: int
class DPIATemplateSection(BaseModel):
section: str
content: str | dict[str, Any] | list[Any]
class DPIATemplateResponse(BaseModel):
use_case_id: str
agent_name: str
intended_purpose: str
owner: str
risk_class: str
oversight_policy: str
data_categories: list[str]
allowed_providers: list[str]
allowed_models: list[str]
allowed_actions: list[str]
human_review_required: bool
validation_warnings: list[str]
disclaimer: str
class IncidentCreate(BaseModel):
incident_type: str = Field(default="ai", max_length=30)
title: str = Field(max_length=300)
description: str = Field(default="", max_length=5000)
affected_use_cases: list[str] = Field(default_factory=list)
affected_versions: list[str] = Field(default_factory=list)
provider: str = Field(default="", max_length=100)
measures_taken: str = Field(default="", max_length=5000)
evidence_refs: list[str] = Field(default_factory=list)
status: str = Field(default="open", max_length=20)
class IncidentUpdate(BaseModel):
title: str | None = None
description: str | None = None
incident_type: str | None = None
affected_use_cases: list[str] | None = None
affected_versions: list[str] | None = None
provider: str | None = None
measures_taken: str | None = None
evidence_refs: list[str] | None = None
status: str | None = None
class IncidentResponse(BaseModel):
id: str
incident_type: str
title: str
description: str
affected_use_cases: list[Any]
affected_versions: list[Any]
provider: str
measures_taken: str
evidence_refs: list[Any]
status: str
created_by: str | None
resolved_by: str | None
resolved_at: str | None
created_at: str | None
updated_at: str | None
class RetentionPolicyEntry(BaseModel):
key: str
label: str
description: str
default_days: int
current_days: int
editable: bool
class RetentionPolicyUpdate(BaseModel):
days: int = Field(ge=1, le=3650)
# ─── Helpers ───
_VALID_INCIDENT_TYPES = {"ai", "privacy", "security"}
_VALID_INCIDENT_STATUS = {"open", "resolved", "closed"}
def _incident_to_dict(c: ComplianceIncident) -> dict:
return {
"id": str(c.id),
"incident_type": c.incident_type,
"title": c.title,
"description": c.description,
"affected_use_cases": c.affected_use_cases or [],
"affected_versions": c.affected_versions or [],
"provider": c.provider,
"measures_taken": c.measures_taken,
"evidence_refs": c.evidence_refs or [],
"status": c.status,
"created_by": str(c.created_by) if c.created_by else None,
"resolved_by": str(c.resolved_by) if c.resolved_by else None,
"resolved_at": c.resolved_at.isoformat() if c.resolved_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
# ─── K-REG: AI System / Use-Case Register ───
@router.get(
"/ai-registry",
response_model=AIRegistryResponse,
dependencies=[Depends(require_permission("system:admin"))],
)
async def list_ai_registry(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""List all AI agents with their use-case metadata. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = (
select(AgentDefinition)
.where(
AgentDefinition.tenant_id == tenant_id,
AgentDefinition.deleted_at.is_(None),
)
.order_by(AgentDefinition.name)
)
result = await db.execute(q)
agents = result.scalars().all()
items: list[AIRegistryEntry] = []
for a in agents:
metadata = AIUseCaseMetadata.from_dict(a.ai_use_case_metadata or {})
warnings = validate_ai_use_case(metadata, a)
items.append(
AIRegistryEntry(
agent_id=str(a.id),
name=a.name,
description=a.description or "",
is_active=a.is_active,
llm_model=a.llm_model,
ai_use_case_metadata=metadata.to_dict(),
validation_warnings=warnings,
)
)
return AIRegistryResponse(items=items, total=len(items))
# ─── K-DPIA: DPIA / AI Impact Template ───
@router.get(
"/dpia-template",
response_model=DPIATemplateResponse,
dependencies=[Depends(require_permission("system:admin"))],
)
async def get_dpia_template(
agent_id: str = Query(..., description="Agent ID to generate DPIA template for"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Generate a pre-filled DPIA template from an agent's ai_use_case_metadata.
This is a structured data export — no automatic legal assessment.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
aid = uuid.UUID(agent_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid agent_id", "code": "invalid_id"}) from None
q = select(AgentDefinition).where(
AgentDefinition.id == aid,
AgentDefinition.tenant_id == tenant_id,
AgentDefinition.deleted_at.is_(None),
)
result = await db.execute(q)
agent = result.scalar_one_or_none()
if agent is None:
raise HTTPException(404, detail={"detail": "Agent not found", "code": "not_found"})
metadata = AIUseCaseMetadata.from_dict(agent.ai_use_case_metadata or {})
warnings = validate_ai_use_case(metadata, agent)
return DPIATemplateResponse(
use_case_id=str(agent.id),
agent_name=agent.name,
intended_purpose=metadata.intended_purpose,
owner=metadata.owner,
risk_class=metadata.risk_class,
oversight_policy=metadata.oversight_policy,
data_categories=metadata.data_categories,
allowed_providers=metadata.allowed_providers,
allowed_models=metadata.allowed_models,
allowed_actions=metadata.allowed_actions,
human_review_required=metadata.human_review_required,
validation_warnings=warnings,
disclaimer=(
"This template is a structured data export from the platform's AI use-case metadata. "
"It does NOT constitute a legal assessment or legal advice. "
"A qualified DPO or legal counsel must review and complete the DPIA."
),
)
# ─── K-INC: AI/Privacy Incident Register ───
@router.get(
"/incidents",
dependencies=[Depends(require_permission("system:admin"))],
)
async def list_incidents(
status: str | None = Query(None),
incident_type: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""List compliance incidents. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = select(ComplianceIncident).where(
ComplianceIncident.tenant_id == tenant_id,
ComplianceIncident.deleted_at.is_(None),
)
if status:
q = q.where(ComplianceIncident.status == status)
if incident_type:
q = q.where(ComplianceIncident.incident_type == incident_type)
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar() or 0
q = q.order_by(ComplianceIncident.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(q)
incidents = result.scalars().all()
return {"items": [_incident_to_dict(c) for c in incidents], "total": total}
@router.post(
"/incidents",
status_code=201,
dependencies=[Depends(require_permission("system:admin"))],
)
async def create_incident(
body: IncidentCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Create a compliance incident. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
if body.incident_type not in _VALID_INCIDENT_TYPES:
raise HTTPException(400, detail={"detail": f"Invalid incident_type. Must be one of {_VALID_INCIDENT_TYPES}", "code": "invalid_type"})
if body.status not in _VALID_INCIDENT_STATUS:
raise HTTPException(400, detail={"detail": f"Invalid status. Must be one of {_VALID_INCIDENT_STATUS}", "code": "invalid_status"})
incident = ComplianceIncident(
tenant_id=tenant_id,
incident_type=body.incident_type,
title=body.title,
description=body.description,
affected_use_cases=body.affected_use_cases,
affected_versions=body.affected_versions,
provider=body.provider,
measures_taken=body.measures_taken,
evidence_refs=body.evidence_refs,
status=body.status,
created_by=user_id,
)
db.add(incident)
await db.flush()
# Audit log
audit = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="create",
entity_type="compliance_incident",
entity_id=incident.id,
changes={"title": body.title, "incident_type": body.incident_type, "status": body.status},
)
db.add(audit)
await db.commit()
await db.refresh(incident)
return _incident_to_dict(incident)
@router.patch(
"/incidents/{incident_id}",
dependencies=[Depends(require_permission("system:admin"))],
)
async def update_incident(
incident_id: str,
body: IncidentUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Update a compliance incident. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
iid = uuid.UUID(incident_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid incident_id", "code": "invalid_id"}) from None
q = select(ComplianceIncident).where(
ComplianceIncident.id == iid,
ComplianceIncident.tenant_id == tenant_id,
ComplianceIncident.deleted_at.is_(None),
)
result = await db.execute(q)
incident = result.scalar_one_or_none()
if incident is None:
raise HTTPException(404, detail={"detail": "Incident not found", "code": "not_found"})
changes: dict[str, Any] = {}
update_data = body.model_dump(exclude_unset=True)
if "incident_type" in update_data and update_data["incident_type"] not in _VALID_INCIDENT_TYPES:
raise HTTPException(400, detail={"detail": f"Invalid incident_type. Must be one of {_VALID_INCIDENT_TYPES}", "code": "invalid_type"})
if "status" in update_data and update_data["status"] not in _VALID_INCIDENT_STATUS:
raise HTTPException(400, detail={"detail": f"Invalid status. Must be one of {_VALID_INCIDENT_STATUS}", "code": "invalid_status"})
for field, value in update_data.items():
old_val = getattr(incident, field)
setattr(incident, field, value)
changes[field] = {"old": old_val, "new": value}
# If status changed to resolved/closed, set resolved_by and resolved_at
if update_data.get("status") in ("resolved", "closed") and incident.resolved_at is None:
incident.resolved_by = user_id
incident.resolved_at = datetime.now(UTC)
changes["resolved_by"] = {"old": None, "new": str(user_id)}
changes["resolved_at"] = {"old": None, "new": incident.resolved_at.isoformat()}
# Audit log
audit = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="update",
entity_type="compliance_incident",
entity_id=incident.id,
changes=changes,
)
db.add(audit)
await db.commit()
await db.refresh(incident)
return _incident_to_dict(incident)
# ─── K-RET: Retention Policies ───
_DEFAULT_RETENTION_POLICIES = [
{"key": "audit_log", "label": "Audit Log", "description": "How long audit log entries are kept before automatic deletion", "default_days": 365, "editable": True},
{"key": "backup", "label": "Backup Retention", "description": "How long backup files are retained before cleanup", "default_days": 7, "editable": True},
{"key": "trash", "label": "Trash / Soft-Delete", "description": "How long soft-deleted records remain before permanent removal", "default_days": 30, "editable": True},
{"key": "knowledge", "label": "Knowledge Base", "description": "Retention for knowledge base articles and extractions", "default_days": 365, "editable": True},
{"key": "agent_memory", "label": "Agent Memory", "description": "How long AI agent memory embeddings are retained", "default_days": 90, "editable": True},
]
@router.get(
"/retention-policies",
dependencies=[Depends(require_permission("system:admin"))],
)
async def list_retention_policies(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""List all retention policies with their current configured days. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Read current values from system_settings.retention_config JSONB
from app.models.system_settings import SystemSettings
q = select(SystemSettings).where(
SystemSettings.tenant_id == tenant_id,
SystemSettings.deleted_at.is_(None),
)
result = await db.execute(q)
settings = result.scalar_one_or_none()
retention_config = (settings.retention_config if settings and settings.retention_config else {}) or {}
items: list[RetentionPolicyEntry] = []
for policy in _DEFAULT_RETENTION_POLICIES:
key = policy["key"]
current_days = retention_config.get(key, policy["default_days"])
items.append(
RetentionPolicyEntry(
key=key,
label=policy["label"],
description=policy["description"],
default_days=policy["default_days"],
current_days=int(current_days),
editable=policy["editable"],
)
)
return {"items": items, "total": len(items)}
@router.patch(
"/retention-policies/{key}",
dependencies=[Depends(require_permission("system:admin"))],
)
async def update_retention_policy(
key: str,
body: RetentionPolicyUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Update a retention policy's days value. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
valid_keys = {p["key"] for p in _DEFAULT_RETENTION_POLICIES}
if key not in valid_keys:
raise HTTPException(400, detail={"detail": f"Invalid retention policy key. Must be one of {valid_keys}", "code": "invalid_key"})
from app.models.system_settings import SystemSettings
q = select(SystemSettings).where(
SystemSettings.tenant_id == tenant_id,
SystemSettings.deleted_at.is_(None),
)
result = await db.execute(q)
settings = result.scalar_one_or_none()
if settings is None:
raise HTTPException(404, detail={"detail": "System settings not found. Configure company settings first.", "code": "settings_not_found"})
retention_config = settings.retention_config or {}
old_days = retention_config.get(key)
retention_config[key] = body.days
settings.retention_config = retention_config
# Audit log
audit = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="update",
entity_type="retention_policy",
entity_id=None,
changes={"key": key, "old_days": old_days, "new_days": body.days},
)
db.add(audit)
await db.commit()
return {"key": key, "days": body.days, "message": "Retention policy updated"}