from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope, require_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_core.core.configuration_control import ( ConfigurationControlError, ensure_configuration_change_allowed, record_configuration_change_applied, ) from govoplan_core.core.policy import PolicyDecision, PolicySourceStep from govoplan_core.db.session import get_session from govoplan_policy.backend.retention import ( PrivacyPolicyError, RETENTION_POLICY_FIELD_KEYS, apply_retention_policy, effective_privacy_policy, effective_privacy_policy_sources, get_privacy_policy_for_scope, parent_privacy_policy, parent_privacy_policy_sources, set_privacy_policy_for_scope, simulate_privacy_policy_change, ) from .schemas import ( PrivacyRetentionPolicyExplainResponse, PrivacyRetentionPolicyItem, PrivacyRetentionPolicyScopeRequest, PrivacyRetentionPolicyScopeResponse, PrivacyRetentionPolicySimulationResponse, RetentionRunRequest, RetentionRunResponse, ) router = APIRouter(prefix="/admin", tags=["admin"]) def _require_permission(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None: if scope_type == "system": _require_permission(principal, "system:settings:read") else: _require_permission(principal, "admin:policies:read") def _require_privacy_policy_write(principal: ApiPrincipal, scope_type: str) -> None: if scope_type == "system": _require_permission(principal, "system:settings:write") else: _require_permission(principal, "admin:policies:write") def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: return HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": str(exc), "plan": exc.plan}, ) @router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) def read_privacy_retention_policy( scope_type: str, scope_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_read(principal, clean_scope) try: policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) return PrivacyRetentionPolicyScopeResponse( scope_type=clean_scope, scope_id=scope_id, policy=policy, effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ) except PrivacyPolicyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) def write_privacy_retention_policy( scope_type: str, payload: PrivacyRetentionPolicyScopeRequest, scope_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_write(principal, clean_scope) policy_value = payload.policy.model_dump(mode="json", exclude_none=True) before_value: dict[str, Any] | None = None if clean_scope == "system": before_value = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) try: approval = ensure_configuration_change_allowed( session, key="privacy_retention_policy", value=policy_value, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), change_request_id=payload.change_request_id, target={"scope_type": clean_scope, "scope_id": scope_id}, ) except ConfigurationControlError as exc: raise _configuration_control_http_error(exc) from exc else: approval = None try: policy = set_privacy_policy_for_scope( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, policy=policy_value, ) if clean_scope == "system": record_configuration_change_applied( session, key="privacy_retention_policy", before_value=before_value, after_value=policy, actor_user_id=principal.user.id, approval=approval, target={"scope_type": clean_scope, "scope_id": scope_id}, audit_event="privacy_retention.policy_updated", ) audit_from_principal( session, principal, action="privacy_retention.policy_updated", scope="system" if clean_scope == "system" else "tenant", object_type="privacy_retention_policy", object_id=clean_scope if scope_id is None else f"{clean_scope}:{scope_id}", details={"scope_type": clean_scope, "scope_id": scope_id}, ) session.commit() effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) return PrivacyRetentionPolicyScopeResponse( scope_type=clean_scope, scope_id=scope_id, policy=policy, effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ) except PrivacyPolicyError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.get("/privacy-retention/policies/{scope_type}/explain", response_model=PrivacyRetentionPolicyExplainResponse) def explain_privacy_retention_policy( scope_type: str, scope_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_read(principal, clean_scope) try: effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) effective_sources = _effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) parent_sources = _parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) blocked_fields = _blocked_privacy_retention_fields(parent) decision_sources = parent_sources or effective_sources decision = PolicyDecision( allowed=not blocked_fields, reason="Parent retention policy locks lower-level changes." if blocked_fields else None, source_path=tuple(PolicySourceStep.from_mapping(source) for source in decision_sources), requirements=tuple(blocked_fields), details={"blocked_fields": blocked_fields}, ) return PrivacyRetentionPolicyExplainResponse( scope_type=clean_scope, scope_id=scope_id, decision=decision.to_dict(), effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources, blocked_fields=blocked_fields, ) except PrivacyPolicyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.post("/privacy-retention/policies/{scope_type}/simulate", response_model=PrivacyRetentionPolicySimulationResponse) def simulate_privacy_retention_policy( scope_type: str, payload: PrivacyRetentionPolicyScopeRequest, scope_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_write(principal, clean_scope) try: return PrivacyRetentionPolicySimulationResponse( scope_type=clean_scope, scope_id=scope_id, simulation=simulate_privacy_policy_change( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, policy=payload.policy.model_dump(mode="json", exclude_none=True), ), ) except PrivacyPolicyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc def _blocked_privacy_retention_fields(parent) -> list[str]: if parent is None: return [] payload = parent.model_dump(mode="json") allow_lower_level_limits = payload.get("allow_lower_level_limits") or {} return [key for key in RETENTION_POLICY_FIELD_KEYS if allow_lower_level_limits.get(key) is False] def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): if scope_type == "system": return [] return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): if scope_type == "system": return effective_privacy_policy_sources(session) if scope_type == "tenant": return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id) if scope_type == "campaign" and scope_id: return effective_privacy_policy_sources(session, campaign_id=scope_id) if scope_type == "user" and scope_id: return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id) if scope_type == "group" and scope_id: return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id) return effective_privacy_policy_sources(session, tenant_id=tenant_id) def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): if scope_type == "system": return None return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): if scope_type == "system": return effective_privacy_policy(session) if scope_type == "tenant": return effective_privacy_policy(session, tenant_id=scope_id or tenant_id) if scope_type == "campaign" and scope_id: return effective_privacy_policy(session, campaign_id=scope_id) if scope_type == "user" and scope_id: return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) if scope_type == "group" and scope_id: return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) return effective_privacy_policy(session, tenant_id=tenant_id) @router.post("/system/retention/run", response_model=RetentionRunResponse) def run_retention_policy( payload: RetentionRunRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): result = apply_retention_policy(session, dry_run=payload.dry_run) audit_from_principal( session, principal, action="retention_policy.run", scope="system", object_type="retention_policy", object_id="global", details={"dry_run": payload.dry_run, "counts": result.get("counts")}, ) session.commit() return RetentionRunResponse(result=result)