feat: preview governed policy impact
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user