feat(policy): resolve datasource visibility overlays
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 20:31:52 +02:00
parent 016965917e
commit 72779d0277
7 changed files with 287 additions and 6 deletions
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-policy" name = "govoplan-policy"
version = "0.1.18" version = "0.1.19"
description = "GovOPlaN policy platform module." description = "GovOPlaN policy platform module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.20",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,129 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from sqlalchemy.orm import Session
from govoplan_core.core.datasources import (
DatasourceVisibilityPolicyDecision,
DatasourceVisibilityPolicyRequest,
)
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
POLICY_FAMILY = "datasource_visibility"
GLOBAL_TARGET = "*"
class DatasourceVisibilityPolicyProvider:
"""Resolve referenced and hierarchical policy overlays without reading rows."""
def decide_datasource_visibility(
self,
session: object,
*,
request: DatasourceVisibilityPolicyRequest,
) -> DatasourceVisibilityPolicyDecision:
if not isinstance(session, Session):
raise TypeError(
"Datasource visibility policy requires a SQLAlchemy session"
)
target_keys = tuple(
dict.fromkeys(
item for item in (GLOBAL_TARGET, request.policy_ref) if item is not None
)
)
rows = resolution_policy_overrides(
session,
policy_family=POLICY_FAMILY,
target_keys=target_keys,
tenant_id=request.tenant_id,
group_ids=request.principal.group_ids,
user_ids=tuple(
dict.fromkeys(
item
for item in (
request.principal.account_id,
request.principal.membership_id,
)
if item
)
),
)
normalized_ref = str(request.policy_ref or "").strip().casefold()
if normalized_ref and not any(row.target_key == normalized_ref for row in rows):
return DatasourceVisibilityPolicyDecision(
allowed=False,
reason="The referenced Datasource visibility policy is unavailable.",
decision_ref=_decision_ref(request, rows),
provenance={
"provider": "policy.datasource_visibility",
"version": "1",
"status": "reference_unresolved",
"policy_ref": request.policy_ref,
},
)
policies: list[Mapping[str, object]] = []
for row in rows:
if not isinstance(row.policy, Mapping):
return DatasourceVisibilityPolicyDecision(
allowed=False,
reason="A Datasource visibility policy is malformed.",
decision_ref=_decision_ref(request, rows),
provenance={
"provider": "policy.datasource_visibility",
"version": "1",
"status": "malformed",
"policy_id": row.id,
},
)
policies.append({str(key): value for key, value in row.policy.items()})
return DatasourceVisibilityPolicyDecision(
allowed=True,
policies=tuple(policies),
decision_ref=_decision_ref(request, rows),
provenance={
"provider": "policy.datasource_visibility",
"version": "1",
"status": "resolved",
"sources": [
{
"policy_id": row.id,
"target_key": row.target_key,
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"revision": row.revision,
}
for row in rows
],
},
)
def _decision_ref(request, rows) -> str:
payload = {
"tenant_id": request.tenant_id,
"datasource_ref": request.datasource_ref,
"action": request.action,
"consistency": request.consistency,
"materialization_ref": request.materialization_ref,
"policy_ref": request.policy_ref,
"rows": [
{
"id": row.id,
"revision": row.revision,
"target_key": row.target_key,
"scope_key": row.scope_key,
}
for row in rows
],
}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return f"datasource-visibility:{digest}"
__all__ = ["DatasourceVisibilityPolicyProvider", "POLICY_FAMILY"]
+36 -1
View File
@@ -10,6 +10,7 @@ from govoplan_core.core.access import (
from govoplan_core.core.distribution_lists import ( from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
) )
from govoplan_core.core.datasources import CAPABILITY_POLICY_DATASOURCE_VISIBILITY
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
@@ -129,6 +130,15 @@ def _campaign_archive_encryption_policy(context: ModuleContext) -> object:
return CampaignArchiveEncryptionPolicyProvider() return CampaignArchiveEncryptionPolicyProvider()
def _datasource_visibility_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.datasource_visibility import (
DatasourceVisibilityPolicyProvider,
)
return DatasourceVisibilityPolicyProvider()
def _dsar_provider(_context: ModuleContext) -> PolicyDsarProvider: def _dsar_provider(_context: ModuleContext) -> PolicyDsarProvider:
return PolicyDsarProvider() return PolicyDsarProvider()
@@ -140,7 +150,7 @@ POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
manifest = ModuleManifest( manifest = ModuleManifest(
id="policy", id="policy",
name="Policy", name="Policy",
version="0.1.18", version="0.1.19",
permissions=( permissions=(
PermissionDefinition( PermissionDefinition(
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE, scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
@@ -201,10 +211,27 @@ manifest = ModuleManifest(
name=CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS, name=CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider(
name="policy.datasource_visibility",
version="1.0.0",
),
ModuleInterfaceProvider(name=POLICY_DSAR_CAPABILITY, version="0.1.0"), ModuleInterfaceProvider(name=POLICY_DSAR_CAPABILITY, version="0.1.0"),
), ),
route_factory=_route_factory, route_factory=_route_factory,
documentation=( documentation=(
DocumentationTopic(
id="policy.datasource-visibility",
title="Datasource visibility policy overlays",
summary="Tighten Datasources-owned ACL, field, and row visibility through referenced hierarchical policies.",
body=(
"Datasources owns enforcement and a local visibility baseline. Policy can add system, tenant, group, or user overlays for the global target and an explicitly referenced policy key. Every matching overlay is applied as an additional restriction; it cannot restore a source, field, or row removed by another layer. An unresolved referenced policy, malformed payload, or unavailable decision fails closed. Decision evidence retains policy identifiers, scopes, revisions, and a stable hash but never row values, field values, connector endpoints, or credentials. When Policy is not installed and no external policy reference is configured, Datasources continues to enforce its local scope, ACL, projection, redaction, and row-filter rules."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("policy_admin", "data_steward", "auditor"),
related_modules=("datasources", "access", "audit"),
order=28,
),
DocumentationTopic( DocumentationTopic(
id="policy.data-subject-requests", id="policy.data-subject-requests",
title="Policy data-subject requests", title="Policy data-subject requests",
@@ -601,6 +628,7 @@ manifest = ModuleManifest(
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service, CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy,
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: _campaign_archive_encryption_policy, CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: _campaign_archive_encryption_policy,
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: _datasource_visibility_policy,
POLICY_DSAR_CAPABILITY: _dsar_provider, POLICY_DSAR_CAPABILITY: _dsar_provider,
}, },
capability_documentation={ capability_documentation={
@@ -631,6 +659,13 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("policy_admin", "campaign_manager"), audience=("policy_admin", "campaign_manager"),
), ),
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: CapabilityDocumentation(
label="Datasource visibility policy",
summary="Returns restrictive referenced policy overlays without reading or exposing datasource content.",
contract_version="1.0",
documentation_types=("admin", "user"),
audience=("policy_admin", "data_steward", "auditor"),
),
POLICY_DSAR_CAPABILITY: CapabilityDocumentation( POLICY_DSAR_CAPABILITY: CapabilityDocumentation(
label="Policy data-subject request provider", label="Policy data-subject request provider",
summary=( summary=(
@@ -11,7 +11,13 @@ from govoplan_policy.backend.db.models import PolicyOverride
POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"}) POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
POLICY_FAMILIES = frozenset( POLICY_FAMILIES = frozenset(
{"campaign_archive_encryption", "definition", "distribution_channels", "view"} {
"campaign_archive_encryption",
"datasource_visibility",
"definition",
"distribution_channels",
"view",
}
) )
@@ -24,7 +30,7 @@ def normalize_policy_target(policy_family: str, target_key: str) -> tuple[str, s
target = target_key.strip().casefold() target = target_key.strip().casefold()
if family not in POLICY_FAMILIES: if family not in POLICY_FAMILIES:
raise PolicyOverrideError( raise PolicyOverrideError(
"Policy family must be campaign_archive_encryption, definition, distribution_channels, or view" "Policy family must be campaign_archive_encryption, datasource_visibility, definition, distribution_channels, or view"
) )
if not target or len(target) > 120: if not target or len(target) > 120:
raise PolicyOverrideError("Policy target must contain 1 to 120 characters") raise PolicyOverrideError("Policy target must contain 1 to 120 characters")
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.datasources import DatasourceVisibilityPolicyRequest
from govoplan_core.db.base import Base
from govoplan_policy.backend.datasource_visibility import (
DatasourceVisibilityPolicyProvider,
)
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.policy_overrides import set_policy_override
class DatasourceVisibilityPolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=[PolicyOverride.__table__])
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.provider = DatasourceVisibilityPolicyProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(self.engine, tables=[PolicyOverride.__table__])
self.engine.dispose()
def _request(self, policy_ref: str | None) -> DatasourceVisibilityPolicyRequest:
return DatasourceVisibilityPolicyRequest(
tenant_id="tenant-1",
datasource_ref="datasource:cases",
principal=PrincipalRef(
account_id="account-1",
membership_id="member-1",
tenant_id="tenant-1",
group_ids=frozenset({"group-1"}),
),
action="read",
policy_ref=policy_ref,
)
def test_unresolved_explicit_reference_fails_closed(self) -> None:
decision = self.provider.decide_datasource_visibility(
self.session,
request=self._request("missing"),
)
self.assertFalse(decision.allowed)
self.assertEqual("reference_unresolved", decision.provenance["status"])
self.assertTrue(decision.decision_ref.startswith("datasource-visibility:"))
def test_global_and_referenced_hierarchy_are_returned_as_overlays(self) -> None:
set_policy_override(
self.session,
policy_family="datasource_visibility",
target_key="*",
tenant_id="tenant-1",
scope_type="system",
scope_id=None,
policy={"source_acl": {"auth_methods": ["session"]}},
actor_id="admin",
)
set_policy_override(
self.session,
policy_family="datasource_visibility",
target_key="Case-Workers",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
policy={"source_acl": {"group_ids": ["group-1"]}},
actor_id="admin",
)
set_policy_override(
self.session,
policy_family="datasource_visibility",
target_key="case-workers",
tenant_id="tenant-1",
scope_type="user",
scope_id="member-1",
policy={
"fields": {
"secret": {
"action": "omit",
"allow": {"role_ids": ["privileged"]},
}
}
},
actor_id="admin",
)
decision = self.provider.decide_datasource_visibility(
self.session,
request=self._request("CASE-WORKERS"),
)
self.assertTrue(decision.allowed)
self.assertEqual(3, len(decision.policies))
self.assertEqual("resolved", decision.provenance["status"])
self.assertEqual(
["system", "tenant", "user"],
[source["scope_type"] for source in decision.provenance["sources"]],
)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -16,6 +16,7 @@ from govoplan_core.core.access import CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJE
from govoplan_core.core.distribution_lists import ( from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
) )
from govoplan_core.core.datasources import CAPABILITY_POLICY_DATASOURCE_VISIBILITY
from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE
from govoplan_policy.backend.manifest import manifest from govoplan_policy.backend.manifest import manifest
from govoplan_policy.backend.dsar_provider import POLICY_DSAR_CAPABILITY from govoplan_policy.backend.dsar_provider import POLICY_DSAR_CAPABILITY
@@ -52,6 +53,7 @@ class PolicyModuleContractTests(unittest.TestCase):
{ {
CAPABILITY_POLICY_DEFINITION_GOVERNANCE, CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE, CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
CAPABILITY_POLICY_PRIVACY_RETENTION, CAPABILITY_POLICY_PRIVACY_RETENTION,
CAPABILITY_POLICY_REPORTING_GOVERNANCE, CAPABILITY_POLICY_REPORTING_GOVERNANCE,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/policy-webui", "name": "@govoplan/policy-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",