From fe247999e9a667bf1641a9aad52a286342a94c84 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:47:00 +0200 Subject: [PATCH] feat: preserve governed configuration context --- AGENTS.md | 6 ++ docs/ACCESS_MODULE_BOUNDARY.md | 12 +++ src/govoplan_access/backend/api/v1/routes.py | 34 +++++- src/govoplan_access/backend/manifest.py | 13 +++ tests/test_configuration_package_context.py | 104 +++++++++++++++++++ 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 tests/test_configuration_package_context.py diff --git a/AGENTS.md b/AGENTS.md index 1a5d251..0716250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # GovOPlaN Access Codex Guide +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Access internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + ## Scope This repository owns the GovOPlaN access platform module seed: identity, diff --git a/docs/ACCESS_MODULE_BOUNDARY.md b/docs/ACCESS_MODULE_BOUNDARY.md index 68eed16..4309f45 100644 --- a/docs/ACCESS_MODULE_BOUNDARY.md +++ b/docs/ACCESS_MODULE_BOUNDARY.md @@ -179,6 +179,18 @@ Governance-template metadata CRUD is not access-owned. It is contributed by `govoplan-admin`; access only materializes those templates into access-owned groups and roles through the `access.governanceMaterializer` capability. +The configuration-package Admin routes remain in Access as a compatibility +surface. Their preflight context is assembled from the active Core registry, +including module-owned external-provider declarations. This allows an +integration package to validate installed provider authority and maturity +without importing provider modules into Access. For dry-run, apply, and export, +Access also asks the active registry for tenant-scoped, sanitized runtime +provider state using the request database transaction. Package preflight can +therefore select an exact stable binding and evaluate its authority, health, +freshness, and recovery readiness. When no provider state is available, +preflight reports it as unverified rather than inferring health from +installation. + ## Verification References Focused verification is run from `/mnt/DATA/git/govoplan-core`. diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index 3bb67f8..f3e26d9 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -165,6 +165,10 @@ from govoplan_core.core.configuration_control import ( record_configuration_change_applied, ) from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change +from govoplan_core.core.provider_governance import ( + ExternalProviderStateContext, + collect_external_provider_states, +) from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef @@ -916,9 +920,10 @@ def configuration_package_catalog_validation( @router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse) def configuration_package_dry_run_endpoint( payload: ConfigurationPackageRunRequest, + session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): - result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) + result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session)) return ConfigurationPackageDryRunResponse( diagnostics=[item.to_dict() for item in result.diagnostics], required_data=[item.to_dict() for item in result.required_data], @@ -944,7 +949,7 @@ def configuration_package_apply_endpoint( ) except ConfigurationControlError as exc: raise _configuration_control_http_error(exc) from exc - result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) + result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session)) record_configuration_change_applied( session, key="configuration_packages.apply", @@ -975,6 +980,7 @@ def configuration_package_apply_endpoint( @router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse) def configuration_package_export_endpoint( payload: ConfigurationPackageExportRequest, + session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): selection = ConfigurationExportSelection( @@ -983,7 +989,7 @@ def configuration_package_export_endpoint( module_ids=tuple(payload.module_ids), object_refs=tuple(payload.object_refs), ) - result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id)) + result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id, session=session)) return ConfigurationPackageExportResponse( fragments=[item.to_dict() for item in result.fragments], data_requirements=[item.to_dict() for item in result.data_requirements], @@ -1010,21 +1016,43 @@ def _configuration_context( *, tenant_id: str | None = None, supplied_data: dict[str, Any] | None = None, + session: Session | None = None, ) -> ConfigurationPreflightContext: registry = get_registry() installed_modules: dict[str, str] = {"access": "0.1.6"} capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY} + external_provider_declarations: dict[str, dict[str, object]] = {} + external_provider_states: dict[str, dict[str, object]] = {} if registry is not None and hasattr(registry, "manifests"): manifests = registry.manifests() installed_modules = {manifest.id: manifest.version for manifest in manifests} if hasattr(registry, "capability_names"): capabilities.update(registry.capability_names()) + if hasattr(registry, "external_provider_declarations"): + external_provider_declarations = { + declaration.id: declaration.to_dict() + for declaration in registry.external_provider_declarations() + } + if session is not None and hasattr( + registry, + "external_provider_state_providers", + ): + external_provider_states = collect_external_provider_states( + registry.external_provider_state_providers(), + ExternalProviderStateContext( + session=session, + tenant_id=tenant_id or principal.tenant_id, + principal=principal, + ), + ) return ConfigurationPreflightContext( tenant_id=tenant_id or principal.tenant_id, operator_user_id=principal.user.id, supplied_data=supplied_data or {}, installed_modules=installed_modules, capabilities=frozenset(capabilities), + external_provider_declarations=external_provider_declarations, + external_provider_states=external_provider_states, ) diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 21291ac..8716037 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -27,6 +27,7 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory from govoplan_core.core.module_guards import persistent_table_uninstall_guard +from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.modules import ( DocumentationCondition, DocumentationLink, @@ -741,6 +742,18 @@ manifest = ModuleManifest( ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, }, documentation=ACCESS_DOCUMENTATION, + architecture=declared_module_architecture( + layer="institutional_foundation", + kind="foundation", + maturity="vertical_slice", + documentation_ref="docs/ACCESS_MODULE_BOUNDARY.md", + test_ref="tests/test_login_security.py", + known_limits=("Recovery and upgrade evidence is not yet complete enough for supported maturity.",), + owned_concepts=("account authentication", "application role", "permission evaluation", "service account"), + non_owned_concepts=("person identity", "organization structure", "function incumbency", "policy definition"), + security_docs=("docs/ACCESS_MODULE_BOUNDARY.md",), + operations_docs=("README.md",), + ), ) diff --git a/tests/test_configuration_package_context.py b/tests/test_configuration_package_context.py new file mode 100644 index 0000000..16d2d55 --- /dev/null +++ b/tests/test_configuration_package_context.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from govoplan_access.backend.api.v1.routes import _configuration_context +from govoplan_core.core.provider_governance import ( + ExternalProviderRuntimeState, + ExternalProviderStateProviderRegistration, +) + + +class ConfigurationPackageContextTests(unittest.TestCase): + def test_context_projects_installed_external_provider_declarations(self) -> None: + declaration = SimpleNamespace( + id="connectors.example", + to_dict=lambda: { + "id": "connectors.example", + "maturity": "read", + "authority_modes": ["external_mirror"], + }, + ) + registry = SimpleNamespace( + manifests=lambda: ( + SimpleNamespace(id="access", version="0.1.14"), + SimpleNamespace(id="connectors", version="0.1.14"), + ), + capability_names=lambda: ("connectors.profiles",), + external_provider_declarations=lambda: (declaration,), + ) + principal = SimpleNamespace( + tenant_id="tenant-1", + user=SimpleNamespace(id="user-1"), + ) + + with patch( + "govoplan_access.backend.api.v1.routes.get_registry", + return_value=registry, + ): + context = _configuration_context(principal) + + self.assertEqual("0.1.14", context.installed_modules["connectors"]) + self.assertIn("connectors.profiles", context.capabilities) + self.assertEqual( + "external_mirror", + context.external_provider_declarations["connectors.example"][ + "authority_modes" + ][0], + ) + + def test_context_projects_tenant_runtime_provider_state(self) -> None: + declaration = SimpleNamespace( + id="calendar.caldav_sync", + to_dict=lambda: { + "id": "calendar.caldav_sync", + "maturity": "synchronize", + "authority_modes": ["governed_sync"], + }, + ) + registration = ExternalProviderStateProviderRegistration( + module_id="calendar", + provider_id="calendar.caldav_sync", + provider=lambda context: ( + ExternalProviderRuntimeState( + provider_id="calendar.caldav_sync", + binding_ref="calendar:sync-source:one", + authority_mode="governed_sync", + observed_at=datetime(2026, 8, 1, 12, 0, tzinfo=UTC), + configured=True, + active=True, + health="healthy", + freshness="current", + conflict="clear", + recovery="ready", + metrics={"tenant_matches": context.tenant_id == "tenant-1"}, + ), + ), + ) + registry = SimpleNamespace( + manifests=lambda: (SimpleNamespace(id="calendar", version="0.1.8"),), + capability_names=lambda: (), + external_provider_declarations=lambda: (declaration,), + external_provider_state_providers=lambda: (registration,), + ) + principal = SimpleNamespace( + tenant_id="tenant-1", + user=SimpleNamespace(id="user-1"), + ) + + with patch( + "govoplan_access.backend.api.v1.routes.get_registry", + return_value=registry, + ): + context = _configuration_context(principal, session=object()) + + state = context.external_provider_states["calendar.caldav_sync"] + self.assertEqual("healthy", state["health"]) + self.assertEqual("calendar:sync-source:one", state["binding_ref"]) + + +if __name__ == "__main__": + unittest.main()