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
This commit is contained in:
Agent Zero
2026-08-21 01:58:46 +02:00
parent fbcfbbced6
commit e59db34a6d
14 changed files with 1917 additions and 1 deletions
+17
View File
@@ -279,4 +279,21 @@ Siehe `ENTERPRISE_READINESS_PLAN.md` für Details.
---
## Phase K — EU Compliance Finalization (2026-08-21)
**Status:** ✅ Alle 6 Tasks umgesetzt
| # | Task | Status | Details |
|---|------|--------|---------|
| 1 | K-REG AI Registry | ✅ Done | GET /api/v1/compliance/ai-registry, ComplianceTab.tsx in SettingsAI.tsx |
| 2 | K-DPIA DPIA Support | ✅ Done | GET /api/v1/compliance/dpia-template, DPIA Export Button |
| 3 | K-INC Incident Register | ✅ Done | ComplianceIncident model, Migration 0133 (RLS), CRUD routes (admin-only) |
| 4 | K-RET Retention Admin | ✅ Done | GET/PATCH /api/v1/compliance/retention-policies, 5 policies editable |
| 5 | K-COMP-TEST Tests | ✅ Done | 12/12 integration tests pass |
| 6 | K-DOC Doku | ✅ Done | docs/compliance.md — Betriebsdoku |
**Tests:** 12/12 passed | **tsc:** 0 errors | **Migration:** 0133 | **RLS:** 115 tables
---
*Diese Datei wird vom Agent bei jedem Task-Status-Wechsel aktualisiert. Sie ist die schnelle Übersicht über den Fortschritt. Detaillierte Diskussion und Bug-Tracking laufen über Forgejo Issues.*
@@ -0,0 +1,51 @@
"""compliance_incidents table for AI/privacy/security incident register
Revision ID: 0133
Revises: 0132
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "0133"
down_revision = "0132"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"compliance_incidents",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("incident_type", sa.String(30), nullable=False, server_default=sa.text("'ai'")),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
sa.Column("affected_use_cases", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("affected_versions", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("provider", sa.String(100), nullable=False, server_default=sa.text("''")),
sa.Column("measures_taken", sa.Text, nullable=False, server_default=sa.text("''")),
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'open'")),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("resolved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_compliance_incidents_tenant_status", "compliance_incidents", ["tenant_id", "status"])
op.create_index("ix_compliance_incidents_tenant_type", "compliance_incidents", ["tenant_id", "incident_type"])
# Add retention_config JSONB column to system_settings for compliance retention overrides
op.add_column("system_settings", sa.Column("retention_config", JSONB, nullable=True, server_default=sa.text("'{}'::jsonb")))
# RLS
op.execute("ALTER TABLE compliance_incidents ENABLE ROW LEVEL SECURITY;")
op.execute("CREATE POLICY compliance_incidents_tenant_isolation ON compliance_incidents USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
def downgrade() -> None:
op.drop_column("system_settings", "retention_config")
op.drop_table("compliance_incidents")
+2
View File
@@ -37,6 +37,7 @@ from app.routes import ( # noqa: E402
attachments,
audit,
auth,
compliance,
backups,
bank_accounts,
contact_folder_permissions,
@@ -567,6 +568,7 @@ def create_app() -> FastAPI:
app.include_router(bank_accounts.router)
app.include_router(audit.router)
app.include_router(backups.router)
app.include_router(compliance.router)
app.include_router(owner_transfer.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
+2
View File
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
from app.models.backup import Backup
from app.models.bank_account import BankAccount
from app.models.consumer_inbox import ConsumerInbox
from app.models.compliance import ComplianceIncident
from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory
@@ -47,6 +48,7 @@ __all__ = [
"NotificationPreference",
"PasswordResetToken",
"ApiToken",
"ComplianceIncident",
"Contact",
"ContactPerson",
"ContactFolder",
+59
View File
@@ -0,0 +1,59 @@
"""Compliance models — AI/privacy incident register for EU compliance."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class ComplianceIncident(Base, TenantMixin):
"""An AI/privacy/security incident tracked for compliance purposes.
Used by the compliance routes under /api/v1/compliance/incidents.
"""
__tablename__ = "compliance_incidents"
__table_args__ = (
Index("ix_compliance_incidents_tenant_status", "tenant_id", "status"),
Index("ix_compliance_incidents_tenant_type", "tenant_id", "incident_type"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
incident_type: Mapped[str] = mapped_column(
String(30), nullable=False, default="ai"
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
affected_use_cases: Mapped[list[Any]] = mapped_column(
JSONB, nullable=False, default=list
)
affected_versions: Mapped[list[Any]] = mapped_column(
JSONB, nullable=False, default=list
)
provider: Mapped[str] = mapped_column(String(100), nullable=False, default="")
measures_taken: Mapped[str] = mapped_column(Text, nullable=False, default="")
evidence_refs: Mapped[list[Any]] = mapped_column(
JSONB, nullable=False, default=list
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="open"
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+2
View File
@@ -54,3 +54,5 @@ class SystemSettings(Base, TenantMixin):
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Automation plugin settings (JSONB)
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Retention policy overrides (JSONB) — compliance module
retention_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+1
View File
@@ -5,6 +5,7 @@ from app.routes import (
ai_copilot, # noqa: F401
attachments, # noqa: F401
audit, # noqa: F401
compliance, # noqa: F401
auth, # noqa: F401
bank_accounts, # noqa: F401
contacts, # noqa: F401
+502
View File
@@ -0,0 +1,502 @@
"""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"}
+164
View File
@@ -0,0 +1,164 @@
# EU Compliance Betriebsdokumentation
> **Wichtiger Hinweis:** Diese Dokumentation beschreibt die Plattformfunktionen zur Unterstützung der EU-Compliance. Die Plattformfunktionen ersetzen **keine** rechtliche Beratung oder automatische Rechtskonformität. Ein qualifizierter Datenschutzbeauftragter (DPO) oder Rechtsberater muss die Compliance stets im Einzelfall prüfen.
## 1. Rollen und Verantwortlichkeiten
| Rolle | Verantwortung | Plattform-Funktion |
|-------|-------------|-------------------|
| **Datenschutzbeauftragter (DPO)** | Aufsicht über Datenverarbeitung, DPIA-Prüfung, Incident-Management | Zugriff auf Compliance-Tab (admin), AI-Register, DPIA-Export |
| **System-Administrator** | Technische Konfiguration, Retention-Policies, Plugin-Verwaltung | Vollzugriff auf alle Compliance-Endpunkte |
| **AI-Agent-Verantwortlicher (Owner)** | Use-Case-Klassifikation, Risiko-Bewertung | AI-Use-Case-Metadata pro Agent |
| **Mitarbeiter** | Nutzung der AI-Systeme im Rahmen der Use-Case-Metadaten | Kein direkter Compliance-Zugriff |
## 2. Provider-Onboarding
Bevor ein neuer AI-Provider in der Plattform verwendet wird:
1. **Technische Einrichtung**: Provider in den KI-Einstellungen konfigurieren (API-Key, Base-URL)
2. **Use-Case-Metadaten**: Für jeden Agenten, der den Provider nutzt, `ai_use_case_metadata` ausfüllen:
- `allowed_providers`: Provider-ID eintragen
- `allowed_models`: Erlaubte Modelle einschränken
- `intended_purpose`: Zweckbeschreibung
- `owner`: Verantwortliche Person
3. **Risiko-Klassifizierung**: `risk_class` festlegen (low/medium/high)
4. **Oversight-Policy**: `oversight_policy` konfigurieren (always_required/on_high_risk/never)
5. **DPIA-Export**: DPIA-Template über den Compliance-Tab exportieren und vom DPO prüfen lassen
## 3. Use-Case-Klassifikation
Jeder AI-Agent in der Plattform hat strukturierte Metadaten (`AIUseCaseMetadata`):
| Feld | Beschreibung | Werte |
|-----|-------------|-------|
| `intended_purpose` | Beschreibung des Verwendungszwecks | Freitext (max. 1000 Zeichen) |
| `owner` | Verantwortliche Person (User-ID oder E-Mail) | Freitext |
| `data_categories` | Datenkategorien, die verarbeitet werden | `contact_data`, `email_content`, `calendar`, `tasks`, `dms`, `communication`, `financial`, `public` |
| `allowed_providers` | Erlaubte Provider (leer = alle) | Provider-IDs |
| `allowed_models` | Erlaubte Modelle (leer = alle) | Modellnamen |
| `allowed_actions` | Erlaubte Aktionen (leer = alle) | `read`, `summarize`, `draft`, `send`, `create`, `update`, `delete` |
| `oversight_policy` | Wann menschliche Prüfung erforderlich ist | `always_required`, `on_high_risk`, `never` |
| `risk_class` | Risikoklassifizierung | `low`, `medium`, `high` |
| `human_review_required` | Muss ein Mensch die Ausgabe prüfen? | Boolean |
### Validierung
Die Plattform validiert die Metadaten automatisch (`validate_ai_use_case`):
- `intended_purpose` und `owner` müssen gesetzt sein
- `data_categories` müssen bekannte Werte sein
- `oversight_policy` und `risk_class` müssen gültig sein
- `allowed_models` müssen das konfigurierte Modell enthalten (falls nicht leer)
- `allowed_providers` müssen den konfigurierten Provider enthalten (falls nicht leer)
- `human_review_required` muss mit `oversight_policy` konsistent sein
Warnungen werden im AI-Register angezeigt.
## 4. DPIA / AI-Impact-Checkliste
Die Plattform bietet einen DPIA-Template-Export (`GET /api/v1/compliance/dpia-template?agent_id=...`):
- Vorbefüllt aus den `ai_use_case_metadata` des Agenten
- Enthält: Zweck, Owner, Risiko-Klasse, Oversight-Policy, Datenkategorien, Provider, Modelle, Aktionen
- Enthält Validierungswarnungen
- Enthält Disclaimer: **keine Rechtsberatung**
### DPIA-Checkliste (manuell vom DPO zu vervollständigen):
- [ ] Zweck der Datenverarbeitung dokumentiert
- [ ] Rechtsgrundlage identifiziert (Art. 6 DSGVO, ggf. Art. 9)
- [ ] Datenkategorien katalogisiert
- [ ] Empfänger/Dritte identifiziert
- [ ] Übermittlung in Drittländer ausgeschlossen oder abgesichert
- [ ] Speicherdauer definiert (siehe Retention-Policies)
- [ ] Betroffenenrechte gewährleistet (Auskunft, Löschung, Berichtigung)
- [ ] Technische und organisatorische Maßnahmen (TOMs) dokumentiert
- [ ] Risiko-Bewertung durchgeführt
- [ ] Bei hohem Risiko: Datenschutz-Folgenabschätzung (Art. 35 DSGVO)
- [ ] Menschliche Aufsicht sichergestellt (oversight_policy)
- [ ] Protokollierung und Audit-Trail aktiviert
## 5. Incident- und DSAR-Ablauf
### AI/Privacy/Security Incidents
Incidents werden über `POST /api/v1/compliance/incidents` erfasst:
1. **Entdeckung**: Mitarbeiter oder System entdeckt einen Vorfall
2. **Erfassung**: Admin erstellt Incident-Eintrag mit:
- `incident_type`: ai, privacy, security
- `title`, `description`: Beschreibung des Vorfalls
- `affected_use_cases`: Betroffene AI-Use-Cases (Agent-IDs)
- `affected_versions`: Betroffene Versionen
- `provider`: Betroffener AI-Provider
- `measures_taken`: Ergriffene Maßnahmen
- `evidence_refs`: Beweisverweise
- `status`: open → resolved → closed
3. **Maßnahmen**: Durchführung und Dokumentation der Maßnahmen
4. **Auflösung**: Status auf `resolved` oder `closed` setzen (setzt `resolved_at` und `resolved_by`)
5. **Audit-Trail**: Alle Incident-Mutationen werden im Audit-Log protokolliert
### Data Subject Access Request (DSAR / DSGVO-Auskunft)
Die Plattform bietet einen DSGVO-Export über `GET /api/v1/system-settings/dsgvo-export/{user_id}`:
- Exportiert alle personenbezogenen Daten eines Users
- Enthält: Profil, Kontakte, Audit-Logs, Mail-Accounts, Tasks, Kalender, Kommunikation
- Admin-only
## 6. Retention-Policies (Aufbewahrungsrichtlinien)
Die Plattform verwaltet Retention-Policies über `GET/PATCH /api/v1/compliance/retention-policies`:
| Policy | Standard (Tage) | Beschreibung |
|--------|----------------|---------------|
| `audit_log` | 365 | Aufbewahrung von Audit-Log-Einträgen |
| `backup` | 7 | Aufbewahrung von Backup-Dateien |
| `trash` | 30 | Aufbewahrung von soft-deleted Datensätzen |
| `knowledge` | 365 | Aufbewahrung von Knowledge-Base-Artikeln und -Extraktionen |
| `agent_memory` | 90 | Aufbewahrung von AI-Agent-Memory-Embeddings |
Retention-Werte werden in `system_settings.retention_config` (JSONB) pro Tenant gespeichert.
## 7. Plugin-Anforderungen
Plugins, die AI-Funktionalität bereitstellen, müssen:
- **Use-Case-Metadaten**: `ai_use_case_metadata` für jeden Agenten ausfüllen
- **Tenant-Isolation**: Alle Tabellen benötigen `tenant_id` und RLS
- **Audit-Logging**: Alle Mutationen müssen Audit-Log-Einträge erstellen
- **Data-Policy-Enforcement**: `app/ai/data_policy.py` für Datenkategorien-Prüfung nutzen
- **Transparency-Logging**: `app/ai/transparency.py` für AI-Entscheidungsprotokollierung nutzen
- **Oversight**: `app/ai/oversight.py` für menschliche Aufsicht nutzen
## 8. Grenze: Plattformfunktion ≠ automatische Rechtskonformität
**Die Plattform bietet Werkzeuge zur Unterstützung der EU-Compliance, ersetzt aber nicht:**
- Eine rechtliche Beratung oder Datenschutz-Folgenabschätzung durch einen qualifizierten DPO
- Die Verantwortung des Verantwortlichen (Art. 24 DSGVO)
- Die Pflicht zur Datenschutz-Folgenabschätzung bei hohem Risiko (Art. 35 DSGVO)
- Die Meldepflicht bei Datenpannen (Art. 33-34 DSGVO)
- Die Dokumentationspflicht der Verarbeitungstätigkeiten (Art. 30 DSGVO)
- Die Berücksichtigung der AI-Verordnung (EU AI Act) bei Hochrisiko-Systemen
Die Plattformfunktionen sind **Werkzeuge**, die die Compliance-Arbeit erleichtern. Die rechtliche Verantwortung verbleibt beim Betreiber.
## 9. API-Endpunkte
| Endpunkt | Methode | Beschreibung | Berechtigung |
|----------|---------|-------------|-------------|
| `/api/v1/compliance/ai-registry` | GET | AI-Use-Case-Register | system:admin |
| `/api/v1/compliance/dpia-template` | GET | DPIA-Template-Export | system:admin |
| `/api/v1/compliance/incidents` | GET | Incident-Liste | system:admin |
| `/api/v1/compliance/incidents` | POST | Incident erstellen | system:admin |
| `/api/v1/compliance/incidents/{id}` | PATCH | Incident aktualisieren | system:admin |
| `/api/v1/compliance/retention-policies` | GET | Retention-Policies auflisten | system:admin |
| `/api/v1/compliance/retention-policies/{key}` | PATCH | Retention-Policy aktualisieren | system:admin |
## 10. Frontend
Der Compliance-Tab ist in den KI-Einstellungen (`SettingsAI.tsx`) unter dem Tab "Compliance" erreichbar. Er enthält drei Sub-Tabs:
- **AI-Register**: Tabelle aller AI-Agenten mit Use-Case-Metadaten und DPIA-Export-Button
- **Vorfälle**: Incident-Liste mit Erstellungsformular und Auflösungsfunktion
- **Aufbewahrung**: Retention-Policies-Tabelle mit editierbaren Tagen
+147
View File
@@ -0,0 +1,147 @@
/**
* Compliance API client — AI registry, DPIA template, incidents, retention policies.
*
* All endpoints are admin-only and target /api/v1/compliance/...
*/
import { apiGet, apiPost, apiPatch } from './client';
// ─── Types ───
export interface AIRegistryEntry {
agent_id: string;
name: string;
description: string;
is_active: boolean;
llm_model: string;
ai_use_case_metadata: Record<string, unknown>;
validation_warnings: string[];
}
export interface AIRegistryResponse {
items: AIRegistryEntry[];
total: number;
}
export interface DPIATemplate {
use_case_id: string;
agent_name: string;
intended_purpose: string;
owner: string;
risk_class: string;
oversight_policy: string;
data_categories: string[];
allowed_providers: string[];
allowed_models: string[];
allowed_actions: string[];
human_review_required: boolean;
validation_warnings: string[];
disclaimer: string;
}
export interface ComplianceIncident {
id: string;
incident_type: string;
title: string;
description: string;
affected_use_cases: string[];
affected_versions: string[];
provider: string;
measures_taken: string;
evidence_refs: string[];
status: string;
created_by: string | null;
resolved_by: string | null;
resolved_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface IncidentCreate {
incident_type: string;
title: string;
description?: string;
affected_use_cases?: string[];
affected_versions?: string[];
provider?: string;
measures_taken?: string;
evidence_refs?: string[];
status?: string;
}
export interface IncidentUpdate {
title?: string;
description?: string;
incident_type?: string;
affected_use_cases?: string[];
affected_versions?: string[];
provider?: string;
measures_taken?: string;
evidence_refs?: string[];
status?: string;
}
export interface IncidentsResponse {
items: ComplianceIncident[];
total: number;
}
export interface RetentionPolicyEntry {
key: string;
label: string;
description: string;
default_days: number;
current_days: number;
editable: boolean;
}
export interface RetentionPoliciesResponse {
items: RetentionPolicyEntry[];
total: number;
}
// ─── API Functions ───
export async function fetchAIRegistry(): Promise<AIRegistryResponse> {
return apiGet<AIRegistryResponse>('/compliance/ai-registry');
}
export async function fetchDPIATemplate(agentId: string): Promise<DPIATemplate> {
return apiGet<DPIATemplate>('/compliance/dpia-template', {
params: { agent_id: agentId },
});
}
export async function fetchIncidents(params?: {
status?: string;
incident_type?: string;
limit?: number;
offset?: number;
}): Promise<IncidentsResponse> {
return apiGet<IncidentsResponse>('/compliance/incidents', { params });
}
export async function createIncident(data: IncidentCreate): Promise<ComplianceIncident> {
return apiPost<ComplianceIncident>('/compliance/incidents', data);
}
export async function updateIncident(
id: string,
data: IncidentUpdate
): Promise<ComplianceIncident> {
return apiPatch<ComplianceIncident>(`/compliance/incidents/${id}`, data);
}
export async function fetchRetentionPolicies(): Promise<RetentionPoliciesResponse> {
return apiGet<RetentionPoliciesResponse>('/compliance/retention-policies');
}
export async function updateRetentionPolicy(
key: string,
days: number
): Promise<{ key: string; days: number; message: string }> {
return apiPatch<{ key: string; days: number; message: string }>(
`/compliance/retention-policies/${key}`,
{ days }
);
}
+463
View File
@@ -0,0 +1,463 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchAIRegistry,
fetchDPIATemplate,
fetchIncidents,
createIncident,
updateIncident,
fetchRetentionPolicies,
updateRetentionPolicy,
type AIRegistryEntry,
type ComplianceIncident,
type RetentionPolicyEntry,
type IncidentCreate,
} from '../api/compliance';
type SubTab = 'registry' | 'incidents' | 'retention';
export function ComplianceTab() {
const { t } = useTranslation();
const [subTab, setSubTab] = useState<SubTab>('registry');
const subTabs: { key: SubTab; label: string }[] = [
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
];
return (
<div data-testid="compliance-tab" className="space-y-4">
<div className="border-b border-secondary-200 mb-4">
<nav className="flex gap-1" role="tablist">
{subTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setSubTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
subTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300'
}`}
role="tab"
aria-selected={subTab === tab.key}
aria-label={tab.label}
>
{tab.label}
</button>
))}
</nav>
</div>
{subTab === 'registry' && <AIRegistryPanel />}
{subTab === 'incidents' && <IncidentsPanel />}
{subTab === 'retention' && <RetentionPanel />}
</div>
);
}
// ─── AI Registry Panel ───
function AIRegistryPanel() {
const { t } = useTranslation();
const { data, isLoading, error } = useQuery({
queryKey: ['compliance', 'ai-registry'],
queryFn: fetchAIRegistry,
});
const handleDPIAExport = async (agentId: string, agentName: string) => {
try {
const template = await fetchDPIATemplate(agentId);
const blob = new Blob([JSON.stringify(template, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dpia_${agentName.replace(/\s+/g, '_').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
// Error handled by query client
}
};
if (isLoading) {
return <p className="text-secondary-500" aria-live="polite">{t('common.loading', 'Laden...')}</p>;
}
if (error) {
return <p className="text-red-600" aria-live="polite">{t('common.error', 'Fehler beim Laden')}</p>;
}
if (!data || data.items.length === 0) {
return <p className="text-secondary-500">{t('compliance.noAgents', 'Keine AI-Agenten gefunden')}</p>;
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200" role="table">
<thead className="bg-secondary-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.name', 'Name')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.intendedPurpose', 'Zweck')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.owner', 'Verantwortlich')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.riskClass', 'Risiko')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.oversight', 'Oversight')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.dataCategories', 'Daten')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.status', 'Status')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.actions', 'Aktionen')}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{data.items.map((entry: AIRegistryEntry) => {
const meta = entry.ai_use_case_metadata as Record<string, unknown>;
return (
<tr key={entry.agent_id} className="hover:bg-secondary-50">
<td className="px-4 py-2 text-sm text-secondary-900">{entry.name}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{String(meta.intended_purpose || '')}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{String(meta.owner || '')}</td>
<td className="px-4 py-2 text-sm">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
meta.risk_class === 'high' ? 'bg-red-100 text-red-700' :
meta.risk_class === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{String(meta.risk_class || 'low')}
</span>
</td>
<td className="px-4 py-2 text-sm text-secondary-600">{String(meta.oversight_policy || '')}</td>
<td className="px-4 py-2 text-sm text-secondary-600">
{Array.isArray(meta.data_categories) ? (meta.data_categories as string[]).join(', ') : ''}
</td>
<td className="px-4 py-2 text-sm">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
entry.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{entry.is_active ? t('compliance.active', 'Aktiv') : t('compliance.inactive', 'Inaktiv')}
</span>
</td>
<td className="px-4 py-2 text-sm">
<button
onClick={() => handleDPIAExport(entry.agent_id, entry.name)}
className="text-primary-600 hover:text-primary-700 text-xs font-medium"
aria-label={`${t('compliance.dpiaExport', 'DPIA Export')} ${entry.name}`}
>
{t('compliance.dpiaExport', 'DPIA Export')}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{data.items.some((e) => e.validation_warnings.length > 0) && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
{t('compliance.warningsNote', 'Einige Agenten haben Validierungswarnungen. Siehe Details im AI-Register.')}
</div>
)}
</div>
);
}
// ─── Incidents Panel ───
function IncidentsPanel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState<IncidentCreate>({
incident_type: 'ai',
title: '',
description: '',
provider: '',
measures_taken: '',
status: 'open',
});
const { data, isLoading, error } = useQuery({
queryKey: ['compliance', 'incidents'],
queryFn: () => fetchIncidents(),
});
const createMutation = useMutation({
mutationFn: (data: IncidentCreate) => createIncident(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['compliance', 'incidents'] });
setShowForm(false);
setFormData({ incident_type: 'ai', title: '', description: '', provider: '', measures_taken: '', status: 'open' });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { status?: string } }) =>
updateIncident(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['compliance', 'incidents'] });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(formData);
};
if (isLoading) {
return <p className="text-secondary-500" aria-live="polite">{t('common.loading', 'Laden...')}</p>;
}
if (error) {
return <p className="text-red-600" aria-live="polite">{t('common.error', 'Fehler beim Laden')}</p>;
}
return (
<div className="space-y-4">
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-primary-600 text-white rounded text-sm font-medium hover:bg-primary-700"
aria-label={t('compliance.createIncident', 'Vorfall erstellen')}
>
{showForm ? t('common.cancel', 'Abbrechen') : t('compliance.createIncident', 'Vorfall erstellen')}
</button>
{showForm && (
<form onSubmit={handleSubmit} className="space-y-3 p-4 border border-secondary-200 rounded">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1" htmlFor="incident-type">
{t('compliance.incidentType', 'Typ')}
</label>
<select
id="incident-type"
value={formData.incident_type}
onChange={(e) => setFormData({ ...formData, incident_type: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 rounded text-sm"
aria-label={t('compliance.incidentType', 'Typ')}
>
<option value="ai">AI</option>
<option value="privacy">Privacy</option>
<option value="security">Security</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1" htmlFor="incident-title">
{t('compliance.title', 'Titel')} *
</label>
<input
id="incident-title"
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 rounded text-sm"
aria-label={t('compliance.title', 'Titel')}
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1" htmlFor="incident-description">
{t('compliance.description', 'Beschreibung')}
</label>
<textarea
id="incident-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 rounded text-sm"
rows={3}
aria-label={t('compliance.description', 'Beschreibung')}
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1" htmlFor="incident-provider">
{t('compliance.provider', 'Provider')}
</label>
<input
id="incident-provider"
type="text"
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 rounded text-sm"
aria-label={t('compliance.provider', 'Provider')}
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1" htmlFor="incident-measures">
{t('compliance.measures', 'Maßnahmen')}
</label>
<textarea
id="incident-measures"
value={formData.measures_taken}
onChange={(e) => setFormData({ ...formData, measures_taken: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 rounded text-sm"
rows={2}
aria-label={t('compliance.measures', 'Maßnahmen')}
/>
</div>
{createMutation.isError && (
<p className="text-red-600 text-sm" role="alert">{t('compliance.createError', 'Fehler beim Erstellen')}</p>
)}
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-primary-600 text-white rounded text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
aria-label={t('common.save', 'Speichern')}
>
{createMutation.isPending ? t('common.saving', 'Speichern...') : t('common.save', 'Speichern')}
</button>
</form>
)}
{!data || data.items.length === 0 ? (
<p className="text-secondary-500">{t('compliance.noIncidents', 'Keine Vorfälle erfasst')}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200" role="table">
<thead className="bg-secondary-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.title', 'Titel')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.incidentType', 'Typ')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.status', 'Status')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.provider', 'Provider')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.createdAt', 'Erstellt')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.actions', 'Aktionen')}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{data.items.map((incident: ComplianceIncident) => (
<tr key={incident.id} className="hover:bg-secondary-50">
<td className="px-4 py-2 text-sm text-secondary-900">{incident.title}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{incident.incident_type}</td>
<td className="px-4 py-2 text-sm">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
incident.status === 'open' ? 'bg-red-100 text-red-700' :
incident.status === 'resolved' ? 'bg-green-100 text-green-700' :
'bg-gray-100 text-gray-600'
}`}>
{incident.status}
</span>
</td>
<td className="px-4 py-2 text-sm text-secondary-600">{incident.provider}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{incident.created_at || ''}</td>
<td className="px-4 py-2 text-sm">
{incident.status === 'open' && (
<button
onClick={() => updateMutation.mutate({ id: incident.id, data: { status: 'resolved' } })}
className="text-primary-600 hover:text-primary-700 text-xs font-medium"
aria-label={`${t('compliance.resolve', 'Auflösen')} ${incident.title}`}
>
{t('compliance.resolve', 'Auflösen')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Retention Policies Panel ───
function RetentionPanel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editDays, setEditDays] = useState<number>(0);
const { data, isLoading, error } = useQuery({
queryKey: ['compliance', 'retention-policies'],
queryFn: fetchRetentionPolicies,
});
const updateMutation = useMutation({
mutationFn: ({ key, days }: { key: string; days: number }) =>
updateRetentionPolicy(key, days),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['compliance', 'retention-policies'] });
setEditingKey(null);
},
});
if (isLoading) {
return <p className="text-secondary-500" aria-live="polite">{t('common.loading', 'Laden...')}</p>;
}
if (error) {
return <p className="text-red-600" aria-live="polite">{t('common.error', 'Fehler beim Laden')}</p>;
}
if (!data || data.items.length === 0) {
return <p className="text-secondary-500">{t('compliance.noPolicies', 'Keine Aufbewahrungsrichtlinien')}</p>;
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200" role="table">
<thead className="bg-secondary-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.policy', 'Richtlinie')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.description', 'Beschreibung')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.defaultDays', 'Standard (Tage)')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.currentDays', 'Aktuell (Tage)')}</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 uppercase">{t('compliance.actions', 'Aktionen')}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{data.items.map((policy: RetentionPolicyEntry) => (
<tr key={policy.key} className="hover:bg-secondary-50">
<td className="px-4 py-2 text-sm font-medium text-secondary-900">{policy.label}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{policy.description}</td>
<td className="px-4 py-2 text-sm text-secondary-600">{policy.default_days}</td>
<td className="px-4 py-2 text-sm text-secondary-900">
{editingKey === policy.key ? (
<input
type="number"
min={1}
max={3650}
value={editDays}
onChange={(e) => setEditDays(parseInt(e.target.value, 10) || 1)}
className="w-20 px-2 py-1 border border-secondary-300 rounded text-sm"
aria-label={t('compliance.days', 'Tage')}
/>
) : (
policy.current_days
)}
</td>
<td className="px-4 py-2 text-sm">
{editingKey === policy.key ? (
<div className="flex gap-2">
<button
onClick={() => updateMutation.mutate({ key: policy.key, days: editDays })}
disabled={updateMutation.isPending}
className="text-green-600 hover:text-green-700 text-xs font-medium disabled:opacity-50"
aria-label={t('common.save', 'Speichern')}
>
{updateMutation.isPending ? t('common.saving', '...') : t('common.save', 'Speichern')}
</button>
<button
onClick={() => setEditingKey(null)}
className="text-secondary-500 hover:text-secondary-700 text-xs font-medium"
aria-label={t('common.cancel', 'Abbrechen')}
>
{t('common.cancel', 'Abbrechen')}
</button>
</div>
) : (
policy.editable && (
<button
onClick={() => {
setEditingKey(policy.key);
setEditDays(policy.current_days);
}}
className="text-primary-600 hover:text-primary-700 text-xs font-medium"
aria-label={`${t('common.edit', 'Bearbeiten')} ${policy.label}`}
>
{t('common.edit', 'Bearbeiten')}
</button>
)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+4 -1
View File
@@ -3,15 +3,17 @@ import { useTranslation } from 'react-i18next';
import { AISettingsPage } from './AISettings';
import { ProactiveAISettings } from './ProactiveAISettings';
import { SettingsMcpPage } from './SettingsMcp';
import { ComplianceTab } from './ComplianceTab';
export function SettingsAIPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'assistant' | 'proactive' | 'mcp'>('assistant');
const [activeTab, setActiveTab] = useState<'assistant' | 'proactive' | 'mcp' | 'compliance'>('assistant');
const tabs = [
{ key: 'assistant' as const, label: t('nav.aiAssistant', 'KI Assistent') },
{ key: 'proactive' as const, label: t('settings.aiProactive', 'Proaktive KI') },
{ key: 'mcp' as const, label: t('settings.mcp', 'MCP') },
{ key: 'compliance' as const, label: t('settings.compliance', 'Compliance') },
];
return (
@@ -40,6 +42,7 @@ export function SettingsAIPage() {
{activeTab === 'assistant' && <AISettingsPage />}
{activeTab === 'proactive' && <ProactiveAISettings />}
{activeTab === 'mcp' && <SettingsMcpPage />}
{activeTab === 'compliance' && <ComplianceTab />}
</div>
</div>
);
+1
View File
@@ -39,6 +39,7 @@ from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
from app.models.compliance import ComplianceIncident # noqa: F401
from app.models.contact import Contact, ContactPerson # noqa: F401
from app.models.contact_merge import ContactMergeHistory # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
+502
View File
@@ -0,0 +1,502 @@
"""Phase K Compliance Tests — integration tests with real DB.
Covers:
- K-REG: AI registry lists all agents with metadata
- K-DPIA: DPIA template export
- K-INC: Incident CRUD (create, list, update)
- K-RET: Retention policies list
- Tenant isolation for compliance incidents
- Admin-only enforcement (non-admin gets 403)
Uses the real PostgreSQL test DB (conftest fixtures).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from app.core.db import close_engine, reset_engine_for_testing
from app.core.permission_registry import init_permission_registry
from app.core.service_container import get_container
from app.main import create_app
from app.models.compliance import ComplianceIncident
from app.models.system_settings import SystemSettings
from app.models.tenant import Tenant
from app.models.user import User
from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture(scope="session", autouse=True)
async def _ensure_compliance_tables(engine: AsyncEngine):
"""Create compliance_incidents table and ensure system_settings has required columns."""
from app.core.db import Base
from sqlalchemy import text
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Ensure system_settings has backup_enabled and retention_config columns
# (test DB may have been created from an older schema)
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name='system_settings' AND column_name='backup_enabled'"
))
if result.rowcount == 0:
await conn.execute(text(
"ALTER TABLE system_settings ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT false"
))
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name='system_settings' AND column_name='retention_config'"
))
if result.rowcount == 0:
await conn.execute(text(
"ALTER TABLE system_settings ADD COLUMN retention_config JSONB DEFAULT '{}'::jsonb"
))
yield
@pytest_asyncio.fixture(autouse=True)
async def _init_perms():
"""Ensure permission registry is initialized for every test."""
init_permission_registry(
active_plugin_names={
"permissions",
"automation",
}
)
yield
# ──────────────────────────────────────────────────────────────────────────
# API fixtures
# ──────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def compliance_app(engine: AsyncEngine, redis_client):
"""FastAPI app with automation + permissions plugins registered and active."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
init_permission_registry(
active_plugin_names={
"permissions",
"automation",
}
)
container = get_container()
await container.initialize()
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.builtins.automation.plugin import AutomationPlugin
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(AutomationPlugin())
reset_plugin_service_for_testing(registry)
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "automation")
await registry.activate(session, "automation")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def compliance_client(compliance_app) -> AsyncClient:
"""HTTP test client with compliance routes available."""
transport = ASGITransport(app=compliance_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def admin_authed_client(
compliance_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data."""
seed = await seed_tenant_and_users(db_session)
# Grant is_system_admin so require_permission('system:admin') passes
await db_session.execute(
update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True)
)
await db_session.commit()
await login_client(compliance_client, "admin@tenanta.com")
return compliance_client, seed
@pytest_asyncio.fixture
async def viewer_authed_client(
compliance_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated non-admin (viewer) client."""
seed = await seed_tenant_and_users(db_session)
await login_client(compliance_client, "viewer@tenanta.com")
return compliance_client, seed
# ──────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────
async def _create_agent(db, tenant_id, user_id, **overrides):
"""Create a real AgentDefinition in the DB and return it."""
agent = AgentDefinition(
tenant_id=tenant_id,
name=overrides.get("name", "Test Agent"),
description=overrides.get("description", "A test agent"),
system_prompt=overrides.get("system_prompt", "You are a helpful assistant."),
tool_ids=overrides.get("tool_ids", []),
temperature=overrides.get("temperature", 0.3),
max_tokens=overrides.get("max_tokens", 1000),
max_steps=overrides.get("max_steps", 20),
created_by=user_id,
ai_use_case_metadata=overrides.get("ai_use_case_metadata", {}),
)
db.add(agent)
await db.flush()
return agent
async def _create_system_settings(db, tenant_id):
"""Create minimal system settings for a tenant."""
settings = SystemSettings(
tenant_id=tenant_id,
company_name="Test Company",
company_street="Test St",
company_city="Test City",
company_zip="12345",
company_country="DE",
)
db.add(settings)
await db.flush()
return settings
# ──────────────────────────────────────────────────────────────────────────
# K-REG: AI Registry Tests
# ──────────────────────────────────────────────────────────────────────────
class TestAIRegistry:
async def test_ai_registry_lists_all_agents(self, admin_authed_client):
"""GET /api/v1/compliance/ai-registry returns all agents with metadata."""
client, seed = admin_authed_client
# Create an agent via the automation API with use-case metadata
resp = await client.post(
"/api/v1/agents/",
json={
"name": "Compliance Test Agent",
"description": "Test agent for compliance",
"system_prompt": "You are a test assistant.",
"ai_use_case_metadata": {
"intended_purpose": "Test purpose",
"owner": "admin@test.com",
"risk_class": "medium",
"oversight_policy": "on_high_risk",
"data_categories": ["contact_data"],
"human_review_required": True,
},
},
)
assert resp.status_code == 201, f"Agent creation failed: {resp.text}"
# Now query the AI registry
resp = await client.get("/api/v1/compliance/ai-registry")
assert resp.status_code == 200, f"AI registry failed: {resp.text}"
data = resp.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 1
# Check the agent appears with metadata
found = False
for item in data["items"]:
if item["name"] == "Compliance Test Agent":
found = True
meta = item["ai_use_case_metadata"]
assert meta["intended_purpose"] == "Test purpose"
assert meta["owner"] == "admin@test.com"
assert meta["risk_class"] == "medium"
assert meta["oversight_policy"] == "on_high_risk"
assert "contact_data" in meta["data_categories"]
assert meta["human_review_required"] is True
assert "validation_warnings" in item
break
assert found, "Created agent not found in AI registry"
async def test_ai_registry_admin_only(self, viewer_authed_client):
"""Non-admin user gets 403 on AI registry."""
client, seed = viewer_authed_client
resp = await client.get("/api/v1/compliance/ai-registry")
assert resp.status_code == 403, f"Expected 403, got {resp.status_code}: {resp.text}"
# ──────────────────────────────────────────────────────────────────────────
# K-DPIA: DPIA Template Tests
# ──────────────────────────────────────────────────────────────────────────
class TestDPIATemplate:
async def test_dpia_template_export(self, admin_authed_client):
"""GET /api/v1/compliance/dpia-template returns pre-filled template."""
client, seed = admin_authed_client
# Create an agent with metadata
resp = await client.post(
"/api/v1/agents/",
json={
"name": "DPIA Test Agent",
"description": "Agent for DPIA test",
"system_prompt": "You are a test assistant.",
"ai_use_case_metadata": {
"intended_purpose": "Email summarization for CRM",
"owner": "dpo@test.com",
"risk_class": "high",
"oversight_policy": "always_required",
"data_categories": ["email_content", "contact_data"],
"allowed_actions": ["read", "summarize"],
"human_review_required": True,
},
},
)
assert resp.status_code == 201, f"Agent creation failed: {resp.text}"
agent_id = resp.json()["id"]
# Get DPIA template
resp = await client.get(
"/api/v1/compliance/dpia-template",
params={"agent_id": agent_id},
)
assert resp.status_code == 200, f"DPIA template failed: {resp.text}"
data = resp.json()
assert data["use_case_id"] == agent_id
assert data["agent_name"] == "DPIA Test Agent"
assert data["intended_purpose"] == "Email summarization for CRM"
assert data["owner"] == "dpo@test.com"
assert data["risk_class"] == "high"
assert data["oversight_policy"] == "always_required"
assert "email_content" in data["data_categories"]
assert "contact_data" in data["data_categories"]
assert data["human_review_required"] is True
assert "disclaimer" in data
assert "NOT" in data["disclaimer"] or "not" in data["disclaimer"].lower()
assert "validation_warnings" in data
async def test_dpia_template_not_found(self, admin_authed_client):
"""GET /api/v1/compliance/dpia-template with invalid agent_id returns 404."""
client, seed = admin_authed_client
fake_id = str(uuid.uuid4())
resp = await client.get(
"/api/v1/compliance/dpia-template",
params={"agent_id": fake_id},
)
assert resp.status_code == 404
# ──────────────────────────────────────────────────────────────────────────
# K-INC: Incident Register Tests
# ──────────────────────────────────────────────────────────────────────────
class TestIncidentCRUD:
async def test_incident_crud(self, admin_authed_client):
"""POST/GET/PATCH /api/v1/compliance/incidents full lifecycle."""
client, seed = admin_authed_client
# Create
resp = await client.post(
"/api/v1/compliance/incidents",
json={
"incident_type": "ai",
"title": "Test AI Incident",
"description": "An AI system produced biased output",
"provider": "openai",
"measures_taken": "Disabled agent, investigating",
"status": "open",
},
)
assert resp.status_code == 201, f"Incident creation failed: {resp.text}"
incident = resp.json()
assert incident["title"] == "Test AI Incident"
assert incident["incident_type"] == "ai"
assert incident["status"] == "open"
assert incident["id"] is not None
incident_id = incident["id"]
# List
resp = await client.get("/api/v1/compliance/incidents")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
found = any(i["id"] == incident_id for i in data["items"])
assert found, "Created incident not found in list"
# Update (resolve)
resp = await client.patch(
f"/api/v1/compliance/incidents/{incident_id}",
json={"status": "resolved", "measures_taken": "Fixed by retraining"},
)
assert resp.status_code == 200, f"Incident update failed: {resp.text}"
updated = resp.json()
assert updated["status"] == "resolved"
assert updated["measures_taken"] == "Fixed by retraining"
assert updated["resolved_at"] is not None
assert updated["resolved_by"] is not None
async def test_incident_admin_only(self, viewer_authed_client):
"""Non-admin user gets 403 on incidents."""
client, seed = viewer_authed_client
# GET
resp = await client.get("/api/v1/compliance/incidents")
assert resp.status_code == 403, f"Expected 403, got {resp.status_code}: {resp.text}"
# POST
resp = await client.post(
"/api/v1/compliance/incidents",
json={"title": "Should Fail", "incident_type": "ai"},
)
assert resp.status_code == 403
async def test_incident_invalid_type(self, admin_authed_client):
"""POST with invalid incident_type returns 400."""
client, seed = admin_authed_client
resp = await client.post(
"/api/v1/compliance/incidents",
json={"title": "Bad Type", "incident_type": "invalid_type"},
)
assert resp.status_code == 400
async def test_incident_not_found(self, admin_authed_client):
"""PATCH with non-existent incident returns 404."""
client, seed = admin_authed_client
fake_id = str(uuid.uuid4())
resp = await client.patch(
f"/api/v1/compliance/incidents/{fake_id}",
json={"status": "resolved"},
)
assert resp.status_code == 404
class TestTenantIsolation:
async def test_tenant_isolation(self, admin_authed_client, db_session):
"""Compliance incidents are tenant-isolated."""
client, seed = admin_authed_client
# Create incident in tenant A
resp = await client.post(
"/api/v1/compliance/incidents",
json={"title": "Tenant A Incident", "incident_type": "ai"},
)
assert resp.status_code == 201
incident_a_id = resp.json()["id"]
# Create incident directly in tenant B via DB
incident_b = ComplianceIncident(
tenant_id=seed["tenant_b"].id,
incident_type="privacy",
title="Tenant B Incident",
)
db_session.add(incident_b)
await db_session.flush()
await db_session.commit()
# List incidents — should only see tenant A's
resp = await client.get("/api/v1/compliance/incidents")
assert resp.status_code == 200
data = resp.json()
titles = [i["title"] for i in data["items"]]
assert "Tenant A Incident" in titles
assert "Tenant B Incident" not in titles
# ──────────────────────────────────────────────────────────────────────────
# K-RET: Retention Policies Tests
# ──────────────────────────────────────────────────────────────────────────
class TestRetentionPolicies:
async def test_retention_policies_list(self, admin_authed_client, db_session):
"""GET /api/v1/compliance/retention-policies returns all policies."""
client, seed = admin_authed_client
# Create system settings for the tenant
await _create_system_settings(db_session, seed["tenant_a"].id)
await db_session.commit()
resp = await client.get("/api/v1/compliance/retention-policies")
assert resp.status_code == 200, f"Retention policies failed: {resp.text}"
data = resp.json()
assert "items" in data
assert data["total"] == 5 # audit_log, backup, trash, knowledge, agent_memory
keys = [p["key"] for p in data["items"]]
assert "audit_log" in keys
assert "backup" in keys
assert "trash" in keys
assert "knowledge" in keys
assert "agent_memory" in keys
# Check default values
for policy in data["items"]:
assert policy["current_days"] == policy["default_days"]
assert policy["editable"] is True
async def test_retention_policy_update(self, admin_authed_client, db_session):
"""PATCH /api/v1/compliance/retention-policies/{key} updates days."""
client, seed = admin_authed_client
# Create system settings
await _create_system_settings(db_session, seed["tenant_a"].id)
await db_session.commit()
resp = await client.patch(
"/api/v1/compliance/retention-policies/audit_log",
json={"days": 180},
)
assert resp.status_code == 200, f"Retention update failed: {resp.text}"
data = resp.json()
assert data["key"] == "audit_log"
assert data["days"] == 180
# Verify the update is reflected
resp = await client.get("/api/v1/compliance/retention-policies")
assert resp.status_code == 200
policies = resp.json()["items"]
audit_policy = next(p for p in policies if p["key"] == "audit_log")
assert audit_policy["current_days"] == 180
async def test_retention_policy_invalid_key(self, admin_authed_client, db_session):
"""PATCH with invalid key returns 400."""
client, seed = admin_authed_client
await _create_system_settings(db_session, seed["tenant_a"].id)
await db_session.commit()
resp = await client.patch(
"/api/v1/compliance/retention-policies/invalid_key",
json={"days": 30},
)
assert resp.status_code == 400