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
|
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`.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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 }> }>(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user