feat: preview governed policy impact
This commit is contained in:
@@ -19,6 +19,18 @@ activation. Optional View-ID and surface-ID ceilings are intersected across the
|
||||
scope path, and the UI displays effective limits and provenance. Lower scopes
|
||||
can narrow but never broaden an ancestor restriction.
|
||||
|
||||
Before saving a View-policy draft, administrators can call
|
||||
`POST /api/v1/admin/policy-impact/preview` with one to ten explicitly selected,
|
||||
bounded subject populations. The dry run does not persist the proposal. It
|
||||
groups newly allowed, newly denied, unchanged, and indeterminate effects and
|
||||
reports complete, sampled, truncated, unavailable, or permission-hidden
|
||||
coverage with rule and source provenance. Aggregate counts follow normal
|
||||
policy-read authority; resource details additionally require
|
||||
`policy:impact:details`. System-wide View-policy commits require a login less
|
||||
than 15 minutes old. Preview and commit are recorded as separate audit events.
|
||||
Optional modules contribute subjects through the Core provider contract, so
|
||||
Policy never imports their implementation.
|
||||
|
||||
Policy decision and provenance payloads use the shared kernel DTOs documented
|
||||
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
|
||||
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -125,6 +125,8 @@ class ViewPolicyScopeRequest(BaseModel):
|
||||
|
||||
policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem)
|
||||
change_request_id: str | None = None
|
||||
impact_preview_id: str | None = Field(default=None, max_length=36)
|
||||
impact_proposal_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
|
||||
class ViewPolicyScopeResponse(BaseModel):
|
||||
@@ -139,6 +141,87 @@ class ViewPolicyScopeResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyImpactPopulationRequestItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider_id: str = Field(min_length=1, max_length=120)
|
||||
selector: dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=200, ge=1, le=500)
|
||||
|
||||
|
||||
class PolicyImpactPreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy_family: Literal["view"] = "view"
|
||||
scope_type: Literal["system", "tenant", "group", "user"]
|
||||
scope_id: str | None = Field(default=None, max_length=240)
|
||||
proposed_policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem)
|
||||
populations: list[PolicyImpactPopulationRequestItem] = Field(
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
)
|
||||
include_details: bool = False
|
||||
|
||||
|
||||
class PolicyImpactSubjectItem(BaseModel):
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
label: str | None = None
|
||||
scope_type: str | None = None
|
||||
scope_id: str | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PolicyImpactEffectItem(BaseModel):
|
||||
category: Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
]
|
||||
subject: PolicyImpactSubjectItem
|
||||
current_allowed: bool | None = None
|
||||
proposed_allowed: bool | None = None
|
||||
rule: str
|
||||
current_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
proposed_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
explanation: str | None = None
|
||||
|
||||
|
||||
class PolicyImpactPopulationResponseItem(BaseModel):
|
||||
provider_id: str
|
||||
state: Literal["complete", "sampled", "truncated", "unavailable"]
|
||||
returned: int
|
||||
total_available: int | None = None
|
||||
explanation: str | None = None
|
||||
subjects: list[PolicyImpactSubjectItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyImpactPreviewResponse(BaseModel):
|
||||
preview_id: str
|
||||
proposal_hash: str
|
||||
policy_family: str
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
base_revision: int | None = None
|
||||
counts: dict[
|
||||
Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
],
|
||||
int,
|
||||
]
|
||||
effects: list[PolicyImpactEffectItem] = Field(default_factory=list)
|
||||
populations: list[PolicyImpactPopulationResponseItem] = Field(default_factory=list)
|
||||
details_hidden: bool = False
|
||||
details_explanation: str | None = None
|
||||
high_impact: bool = False
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyExplainResponse(BaseModel):
|
||||
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
scope_id: str | None = None
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
PolicySourceStep,
|
||||
normalize_policy_scope_type,
|
||||
policy_impact_subject_provider,
|
||||
)
|
||||
from govoplan_policy.backend.view_governance import (
|
||||
VIEW_POLICY_BOOLEAN_FIELDS,
|
||||
ViewPolicyResolution,
|
||||
)
|
||||
from govoplan_policy.backend.view_policy_service import (
|
||||
validate_view_policy_change,
|
||||
)
|
||||
|
||||
|
||||
PolicyImpactCategory = Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyImpactPreviewError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPopulationSpec:
|
||||
provider_id: str
|
||||
selector: Mapping[str, Any] = field(default_factory=dict)
|
||||
limit: int = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactEffect:
|
||||
category: PolicyImpactCategory
|
||||
subject: PolicyImpactSubject
|
||||
current_allowed: bool | None
|
||||
proposed_allowed: bool | None
|
||||
rule: str
|
||||
current_sources: tuple[PolicySourceStep, ...] = ()
|
||||
proposed_sources: tuple[PolicySourceStep, ...] = ()
|
||||
explanation: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"category": self.category,
|
||||
"subject": self.subject.to_dict(),
|
||||
"current_allowed": self.current_allowed,
|
||||
"proposed_allowed": self.proposed_allowed,
|
||||
"rule": self.rule,
|
||||
"current_sources": [step.to_dict() for step in self.current_sources],
|
||||
"proposed_sources": [step.to_dict() for step in self.proposed_sources],
|
||||
"explanation": self.explanation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPreview:
|
||||
preview_id: str
|
||||
proposal_hash: str
|
||||
policy_family: str
|
||||
scope_type: str
|
||||
scope_id: str | None
|
||||
base_revision: int | None
|
||||
counts: Mapping[PolicyImpactCategory, int]
|
||||
effects: tuple[PolicyImpactEffect, ...]
|
||||
populations: tuple[Mapping[str, Any], ...]
|
||||
details_hidden: bool
|
||||
details_explanation: str | None
|
||||
high_impact: bool
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"preview_id": self.preview_id,
|
||||
"proposal_hash": self.proposal_hash,
|
||||
"policy_family": self.policy_family,
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"base_revision": self.base_revision,
|
||||
"counts": dict(self.counts),
|
||||
"effects": [effect.to_dict() for effect in self.effects],
|
||||
"populations": [dict(population) for population in self.populations],
|
||||
"details_hidden": self.details_hidden,
|
||||
"details_explanation": self.details_explanation,
|
||||
"high_impact": self.high_impact,
|
||||
}
|
||||
|
||||
|
||||
def preview_policy_impact(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object,
|
||||
tenant_id: str,
|
||||
policy_family: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
proposed_policy: object,
|
||||
populations: Sequence[PolicyImpactPopulationSpec],
|
||||
actor_scopes: Sequence[str] = (),
|
||||
include_details: bool = False,
|
||||
details_allowed: bool = False,
|
||||
) -> PolicyImpactPreview:
|
||||
clean_family = policy_family.strip().casefold()
|
||||
clean_scope = normalize_policy_scope_type(scope_type)
|
||||
if clean_family != "view":
|
||||
raise PolicyImpactPreviewError(
|
||||
"The current impact evaluator supports the View policy family."
|
||||
)
|
||||
if not populations or len(populations) > 10:
|
||||
raise PolicyImpactPreviewError(
|
||||
"Policy impact preview requires between 1 and 10 explicit populations."
|
||||
)
|
||||
|
||||
clean_policy, current_state = validate_view_policy_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=proposed_policy,
|
||||
)
|
||||
proposed = _proposed_view_resolution(
|
||||
parent=current_state.parent,
|
||||
policy=clean_policy,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
allow_details = include_details and details_allowed
|
||||
counts: dict[PolicyImpactCategory, int] = {
|
||||
"newly_allowed": 0,
|
||||
"newly_denied": 0,
|
||||
"unchanged": 0,
|
||||
"indeterminate": 0,
|
||||
}
|
||||
effects: list[PolicyImpactEffect] = []
|
||||
population_results: list[Mapping[str, Any]] = []
|
||||
seen_subjects: set[tuple[str, str, str, str]] = set()
|
||||
|
||||
for specification in populations:
|
||||
provider_id = specification.provider_id.strip()
|
||||
provider = policy_impact_subject_provider(registry, provider_id)
|
||||
if provider is None:
|
||||
population_results.append(
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id or "unknown",
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The requested impact-subject provider is not enabled or "
|
||||
"does not implement the Core contract."
|
||||
),
|
||||
).to_dict(include_subjects=False)
|
||||
)
|
||||
continue
|
||||
if clean_family not in provider.supported_policy_families:
|
||||
population_results.append(
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id,
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The provider does not support the requested policy family."
|
||||
),
|
||||
).to_dict(include_subjects=False)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
batch = provider.collect_policy_impact_subjects(
|
||||
session,
|
||||
request=PolicyImpactPopulationRequest(
|
||||
tenant_id=tenant_id,
|
||||
policy_family=clean_family,
|
||||
selector=specification.selector,
|
||||
limit=specification.limit,
|
||||
actor_scopes=tuple(actor_scopes),
|
||||
allow_sensitive_details=allow_details,
|
||||
),
|
||||
)
|
||||
if batch.provider_id != provider_id:
|
||||
raise PolicyImpactPreviewError(
|
||||
"Policy impact provider returned a mismatched provider ID."
|
||||
)
|
||||
except Exception: # noqa: BLE001 - isolate optional providers.
|
||||
logger.exception(
|
||||
"Policy impact subject provider failed provider_id=%s family=%s",
|
||||
provider_id,
|
||||
clean_family,
|
||||
)
|
||||
batch = PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id,
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The provider could not evaluate this population. Inspect "
|
||||
"operator logs before committing the proposed change."
|
||||
),
|
||||
)
|
||||
population_results.append(batch.to_dict(include_subjects=False))
|
||||
for subject in batch.subjects:
|
||||
if subject.key in seen_subjects:
|
||||
continue
|
||||
seen_subjects.add(subject.key)
|
||||
effect = _compare_view_subject(
|
||||
subject,
|
||||
current=current_state.effective,
|
||||
proposed=proposed,
|
||||
)
|
||||
counts[effect.category] += 1
|
||||
if allow_details:
|
||||
effects.append(effect)
|
||||
|
||||
changed = counts["newly_allowed"] + counts["newly_denied"]
|
||||
return PolicyImpactPreview(
|
||||
preview_id=str(uuid4()),
|
||||
proposal_hash=policy_impact_proposal_hash(
|
||||
family=clean_family,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=clean_policy,
|
||||
base_policy=current_state.local_policy,
|
||||
base_revision=(
|
||||
current_state.row.revision if current_state.row is not None else None
|
||||
),
|
||||
),
|
||||
policy_family=clean_family,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
base_revision=(
|
||||
current_state.row.revision if current_state.row is not None else None
|
||||
),
|
||||
counts=counts,
|
||||
effects=tuple(effects),
|
||||
populations=tuple(population_results),
|
||||
details_hidden=include_details and not details_allowed,
|
||||
details_explanation=(
|
||||
None
|
||||
if not include_details or details_allowed
|
||||
else "Subject details require policy:impact:details; aggregate counts remain visible."
|
||||
),
|
||||
high_impact=changed > 0 and clean_scope == "system",
|
||||
)
|
||||
|
||||
|
||||
def _proposed_view_resolution(
|
||||
*,
|
||||
parent: ViewPolicyResolution,
|
||||
policy: Mapping[str, bool | tuple[str, ...]],
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> ViewPolicyResolution:
|
||||
limits = dict(parent.limits)
|
||||
for field_name in VIEW_POLICY_BOOLEAN_FIELDS:
|
||||
value = policy.get(field_name)
|
||||
if isinstance(value, bool):
|
||||
limits[field_name] = limits[field_name] and value
|
||||
allowed_view_ids = _narrow_set(
|
||||
parent.allowed_view_ids,
|
||||
policy.get("allowed_view_ids"),
|
||||
)
|
||||
visible_surface_ids = _narrow_set(
|
||||
parent.visible_surface_ids,
|
||||
policy.get("visible_surface_ids"),
|
||||
)
|
||||
source_path = parent.source_path
|
||||
if policy:
|
||||
source_path = (
|
||||
*source_path,
|
||||
PolicySourceStep(
|
||||
scope_type=normalize_policy_scope_type(scope_type),
|
||||
scope_id=tenant_id if scope_type == "tenant" else scope_id,
|
||||
label=f"Proposed {scope_type.capitalize()} View policy",
|
||||
applied_fields=tuple(sorted(policy)),
|
||||
policy={
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in policy.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
return ViewPolicyResolution(
|
||||
limits=limits,
|
||||
allowed_view_ids=allowed_view_ids,
|
||||
visible_surface_ids=visible_surface_ids,
|
||||
source_path=source_path,
|
||||
diagnostics=parent.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def _narrow_set(
|
||||
parent: frozenset[str] | None,
|
||||
value: object,
|
||||
) -> frozenset[str] | None:
|
||||
if not isinstance(value, tuple):
|
||||
return parent
|
||||
proposed = frozenset(value)
|
||||
return proposed if parent is None else parent.intersection(proposed)
|
||||
|
||||
|
||||
def _compare_view_subject(
|
||||
subject: PolicyImpactSubject,
|
||||
*,
|
||||
current: ViewPolicyResolution,
|
||||
proposed: ViewPolicyResolution,
|
||||
) -> PolicyImpactEffect:
|
||||
current_allowed, rule = _view_subject_decision(subject, current)
|
||||
proposed_allowed, _ = _view_subject_decision(subject, proposed)
|
||||
if current_allowed is None or proposed_allowed is None:
|
||||
category: PolicyImpactCategory = "indeterminate"
|
||||
elif current_allowed == proposed_allowed:
|
||||
category = "unchanged"
|
||||
elif proposed_allowed:
|
||||
category = "newly_allowed"
|
||||
else:
|
||||
category = "newly_denied"
|
||||
source_fields = [
|
||||
field_name
|
||||
for field_name in (rule.removeprefix("view."), "allow_view")
|
||||
if field_name
|
||||
]
|
||||
if subject.resource_type == "view":
|
||||
source_fields.append("allowed_view_ids")
|
||||
elif subject.resource_type == "surface":
|
||||
source_fields.append("visible_surface_ids")
|
||||
return PolicyImpactEffect(
|
||||
category=category,
|
||||
subject=subject,
|
||||
current_allowed=current_allowed,
|
||||
proposed_allowed=proposed_allowed,
|
||||
rule=rule,
|
||||
current_sources=_sources_for_fields(current.source_path, tuple(source_fields)),
|
||||
proposed_sources=_sources_for_fields(proposed.source_path, tuple(source_fields)),
|
||||
explanation=(
|
||||
"The provider subject or action is not supported by the View evaluator."
|
||||
if category == "indeterminate"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _view_subject_decision(
|
||||
subject: PolicyImpactSubject,
|
||||
resolution: ViewPolicyResolution,
|
||||
) -> tuple[bool | None, str]:
|
||||
if subject.resource_type == "surface":
|
||||
allowed = resolution.limits["allow_view"]
|
||||
if resolution.visible_surface_ids is not None:
|
||||
allowed = allowed and subject.resource_id in resolution.visible_surface_ids
|
||||
return allowed, "view.visible_surface_ids"
|
||||
if subject.resource_type != "view" or subject.action not in {
|
||||
"view",
|
||||
"select",
|
||||
"assign",
|
||||
"edit",
|
||||
"derive",
|
||||
"workflow_activate",
|
||||
}:
|
||||
return None, "view.unsupported"
|
||||
action_field = f"allow_{subject.action}"
|
||||
allowed = resolution.limits["allow_view"] and resolution.limits[action_field]
|
||||
if resolution.allowed_view_ids is not None:
|
||||
allowed = allowed and subject.resource_id in resolution.allowed_view_ids
|
||||
return allowed, f"view.{action_field}"
|
||||
|
||||
|
||||
def _sources_for_fields(
|
||||
source_path: Sequence[PolicySourceStep],
|
||||
fields: Sequence[str],
|
||||
) -> tuple[PolicySourceStep, ...]:
|
||||
relevant = set(fields)
|
||||
return tuple(
|
||||
step
|
||||
for step in source_path
|
||||
if relevant.intersection(step.applied_fields)
|
||||
)
|
||||
|
||||
|
||||
def policy_impact_proposal_hash(
|
||||
*,
|
||||
family: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: Mapping[str, Any],
|
||||
base_policy: Mapping[str, Any],
|
||||
base_revision: int | None,
|
||||
) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"policy_family": family,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"base_revision": base_revision,
|
||||
"base_policy": {
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in base_policy.items()
|
||||
},
|
||||
"policy": {
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in policy.items()
|
||||
},
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PolicyImpactEffect",
|
||||
"PolicyImpactPopulationSpec",
|
||||
"PolicyImpactPreview",
|
||||
"PolicyImpactPreviewError",
|
||||
"policy_impact_proposal_hash",
|
||||
"preview_policy_impact",
|
||||
]
|
||||
@@ -126,6 +126,7 @@ def _campaign_archive_encryption_policy(context: ModuleContext) -> object:
|
||||
|
||||
|
||||
ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user"
|
||||
POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
@@ -145,6 +146,19 @@ manifest = ModuleManifest(
|
||||
resource="access_explanation",
|
||||
action="select_user",
|
||||
),
|
||||
PermissionDefinition(
|
||||
scope=POLICY_IMPACT_DETAILS_SCOPE,
|
||||
label="Inspect policy impact subjects",
|
||||
description=(
|
||||
"Inspect resource identifiers and provenance in bounded policy "
|
||||
"impact previews; aggregate counts require only policy-read authority."
|
||||
),
|
||||
category="Policy",
|
||||
level="tenant",
|
||||
module_id="policy",
|
||||
resource="impact",
|
||||
action="details",
|
||||
),
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
@@ -163,6 +177,10 @@ manifest = ModuleManifest(
|
||||
name="policy.function_assignment_governance",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="policy.impact_preview",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
version="1.0.0",
|
||||
@@ -233,6 +251,57 @@ manifest = ModuleManifest(
|
||||
audience=("user", "tenant_admin", "policy_admin"),
|
||||
metadata={"kind": "reference"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.impact-preview",
|
||||
title="Preview policy impact before saving",
|
||||
summary=(
|
||||
"Compare the current and proposed effective policy over explicit, "
|
||||
"bounded provider populations without persisting the proposal."
|
||||
),
|
||||
body=(
|
||||
"The Policy impact preview groups newly allowed, newly denied, "
|
||||
"unchanged, and indeterminate effects and retains rule, source, and "
|
||||
"scope provenance. Callers must select one to ten provider populations "
|
||||
"and a limit of at most 500 subjects per population; Policy never scans "
|
||||
"the platform implicitly. Population evidence says whether results are "
|
||||
"complete, sampled, truncated, or unavailable. Policy-read authority "
|
||||
"may inspect aggregate counts, while policy:impact:details controls "
|
||||
"resource identifiers and labels. Every preview is audited using its "
|
||||
"proposal hash and bounded counts. System-wide View-policy commits "
|
||||
"require authentication within the last 15 minutes and retain their "
|
||||
"existing commit audit and configuration-approval evidence. Optional "
|
||||
"modules contribute subjects through the Core provider contract; Policy "
|
||||
"does not import their models or services."
|
||||
),
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "tenant_admin", "policy_admin"),
|
||||
related_modules=("admin", "audit", "views"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=system-view-policy",
|
||||
"screen": "View policy impact preview",
|
||||
"help_contexts": [
|
||||
"policy.impact-preview",
|
||||
"policy.impact-preview.action.preview",
|
||||
"policy.impact-preview.results",
|
||||
],
|
||||
"api": {
|
||||
"preview": "/api/v1/admin/policy-impact/preview",
|
||||
"maximum_populations": 10,
|
||||
"maximum_subjects_per_population": 500,
|
||||
},
|
||||
"steps": [
|
||||
"Select an explicit impact provider population and bounded limit.",
|
||||
"Preview and inspect outcome counts, coverage state, and provenance.",
|
||||
"Reauthenticate when a system-wide change is classified as high impact.",
|
||||
"Save only after the preview matches the current dirty draft.",
|
||||
],
|
||||
"limitations": [
|
||||
"Unavailable optional providers are explained and are never treated as zero impact.",
|
||||
"Resource details are hidden without policy:impact:details.",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.campaign-archive-encryption",
|
||||
title="Govern Campaign archive encryption",
|
||||
@@ -532,6 +601,7 @@ manifest = ModuleManifest(
|
||||
"policy definition",
|
||||
"policy override",
|
||||
"policy decision provenance",
|
||||
"bounded policy impact preview",
|
||||
),
|
||||
non_owned_concepts=("application permission", "domain record", "audit record"),
|
||||
recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
|
||||
|
||||
@@ -85,6 +85,43 @@ def save_view_policy(
|
||||
policy: object,
|
||||
actor_id: str | None,
|
||||
) -> ViewPolicyState:
|
||||
clean_policy, _before = validate_view_policy_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy=policy,
|
||||
)
|
||||
set_policy_override(
|
||||
session,
|
||||
policy_family="view",
|
||||
target_key="*",
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy={
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in clean_policy.items()
|
||||
},
|
||||
actor_id=actor_id,
|
||||
)
|
||||
_clear_resolution_cache(session)
|
||||
return view_policy_state(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_view_policy_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: object,
|
||||
) -> tuple[dict[str, bool | tuple[str, ...]], ViewPolicyState]:
|
||||
clean_policy, malformed = validate_view_policy(policy)
|
||||
if malformed:
|
||||
raise ViewPolicyError("View policy fields have invalid names or values")
|
||||
@@ -115,26 +152,7 @@ def save_view_policy(
|
||||
"Lower-scope View policy cannot broaden parent restrictions: "
|
||||
+ ", ".join(broadened)
|
||||
)
|
||||
set_policy_override(
|
||||
session,
|
||||
policy_family="view",
|
||||
target_key="*",
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy={
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in clean_policy.items()
|
||||
},
|
||||
actor_id=actor_id,
|
||||
)
|
||||
_clear_resolution_cache(session)
|
||||
return view_policy_state(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return clean_policy, before
|
||||
|
||||
|
||||
def remove_view_policy(
|
||||
@@ -239,6 +257,7 @@ __all__ = [
|
||||
"ViewPolicyState",
|
||||
"remove_view_policy",
|
||||
"save_view_policy",
|
||||
"validate_view_policy_change",
|
||||
"view_policy_response_payload",
|
||||
"view_policy_state",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX,
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
)
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.impact_preview import (
|
||||
PolicyImpactPopulationSpec,
|
||||
preview_policy_impact,
|
||||
)
|
||||
from govoplan_policy.backend.api.v1.routes import (
|
||||
_require_recent_policy_authentication,
|
||||
)
|
||||
|
||||
|
||||
class _SubjectProvider:
|
||||
provider_id = "example"
|
||||
supported_policy_families = ("view",)
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch:
|
||||
del session
|
||||
subjects = (
|
||||
PolicyImpactSubject(
|
||||
module_id="views",
|
||||
resource_type="view",
|
||||
resource_id="view-1",
|
||||
action="edit",
|
||||
label="First" if request.allow_sensitive_details else None,
|
||||
),
|
||||
PolicyImpactSubject(
|
||||
module_id="views",
|
||||
resource_type="view",
|
||||
resource_id="view-2",
|
||||
action="view",
|
||||
label="Second" if request.allow_sensitive_details else None,
|
||||
),
|
||||
)
|
||||
return PolicyImpactSubjectBatch(
|
||||
provider_id=self.provider_id,
|
||||
subjects=subjects[: request.limit],
|
||||
state="truncated" if request.limit < len(subjects) else "complete",
|
||||
total_available=len(subjects),
|
||||
explanation="Explicit test population.",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: object | None = None) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
self.provider is not None
|
||||
and name == f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}example"
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
del name
|
||||
return self.provider
|
||||
|
||||
|
||||
class PolicyImpactPreviewTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
PolicyOverride.__table__.create(self.engine)
|
||||
self.session: Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_preview_compares_effective_view_policy_without_persistence(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(_SubjectProvider()),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="tenant",
|
||||
scope_id=None,
|
||||
proposed_policy={
|
||||
"allow_edit": False,
|
||||
"allowed_view_ids": ["view-1"],
|
||||
},
|
||||
populations=(PolicyImpactPopulationSpec(provider_id="example"),),
|
||||
actor_scopes=("admin:policies:read", "policy:impact:details"),
|
||||
include_details=True,
|
||||
details_allowed=True,
|
||||
)
|
||||
|
||||
self.assertEqual(2, preview.counts["newly_denied"])
|
||||
self.assertEqual(2, len(preview.effects))
|
||||
self.assertEqual(
|
||||
{"view.allow_edit", "view.allow_view"},
|
||||
{effect.rule for effect in preview.effects},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
effect.proposed_sources[-1].label.startswith("Proposed Tenant")
|
||||
for effect in preview.effects
|
||||
)
|
||||
)
|
||||
self.assertEqual(0, self.session.query(PolicyOverride).count())
|
||||
|
||||
def test_details_are_hidden_but_permission_filtered_counts_remain(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(_SubjectProvider()),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
proposed_policy={"allow_view": False},
|
||||
populations=(
|
||||
PolicyImpactPopulationSpec(provider_id="example", limit=1),
|
||||
),
|
||||
include_details=True,
|
||||
details_allowed=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, preview.counts["newly_denied"])
|
||||
self.assertEqual((), preview.effects)
|
||||
self.assertTrue(preview.details_hidden)
|
||||
self.assertIn("policy:impact:details", preview.details_explanation or "")
|
||||
self.assertEqual("truncated", preview.populations[0]["state"])
|
||||
self.assertTrue(preview.high_impact)
|
||||
|
||||
def test_unavailable_provider_is_explained_instead_of_counted_as_zero(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="tenant",
|
||||
scope_id=None,
|
||||
proposed_policy={},
|
||||
populations=(PolicyImpactPopulationSpec(provider_id="missing"),),
|
||||
)
|
||||
|
||||
self.assertEqual("unavailable", preview.populations[0]["state"])
|
||||
self.assertIn("not enabled", preview.populations[0]["explanation"])
|
||||
|
||||
def test_system_policy_guard_requires_a_recent_interactive_session(self) -> None:
|
||||
fresh = SimpleNamespace(
|
||||
auth_session=SimpleNamespace(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(minutes=2)
|
||||
)
|
||||
)
|
||||
_require_recent_policy_authentication(fresh) # type: ignore[arg-type]
|
||||
|
||||
stale = SimpleNamespace(
|
||||
auth_session=SimpleNamespace(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(minutes=30)
|
||||
)
|
||||
)
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
_require_recent_policy_authentication(stale) # type: ignore[arg-type]
|
||||
self.assertEqual(403, context.exception.status_code)
|
||||
self.assertEqual(
|
||||
"recent_authentication_required",
|
||||
context.exception.detail["code"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8");
|
||||
const viewPanel = readFileSync(resolve(webuiRoot, "src/features/policy/ViewPoliciesPanel.tsx"), "utf8");
|
||||
|
||||
assert.match(panel, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
|
||||
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation");
|
||||
@@ -18,4 +19,11 @@ assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pa
|
||||
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
|
||||
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
|
||||
|
||||
assert.match(viewPanel, /policy\.impact-preview\.action\.preview/, "View policy exposes stable impact-preview help");
|
||||
assert.match(viewPanel, /previewCurrent/, "View policy binds Save to the current dirty-draft preview");
|
||||
assert.match(viewPanel, /updateViewPolicy\([\s\S]*impactPreview/, "View policy carries preview evidence into the commit request");
|
||||
assert.match(viewPanel, /prepareResetPolicy/, "inherited-policy removal receives its own impact preview");
|
||||
assert.match(viewPanel, /newly_allowed/, "View policy presents typed impact outcome counts");
|
||||
assert.match(viewPanel, /populations\.map/, "View policy explains provider coverage state");
|
||||
|
||||
console.log("Policy interface-pattern contracts passed.");
|
||||
|
||||
@@ -46,6 +46,46 @@ export type ViewPolicyReferenceData = {
|
||||
surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>;
|
||||
};
|
||||
|
||||
export type PolicyImpactCategory = "newly_allowed" | "newly_denied" | "unchanged" | "indeterminate";
|
||||
|
||||
export type PolicyImpactPreviewResponse = {
|
||||
preview_id: string;
|
||||
proposal_hash: string;
|
||||
policy_family: string;
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
base_revision?: number | null;
|
||||
counts: Record<PolicyImpactCategory, number>;
|
||||
effects: Array<{
|
||||
category: PolicyImpactCategory;
|
||||
subject: {
|
||||
module_id: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
label?: string | null;
|
||||
scope_type?: string | null;
|
||||
scope_id?: string | null;
|
||||
};
|
||||
current_allowed?: boolean | null;
|
||||
proposed_allowed?: boolean | null;
|
||||
rule: string;
|
||||
current_sources: Array<{ path: string; label: string }>;
|
||||
proposed_sources: Array<{ path: string; label: string }>;
|
||||
explanation?: string | null;
|
||||
}>;
|
||||
populations: Array<{
|
||||
provider_id: string;
|
||||
state: "complete" | "sampled" | "truncated" | "unavailable";
|
||||
returned: number;
|
||||
total_available?: number | null;
|
||||
explanation?: string | null;
|
||||
}>;
|
||||
details_hidden: boolean;
|
||||
details_explanation?: string | null;
|
||||
high_impact: boolean;
|
||||
};
|
||||
|
||||
export function fetchViewPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
@@ -60,26 +100,73 @@ export function updateViewPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
scopeId: string | null | undefined,
|
||||
policy: ViewPolicyItem
|
||||
policy: ViewPolicyItem,
|
||||
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
|
||||
): Promise<ViewPolicyScopeResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
|
||||
scope_id: scopeId || undefined
|
||||
}), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ policy })
|
||||
body: JSON.stringify({
|
||||
policy,
|
||||
impact_preview_id: impactPreview?.preview_id,
|
||||
impact_proposal_hash: impactPreview?.proposal_hash
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteViewPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
scopeId?: string | null
|
||||
scopeId?: string | null,
|
||||
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
|
||||
): Promise<ViewPolicyScopeResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
|
||||
scope_id: scopeId || undefined
|
||||
scope_id: scopeId || undefined,
|
||||
impact_preview_id: impactPreview?.preview_id,
|
||||
impact_proposal_hash: impactPreview?.proposal_hash
|
||||
}), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function previewViewPolicyImpact(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
scopeId: string | null | undefined,
|
||||
policy: ViewPolicyItem,
|
||||
population: { viewIds: string[]; surfaceIds: string[] }
|
||||
): Promise<PolicyImpactPreviewResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/policy-impact/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
policy_family: "view",
|
||||
scope_type: scope,
|
||||
scope_id: scopeId || null,
|
||||
proposed_policy: policy,
|
||||
populations: [
|
||||
{
|
||||
provider_id: "views",
|
||||
selector: {
|
||||
include_views: true,
|
||||
include_surfaces: false,
|
||||
view_ids: population.viewIds.slice(0, 500)
|
||||
},
|
||||
limit: 500
|
||||
},
|
||||
{
|
||||
provider_id: "views",
|
||||
selector: {
|
||||
include_views: false,
|
||||
include_surfaces: true,
|
||||
surface_ids: population.surfaceIds.slice(0, 500)
|
||||
},
|
||||
limit: 500
|
||||
}
|
||||
],
|
||||
include_details: true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
|
||||
const [definitionResult, surfaceResult] = await Promise.allSettled([
|
||||
apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>(
|
||||
|
||||
@@ -19,14 +19,16 @@ import {
|
||||
type ReferenceOption,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react";
|
||||
import { RefreshCw, Save, ScanSearch, Trash2, Undo2 } from "lucide-react";
|
||||
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
|
||||
import {
|
||||
deleteViewPolicy,
|
||||
fetchViewPolicy,
|
||||
fetchViewPolicyReferences,
|
||||
previewViewPolicyImpact,
|
||||
updateViewPolicy,
|
||||
type EffectiveViewPolicy,
|
||||
type PolicyImpactPreviewResponse,
|
||||
type ViewPolicyItem,
|
||||
type ViewPolicyScope,
|
||||
type ViewPolicyScopeResponse
|
||||
@@ -91,6 +93,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [impactPreview, setImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
const [previewDraftKey, setPreviewDraftKey] = useState("");
|
||||
const [resetImpactPreview, setResetImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
|
||||
const needsTarget = scopeType === "group" || scopeType === "user";
|
||||
const parentViewIds = state?.parent_policy.allowed_view_ids;
|
||||
@@ -109,6 +114,8 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
&& stablePolicy(buildPolicy(draft))
|
||||
!== stablePolicy(buildPolicy(draftFromPolicy(state.policy)))
|
||||
);
|
||||
const draftKey = draft ? stablePolicy(buildPolicy(draft)) : "";
|
||||
const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey);
|
||||
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
|
||||
|
||||
@@ -165,6 +172,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
@@ -180,19 +190,63 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
|
||||
function discard() {
|
||||
if (state) setDraft(draftFromPolicy(state.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !state || !dirty) return true;
|
||||
async function previewImpact() {
|
||||
if (!draft || !state || !dirty) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
|
||||
const policy = buildPolicy(draft);
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
policy,
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setImpactPreview(preview);
|
||||
setPreviewDraftKey(stablePolicy(policy));
|
||||
setSuccess("Policy impact preview completed without saving the draft.");
|
||||
} catch (err) {
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !state || !dirty) return true;
|
||||
if (!previewCurrent) {
|
||||
setError("Preview the current policy draft before saving it.");
|
||||
return false;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
buildPolicy(draft),
|
||||
impactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setSuccess("View policy saved.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -203,14 +257,50 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPolicy() {
|
||||
async function prepareResetPolicy() {
|
||||
if (!state?.id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await deleteViewPolicy(settings, scopeType, targetId || null);
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
{},
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setResetImpactPreview(preview);
|
||||
setConfirmReset(true);
|
||||
setSuccess("Inherited-policy impact preview completed without removing the override.");
|
||||
} catch (err) {
|
||||
setResetImpactPreview(null);
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPolicy() {
|
||||
if (!resetImpactPreview) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await deleteViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
resetImpactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
setSuccess("Local View policy removed; inherited policy now applies.");
|
||||
setConfirmReset(false);
|
||||
} catch (err) {
|
||||
@@ -236,8 +326,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</Button>
|
||||
<Button onClick={() => setConfirmReset(true)} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
|
||||
<Button onClick={() => void prepareResetPolicy()} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
|
||||
<Button helpContextId="policy.impact-preview.action.preview" onClick={() => void previewImpact()} disabled={!canWrite || !dirty || busy}><ScanSearch size={16} /> Preview impact</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy || !previewCurrent} disabledReason={dirty && !previewCurrent ? "Preview the current draft before saving." : undefined}><Save size={16} /> {busy ? "Working..." : "Save"}</Button>
|
||||
<DocumentationHelpLink reference={DOCUMENTATION} label="Open View policy documentation" />
|
||||
</>
|
||||
}
|
||||
@@ -341,6 +432,35 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<DescriptionItem term={<>Policy path</>}>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
|
||||
{impactPreview && (
|
||||
<Card title="Policy impact preview">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={<>Preview</>}><code>{impactPreview.preview_id}</code></DescriptionItem>
|
||||
<DescriptionItem term={<>Draft state</>}><StatusBadge status={previewCurrent ? "success" : "warning"} label={previewCurrent ? "Current" : "Outdated"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Newly allowed</>}>{impactPreview.counts.newly_allowed}</DescriptionItem>
|
||||
<DescriptionItem term={<>Newly denied</>}>{impactPreview.counts.newly_denied}</DescriptionItem>
|
||||
<DescriptionItem term={<>Unchanged</>}>{impactPreview.counts.unchanged}</DescriptionItem>
|
||||
<DescriptionItem term={<>Indeterminate</>}>{impactPreview.counts.indeterminate}</DescriptionItem>
|
||||
<DescriptionItem term={<>Risk</>}><StatusBadge status={impactPreview.high_impact ? "warning" : "neutral"} label={impactPreview.high_impact ? "High impact - recent login required" : "Bounded change"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Coverage</>}>{impactPreview.populations.map((population) => `${population.provider_id}: ${population.state} (${population.returned}${population.total_available == null ? "" : `/${population.total_available}`})${population.explanation ? ` - ${population.explanation}` : ""}`).join("; ")}</DescriptionItem>
|
||||
{impactPreview.details_hidden && <DescriptionItem term={<>Details</>}>{impactPreview.details_explanation || "Subject details are hidden by policy."}</DescriptionItem>}
|
||||
</DescriptionList>
|
||||
{impactPreview.effects.length > 0 && (
|
||||
<div help-context-id="policy.impact-preview.results">
|
||||
<h4>Changed subjects</h4>
|
||||
<ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").slice(0, 20).map((effect) => (
|
||||
<li key={`${effect.subject.module_id}:${effect.subject.resource_type}:${effect.subject.resource_id}:${effect.subject.action}`}>
|
||||
<strong>{effect.category.replaceAll("_", " ")}</strong>: {effect.subject.label || effect.subject.resource_id} - {effect.subject.action} ({effect.rule})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").length > 20 && <p>Only the first 20 changed subjects are shown; aggregate counts cover the complete returned population.</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
@@ -348,11 +468,14 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
title="Use inherited View policy?"
|
||||
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
|
||||
message={resetImpactPreview ? `The local override will be removed. The bounded preview found ${resetImpactPreview.counts.newly_allowed} newly allowed, ${resetImpactPreview.counts.newly_denied} newly denied, and ${resetImpactPreview.counts.indeterminate} indeterminate effects. All restrictions inherited from higher scopes continue to apply.` : "Previewing inherited-policy impact..."}
|
||||
confirmLabel="Use inherited policy"
|
||||
busy={busy}
|
||||
onConfirm={() => void resetPolicy()}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onCancel={() => {
|
||||
setConfirmReset(false);
|
||||
setResetImpactPreview(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user