diff --git a/README.md b/README.md index 120a79e..c7bf4bf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,18 @@ activation. Optional View-ID and surface-ID ceilings are intersected across the scope path, and the UI displays effective limits and provenance. Lower scopes can narrow but never broaden an ancestor restriction. +Before saving a View-policy draft, administrators can call +`POST /api/v1/admin/policy-impact/preview` with one to ten explicitly selected, +bounded subject populations. The dry run does not persist the proposal. It +groups newly allowed, newly denied, unchanged, and indeterminate effects and +reports complete, sampled, truncated, unavailable, or permission-hidden +coverage with rule and source provenance. Aggregate counts follow normal +policy-read authority; resource details additionally require +`policy:impact:details`. System-wide View-policy commits require a login less +than 15 minutes old. Preview and commit are recorded as separate audit events. +Optional modules contribute subjects through the Core provider contract, so +Policy never imports their implementation. + Policy decision and provenance payloads use the shared kernel DTOs documented in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md) and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`. diff --git a/src/govoplan_policy/backend/api/v1/routes.py b/src/govoplan_policy/backend/api/v1/routes.py index 0115172..b409258 100644 --- a/src/govoplan_policy/backend/api/v1/routes.py +++ b/src/govoplan_policy/backend/api/v1/routes.py @@ -1,8 +1,9 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope, require_scope @@ -34,6 +35,12 @@ from govoplan_policy.backend.definition_policy_service import ( save_definition_policy, ) from govoplan_policy.backend.policy_overrides import PolicyOverrideError +from govoplan_policy.backend.impact_preview import ( + PolicyImpactPopulationSpec, + PolicyImpactPreviewError, + policy_impact_proposal_hash, + preview_policy_impact as build_policy_impact_preview, +) from govoplan_policy.backend.campaign_archive_encryption import ( CampaignArchiveEncryptionPolicyError, campaign_archive_encryption_policy_state, @@ -57,6 +64,8 @@ from .schemas import ( PrivacyRetentionPolicyScopeRequest, PrivacyRetentionPolicyScopeResponse, PrivacyRetentionPolicySimulationResponse, + PolicyImpactPreviewRequest, + PolicyImpactPreviewResponse, RetentionRunRequest, RetentionRunResponse, ViewPolicyScopeRequest, @@ -65,6 +74,8 @@ from .schemas import ( router = APIRouter(prefix="/admin", tags=["admin"]) +RECENT_POLICY_AUTHENTICATION_WINDOW = timedelta(minutes=15) + def _require_permission(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): @@ -94,6 +105,27 @@ def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPExc ) +def _require_recent_policy_authentication(principal: ApiPrincipal) -> None: + auth_session = principal.auth_session + created_at = getattr(auth_session, "created_at", None) + if isinstance(created_at, datetime): + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + elapsed = datetime.now(timezone.utc) - created_at + if timedelta(0) <= elapsed <= RECENT_POLICY_AUTHENTICATION_WINDOW: + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "recent_authentication_required", + "message": ( + "System-wide policy changes require authentication within the " + "last 15 minutes." + ), + }, + ) + + def _archive_encryption_policy_response( *, scope_type: str, @@ -478,6 +510,83 @@ def _view_policy_response( ) +@router.post( + "/policy-impact/preview", + response_model=PolicyImpactPreviewResponse, +) +def preview_policy_impact_route( + payload: PolicyImpactPreviewRequest, + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_privacy_policy_read(principal, payload.scope_type) + registry = getattr(request.app.state, "govoplan_registry", None) + if registry is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="The platform registry is not available.", + ) + try: + preview = build_policy_impact_preview( + session, + registry=registry, + tenant_id=principal.tenant_id, + policy_family=payload.policy_family, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + proposed_policy=payload.proposed_policy.model_dump( + mode="json", + exclude_none=True, + ), + populations=tuple( + PolicyImpactPopulationSpec( + provider_id=population.provider_id, + selector=population.selector, + limit=population.limit, + ) + for population in payload.populations + ), + actor_scopes=tuple(principal.scopes), + include_details=payload.include_details, + details_allowed=has_scope(principal, "policy:impact:details"), + ) + audit_from_principal( + session, + principal, + action="policy.impact_previewed", + scope="system" if payload.scope_type == "system" else "tenant", + object_type="policy_impact_preview", + object_id=preview.preview_id, + details={ + "policy_family": preview.policy_family, + "scope_type": preview.scope_type, + "scope_id": preview.scope_id, + "proposal_hash": preview.proposal_hash, + "counts": dict(preview.counts), + "providers": [ + { + "provider_id": population.get("provider_id"), + "state": population.get("state"), + "returned": population.get("returned"), + "total_available": population.get("total_available"), + } + for population in preview.populations + ], + "details_hidden": preview.details_hidden, + "high_impact": preview.high_impact, + }, + ) + session.commit() + return PolicyImpactPreviewResponse.model_validate(preview.to_dict()) + except (PolicyImpactPreviewError, ViewPolicyError, PolicyOverrideError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + @router.get( "/view-policies/{scope_type}", response_model=ViewPolicyScopeResponse, @@ -522,7 +631,16 @@ def write_view_policy( ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_write(principal, clean_scope) + if clean_scope == "system": + _require_recent_policy_authentication(principal) policy_value = payload.policy.model_dump(mode="json", exclude_none=True) + if bool(payload.impact_preview_id) != bool(payload.impact_proposal_hash): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + "Impact preview ID and proposal hash must be supplied together." + ), + ) try: before = view_policy_state( session, @@ -530,6 +648,26 @@ def write_view_policy( scope_type=clean_scope, scope_id=scope_id, ) + if payload.impact_proposal_hash is not None: + proposal_hash = policy_impact_proposal_hash( + family="view", + scope_type=clean_scope, + scope_id=scope_id, + policy=policy_value, + base_policy=before.local_policy, + base_revision=(before.row.revision if before.row is not None else None), + ) + if proposal_hash != payload.impact_proposal_hash: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "policy_impact_preview_stale", + "message": ( + "The policy draft or saved base changed after its " + "impact preview. Run the preview again before saving." + ), + }, + ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, @@ -572,6 +710,8 @@ def write_view_policy( "scope_type": clean_scope, "scope_id": scope_id, "fields": sorted(policy_value), + "impact_preview_id": payload.impact_preview_id, + "impact_proposal_hash": payload.impact_proposal_hash, }, ) session.commit() @@ -599,11 +739,26 @@ def delete_view_policy_route( scope_type: str, scope_id: str | None = Query(default=None), change_request_id: str | None = Query(default=None), + impact_preview_id: str | None = Query(default=None, max_length=36), + impact_proposal_hash: str | None = Query( + default=None, + min_length=64, + max_length=64, + ), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): clean_scope = scope_type.strip().casefold() _require_privacy_policy_write(principal, clean_scope) + if clean_scope == "system": + _require_recent_policy_authentication(principal) + if bool(impact_preview_id) != bool(impact_proposal_hash): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + "Impact preview ID and proposal hash must be supplied together." + ), + ) try: before = view_policy_state( session, @@ -611,6 +766,27 @@ def delete_view_policy_route( scope_type=clean_scope, scope_id=scope_id, ) + if impact_proposal_hash is not None: + proposal_hash = policy_impact_proposal_hash( + family="view", + scope_type=clean_scope, + scope_id=scope_id, + policy={}, + base_policy=before.local_policy, + base_revision=(before.row.revision if before.row is not None else None), + ) + if proposal_hash != impact_proposal_hash: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "policy_impact_preview_stale", + "message": ( + "The saved policy base changed after its inherited-policy " + "impact preview. Run the preview again before removing " + "the override." + ), + }, + ) if clean_scope == "system": approval = ensure_configuration_change_allowed( session, @@ -648,7 +824,12 @@ def delete_view_policy_route( scope="system" if clean_scope == "system" else "tenant", object_type="view_policy", object_id=f"{clean_scope}:{scope_id or ''}", - details={"scope_type": clean_scope, "scope_id": scope_id}, + details={ + "scope_type": clean_scope, + "scope_id": scope_id, + "impact_preview_id": impact_preview_id, + "impact_proposal_hash": impact_proposal_hash, + }, ) session.commit() state = view_policy_state( diff --git a/src/govoplan_policy/backend/api/v1/schemas.py b/src/govoplan_policy/backend/api/v1/schemas.py index f9a0e61..80e1d30 100644 --- a/src/govoplan_policy/backend/api/v1/schemas.py +++ b/src/govoplan_policy/backend/api/v1/schemas.py @@ -125,6 +125,8 @@ class ViewPolicyScopeRequest(BaseModel): policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem) change_request_id: str | None = None + impact_preview_id: str | None = Field(default=None, max_length=36) + impact_proposal_hash: str | None = Field(default=None, min_length=64, max_length=64) class ViewPolicyScopeResponse(BaseModel): @@ -139,6 +141,87 @@ class ViewPolicyScopeResponse(BaseModel): diagnostics: list[dict[str, Any]] = Field(default_factory=list) +class PolicyImpactPopulationRequestItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider_id: str = Field(min_length=1, max_length=120) + selector: dict[str, Any] = Field(default_factory=dict) + limit: int = Field(default=200, ge=1, le=500) + + +class PolicyImpactPreviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + policy_family: Literal["view"] = "view" + scope_type: Literal["system", "tenant", "group", "user"] + scope_id: str | None = Field(default=None, max_length=240) + proposed_policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem) + populations: list[PolicyImpactPopulationRequestItem] = Field( + min_length=1, + max_length=10, + ) + include_details: bool = False + + +class PolicyImpactSubjectItem(BaseModel): + module_id: str + resource_type: str + resource_id: str + action: str + label: str | None = None + scope_type: str | None = None + scope_id: str | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class PolicyImpactEffectItem(BaseModel): + category: Literal[ + "newly_allowed", + "newly_denied", + "unchanged", + "indeterminate", + ] + subject: PolicyImpactSubjectItem + current_allowed: bool | None = None + proposed_allowed: bool | None = None + rule: str + current_sources: list[PolicySourceStepItem] = Field(default_factory=list) + proposed_sources: list[PolicySourceStepItem] = Field(default_factory=list) + explanation: str | None = None + + +class PolicyImpactPopulationResponseItem(BaseModel): + provider_id: str + state: Literal["complete", "sampled", "truncated", "unavailable"] + returned: int + total_available: int | None = None + explanation: str | None = None + subjects: list[PolicyImpactSubjectItem] = Field(default_factory=list) + + +class PolicyImpactPreviewResponse(BaseModel): + preview_id: str + proposal_hash: str + policy_family: str + scope_type: str + scope_id: str | None = None + base_revision: int | None = None + counts: dict[ + Literal[ + "newly_allowed", + "newly_denied", + "unchanged", + "indeterminate", + ], + int, + ] + effects: list[PolicyImpactEffectItem] = Field(default_factory=list) + populations: list[PolicyImpactPopulationResponseItem] = Field(default_factory=list) + details_hidden: bool = False + details_explanation: str | None = None + high_impact: bool = False + + class PrivacyRetentionPolicyExplainResponse(BaseModel): scope_type: Literal["system", "tenant", "user", "group", "campaign"] scope_id: str | None = None diff --git a/src/govoplan_policy/backend/impact_preview.py b/src/govoplan_policy/backend/impact_preview.py new file mode 100644 index 0000000..c937f29 --- /dev/null +++ b/src/govoplan_policy/backend/impact_preview.py @@ -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", +] diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index 20a4d3b..87c9d6d 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -126,6 +126,7 @@ def _campaign_archive_encryption_policy(context: ModuleContext) -> object: ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user" +POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details" manifest = ModuleManifest( @@ -145,6 +146,19 @@ manifest = ModuleManifest( resource="access_explanation", action="select_user", ), + PermissionDefinition( + scope=POLICY_IMPACT_DETAILS_SCOPE, + label="Inspect policy impact subjects", + description=( + "Inspect resource identifiers and provenance in bounded policy " + "impact previews; aggregate counts require only policy-read authority." + ), + category="Policy", + level="tenant", + module_id="policy", + resource="impact", + action="details", + ), ), required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, @@ -163,6 +177,10 @@ manifest = ModuleManifest( name="policy.function_assignment_governance", version="1.0.0", ), + ModuleInterfaceProvider( + name="policy.impact_preview", + version="1.0.0", + ), ModuleInterfaceProvider( name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, version="1.0.0", @@ -233,6 +251,57 @@ manifest = ModuleManifest( audience=("user", "tenant_admin", "policy_admin"), metadata={"kind": "reference"}, ), + DocumentationTopic( + id="policy.impact-preview", + title="Preview policy impact before saving", + summary=( + "Compare the current and proposed effective policy over explicit, " + "bounded provider populations without persisting the proposal." + ), + body=( + "The Policy impact preview groups newly allowed, newly denied, " + "unchanged, and indeterminate effects and retains rule, source, and " + "scope provenance. Callers must select one to ten provider populations " + "and a limit of at most 500 subjects per population; Policy never scans " + "the platform implicitly. Population evidence says whether results are " + "complete, sampled, truncated, or unavailable. Policy-read authority " + "may inspect aggregate counts, while policy:impact:details controls " + "resource identifiers and labels. Every preview is audited using its " + "proposal hash and bounded counts. System-wide View-policy commits " + "require authentication within the last 15 minutes and retain their " + "existing commit audit and configuration-approval evidence. Optional " + "modules contribute subjects through the Core provider contract; Policy " + "does not import their models or services." + ), + documentation_types=("admin",), + audience=("system_admin", "tenant_admin", "policy_admin"), + related_modules=("admin", "audit", "views"), + metadata={ + "kind": "workflow", + "route": "/admin?section=system-view-policy", + "screen": "View policy impact preview", + "help_contexts": [ + "policy.impact-preview", + "policy.impact-preview.action.preview", + "policy.impact-preview.results", + ], + "api": { + "preview": "/api/v1/admin/policy-impact/preview", + "maximum_populations": 10, + "maximum_subjects_per_population": 500, + }, + "steps": [ + "Select an explicit impact provider population and bounded limit.", + "Preview and inspect outcome counts, coverage state, and provenance.", + "Reauthenticate when a system-wide change is classified as high impact.", + "Save only after the preview matches the current dirty draft.", + ], + "limitations": [ + "Unavailable optional providers are explained and are never treated as zero impact.", + "Resource details are hidden without policy:impact:details.", + ], + }, + ), DocumentationTopic( id="policy.campaign-archive-encryption", title="Govern Campaign archive encryption", @@ -532,6 +601,7 @@ manifest = ModuleManifest( "policy definition", "policy override", "policy decision provenance", + "bounded policy impact preview", ), non_owned_concepts=("application permission", "domain record", "audit record"), recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",), diff --git a/src/govoplan_policy/backend/view_policy_service.py b/src/govoplan_policy/backend/view_policy_service.py index e652d90..ec2896f 100644 --- a/src/govoplan_policy/backend/view_policy_service.py +++ b/src/govoplan_policy/backend/view_policy_service.py @@ -85,6 +85,43 @@ def save_view_policy( policy: object, actor_id: str | None, ) -> ViewPolicyState: + clean_policy, _before = validate_view_policy_change( + session, + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + policy=policy, + ) + set_policy_override( + session, + policy_family="view", + target_key="*", + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + policy={ + key: list(value) if isinstance(value, tuple) else value + for key, value in clean_policy.items() + }, + actor_id=actor_id, + ) + _clear_resolution_cache(session) + return view_policy_state( + session, + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + + +def validate_view_policy_change( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, + policy: object, +) -> tuple[dict[str, bool | tuple[str, ...]], ViewPolicyState]: clean_policy, malformed = validate_view_policy(policy) if malformed: raise ViewPolicyError("View policy fields have invalid names or values") @@ -115,26 +152,7 @@ def save_view_policy( "Lower-scope View policy cannot broaden parent restrictions: " + ", ".join(broadened) ) - set_policy_override( - session, - policy_family="view", - target_key="*", - tenant_id=tenant_id, - scope_type=scope_type, - scope_id=scope_id, - policy={ - key: list(value) if isinstance(value, tuple) else value - for key, value in clean_policy.items() - }, - actor_id=actor_id, - ) - _clear_resolution_cache(session) - return view_policy_state( - session, - tenant_id=tenant_id, - scope_type=scope_type, - scope_id=scope_id, - ) + return clean_policy, before def remove_view_policy( @@ -239,6 +257,7 @@ __all__ = [ "ViewPolicyState", "remove_view_policy", "save_view_policy", + "validate_view_policy_change", "view_policy_response_payload", "view_policy_state", ] diff --git a/tests/test_impact_preview.py b/tests/test_impact_preview.py new file mode 100644 index 0000000..4e951e1 --- /dev/null +++ b/tests/test_impact_preview.py @@ -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() diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index b36ce07..d9c2412 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8"); +const viewPanel = readFileSync(resolve(webuiRoot, "src/features/policy/ViewPoliciesPanel.tsx"), "utf8"); assert.match(panel, /; }; +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; + effects: Array<{ + category: PolicyImpactCategory; + subject: { + module_id: string; + resource_type: string; + resource_id: string; + action: string; + label?: string | null; + scope_type?: string | null; + scope_id?: string | null; + }; + current_allowed?: boolean | null; + proposed_allowed?: boolean | null; + rule: string; + current_sources: Array<{ path: string; label: string }>; + proposed_sources: Array<{ path: string; label: string }>; + explanation?: string | null; + }>; + populations: Array<{ + provider_id: string; + state: "complete" | "sampled" | "truncated" | "unavailable"; + returned: number; + total_available?: number | null; + explanation?: string | null; + }>; + details_hidden: boolean; + details_explanation?: string | null; + high_impact: boolean; +}; + export function fetchViewPolicy( settings: ApiSettings, scope: ViewPolicyScope, @@ -60,26 +100,73 @@ export function updateViewPolicy( settings: ApiSettings, scope: ViewPolicyScope, scopeId: string | null | undefined, - policy: ViewPolicyItem + policy: ViewPolicyItem, + impactPreview?: Pick | null ): Promise { return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { scope_id: scopeId || undefined }), { method: "PUT", - body: JSON.stringify({ policy }) + body: JSON.stringify({ + policy, + impact_preview_id: impactPreview?.preview_id, + impact_proposal_hash: impactPreview?.proposal_hash + }) }); } export function deleteViewPolicy( settings: ApiSettings, scope: ViewPolicyScope, - scopeId?: string | null + scopeId?: string | null, + impactPreview?: Pick | null ): Promise { return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, { - scope_id: scopeId || undefined + scope_id: scopeId || undefined, + impact_preview_id: impactPreview?.preview_id, + impact_proposal_hash: impactPreview?.proposal_hash }), { method: "DELETE" }); } +export function previewViewPolicyImpact( + settings: ApiSettings, + scope: ViewPolicyScope, + scopeId: string | null | undefined, + policy: ViewPolicyItem, + population: { viewIds: string[]; surfaceIds: string[] } +): Promise { + 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 { const [definitionResult, surfaceResult] = await Promise.allSettled([ apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>( diff --git a/webui/src/features/policy/ViewPoliciesPanel.tsx b/webui/src/features/policy/ViewPoliciesPanel.tsx index 9e8e007..8970416 100644 --- a/webui/src/features/policy/ViewPoliciesPanel.tsx +++ b/webui/src/features/policy/ViewPoliciesPanel.tsx @@ -19,14 +19,16 @@ import { type ReferenceOption, type SearchableSelectOption } from "@govoplan/core-webui"; -import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react"; +import { RefreshCw, Save, ScanSearch, Trash2, Undo2 } from "lucide-react"; import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets"; import { deleteViewPolicy, fetchViewPolicy, fetchViewPolicyReferences, + previewViewPolicyImpact, updateViewPolicy, type EffectiveViewPolicy, + type PolicyImpactPreviewResponse, type ViewPolicyItem, type ViewPolicyScope, type ViewPolicyScopeResponse @@ -91,6 +93,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [confirmReset, setConfirmReset] = useState(false); + const [impactPreview, setImpactPreview] = useState(null); + const [previewDraftKey, setPreviewDraftKey] = useState(""); + const [resetImpactPreview, setResetImpactPreview] = useState(null); const needsTarget = scopeType === "group" || scopeType === "user"; const parentViewIds = state?.parent_policy.allowed_view_ids; @@ -109,6 +114,8 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro && stablePolicy(buildPolicy(draft)) !== stablePolicy(buildPolicy(draftFromPolicy(state.policy))) ); + const draftKey = draft ? stablePolicy(buildPolicy(draft)) : ""; + const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey); useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard }); @@ -165,6 +172,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null); setState(loaded); setDraft(draftFromPolicy(loaded.policy)); + setImpactPreview(null); + setPreviewDraftKey(""); + setResetImpactPreview(null); } catch (err) { setError(adminErrorMessage(err)); } finally { @@ -180,19 +190,63 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro function discard() { if (state) setDraft(draftFromPolicy(state.policy)); + setImpactPreview(null); + setPreviewDraftKey(""); + setResetImpactPreview(null); setError(""); setSuccess(""); } - async function save(): Promise { - if (!draft || !state || !dirty) return true; + async function previewImpact() { + if (!draft || !state || !dirty) return; setBusy(true); setError(""); setSuccess(""); try { - const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft)); + const policy = buildPolicy(draft); + const preview = await previewViewPolicyImpact( + settings, + scopeType, + targetId || null, + policy, + { + viewIds: viewOptions.map((option) => option.value), + surfaceIds: surfaceOptions.map((option) => option.value) + } + ); + setImpactPreview(preview); + setPreviewDraftKey(stablePolicy(policy)); + setSuccess("Policy impact preview completed without saving the draft."); + } catch (err) { + setImpactPreview(null); + setPreviewDraftKey(""); + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + async function save(): Promise { + if (!draft || !state || !dirty) return true; + if (!previewCurrent) { + setError("Preview the current policy draft before saving it."); + return false; + } + setBusy(true); + setError(""); + setSuccess(""); + try { + const loaded = await updateViewPolicy( + settings, + scopeType, + targetId || null, + buildPolicy(draft), + impactPreview + ); setState(loaded); setDraft(draftFromPolicy(loaded.policy)); + setImpactPreview(null); + setPreviewDraftKey(""); setSuccess("View policy saved."); return true; } catch (err) { @@ -203,14 +257,50 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro } } - async function resetPolicy() { + async function prepareResetPolicy() { + if (!state?.id) return; setBusy(true); setError(""); setSuccess(""); try { - const loaded = await deleteViewPolicy(settings, scopeType, targetId || null); + const preview = await previewViewPolicyImpact( + settings, + scopeType, + targetId || null, + {}, + { + viewIds: viewOptions.map((option) => option.value), + surfaceIds: surfaceOptions.map((option) => option.value) + } + ); + setResetImpactPreview(preview); + setConfirmReset(true); + setSuccess("Inherited-policy impact preview completed without removing the override."); + } catch (err) { + setResetImpactPreview(null); + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + async function resetPolicy() { + if (!resetImpactPreview) return; + setBusy(true); + setError(""); + setSuccess(""); + try { + const loaded = await deleteViewPolicy( + settings, + scopeType, + targetId || null, + resetImpactPreview + ); setState(loaded); setDraft(draftFromPolicy(loaded.policy)); + setImpactPreview(null); + setPreviewDraftKey(""); + setResetImpactPreview(null); setSuccess("Local View policy removed; inherited policy now applies."); setConfirmReset(false); } catch (err) { @@ -236,8 +326,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro - - + + + } @@ -341,6 +432,35 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro Policy path}>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"} + + {impactPreview && ( + + + Preview}>{impactPreview.preview_id} + Draft state}> + Newly allowed}>{impactPreview.counts.newly_allowed} + Newly denied}>{impactPreview.counts.newly_denied} + Unchanged}>{impactPreview.counts.unchanged} + Indeterminate}>{impactPreview.counts.indeterminate} + Risk}> + Coverage}>{impactPreview.populations.map((population) => `${population.provider_id}: ${population.state} (${population.returned}${population.total_available == null ? "" : `/${population.total_available}`})${population.explanation ? ` - ${population.explanation}` : ""}`).join("; ")} + {impactPreview.details_hidden && Details}>{impactPreview.details_explanation || "Subject details are hidden by policy."}} + + {impactPreview.effects.length > 0 && ( +
+

Changed subjects

+
    + {impactPreview.effects.filter((effect) => effect.category !== "unchanged").slice(0, 20).map((effect) => ( +
  • + {effect.category.replaceAll("_", " ")}: {effect.subject.label || effect.subject.resource_id} - {effect.subject.action} ({effect.rule}) +
  • + ))} +
+ {impactPreview.effects.filter((effect) => effect.category !== "unchanged").length > 20 &&

Only the first 20 changed subjects are shown; aggregate counts cover the complete returned population.

} +
+ )} +
+ )} )} @@ -348,11 +468,14 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro void resetPolicy()} - onCancel={() => setConfirmReset(false)} + onCancel={() => { + setConfirmReset(false); + setResetImpactPreview(null); + }} /> );