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,
|
||||
)
|
||||
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"),
|
||||
|
||||
@@ -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