"""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.flush() result = _incident_to_dict(incident) await db.commit() return result @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"}