From c22da17c645b5110880fb1a645efe42f13563853 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 13:17:25 +0200 Subject: [PATCH] feat(admin): declare governed DSAR coverage --- src/govoplan_admin/backend/dsar_provider.py | 61 ++++++++ src/govoplan_admin/backend/manifest.py | 165 ++++++++++++++++++-- tests/test_dsar_provider.py | 112 +++++++++++++ 3 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 src/govoplan_admin/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/src/govoplan_admin/backend/dsar_provider.py b/src/govoplan_admin/backend/dsar_provider.py new file mode 100644 index 0000000..4d7eeef --- /dev/null +++ b/src/govoplan_admin/backend/dsar_provider.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) + + +ADMIN_DSAR_CAPABILITY = dsar_capability_name("admin") + + +class AdminDsarProvider: + """Declare the reviewed absence of subject data in Admin-owned tables.""" + + provider_id = "admin" + module_id = "admin" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + del session, tenant_id, subject + return () + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del session, tenant_id, subject + if records: + raise ValueError("Admin DSAR cannot plan records it does not own.") + return () + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del session, tenant_id, subject, request_id + if actions: + raise ValueError("Admin DSAR cannot execute actions it does not own.") + return () + + +__all__ = ["ADMIN_DSAR_CAPABILITY", "AdminDsarProvider"] diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index f5fd30e..4b1e358 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -1,14 +1,19 @@ from __future__ import annotations from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationCondition, DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, + ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate, @@ -16,6 +21,10 @@ from govoplan_core.core.modules import ( from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +from govoplan_admin.backend.dsar_provider import ( + ADMIN_DSAR_CAPABILITY, + AdminDsarProvider, +) def _route_factory(context: ModuleContext): @@ -25,6 +34,10 @@ def _route_factory(context: ModuleContext): return router +def _dsar_provider(_context: ModuleContext) -> AdminDsarProvider: + return AdminDsarProvider() + + ADMIN_PERMISSIONS = ( PermissionDefinition( scope="admin:module:read", @@ -68,9 +81,53 @@ manifest = ModuleManifest( version="0.1.18", permissions=ADMIN_PERMISSIONS, role_templates=ADMIN_ROLE_TEMPLATES, - required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + provides_interfaces=( + ModuleInterfaceProvider(name=ADMIN_DSAR_CAPABILITY, version="0.1.0"), + ), + capability_factories={ADMIN_DSAR_CAPABILITY: _dsar_provider}, + capability_documentation={ + ADMIN_DSAR_CAPABILITY: CapabilityDocumentation( + label="Administration data-subject request coverage", + summary=( + "Records the reviewed absence of subject identifiers in Admin-owned " + "governance-template state." + ), + contract_version="0.1.0", + ), + }, route_factory=_route_factory, documentation=( + DocumentationTopic( + id="admin.data-subject-request-coverage", + title="Administration data-subject request coverage", + summary=( + "Admin-owned governance templates contain no data-subject or operator " + "identity fields." + ), + body=( + "The Admin module stores reusable governance-template definitions and " + "tenant availability assignments. These tables contain permissions, " + "template labels, and tenant identifiers, but no account, membership, " + "identity, email, author, approver, or other data-subject reference. " + "Admin therefore contributes an explicit zero-result provider so the " + "privacy workspace can distinguish reviewed non-applicability from an " + "unexplained coverage gap. Module-lifecycle and configuration-change " + "actor evidence remains owned by Core, Audit, Access, or Ops and is " + "returned by those modules' providers." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("privacy_officer", "system_admin", "auditor"), + related_modules=("core", "access", "audit", "ops"), + metadata={ + "help_contexts": ["admin.privacy.data-subject-requests"], + "coverage_classification": "reviewed_no_subject_data", + }, + ), DocumentationTopic( id="admin.workspace", title="Use the administration workspace", @@ -236,17 +293,83 @@ manifest = ModuleManifest( module_id="admin", package_name="@govoplan/admin-webui", view_surfaces=( - ViewSurface(id="admin.section.overview", module_id="admin", kind="section", label="Administration overview", order=0), - ViewSurface(id="admin.section.system-settings", module_id="admin", kind="section", label="System settings", order=10), - ViewSurface(id="admin.section.system-language-packages", module_id="admin", kind="section", label="Language packages", order=15), - ViewSurface(id="admin.section.system-configuration-changes", module_id="admin", kind="section", label="Configuration changes", order=20), - ViewSurface(id="admin.section.system-configuration-packages", module_id="admin", kind="section", label="Configuration packages", order=30), - ViewSurface(id="admin.section.system-role-templates", module_id="admin", kind="section", label="Role templates", order=40), - ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50), - ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85), - ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86), - ViewSurface(id="admin.section.tenant-data-subject-requests", module_id="admin", kind="section", label="Data-subject requests", order=55), - ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60), + ViewSurface( + id="admin.section.overview", + module_id="admin", + kind="section", + label="Administration overview", + order=0, + ), + ViewSurface( + id="admin.section.system-settings", + module_id="admin", + kind="section", + label="System settings", + order=10, + ), + ViewSurface( + id="admin.section.system-language-packages", + module_id="admin", + kind="section", + label="Language packages", + order=15, + ), + ViewSurface( + id="admin.section.system-configuration-changes", + module_id="admin", + kind="section", + label="Configuration changes", + order=20, + ), + ViewSurface( + id="admin.section.system-configuration-packages", + module_id="admin", + kind="section", + label="Configuration packages", + order=30, + ), + ViewSurface( + id="admin.section.system-role-templates", + module_id="admin", + kind="section", + label="Role templates", + order=40, + ), + ViewSurface( + id="admin.section.system-groups", + module_id="admin", + kind="section", + label="Group templates", + order=50, + ), + ViewSurface( + id="admin.section.system-modules", + module_id="admin", + kind="section", + label="Modules", + order=85, + ), + ViewSurface( + id="admin.section.system-tenant-modules", + module_id="admin", + kind="section", + label="Tenant modules", + order=86, + ), + ViewSurface( + id="admin.section.tenant-data-subject-requests", + module_id="admin", + kind="section", + label="Data-subject requests", + order=55, + ), + ViewSurface( + id="admin.section.tenant-modules", + module_id="admin", + kind="section", + label="Modules", + order=60, + ), ), ), migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata), @@ -263,9 +386,19 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="README.md", test_ref="tests/test_catalog_plan.py", - known_limits=("Some module-specific administration surfaces still own their own navigation and release evidence.",), - owned_concepts=("administration workspace", "configuration package workflow", "module lifecycle request"), - non_owned_concepts=("module installation effect", "access policy", "module-owned settings"), + known_limits=( + "Some module-specific administration surfaces still own their own navigation and release evidence.", + ), + owned_concepts=( + "administration workspace", + "configuration package workflow", + "module lifecycle request", + ), + non_owned_concepts=( + "module installation effect", + "access policy", + "module-owned settings", + ), operations_docs=("README.md",), ), ) diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..db6dd71 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_admin.backend.dsar_provider import ( + ADMIN_DSAR_CAPABILITY, + AdminDsarProvider, +) +from govoplan_admin.backend.manifest import manifest +from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef +from govoplan_core.db.base import Base +from govoplan_core.privacy.dsar_workflow import ( + create_data_subject_request, + search_data_subject_request, +) + + +class _Registry: + def __init__(self, provider: AdminDsarProvider) -> None: + self.provider = provider + + def capability_names(self): + return (ADMIN_DSAR_CAPABILITY,) + + def capability_owner(self, name): + if name != ADMIN_DSAR_CAPABILITY: + raise KeyError(name) + return "admin" + + def tenant_entitlement_resolver(self): + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type("State", (), {"effective_modules": ("admin",)})() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + if name != ADMIN_DSAR_CAPABILITY: + raise KeyError(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "admin"})(),) + + +class AdminDsarProviderTests(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 = AdminDsarProvider() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_provider_explicitly_reports_no_subject_records(self) -> None: + self.assertIsInstance(self.provider, DsarProvider) + subject = DsarSubjectRef(account_id="account-1") + self.assertEqual( + (), + self.provider.search_subject( + self.session, tenant_id="tenant-1", subject=subject + ), + ) + self.assertEqual( + (), + self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=subject, + records=(), + ), + ) + + def test_manifest_and_workflow_record_reviewed_coverage(self) -> None: + self.assertIn(ADMIN_DSAR_CAPABILITY, manifest.capability_factories) + self.assertIn( + "admin.data-subject-request-coverage", + {topic.id for topic in manifest.documentation}, + ) + row = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-ADMIN-1", + request_kind="access", + subject=DsarSubjectRef(account_id="account-1"), + purpose="Administration coverage review", + legal_basis=None, + due_at=None, + requested_by_account_id="operator-1", + ) + search_data_subject_request( + self.session, + registry=_Registry(self.provider), + row=row, + expected_revision=row.resource_revision, + ) + self.assertEqual("searched", row.status) + self.assertEqual(0, row.search_result["record_count"]) + self.assertEqual(["admin"], row.coverage["covered_modules"]) + self.assertEqual([], row.coverage["modules_without_provider"]) + + +if __name__ == "__main__": + unittest.main()