feat: contribute bounded policy impact subjects
This commit is contained in:
@@ -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"]
|
||||||
@@ -22,6 +22,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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.core.views import CAPABILITY_VIEWS_RESOLVER, ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_views.backend.db import models as view_models
|
from govoplan_views.backend.db import models as view_models
|
||||||
@@ -207,6 +208,14 @@ def _resolver(context: ModuleContext):
|
|||||||
return resolver_capability(context)
|
return resolver_capability(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_impact_subjects(context: ModuleContext):
|
||||||
|
from govoplan_views.backend.impact_subjects import (
|
||||||
|
ViewsPolicyImpactSubjectProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ViewsPolicyImpactSubjectProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -288,6 +297,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_VIEWS_RESOLVER: _resolver,
|
CAPABILITY_VIEWS_RESOLVER: _resolver,
|
||||||
|
f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}views": _policy_impact_subjects,
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -313,6 +323,10 @@ manifest = ModuleManifest(
|
|||||||
"While a focus is active, All available tools temporarily restores that same permission-derived "
|
"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 "
|
"rail without changing the View or saving an override. Workflow uses "
|
||||||
"the same behavior by resolving the exact immutable View revision."
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user