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:
+17
@@ -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.*
|
*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")
|
||||||
@@ -37,6 +37,7 @@ from app.routes import ( # noqa: E402
|
|||||||
attachments,
|
attachments,
|
||||||
audit,
|
audit,
|
||||||
auth,
|
auth,
|
||||||
|
compliance,
|
||||||
backups,
|
backups,
|
||||||
bank_accounts,
|
bank_accounts,
|
||||||
contact_folder_permissions,
|
contact_folder_permissions,
|
||||||
@@ -567,6 +568,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(bank_accounts.router)
|
app.include_router(bank_accounts.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
|
app.include_router(compliance.router)
|
||||||
app.include_router(owner_transfer.router)
|
app.include_router(owner_transfer.router)
|
||||||
app.include_router(custom_field_definitions.router)
|
app.include_router(custom_field_definitions.router)
|
||||||
app.include_router(custom_fields.router)
|
app.include_router(custom_fields.router)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
|
|||||||
from app.models.backup import Backup
|
from app.models.backup import Backup
|
||||||
from app.models.bank_account import BankAccount
|
from app.models.bank_account import BankAccount
|
||||||
from app.models.consumer_inbox import ConsumerInbox
|
from app.models.consumer_inbox import ConsumerInbox
|
||||||
|
from app.models.compliance import ComplianceIncident
|
||||||
from app.models.contact import Contact, ContactPerson
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.contact_folder import ContactFolder
|
from app.models.contact_folder import ContactFolder
|
||||||
from app.models.contact_merge import ContactMergeHistory
|
from app.models.contact_merge import ContactMergeHistory
|
||||||
@@ -47,6 +48,7 @@ __all__ = [
|
|||||||
"NotificationPreference",
|
"NotificationPreference",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
"ApiToken",
|
"ApiToken",
|
||||||
|
"ComplianceIncident",
|
||||||
"Contact",
|
"Contact",
|
||||||
"ContactPerson",
|
"ContactPerson",
|
||||||
"ContactFolder",
|
"ContactFolder",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -54,3 +54,5 @@ class SystemSettings(Base, TenantMixin):
|
|||||||
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
# Automation plugin settings (JSONB)
|
# Automation plugin settings (JSONB)
|
||||||
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
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)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.routes import (
|
|||||||
ai_copilot, # noqa: F401
|
ai_copilot, # noqa: F401
|
||||||
attachments, # noqa: F401
|
attachments, # noqa: F401
|
||||||
audit, # noqa: F401
|
audit, # noqa: F401
|
||||||
|
compliance, # noqa: F401
|
||||||
auth, # noqa: F401
|
auth, # noqa: F401
|
||||||
bank_accounts, # noqa: F401
|
bank_accounts, # noqa: F401
|
||||||
contacts, # noqa: F401
|
contacts, # noqa: F401
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,17 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AISettingsPage } from './AISettings';
|
import { AISettingsPage } from './AISettings';
|
||||||
import { ProactiveAISettings } from './ProactiveAISettings';
|
import { ProactiveAISettings } from './ProactiveAISettings';
|
||||||
import { SettingsMcpPage } from './SettingsMcp';
|
import { SettingsMcpPage } from './SettingsMcp';
|
||||||
|
import { ComplianceTab } from './ComplianceTab';
|
||||||
|
|
||||||
export function SettingsAIPage() {
|
export function SettingsAIPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<'assistant' | 'proactive' | 'mcp'>('assistant');
|
const [activeTab, setActiveTab] = useState<'assistant' | 'proactive' | 'mcp' | 'compliance'>('assistant');
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'assistant' as const, label: t('nav.aiAssistant', 'KI Assistent') },
|
{ key: 'assistant' as const, label: t('nav.aiAssistant', 'KI Assistent') },
|
||||||
{ key: 'proactive' as const, label: t('settings.aiProactive', 'Proaktive KI') },
|
{ key: 'proactive' as const, label: t('settings.aiProactive', 'Proaktive KI') },
|
||||||
{ key: 'mcp' as const, label: t('settings.mcp', 'MCP') },
|
{ key: 'mcp' as const, label: t('settings.mcp', 'MCP') },
|
||||||
|
{ key: 'compliance' as const, label: t('settings.compliance', 'Compliance') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,7 @@ export function SettingsAIPage() {
|
|||||||
{activeTab === 'assistant' && <AISettingsPage />}
|
{activeTab === 'assistant' && <AISettingsPage />}
|
||||||
{activeTab === 'proactive' && <ProactiveAISettings />}
|
{activeTab === 'proactive' && <ProactiveAISettings />}
|
||||||
{activeTab === 'mcp' && <SettingsMcpPage />}
|
{activeTab === 'mcp' && <SettingsMcpPage />}
|
||||||
|
{activeTab === 'compliance' && <ComplianceTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.core.service_container import get_container # noqa: F401
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
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 import Contact, ContactPerson # noqa: F401
|
||||||
from app.models.contact_merge import ContactMergeHistory # noqa: F401
|
from app.models.contact_merge import ContactMergeHistory # noqa: F401
|
||||||
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user