feat: preserve governed configuration context
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user