diff --git a/src/govoplan_views/backend/impact_subjects.py b/src/govoplan_views/backend/impact_subjects.py new file mode 100644 index 0000000..dbb8bad --- /dev/null +++ b/src/govoplan_views/backend/impact_subjects.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.policy import ( + POLICY_SCOPE_TYPES, + PolicyImpactPopulationRequest, + PolicyImpactSubject, + PolicyImpactSubjectBatch, + PolicyScopeType, +) +from govoplan_views.backend.db.models import ViewDefinition + + +VIEW_IMPACT_ACTIONS = ( + "view", + "select", + "assign", + "edit", + "derive", + "workflow_activate", +) + + +class ViewsPolicyImpactSubjectProvider: + provider_id = "views" + supported_policy_families = ("view",) + + def __init__(self, registry: object) -> None: + self._registry = registry + + def collect_policy_impact_subjects( + self, + session: object | None = None, + *, + request: PolicyImpactPopulationRequest, + ) -> PolicyImpactSubjectBatch: + if request.policy_family != "view": + return PolicyImpactSubjectBatch( + provider_id=self.provider_id, + state="unavailable", + explanation="Views only contributes subjects for the View policy family.", + ) + if not isinstance(session, Session): + return PolicyImpactSubjectBatch( + provider_id=self.provider_id, + state="unavailable", + explanation="The Views impact population requires a database session.", + ) + + selector = request.selector + _validate_selector_keys(selector) + actions = _actions(selector) + include_views = _selector_flag(selector, "include_views", default=True) + include_surfaces = _selector_flag( + selector, + "include_surfaces", + default=True, + ) + explicit_view_ids = _selector_ids(selector, "view_ids") + explicit_surface_ids = _selector_ids(selector, "surface_ids") + + subjects: list[PolicyImpactSubject] = [] + total_available = 0 + if include_views: + query = session.query(ViewDefinition).filter( + ViewDefinition.deleted_at.is_(None), + or_( + ViewDefinition.tenant_id == request.tenant_id, + ViewDefinition.tenant_id.is_(None), + ), + ) + if explicit_view_ids is not None: + query = query.filter(ViewDefinition.id.in_(explicit_view_ids)) + definition_count = query.count() + definition_limit = max( + 1, + (request.limit + len(actions) - 1) // len(actions), + ) + definitions = query.order_by( + ViewDefinition.scope_type.asc(), + ViewDefinition.name.asc(), + ViewDefinition.id.asc(), + ).limit(definition_limit).all() + total_available += definition_count * len(actions) + for definition in definitions: + scope_type = ( + cast(PolicyScopeType, definition.scope_type) + if definition.scope_type in POLICY_SCOPE_TYPES + else None + ) + for action in actions: + subjects.append( + PolicyImpactSubject( + module_id="views", + resource_type="view", + resource_id=definition.id, + action=action, + label=( + definition.name + if request.allow_sensitive_details + else None + ), + scope_type=scope_type, + scope_id=definition.scope_id, + attributes={"definition_scope": definition.scope_type}, + ) + ) + + if include_surfaces: + surfaces = _surfaces(self._registry) + if explicit_surface_ids is not None: + allowed_surface_ids = set(explicit_surface_ids) + surfaces = tuple( + surface + for surface in surfaces + if str(getattr(surface, "id", "")) in allowed_surface_ids + ) + total_available += len(surfaces) + for surface in surfaces: + subjects.append( + PolicyImpactSubject( + module_id=str(getattr(surface, "module_id", "core")), + resource_type="surface", + resource_id=str(getattr(surface, "id", "")), + action="view", + label=( + str(getattr(surface, "label", "")) or None + if request.allow_sensitive_details + else None + ), + attributes={"surface_kind": str(getattr(surface, "kind", ""))}, + ) + ) + + subjects = subjects[: request.limit] + truncated = total_available > len(subjects) + return PolicyImpactSubjectBatch( + provider_id=self.provider_id, + subjects=tuple(subjects), + state="truncated" if truncated else "complete", + total_available=total_available, + explanation=( + f"The bounded preview returned {len(subjects)} of " + f"{total_available} matching View subjects." + if truncated + else "The explicit Views catalogue population was evaluated completely." + ), + ) + + +def _actions(selector: Mapping[str, Any]) -> tuple[str, ...]: + value = selector.get("actions") + if value is None: + return VIEW_IMPACT_ACTIONS + if not isinstance(value, (list, tuple)): + raise ValueError("View impact actions must be a list") + actions = tuple(dict.fromkeys(str(item).strip() for item in value)) + if not actions or any(action not in VIEW_IMPACT_ACTIONS for action in actions): + raise ValueError("View impact actions contain an unsupported action") + return actions + + +def _validate_selector_keys(selector: Mapping[str, Any]) -> None: + supported = { + "actions", + "include_views", + "include_surfaces", + "view_ids", + "surface_ids", + } + unknown = sorted(str(key) for key in selector if str(key) not in supported) + if unknown: + raise ValueError( + "View impact selector contains unsupported fields: " + ", ".join(unknown) + ) + + +def _selector_flag( + selector: Mapping[str, Any], + key: str, + *, + default: bool, +) -> bool: + value = selector.get(key, default) + if not isinstance(value, bool): + raise ValueError(f"View impact selector {key} must be boolean") + return value + + +def _selector_ids( + selector: Mapping[str, Any], + key: str, +) -> tuple[str, ...] | None: + if key not in selector: + return None + value = selector.get(key) + if not isinstance(value, (list, tuple)) or len(value) > 500: + raise ValueError(f"View impact selector {key} must contain at most 500 IDs") + result = tuple(dict.fromkeys(str(item).strip() for item in value)) + if any(not item or len(item) > 240 for item in result): + raise ValueError(f"View impact selector {key} contains an invalid ID") + return result + + +def _surfaces(registry: object) -> tuple[object, ...]: + if not hasattr(registry, "view_surfaces"): + return () + value = registry.view_surfaces() + return tuple(value) if isinstance(value, (list, tuple)) else tuple(value or ()) + + +__all__ = ["VIEW_IMPACT_ACTIONS", "ViewsPolicyImpactSubjectProvider"] diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index 41c3d82..d0f07ee 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -22,6 +22,7 @@ from govoplan_core.core.modules import ( RoleTemplate, ) from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.policy import CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER, ViewSurface from govoplan_core.db.base import Base from govoplan_views.backend.db import models as view_models @@ -207,6 +208,14 @@ def _resolver(context: ModuleContext): return resolver_capability(context) +def _policy_impact_subjects(context: ModuleContext): + from govoplan_views.backend.impact_subjects import ( + ViewsPolicyImpactSubjectProvider, + ) + + return ViewsPolicyImpactSubjectProvider(context.registry) + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -288,6 +297,7 @@ manifest = ModuleManifest( ), capability_factories={ CAPABILITY_VIEWS_RESOLVER: _resolver, + f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}views": _policy_impact_subjects, }, documentation=( DocumentationTopic( @@ -313,6 +323,10 @@ manifest = ModuleManifest( "While a focus is active, All available tools temporarily restores that same permission-derived " "rail without changing the View or saving an override. Workflow uses " "the same behavior by resolving the exact immutable View revision." + " When Policy is enabled, Views contributes a bounded catalogue of " + "View definitions, actions, and registered surfaces to policy-impact " + "previews. The provider is tenant-filtered, honors an explicit limit, " + "and never grants Policy access to View implementation internals." ), layer="available", documentation_types=("admin", "user"), diff --git a/tests/test_policy_impact_subjects.py b/tests/test_policy_impact_subjects.py new file mode 100644 index 0000000..93d97b5 --- /dev/null +++ b/tests/test_policy_impact_subjects.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.policy import PolicyImpactPopulationRequest +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_views.backend.db.models import ViewDefinition +from govoplan_views.backend.impact_subjects import ( + ViewsPolicyImpactSubjectProvider, +) + + +class _Registry: + def view_surfaces(self) -> tuple[ViewSurface, ...]: + return ( + ViewSurface( + id="views.selector", + module_id="views", + kind="selector", + label="View selector", + ), + ) + + +class ViewsPolicyImpactSubjectTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = Session(self.engine) + self.provider = ViewsPolicyImpactSubjectProvider(_Registry()) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _definition(self, *, tenant_id: str, name: str) -> ViewDefinition: + definition = ViewDefinition( + tenant_id=tenant_id, + scope_type="tenant", + scope_id=None, + scope_key=f"tenant:{tenant_id}", + definition_key=name.casefold(), + name=name, + status="published", + ) + self.session.add(definition) + self.session.flush() + return definition + + def test_provider_is_tenant_filtered_and_returns_bounded_actions(self) -> None: + included = self._definition(tenant_id="tenant-1", name="Included") + self._definition(tenant_id="tenant-2", name="Hidden") + + batch = self.provider.collect_policy_impact_subjects( + self.session, + request=PolicyImpactPopulationRequest( + tenant_id="tenant-1", + policy_family="view", + selector={ + "actions": ["view", "edit"], + "include_surfaces": False, + }, + limit=10, + allow_sensitive_details=True, + ), + ) + + self.assertEqual("complete", batch.state) + self.assertEqual(2, batch.total_available) + self.assertEqual({included.id}, {item.resource_id for item in batch.subjects}) + self.assertEqual({"view", "edit"}, {item.action for item in batch.subjects}) + self.assertEqual({"Included"}, {item.label for item in batch.subjects}) + + def test_provider_reports_truncation_and_hides_labels(self) -> None: + self._definition(tenant_id="tenant-1", name="One") + self._definition(tenant_id="tenant-1", name="Two") + + batch = self.provider.collect_policy_impact_subjects( + self.session, + request=PolicyImpactPopulationRequest( + tenant_id="tenant-1", + policy_family="view", + selector={"include_surfaces": True}, + limit=2, + ), + ) + + self.assertEqual("truncated", batch.state) + self.assertGreater(batch.total_available or 0, len(batch.subjects)) + self.assertEqual(2, len(batch.subjects)) + self.assertTrue(all(subject.label is None for subject in batch.subjects)) + + def test_explicit_empty_population_never_falls_back_to_catalogue_scan(self) -> None: + self._definition(tenant_id="tenant-1", name="Not requested") + + batch = self.provider.collect_policy_impact_subjects( + self.session, + request=PolicyImpactPopulationRequest( + tenant_id="tenant-1", + policy_family="view", + selector={ + "view_ids": [], + "include_surfaces": False, + }, + limit=10, + ), + ) + + self.assertEqual("complete", batch.state) + self.assertEqual(0, batch.total_available) + self.assertEqual((), batch.subjects) + + +if __name__ == "__main__": + unittest.main()