From e59db34a6d18e300e92cd68a1cde2f5bd9ed6075 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 21 Aug 2026 01:58:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(K):=20Phase=20K=20EU=20Compliance=20?= =?UTF-8?q?=E2=80=94=20AI=20Registry,=20DPIA,=20Incident=20Register,=20Ret?= =?UTF-8?q?ention=20Admin,=20Tests,=20Doku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROGRESS.md | 17 + alembic/versions/0133_compliance_incidents.py | 51 ++ app/main.py | 2 + app/models/__init__.py | 2 + app/models/compliance.py | 59 ++ app/models/system_settings.py | 2 + app/routes/__init__.py | 1 + app/routes/compliance.py | 502 ++++++++++++++++++ docs/compliance.md | 164 ++++++ frontend/src/api/compliance.ts | 147 +++++ frontend/src/pages/ComplianceTab.tsx | 463 ++++++++++++++++ frontend/src/pages/SettingsAI.tsx | 5 +- tests/conftest.py | 1 + tests/test_phase_k_compliance.py | 502 ++++++++++++++++++ 14 files changed, 1917 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0133_compliance_incidents.py create mode 100644 app/models/compliance.py create mode 100644 app/routes/compliance.py create mode 100644 docs/compliance.md create mode 100644 frontend/src/api/compliance.ts create mode 100644 frontend/src/pages/ComplianceTab.tsx create mode 100644 tests/test_phase_k_compliance.py diff --git a/PROGRESS.md b/PROGRESS.md index 22c7141..bfd54d6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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.* diff --git a/alembic/versions/0133_compliance_incidents.py b/alembic/versions/0133_compliance_incidents.py new file mode 100644 index 0000000..54bc332 --- /dev/null +++ b/alembic/versions/0133_compliance_incidents.py @@ -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") diff --git a/app/main.py b/app/main.py index 7beb176..8bc8ea0 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 2d161a6..4c9f5cb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/compliance.py b/app/models/compliance.py new file mode 100644 index 0000000..0113448 --- /dev/null +++ b/app/models/compliance.py @@ -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 + ) diff --git a/app/models/system_settings.py b/app/models/system_settings.py index 5e1d2a7..61faeee 100644 --- a/app/models/system_settings.py +++ b/app/models/system_settings.py @@ -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) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index a44e025..7166944 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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 diff --git a/app/routes/compliance.py b/app/routes/compliance.py new file mode 100644 index 0000000..f6fe808 --- /dev/null +++ b/app/routes/compliance.py @@ -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"} diff --git a/docs/compliance.md b/docs/compliance.md new file mode 100644 index 0000000..5724709 --- /dev/null +++ b/docs/compliance.md @@ -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 diff --git a/frontend/src/api/compliance.ts b/frontend/src/api/compliance.ts new file mode 100644 index 0000000..973cf24 --- /dev/null +++ b/frontend/src/api/compliance.ts @@ -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; + 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 { + return apiGet('/compliance/ai-registry'); +} + +export async function fetchDPIATemplate(agentId: string): Promise { + return apiGet('/compliance/dpia-template', { + params: { agent_id: agentId }, + }); +} + +export async function fetchIncidents(params?: { + status?: string; + incident_type?: string; + limit?: number; + offset?: number; +}): Promise { + return apiGet('/compliance/incidents', { params }); +} + +export async function createIncident(data: IncidentCreate): Promise { + return apiPost('/compliance/incidents', data); +} + +export async function updateIncident( + id: string, + data: IncidentUpdate +): Promise { + return apiPatch(`/compliance/incidents/${id}`, data); +} + +export async function fetchRetentionPolicies(): Promise { + return apiGet('/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 } + ); +} diff --git a/frontend/src/pages/ComplianceTab.tsx b/frontend/src/pages/ComplianceTab.tsx new file mode 100644 index 0000000..1731c6c --- /dev/null +++ b/frontend/src/pages/ComplianceTab.tsx @@ -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('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 ( +
+
+ +
+ + {subTab === 'registry' && } + {subTab === 'incidents' && } + {subTab === 'retention' && } +
+ ); +} + +// ─── 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

{t('common.loading', 'Laden...')}

; + } + if (error) { + return

{t('common.error', 'Fehler beim Laden')}

; + } + if (!data || data.items.length === 0) { + return

{t('compliance.noAgents', 'Keine AI-Agenten gefunden')}

; + } + + return ( +
+ + + + + + + + + + + + + + + {data.items.map((entry: AIRegistryEntry) => { + const meta = entry.ai_use_case_metadata as Record; + return ( + + + + + + + + + + + ); + })} + +
{t('compliance.name', 'Name')}{t('compliance.intendedPurpose', 'Zweck')}{t('compliance.owner', 'Verantwortlich')}{t('compliance.riskClass', 'Risiko')}{t('compliance.oversight', 'Oversight')}{t('compliance.dataCategories', 'Daten')}{t('compliance.status', 'Status')}{t('compliance.actions', 'Aktionen')}
{entry.name}{String(meta.intended_purpose || '')}{String(meta.owner || '')} + + {String(meta.risk_class || 'low')} + + {String(meta.oversight_policy || '')} + {Array.isArray(meta.data_categories) ? (meta.data_categories as string[]).join(', ') : ''} + + + {entry.is_active ? t('compliance.active', 'Aktiv') : t('compliance.inactive', 'Inaktiv')} + + + +
+ {data.items.some((e) => e.validation_warnings.length > 0) && ( +
+ {t('compliance.warningsNote', 'Einige Agenten haben Validierungswarnungen. Siehe Details im AI-Register.')} +
+ )} +
+ ); +} + +// ─── Incidents Panel ─── + +function IncidentsPanel() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ + 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

{t('common.loading', 'Laden...')}

; + } + if (error) { + return

{t('common.error', 'Fehler beim Laden')}

; + } + + return ( +
+ + + {showForm && ( +
+
+ + +
+
+ + 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')} + /> +
+
+ +