from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import Any from sqlalchemy.orm import Session from govoplan_policy.backend.db.models import PolicyOverride from govoplan_policy.backend.policy_overrides import ( delete_policy_override, get_policy_override, normalize_policy_scope, resolution_policy_overrides, set_policy_override, ) from govoplan_policy.backend.view_governance import ( VIEW_POLICY_BOOLEAN_FIELDS, ViewPolicyResolution, resolve_view_policy_rows, validate_view_policy, ) class ViewPolicyError(ValueError): pass @dataclass(frozen=True, slots=True) class ViewPolicyState: row: PolicyOverride | None local_policy: Mapping[str, bool | tuple[str, ...]] effective: ViewPolicyResolution parent: ViewPolicyResolution def view_policy_state( session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None, ) -> ViewPolicyState: _, clean_scope_id, _ = normalize_policy_scope( tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, ) row = get_policy_override( session, policy_family="view", target_key="*", tenant_id=tenant_id, scope_type=scope_type, scope_id=clean_scope_id, ) local_policy, malformed = validate_view_policy( row.policy if row is not None else {} ) if malformed: local_policy = {} rows = _rows_for_scope( session, tenant_id=tenant_id, scope_type=scope_type, scope_id=clean_scope_id, ) rank = _scope_rank(scope_type) return ViewPolicyState( row=row, local_policy=local_policy, effective=resolve_view_policy_rows(rows), parent=resolve_view_policy_rows( tuple(item for item in rows if _scope_rank(item.scope_type) < rank) ), ) def save_view_policy( session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None, 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") before = view_policy_state( session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, ) broadened = sorted( field for field in VIEW_POLICY_BOOLEAN_FIELDS if clean_policy.get(field) is True and before.parent.limits[field] is False ) for field, parent_values in ( ("allowed_view_ids", before.parent.allowed_view_ids), ("visible_surface_ids", before.parent.visible_surface_ids), ): local_values = clean_policy.get(field) if ( isinstance(local_values, tuple) and parent_values is not None and not set(local_values).issubset(parent_values) ): broadened.append(field) if broadened: raise ViewPolicyError( "Lower-scope View policy cannot broaden parent restrictions: " + ", ".join(broadened) ) return clean_policy, before def remove_view_policy( session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None, ) -> bool: row = get_policy_override( session, policy_family="view", target_key="*", tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, ) if row is None: return False delete_policy_override(session, row) _clear_resolution_cache(session) return True def view_policy_response_payload(state: ViewPolicyState) -> dict[str, Any]: local_policy: Mapping[str, Any] = state.local_policy if state.row is not None: _, malformed = validate_view_policy(state.row.policy) if malformed: local_policy = {"configuration_status": "invalid_fail_closed"} return { "id": state.row.id if state.row is not None else None, "revision": state.row.revision if state.row is not None else None, "policy": _json_policy(local_policy), "effective_policy": _resolution_payload(state.effective), "parent_policy": _resolution_payload(state.parent), "source_path": [step.to_dict() for step in state.effective.source_path], "diagnostics": [dict(item) for item in state.effective.diagnostics], } def _resolution_payload(resolution: ViewPolicyResolution) -> dict[str, Any]: return { **dict(resolution.limits), "allowed_view_ids": ( sorted(resolution.allowed_view_ids) if resolution.allowed_view_ids is not None else None ), "visible_surface_ids": ( sorted(resolution.visible_surface_ids) if resolution.visible_surface_ids is not None else None ), } def _json_policy(policy: Mapping[str, Any]) -> dict[str, Any]: return { key: list(value) if isinstance(value, tuple) else value for key, value in policy.items() } def _rows_for_scope( session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None, ) -> tuple[PolicyOverride, ...]: clean_scope = scope_type.strip().casefold() rows = resolution_policy_overrides( session, policy_family="view", target_keys=("*",), tenant_id=tenant_id, group_ids=(scope_id,) if clean_scope == "group" and scope_id else (), user_ids=(scope_id,) if clean_scope == "user" and scope_id else (), ) maximum_rank = _scope_rank(clean_scope) return tuple(row for row in rows if _scope_rank(row.scope_type) <= maximum_rank) def _scope_rank(scope_type: str) -> int: try: return ("system", "tenant", "group", "user").index( scope_type.strip().casefold() ) except ValueError as exc: raise ViewPolicyError( "View policy scope must be system, tenant, group, or user" ) from exc def _clear_resolution_cache(session: Session) -> None: session.info.pop("govoplan_policy_override_resolution", None) __all__ = [ "ViewPolicyError", "ViewPolicyState", "remove_view_policy", "save_view_policy", "validate_view_policy_change", "view_policy_response_payload", "view_policy_state", ]