From 8fcc12dbb2f41b2ca24a19f933e1b3eb681ade2b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 06:16:43 +0200 Subject: [PATCH] feat: govern selected-user access diagnostics --- .../backend/access_explanation_subjects.py | 50 ++++++++++++++ src/govoplan_policy/backend/manifest.py | 69 +++++++++++++++++++ tests/test_access_explanation_subjects.py | 63 +++++++++++++++++ tests/test_policy_module_contract.py | 2 + 4 files changed, 184 insertions(+) create mode 100644 src/govoplan_policy/backend/access_explanation_subjects.py create mode 100644 tests/test_access_explanation_subjects.py diff --git a/src/govoplan_policy/backend/access_explanation_subjects.py b/src/govoplan_policy/backend/access_explanation_subjects.py new file mode 100644 index 0000000..b1773c6 --- /dev/null +++ b/src/govoplan_policy/backend/access_explanation_subjects.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from govoplan_core.core.access import ( + AccessExplanationSubjectDecision, + PrincipalRef, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible + + +ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user" + + +class AccessExplanationSubjectPolicyProvider: + """Decide whether an actor may run an explanation for another user.""" + + def decide_subject_selection( + self, + session: object, + principal: PrincipalRef, + *, + tenant_id: str, + ) -> AccessExplanationSubjectDecision: + del session + if principal.tenant_id != tenant_id: + return AccessExplanationSubjectDecision( + allow_other_users=False, + reason="Access explanations are limited to the active tenant.", + source="policy.tenant_boundary", + required_scope=ACCESS_EXPLANATION_SUBJECT_SCOPE, + provenance={"tenant_id": tenant_id, "mode": "current_user"}, + ) + + allowed = scopes_grant_compatible( + principal.scopes, + ACCESS_EXPLANATION_SUBJECT_SCOPE, + ) + return AccessExplanationSubjectDecision( + allow_other_users=allowed, + reason=( + "Policy permits selected-user access diagnostics." + if allowed + else "Policy limits access explanations to the signed-in user." + ), + source="policy.permission", + required_scope=ACCESS_EXPLANATION_SUBJECT_SCOPE, + provenance={ + "tenant_id": tenant_id, + "mode": "cross_user" if allowed else "current_user", + }, + ) diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index 2cb8522..fc46a39 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -5,6 +5,7 @@ from pathlib import Path from govoplan_core.core.access import ( CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS, ) from govoplan_core.core.distribution_lists import ( CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, @@ -28,6 +29,7 @@ from govoplan_core.core.modules import ( ModuleContext, ModuleInterfaceProvider, ModuleManifest, + PermissionDefinition, ) from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE from govoplan_core.core.provider_governance import declared_module_architecture @@ -104,10 +106,36 @@ def _reporting_governance_policy(context: ModuleContext) -> object: return ReportingGovernancePolicyProvider() +def _access_explanation_subject_policy(context: ModuleContext) -> object: + del context + from govoplan_policy.backend.access_explanation_subjects import ( + AccessExplanationSubjectPolicyProvider, + ) + + return AccessExplanationSubjectPolicyProvider() + + +ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user" + + manifest = ModuleManifest( id="policy", name="Policy", version="0.1.18", + permissions=( + PermissionDefinition( + scope=ACCESS_EXPLANATION_SUBJECT_SCOPE, + label="Select users for access diagnostics", + description=( + "Run resource-access explanations for another user in the active tenant." + ), + category="Policy", + level="tenant", + module_id="policy", + resource="access_explanation", + action="select_user", + ), + ), required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -133,9 +161,37 @@ manifest = ModuleManifest( name=CAPABILITY_POLICY_REPORTING_GOVERNANCE, version="1.0.0", ), + ModuleInterfaceProvider( + name=CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS, + version="1.0.0", + ), ), route_factory=_route_factory, documentation=( + DocumentationTopic( + id="policy.access-explanation-subjects", + title="Choose subjects for access diagnostics", + summary=( + "Policy keeps access explanations on the signed-in user unless " + "the actor has the selected-user diagnostic permission." + ), + body=( + "Files and Campaign use the shared access-explanation picker. " + "Without policy:access_explanation:select_user, Access returns " + "only the signed-in user and does not disclose tenant-directory " + "metadata. Permitted cross-user explanations remain limited to " + "the active tenant and are recorded as administrator diagnostics " + "in audit evidence. The permission changes diagnostic visibility; " + "it does not grant access to the explained resource." + ), + documentation_types=("admin", "user"), + audience=("user", "tenant_admin", "policy_admin"), + related_modules=("access", "audit", "campaign", "files"), + metadata={ + "kind": "reference", + "help_contexts": ["access.resource-explanation.subject"], + }, + ), DocumentationTopic( id="policy.view-governance-administration", title="Govern View availability and actions", @@ -371,10 +427,23 @@ manifest = ModuleManifest( ), CAPABILITY_POLICY_DISTRIBUTION_CHANNELS: _distribution_channel_policy, CAPABILITY_POLICY_REPORTING_GOVERNANCE: _reporting_governance_policy, + CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS: ( + _access_explanation_subject_policy + ), CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy, }, capability_documentation={ + CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS: CapabilityDocumentation( + label="Access-explanation subject policy", + summary=( + "Limits access explanations to the current user or permits " + "audited selected-user administrator diagnostics." + ), + contract_version="1.0", + documentation_types=("admin", "user"), + audience=("tenant_admin", "policy_admin"), + ), CAPABILITY_POLICY_REPORTING_GOVERNANCE: CapabilityDocumentation( label="Reporting privacy governance", summary=( diff --git a/tests/test_access_explanation_subjects.py b/tests/test_access_explanation_subjects.py new file mode 100644 index 0000000..4322187 --- /dev/null +++ b/tests/test_access_explanation_subjects.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.access import PrincipalRef +from govoplan_policy.backend.access_explanation_subjects import ( + ACCESS_EXPLANATION_SUBJECT_SCOPE, + AccessExplanationSubjectPolicyProvider, +) + + +class AccessExplanationSubjectPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.provider = AccessExplanationSubjectPolicyProvider() + + def test_defaults_to_current_user_without_permission(self) -> None: + decision = self.provider.decide_subject_selection( + object(), + PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + ), + tenant_id="tenant-1", + ) + + self.assertFalse(decision.allow_other_users) + self.assertEqual(ACCESS_EXPLANATION_SUBJECT_SCOPE, decision.required_scope) + self.assertEqual("current_user", decision.provenance["mode"]) + + def test_permission_enables_cross_user_diagnostics(self) -> None: + decision = self.provider.decide_subject_selection( + object(), + PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + scopes=frozenset({ACCESS_EXPLANATION_SUBJECT_SCOPE}), + ), + tenant_id="tenant-1", + ) + + self.assertTrue(decision.allow_other_users) + self.assertEqual("cross_user", decision.provenance["mode"]) + + def test_never_crosses_the_active_tenant(self) -> None: + decision = self.provider.decide_subject_selection( + object(), + PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + scopes=frozenset({ACCESS_EXPLANATION_SUBJECT_SCOPE}), + ), + tenant_id="tenant-2", + ) + + self.assertFalse(decision.allow_other_users) + self.assertEqual("policy.tenant_boundary", decision.source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_policy_module_contract.py b/tests/test_policy_module_contract.py index 3b82bf8..e94fcf2 100644 --- a/tests/test_policy_module_contract.py +++ b/tests/test_policy_module_contract.py @@ -11,6 +11,7 @@ from govoplan_core.core.policy import ( CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, CAPABILITY_POLICY_VIEW_GOVERNANCE, ) +from govoplan_core.core.access import CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS from govoplan_core.core.distribution_lists import ( CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, ) @@ -54,6 +55,7 @@ class PolicyModuleContractTests(unittest.TestCase): CAPABILITY_POLICY_REPORTING_GOVERNANCE, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, CAPABILITY_POLICY_VIEW_GOVERNANCE, + CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS, }, set(manifest.capability_factories), )