from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, 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_core.privacy.schemas import RETENTION_POLICY_FIELD_KEYS from govoplan_policy.backend.retention import ( PrivacyPolicyError, 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 govoplan_policy.backend.definition_policy_service import ( DefinitionPolicyError, definition_policy_response_payload, definition_policy_state, remove_definition_policy, save_definition_policy, ) from govoplan_policy.backend.policy_overrides import PolicyOverrideError from govoplan_policy.backend.impact_preview import ( PolicyImpactPopulationSpec, PolicyImpactPreviewError, policy_impact_proposal_hash, preview_policy_impact as build_policy_impact_preview, ) from govoplan_policy.backend.campaign_archive_encryption import ( CampaignArchiveEncryptionPolicyError, campaign_archive_encryption_policy_state, save_campaign_archive_encryption_policy, ) from govoplan_policy.backend.view_policy_service import ( ViewPolicyError, remove_view_policy, save_view_policy, view_policy_response_payload, view_policy_state, ) from .schemas import ( CampaignArchiveEncryptionPolicyScopeRequest, CampaignArchiveEncryptionPolicyScopeResponse, DefinitionPolicyScopeRequest, DefinitionPolicyScopeResponse, PrivacyRetentionPolicyExplainResponse, PrivacyRetentionPolicyItem, PrivacyRetentionPolicyScopeRequest, PrivacyRetentionPolicyScopeResponse, PrivacyRetentionPolicySimulationResponse, PolicyImpactPreviewRequest, PolicyImpactPreviewResponse, RetentionRunRequest, RetentionRunResponse, ViewPolicyScopeRequest, ViewPolicyScopeResponse, ) router = APIRouter(prefix="/admin", tags=["admin"]) RECENT_POLICY_AUTHENTICATION_WINDOW = timedelta(minutes=15) 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}, ) def _require_recent_policy_authentication(principal: ApiPrincipal) -> None: auth_session = principal.auth_session created_at = getattr(auth_session, "created_at", None) if isinstance(created_at, datetime): if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) elapsed = datetime.now(timezone.utc) - created_at if timedelta(0) <= elapsed <= RECENT_POLICY_AUTHENTICATION_WINDOW: return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "code": "recent_authentication_required", "message": ( "System-wide policy changes require authentication within the " "last 15 minutes." ), }, ) def _archive_encryption_policy_response( *, scope_type: str, scope_id: str | None, state, ) -> CampaignArchiveEncryptionPolicyScopeResponse: row = state.row return CampaignArchiveEncryptionPolicyScopeResponse( scope_type=scope_type, scope_id=scope_id, id=row.id if row else None, revision=row.revision if row else None, policy=dict(row.policy) if row and isinstance(row.policy, dict) else {}, effective_policy=state.effective.to_dict(), parent_policy=state.parent.to_dict(), ) @router.get( "/campaign-archive-encryption/policies/{scope_type}", response_model=CampaignArchiveEncryptionPolicyScopeResponse, ) def read_campaign_archive_encryption_policy( scope_type: str, scope_id: str | None = Query(default=None), owner_type: str | None = Query(default=None), owner_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): _require_permission(principal, "admin:policies:read") clean_scope = scope_type.strip().casefold() try: state = campaign_archive_encryption_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, owner_type=owner_type, owner_id=owner_id, ) return _archive_encryption_policy_response( scope_type=clean_scope, scope_id=scope_id, state=state, ) except (CampaignArchiveEncryptionPolicyError, PolicyOverrideError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.put( "/campaign-archive-encryption/policies/{scope_type}", response_model=CampaignArchiveEncryptionPolicyScopeResponse, ) def write_campaign_archive_encryption_policy( scope_type: str, payload: CampaignArchiveEncryptionPolicyScopeRequest, scope_id: str | None = Query(default=None), owner_type: str | None = Query(default=None), owner_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): _require_permission(principal, "admin:policies:write") clean_scope = scope_type.strip().casefold() policy_value = payload.policy.model_dump(mode="json", exclude_none=True) try: before = campaign_archive_encryption_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, owner_type=owner_type, owner_id=owner_id, ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, key="campaign_archive_encryption_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}, ) else: approval = None state = save_campaign_archive_encryption_policy( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, owner_type=owner_type, owner_id=owner_id, policy=policy_value, actor_id=principal.user.id, ) if clean_scope == "system": record_configuration_change_applied( session, key="campaign_archive_encryption_policy", before_value=(dict(before.row.policy) if before.row else {}), after_value=policy_value, actor_user_id=principal.user.id, approval=approval, target={"scope_type": clean_scope}, audit_event="campaign_archive_encryption_policy.updated", ) audit_from_principal( session, principal, action="campaign_archive_encryption_policy.updated", scope="system" if clean_scope == "system" else "tenant", object_type="campaign_archive_encryption_policy", object_id=f"{clean_scope}:{scope_id or ''}", details={ "scope_type": clean_scope, "scope_id": scope_id, "owner_type": owner_type, "owner_id": owner_id, "policy_hash": state.effective.policy_hash, "fields": sorted(policy_value), }, ) session.commit() return _archive_encryption_policy_response( scope_type=clean_scope, scope_id=scope_id, state=state, ) except ConfigurationControlError as exc: session.rollback() raise _configuration_control_http_error(exc) from exc except (CampaignArchiveEncryptionPolicyError, PolicyOverrideError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc def _definition_policy_response( *, module_id: str, scope_type: str, scope_id: str | None, state, ) -> DefinitionPolicyScopeResponse: return DefinitionPolicyScopeResponse( module_id=module_id, scope_type=scope_type, scope_id=scope_id, **definition_policy_response_payload(state), ) @router.get( "/definition-policies/{module_id}/{scope_type}", response_model=DefinitionPolicyScopeResponse, ) def read_definition_policy( module_id: str, 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: state = definition_policy_state( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) return _definition_policy_response( module_id=module_id, scope_type=clean_scope, scope_id=scope_id, state=state, ) except (DefinitionPolicyError, PolicyOverrideError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.put( "/definition-policies/{module_id}/{scope_type}", response_model=DefinitionPolicyScopeResponse, ) def write_definition_policy( module_id: str, scope_type: str, payload: DefinitionPolicyScopeRequest, 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) try: before = definition_policy_state( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, key="definition_policy", value=policy_value, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), change_request_id=payload.change_request_id, target={"module_id": module_id, "scope_type": clean_scope}, ) else: approval = None state = save_definition_policy( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, policy=policy_value, actor_id=principal.user.id, ) if clean_scope == "system": record_configuration_change_applied( session, key="definition_policy", before_value=definition_policy_response_payload(before)["policy"], after_value=policy_value, actor_user_id=principal.user.id, approval=approval, target={"module_id": module_id, "scope_type": clean_scope}, audit_event="definition_policy.updated", ) audit_from_principal( session, principal, action="definition_policy.updated", scope="system" if clean_scope == "system" else "tenant", object_type="definition_policy", object_id=f"{module_id}:{clean_scope}:{scope_id or ''}", details={ "module_id": module_id, "scope_type": clean_scope, "scope_id": scope_id, "fields": sorted(policy_value), }, ) session.commit() return _definition_policy_response( module_id=module_id, scope_type=clean_scope, scope_id=scope_id, state=state, ) except ConfigurationControlError as exc: session.rollback() raise _configuration_control_http_error(exc) from exc except (DefinitionPolicyError, PolicyOverrideError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.delete( "/definition-policies/{module_id}/{scope_type}", response_model=DefinitionPolicyScopeResponse, ) def delete_definition_policy_route( module_id: str, scope_type: str, scope_id: str | None = Query(default=None), change_request_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: before = definition_policy_state( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, key="definition_policy", value={}, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), change_request_id=change_request_id, target={"module_id": module_id, "scope_type": clean_scope}, ) else: approval = None removed = remove_definition_policy( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if removed: if clean_scope == "system": record_configuration_change_applied( session, key="definition_policy", before_value=definition_policy_response_payload(before)["policy"], after_value={}, actor_user_id=principal.user.id, approval=approval, target={"module_id": module_id, "scope_type": clean_scope}, audit_event="definition_policy.removed", ) audit_from_principal( session, principal, action="definition_policy.removed", scope="system" if clean_scope == "system" else "tenant", object_type="definition_policy", object_id=f"{module_id}:{clean_scope}:{scope_id or ''}", details={ "module_id": module_id, "scope_type": clean_scope, "scope_id": scope_id, }, ) session.commit() state = definition_policy_state( session, module_id=module_id, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) return _definition_policy_response( module_id=module_id, scope_type=clean_scope, scope_id=scope_id, state=state, ) except ConfigurationControlError as exc: session.rollback() raise _configuration_control_http_error(exc) from exc except (DefinitionPolicyError, PolicyOverrideError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc def _view_policy_response( *, scope_type: str, scope_id: str | None, state, ) -> ViewPolicyScopeResponse: return ViewPolicyScopeResponse( scope_type=scope_type, scope_id=scope_id, **view_policy_response_payload(state), ) @router.post( "/policy-impact/preview", response_model=PolicyImpactPreviewResponse, ) def preview_policy_impact_route( payload: PolicyImpactPreviewRequest, request: Request, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): _require_privacy_policy_read(principal, payload.scope_type) registry = getattr(request.app.state, "govoplan_registry", None) if registry is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="The platform registry is not available.", ) try: preview = build_policy_impact_preview( session, registry=registry, tenant_id=principal.tenant_id, policy_family=payload.policy_family, scope_type=payload.scope_type, scope_id=payload.scope_id, proposed_policy=payload.proposed_policy.model_dump( mode="json", exclude_none=True, ), populations=tuple( PolicyImpactPopulationSpec( provider_id=population.provider_id, selector=population.selector, limit=population.limit, ) for population in payload.populations ), actor_scopes=tuple(principal.scopes), include_details=payload.include_details, details_allowed=has_scope(principal, "policy:impact:details"), ) audit_from_principal( session, principal, action="policy.impact_previewed", scope="system" if payload.scope_type == "system" else "tenant", object_type="policy_impact_preview", object_id=preview.preview_id, details={ "policy_family": preview.policy_family, "scope_type": preview.scope_type, "scope_id": preview.scope_id, "proposal_hash": preview.proposal_hash, "counts": dict(preview.counts), "providers": [ { "provider_id": population.get("provider_id"), "state": population.get("state"), "returned": population.get("returned"), "total_available": population.get("total_available"), } for population in preview.populations ], "details_hidden": preview.details_hidden, "high_impact": preview.high_impact, }, ) session.commit() return PolicyImpactPreviewResponse.model_validate(preview.to_dict()) except (PolicyImpactPreviewError, ViewPolicyError, PolicyOverrideError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.get( "/view-policies/{scope_type}", response_model=ViewPolicyScopeResponse, ) def read_view_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: state = view_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) return _view_policy_response( scope_type=clean_scope, scope_id=scope_id, state=state, ) except (ViewPolicyError, PolicyOverrideError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.put( "/view-policies/{scope_type}", response_model=ViewPolicyScopeResponse, ) def write_view_policy( scope_type: str, payload: ViewPolicyScopeRequest, 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) if clean_scope == "system": _require_recent_policy_authentication(principal) policy_value = payload.policy.model_dump(mode="json", exclude_none=True) if bool(payload.impact_preview_id) != bool(payload.impact_proposal_hash): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=( "Impact preview ID and proposal hash must be supplied together." ), ) try: before = view_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if payload.impact_proposal_hash is not None: proposal_hash = policy_impact_proposal_hash( family="view", scope_type=clean_scope, scope_id=scope_id, policy=policy_value, base_policy=before.local_policy, base_revision=(before.row.revision if before.row is not None else None), ) if proposal_hash != payload.impact_proposal_hash: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "code": "policy_impact_preview_stale", "message": ( "The policy draft or saved base changed after its " "impact preview. Run the preview again before saving." ), }, ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, key="view_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}, ) else: approval = None state = save_view_policy( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, policy=policy_value, actor_id=principal.user.id, ) if clean_scope == "system": record_configuration_change_applied( session, key="view_policy", before_value=view_policy_response_payload(before)["policy"], after_value=policy_value, actor_user_id=principal.user.id, approval=approval, target={"scope_type": clean_scope}, audit_event="view_policy.updated", ) audit_from_principal( session, principal, action="view_policy.updated", scope="system" if clean_scope == "system" else "tenant", object_type="view_policy", object_id=f"{clean_scope}:{scope_id or ''}", details={ "scope_type": clean_scope, "scope_id": scope_id, "fields": sorted(policy_value), "impact_preview_id": payload.impact_preview_id, "impact_proposal_hash": payload.impact_proposal_hash, }, ) session.commit() return _view_policy_response( scope_type=clean_scope, scope_id=scope_id, state=state, ) except ConfigurationControlError as exc: session.rollback() raise _configuration_control_http_error(exc) from exc except (ViewPolicyError, PolicyOverrideError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.delete( "/view-policies/{scope_type}", response_model=ViewPolicyScopeResponse, ) def delete_view_policy_route( scope_type: str, scope_id: str | None = Query(default=None), change_request_id: str | None = Query(default=None), impact_preview_id: str | None = Query(default=None, max_length=36), impact_proposal_hash: str | None = Query( default=None, min_length=64, max_length=64, ), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_write(principal, clean_scope) if clean_scope == "system": _require_recent_policy_authentication(principal) if bool(impact_preview_id) != bool(impact_proposal_hash): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=( "Impact preview ID and proposal hash must be supplied together." ), ) try: before = view_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if impact_proposal_hash is not None: proposal_hash = policy_impact_proposal_hash( family="view", scope_type=clean_scope, scope_id=scope_id, policy={}, base_policy=before.local_policy, base_revision=(before.row.revision if before.row is not None else None), ) if proposal_hash != impact_proposal_hash: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "code": "policy_impact_preview_stale", "message": ( "The saved policy base changed after its inherited-policy " "impact preview. Run the preview again before removing " "the override." ), }, ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, key="view_policy", value={}, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), change_request_id=change_request_id, target={"scope_type": clean_scope}, ) else: approval = None removed = remove_view_policy( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) if removed: if clean_scope == "system": record_configuration_change_applied( session, key="view_policy", before_value=view_policy_response_payload(before)["policy"], after_value={}, actor_user_id=principal.user.id, approval=approval, target={"scope_type": clean_scope}, audit_event="view_policy.removed", ) audit_from_principal( session, principal, action="view_policy.removed", scope="system" if clean_scope == "system" else "tenant", object_type="view_policy", object_id=f"{clean_scope}:{scope_id or ''}", details={ "scope_type": clean_scope, "scope_id": scope_id, "impact_preview_id": impact_preview_id, "impact_proposal_hash": impact_proposal_hash, }, ) session.commit() state = view_policy_state( session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id, ) return _view_policy_response( scope_type=clean_scope, scope_id=scope_id, state=state, ) except ConfigurationControlError as exc: session.rollback() raise _configuration_control_http_error(exc) from exc except (ViewPolicyError, PolicyOverrideError) 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}", 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)