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
+12
View File
@@ -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 scope path, and the UI displays effective limits and provenance. Lower scopes
can narrow but never broaden an ancestor restriction. 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 Policy decision and provenance payloads use the shared kernel DTOs documented
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md) in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`. and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
+183 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any 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 sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope, require_scope 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, save_definition_policy,
) )
from govoplan_policy.backend.policy_overrides import PolicyOverrideError 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 ( from govoplan_policy.backend.campaign_archive_encryption import (
CampaignArchiveEncryptionPolicyError, CampaignArchiveEncryptionPolicyError,
campaign_archive_encryption_policy_state, campaign_archive_encryption_policy_state,
@@ -57,6 +64,8 @@ from .schemas import (
PrivacyRetentionPolicyScopeRequest, PrivacyRetentionPolicyScopeRequest,
PrivacyRetentionPolicyScopeResponse, PrivacyRetentionPolicyScopeResponse,
PrivacyRetentionPolicySimulationResponse, PrivacyRetentionPolicySimulationResponse,
PolicyImpactPreviewRequest,
PolicyImpactPreviewResponse,
RetentionRunRequest, RetentionRunRequest,
RetentionRunResponse, RetentionRunResponse,
ViewPolicyScopeRequest, ViewPolicyScopeRequest,
@@ -65,6 +74,8 @@ from .schemas import (
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
RECENT_POLICY_AUTHENTICATION_WINDOW = timedelta(minutes=15)
def _require_permission(principal: ApiPrincipal, scope: str) -> None: def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope): 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( def _archive_encryption_policy_response(
*, *,
scope_type: str, 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( @router.get(
"/view-policies/{scope_type}", "/view-policies/{scope_type}",
response_model=ViewPolicyScopeResponse, response_model=ViewPolicyScopeResponse,
@@ -522,7 +631,16 @@ def write_view_policy(
): ):
clean_scope = scope_type.strip().casefold() clean_scope = scope_type.strip().casefold()
_require_privacy_policy_write(principal, clean_scope) _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) 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: try:
before = view_policy_state( before = view_policy_state(
session, session,
@@ -530,6 +648,26 @@ def write_view_policy(
scope_type=clean_scope, scope_type=clean_scope,
scope_id=scope_id, 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": if clean_scope == "system":
approval = ensure_configuration_change_allowed( approval = ensure_configuration_change_allowed(
session, session,
@@ -572,6 +710,8 @@ def write_view_policy(
"scope_type": clean_scope, "scope_type": clean_scope,
"scope_id": scope_id, "scope_id": scope_id,
"fields": sorted(policy_value), "fields": sorted(policy_value),
"impact_preview_id": payload.impact_preview_id,
"impact_proposal_hash": payload.impact_proposal_hash,
}, },
) )
session.commit() session.commit()
@@ -599,11 +739,26 @@ def delete_view_policy_route(
scope_type: str, scope_type: str,
scope_id: str | None = Query(default=None), scope_id: str | None = Query(default=None),
change_request_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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
): ):
clean_scope = scope_type.strip().casefold() clean_scope = scope_type.strip().casefold()
_require_privacy_policy_write(principal, clean_scope) _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: try:
before = view_policy_state( before = view_policy_state(
session, session,
@@ -611,6 +766,27 @@ def delete_view_policy_route(
scope_type=clean_scope, scope_type=clean_scope,
scope_id=scope_id, 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": if clean_scope == "system":
approval = ensure_configuration_change_allowed( approval = ensure_configuration_change_allowed(
session, session,
@@ -648,7 +824,12 @@ def delete_view_policy_route(
scope="system" if clean_scope == "system" else "tenant", scope="system" if clean_scope == "system" else "tenant",
object_type="view_policy", object_type="view_policy",
object_id=f"{clean_scope}:{scope_id or ''}", 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() session.commit()
state = view_policy_state( state = view_policy_state(
@@ -125,6 +125,8 @@ class ViewPolicyScopeRequest(BaseModel):
policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem) policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem)
change_request_id: str | None = None 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): class ViewPolicyScopeResponse(BaseModel):
@@ -139,6 +141,87 @@ class ViewPolicyScopeResponse(BaseModel):
diagnostics: list[dict[str, Any]] = Field(default_factory=list) 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): class PrivacyRetentionPolicyExplainResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"] scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None 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",
]
+70
View File
@@ -126,6 +126,7 @@ def _campaign_archive_encryption_policy(context: ModuleContext) -> object:
ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user" ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user"
POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
manifest = ModuleManifest( manifest = ModuleManifest(
@@ -145,6 +146,19 @@ manifest = ModuleManifest(
resource="access_explanation", resource="access_explanation",
action="select_user", 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=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
@@ -163,6 +177,10 @@ manifest = ModuleManifest(
name="policy.function_assignment_governance", name="policy.function_assignment_governance",
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider(
name="policy.impact_preview",
version="1.0.0",
),
ModuleInterfaceProvider( ModuleInterfaceProvider(
name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
version="1.0.0", version="1.0.0",
@@ -233,6 +251,57 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "policy_admin"), audience=("user", "tenant_admin", "policy_admin"),
metadata={"kind": "reference"}, 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( DocumentationTopic(
id="policy.campaign-archive-encryption", id="policy.campaign-archive-encryption",
title="Govern Campaign archive encryption", title="Govern Campaign archive encryption",
@@ -532,6 +601,7 @@ manifest = ModuleManifest(
"policy definition", "policy definition",
"policy override", "policy override",
"policy decision provenance", "policy decision provenance",
"bounded policy impact preview",
), ),
non_owned_concepts=("application permission", "domain record", "audit record"), non_owned_concepts=("application permission", "domain record", "audit record"),
recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",), recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
@@ -85,6 +85,43 @@ def save_view_policy(
policy: object, policy: object,
actor_id: str | None, actor_id: str | None,
) -> ViewPolicyState: ) -> 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) clean_policy, malformed = validate_view_policy(policy)
if malformed: if malformed:
raise ViewPolicyError("View policy fields have invalid names or values") 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: " "Lower-scope View policy cannot broaden parent restrictions: "
+ ", ".join(broadened) + ", ".join(broadened)
) )
set_policy_override( return clean_policy, before
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 remove_view_policy( def remove_view_policy(
@@ -239,6 +257,7 @@ __all__ = [
"ViewPolicyState", "ViewPolicyState",
"remove_view_policy", "remove_view_policy",
"save_view_policy", "save_view_policy",
"validate_view_policy_change",
"view_policy_response_payload", "view_policy_response_payload",
"view_policy_state", "view_policy_state",
] ]
+184
View File
@@ -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 webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8"); 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, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation"); 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, /admin-json-preview/, "retention outcome is not presented as raw JSON");
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection"); 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."); console.log("Policy interface-pattern contracts passed.");
+91 -4
View File
@@ -46,6 +46,46 @@ export type ViewPolicyReferenceData = {
surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>; 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( export function fetchViewPolicy(
settings: ApiSettings, settings: ApiSettings,
scope: ViewPolicyScope, scope: ViewPolicyScope,
@@ -60,26 +100,73 @@ export function updateViewPolicy(
settings: ApiSettings, settings: ApiSettings,
scope: ViewPolicyScope, scope: ViewPolicyScope,
scopeId: string | null | undefined, scopeId: string | null | undefined,
policy: ViewPolicyItem policy: ViewPolicyItem,
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
): Promise<ViewPolicyScopeResponse> { ): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined scope_id: scopeId || undefined
}), { }), {
method: "PUT", 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( export function deleteViewPolicy(
settings: ApiSettings, settings: ApiSettings,
scope: ViewPolicyScope, scope: ViewPolicyScope,
scopeId?: string | null scopeId?: string | null,
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
): Promise<ViewPolicyScopeResponse> { ): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { 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" }); }), { 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> { export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
const [definitionResult, surfaceResult] = await Promise.allSettled([ const [definitionResult, surfaceResult] = await Promise.allSettled([
apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>( apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>(
+133 -10
View File
@@ -19,14 +19,16 @@ import {
type ReferenceOption, type ReferenceOption,
type SearchableSelectOption type SearchableSelectOption
} from "@govoplan/core-webui"; } 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 { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
import { import {
deleteViewPolicy, deleteViewPolicy,
fetchViewPolicy, fetchViewPolicy,
fetchViewPolicyReferences, fetchViewPolicyReferences,
previewViewPolicyImpact,
updateViewPolicy, updateViewPolicy,
type EffectiveViewPolicy, type EffectiveViewPolicy,
type PolicyImpactPreviewResponse,
type ViewPolicyItem, type ViewPolicyItem,
type ViewPolicyScope, type ViewPolicyScope,
type ViewPolicyScopeResponse type ViewPolicyScopeResponse
@@ -91,6 +93,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [confirmReset, setConfirmReset] = useState(false); 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 needsTarget = scopeType === "group" || scopeType === "user";
const parentViewIds = state?.parent_policy.allowed_view_ids; 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(draft))
!== stablePolicy(buildPolicy(draftFromPolicy(state.policy))) !== stablePolicy(buildPolicy(draftFromPolicy(state.policy)))
); );
const draftKey = draft ? stablePolicy(buildPolicy(draft)) : "";
const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey);
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard }); 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); const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null);
setState(loaded); setState(loaded);
setDraft(draftFromPolicy(loaded.policy)); setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
} catch (err) { } catch (err) {
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
} finally { } finally {
@@ -180,19 +190,63 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
function discard() { function discard() {
if (state) setDraft(draftFromPolicy(state.policy)); if (state) setDraft(draftFromPolicy(state.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
setError(""); setError("");
setSuccess(""); setSuccess("");
} }
async function save(): Promise<boolean> { async function previewImpact() {
if (!draft || !state || !dirty) return true; if (!draft || !state || !dirty) return;
setBusy(true); setBusy(true);
setError(""); setError("");
setSuccess(""); setSuccess("");
try { 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); setState(loaded);
setDraft(draftFromPolicy(loaded.policy)); setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setSuccess("View policy saved."); setSuccess("View policy saved.");
return true; return true;
} catch (err) { } 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); setBusy(true);
setError(""); setError("");
setSuccess(""); setSuccess("");
try { 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); setState(loaded);
setDraft(draftFromPolicy(loaded.policy)); setDraft(draftFromPolicy(loaded.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
setSuccess("Local View policy removed; inherited policy now applies."); setSuccess("Local View policy removed; inherited policy now applies.");
setConfirmReset(false); setConfirmReset(false);
} catch (err) { } catch (err) {
@@ -236,8 +326,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
<RefreshCw size={16} /> <RefreshCw size={16} />
</Button> </Button>
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</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 onClick={() => void prepareResetPolicy()} 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 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" /> <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> <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> </DescriptionList>
</Card> </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> </AdminPageLayout>
@@ -348,11 +468,14 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
<ConfirmDialog <ConfirmDialog
open={confirmReset} open={confirmReset}
title="Use inherited View policy?" 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" confirmLabel="Use inherited policy"
busy={busy} busy={busy}
onConfirm={() => void resetPolicy()} onConfirm={() => void resetPolicy()}
onCancel={() => setConfirmReset(false)} onCancel={() => {
setConfirmReset(false);
setResetImpactPreview(null);
}}
/> />
</> </>
); );