feat(policy): resolve datasource visibility overlays
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+2
-2
@@ -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"]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user