2026-08-21 01:58:46 +02:00
""" 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
2026-08-23 12:18:02 +02:00
from app . plugins . builtins . contracts import get_contract as _get_automation_contract
2026-08-21 01:58:46 +02:00
router = APIRouter ( prefix = " /api/v1/compliance " , tags = [ " compliance " ] )
2026-08-23 12:18:02 +02:00
def _get_agent_definition_model ( ) :
""" Resolve the AgentDefinition model via the automation contract.
Returns ``None`` when the automation plugin is not active — callers
respond with 503 instead of failing at import time.
"""
contract = _get_automation_contract ( " automation " )
return getattr ( contract , " AgentDefinition " , None ) if contract else None
2026-08-21 01:58:46 +02:00
# ─── 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 " ] )
2026-08-23 12:18:02 +02:00
agent_model = _get_agent_definition_model ( )
if agent_model is None :
raise HTTPException ( 503 , detail = { " detail " : " Automation plugin not active " , " code " : " plugin_inactive " } )
2026-08-21 01:58:46 +02:00
q = (
2026-08-23 12:18:02 +02:00
select ( agent_model )
2026-08-21 01:58:46 +02:00
. where (
2026-08-23 12:18:02 +02:00
agent_model . tenant_id == tenant_id ,
agent_model . deleted_at . is_ ( None ) ,
2026-08-21 01:58:46 +02:00
)
2026-08-23 12:18:02 +02:00
. order_by ( agent_model . name )
2026-08-21 01:58:46 +02:00
)
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
2026-08-23 12:18:02 +02:00
agent_model = _get_agent_definition_model ( )
if agent_model is None :
raise HTTPException ( 503 , detail = { " detail " : " Automation plugin not active " , " code " : " plugin_inactive " } )
q = select ( agent_model ) . where (
agent_model . id == aid ,
agent_model . tenant_id == tenant_id ,
agent_model . deleted_at . is_ ( None ) ,
2026-08-21 01:58:46 +02:00
)
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 )
2026-08-22 07:10:25 +02:00
await db . flush ( )
result = _incident_to_dict ( incident )
2026-08-21 01:58:46 +02:00
await db . commit ( )
2026-08-22 07:10:25 +02:00
return result
2026-08-21 01:58:46 +02:00
@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 " }