e59db34a6d
- 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
503 lines
17 KiB
Python
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"}
|