feat: preview governed policy impact

This commit is contained in:
2026-08-20 20:27:17 +02:00
parent be5e3a7d72
commit 861e9b8b8d
10 changed files with 1232 additions and 36 deletions
+183 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
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
@@ -34,6 +35,12 @@ from govoplan_policy.backend.definition_policy_service import (
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,
@@ -57,6 +64,8 @@ from .schemas import (
PrivacyRetentionPolicyScopeRequest,
PrivacyRetentionPolicyScopeResponse,
PrivacyRetentionPolicySimulationResponse,
PolicyImpactPreviewRequest,
PolicyImpactPreviewResponse,
RetentionRunRequest,
RetentionRunResponse,
ViewPolicyScopeRequest,
@@ -65,6 +74,8 @@ from .schemas import (
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):
@@ -94,6 +105,27 @@ def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPExc
)
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,
@@ -478,6 +510,83 @@ def _view_policy_response(
)
@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,
@@ -522,7 +631,16 @@ def write_view_policy(
):
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,
@@ -530,6 +648,26 @@ def write_view_policy(
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,
@@ -572,6 +710,8 @@ def write_view_policy(
"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()
@@ -599,11 +739,26 @@ 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,
@@ -611,6 +766,27 @@ def delete_view_policy_route(
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,
@@ -648,7 +824,12 @@ def delete_view_policy_route(
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},
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(