feat: preserve governed configuration context

This commit is contained in:
2026-08-01 17:47:00 +02:00
parent 9776f862f8
commit fe247999e9
5 changed files with 166 additions and 3 deletions
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Access Codex Guide # 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 ## Scope
This repository owns the GovOPlaN access platform module seed: identity, This repository owns the GovOPlaN access platform module seed: identity,
+12
View File
@@ -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 `govoplan-admin`; access only materializes those templates into access-owned
groups and roles through the `access.governanceMaterializer` capability. 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 ## Verification References
Focused verification is run from `/mnt/DATA/git/govoplan-core`. Focused verification is run from `/mnt/DATA/git/govoplan-core`.
+31 -3
View File
@@ -165,6 +165,10 @@ from govoplan_core.core.configuration_control import (
record_configuration_change_applied, record_configuration_change_applied,
) )
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change 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.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef 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) @router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse)
def configuration_package_dry_run_endpoint( def configuration_package_dry_run_endpoint(
payload: ConfigurationPackageRunRequest, 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")), 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( return ConfigurationPackageDryRunResponse(
diagnostics=[item.to_dict() for item in result.diagnostics], diagnostics=[item.to_dict() for item in result.diagnostics],
required_data=[item.to_dict() for item in result.required_data], required_data=[item.to_dict() for item in result.required_data],
@@ -944,7 +949,7 @@ def configuration_package_apply_endpoint(
) )
except ConfigurationControlError as exc: except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from 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( record_configuration_change_applied(
session, session,
key="configuration_packages.apply", key="configuration_packages.apply",
@@ -975,6 +980,7 @@ def configuration_package_apply_endpoint(
@router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse) @router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse)
def configuration_package_export_endpoint( def configuration_package_export_endpoint(
payload: ConfigurationPackageExportRequest, 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")), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
): ):
selection = ConfigurationExportSelection( selection = ConfigurationExportSelection(
@@ -983,7 +989,7 @@ def configuration_package_export_endpoint(
module_ids=tuple(payload.module_ids), module_ids=tuple(payload.module_ids),
object_refs=tuple(payload.object_refs), 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( return ConfigurationPackageExportResponse(
fragments=[item.to_dict() for item in result.fragments], fragments=[item.to_dict() for item in result.fragments],
data_requirements=[item.to_dict() for item in result.data_requirements], data_requirements=[item.to_dict() for item in result.data_requirements],
@@ -1010,21 +1016,43 @@ def _configuration_context(
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
supplied_data: dict[str, Any] | None = None, supplied_data: dict[str, Any] | None = None,
session: Session | None = None,
) -> ConfigurationPreflightContext: ) -> ConfigurationPreflightContext:
registry = get_registry() registry = get_registry()
installed_modules: dict[str, str] = {"access": "0.1.6"} installed_modules: dict[str, str] = {"access": "0.1.6"}
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY} 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"): if registry is not None and hasattr(registry, "manifests"):
manifests = registry.manifests() manifests = registry.manifests()
installed_modules = {manifest.id: manifest.version for manifest in manifests} installed_modules = {manifest.id: manifest.version for manifest in manifests}
if hasattr(registry, "capability_names"): if hasattr(registry, "capability_names"):
capabilities.update(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( return ConfigurationPreflightContext(
tenant_id=tenant_id or principal.tenant_id, tenant_id=tenant_id or principal.tenant_id,
operator_user_id=principal.user.id, operator_user_id=principal.user.id,
supplied_data=supplied_data or {}, supplied_data=supplied_data or {},
installed_modules=installed_modules, installed_modules=installed_modules,
capabilities=frozenset(capabilities), capabilities=frozenset(capabilities),
external_provider_declarations=external_provider_declarations,
external_provider_states=external_provider_states,
) )
+13
View File
@@ -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.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory 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.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition, DocumentationCondition,
DocumentationLink, DocumentationLink,
@@ -741,6 +742,18 @@ manifest = ModuleManifest(
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
}, },
documentation=ACCESS_DOCUMENTATION, 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",),
),
) )
+104
View File
@@ -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()