Add governed module and interface controls

This commit is contained in:
2026-08-04 05:20:47 +02:00
parent 5bc7d748f8
commit d6e7c8b0b1
27 changed files with 1700 additions and 74 deletions
+59 -4
View File
@@ -4,9 +4,13 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from threading import RLock
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
from govoplan_core.core.module_entitlements import tenant_module_entitlement_state
from govoplan_core.core.module_lifecycle_recovery import (
ModuleLifecycleRecovery,
begin_runtime_graph_recovery,
@@ -18,7 +22,9 @@ from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.workflows import (
workflow_definition_contribution_provider,
)
from govoplan_core.db.session import get_session
from govoplan_core.server.route_validation import validate_router_can_mount
from govoplan_core.tenancy.scope import Tenant
@dataclass(frozen=True, slots=True)
@@ -31,11 +37,60 @@ class ModuleLifecycleResult:
def require_module_active(module_id: str):
def dependency(request: Request) -> None:
def dependency(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> None:
registry = getattr(request.app.state, "govoplan_registry", None)
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
# Public module routes must remain reachable without Access. When an
# authenticated request is present, cache its principal and apply the
# active tenant's module entitlement before the owning route executes.
if not authorization and not x_api_key and not request.cookies:
return
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
try:
principal = get_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
except HTTPException as exc:
if exc.status_code in {
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
}:
return
raise
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None:
return
try:
tenant = session.get(Tenant, principal.principal.tenant_id)
except (RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant module entitlement could not be resolved.",
) from exc
if tenant is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The active tenant is unavailable.",
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
entitlement = tenant_module_entitlement_state(
tenant.settings or {},
manifests,
runtime_active_modules=manifests,
)
if module_id not in entitlement.effective_modules:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Module is unavailable in the active tenant: {module_id}",
)
return dependency
@@ -0,0 +1,483 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from govoplan_core.core.modules import ModuleManifest
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
TENANT_PROTECTED_MODULES = ("access", "admin")
class ModuleEntitlementError(ValueError):
pass
class ModuleEntitlementConflict(ModuleEntitlementError):
pass
@dataclass(frozen=True, slots=True)
class TenantModuleItem:
id: str
name: str
dependencies: tuple[str, ...]
runtime_active: bool
availability: str
selected: bool
effective: bool
forced: bool
derived_dependency: bool
tenant_can_toggle: bool
reason: str | None = None
@dataclass(frozen=True, slots=True)
class TenantModuleEntitlementState:
revision: int
configured: bool
available_modules: tuple[str, ...]
forced_modules: tuple[str, ...]
selected_modules: tuple[str, ...]
effective_modules: tuple[str, ...]
derived_dependencies: tuple[str, ...]
modules: tuple[TenantModuleItem, ...]
diagnostics: tuple[dict[str, str], ...] = ()
def tenant_module_entitlement_state(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> TenantModuleEntitlementState:
module_ids = tuple(sorted(manifests))
known = set(module_ids)
runtime_active = (
known
if runtime_active_modules is None
else known.intersection(_normalized_ids(runtime_active_modules))
)
protected = known.intersection(_normalized_ids(protected_modules))
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
configured = isinstance(raw_document, Mapping)
diagnostics: list[dict[str, str]] = []
if not configured:
revision = 0
requested_available = set(known)
requested_forced = set(protected)
requested_selected = set(known)
else:
document = raw_document
revision = _revision(document.get("revision"), diagnostics)
system_policy = document.get("system_policy")
tenant_selection = document.get("tenant_selection")
if not isinstance(system_policy, Mapping) or not isinstance(
tenant_selection, Mapping
):
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_document",
"The tenant module entitlement document is malformed and was restricted to protected modules.",
)
)
requested_available = set(protected)
requested_forced = set(protected)
requested_selected = set()
else:
requested_available = _configured_ids(
system_policy.get("available_modules"),
field="system_policy.available_modules",
known=known,
fallback=protected,
diagnostics=diagnostics,
)
requested_forced = _configured_ids(
system_policy.get("forced_modules"),
field="system_policy.forced_modules",
known=known,
fallback=protected,
diagnostics=diagnostics,
)
requested_selected = _configured_ids(
tenant_selection.get("enabled_modules"),
field="tenant_selection.enabled_modules",
known=known,
fallback=(),
diagnostics=diagnostics,
)
available, missing_available = _dependency_closure(
requested_available | requested_forced | protected,
manifests,
)
forced, missing_forced = _dependency_closure(
requested_forced | protected,
manifests,
)
selected = requested_selected.intersection(available)
effective_candidates, missing_selected = _dependency_closure(
selected | forced,
manifests,
)
effective_candidates.intersection_update(available)
effective = effective_candidates.intersection(runtime_active)
derived = effective_candidates - selected - forced
for module_id in sorted(
missing_available | missing_forced | missing_selected
):
diagnostics.append(
_diagnostic(
"module_entitlements.missing_dependency",
f"A selected module requires unavailable dependency {module_id}.",
)
)
items: list[TenantModuleItem] = []
for module_id in module_ids:
manifest = manifests[module_id]
is_available = module_id in available
is_forced = module_id in forced
is_selected = module_id in selected
is_derived = module_id in derived
is_runtime_active = module_id in runtime_active
is_effective = module_id in effective
reason: str | None = None
if not is_available:
reason = "Unavailable by system policy."
elif is_forced:
reason = "Required by system policy or a protected platform dependency."
elif is_derived:
reason = "Required by another selected module."
elif not is_runtime_active and (is_selected or is_forced):
reason = "Selected for this tenant, but the module is not active in the deployment."
items.append(
TenantModuleItem(
id=module_id,
name=manifest.name,
dependencies=tuple(manifest.dependencies),
runtime_active=is_runtime_active,
availability=(
"forced" if is_forced else "available" if is_available else "unavailable"
),
selected=is_selected,
effective=is_effective,
forced=is_forced,
derived_dependency=is_derived,
tenant_can_toggle=is_available and not is_forced and not is_derived,
reason=reason,
)
)
return TenantModuleEntitlementState(
revision=revision,
configured=configured,
available_modules=tuple(sorted(available)),
forced_modules=tuple(sorted(forced)),
selected_modules=tuple(sorted(selected)),
effective_modules=tuple(sorted(effective)),
derived_dependencies=tuple(sorted(derived)),
modules=tuple(items),
diagnostics=tuple(diagnostics),
)
def update_system_tenant_module_policy(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
available_modules: Iterable[str],
forced_modules: Iterable[str],
enabled_modules: Iterable[str],
expected_revision: int | None,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
current = tenant_module_entitlement_state(
settings,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
_check_revision(current.revision, expected_revision)
known = set(manifests)
available_requested = _validated_requested_ids(
available_modules, known=known, field="available_modules"
)
forced_requested = _validated_requested_ids(
forced_modules, known=known, field="forced_modules"
)
enabled_requested = _validated_requested_ids(
enabled_modules, known=known, field="enabled_modules"
)
protected = known.intersection(_normalized_ids(protected_modules))
available, missing = _dependency_closure(
available_requested | forced_requested | protected,
manifests,
)
forced, forced_missing = _dependency_closure(
forced_requested | protected,
manifests,
)
if missing or forced_missing:
missing_text = ", ".join(sorted(missing | forced_missing))
raise ModuleEntitlementError(
f"Module policy references dependencies that are not installed: {missing_text}"
)
unavailable_enabled = enabled_requested - available
if unavailable_enabled:
raise ModuleEntitlementError(
"Tenant selection contains modules unavailable by system policy: "
+ ", ".join(sorted(unavailable_enabled))
)
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
updated = _write_document(
settings,
revision=current.revision + 1,
available_modules=available,
forced_modules=forced,
enabled_modules=enabled_requested,
)
return updated, tenant_module_entitlement_state(
updated,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
def update_tenant_module_selection(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
enabled_modules: Iterable[str],
expected_revision: int | None,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
current = tenant_module_entitlement_state(
settings,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
_check_revision(current.revision, expected_revision)
enabled = _validated_requested_ids(
enabled_modules,
known=set(manifests),
field="enabled_modules",
)
unavailable = enabled - set(current.available_modules)
if unavailable:
raise ModuleEntitlementError(
"Tenant selection contains modules unavailable by system policy: "
+ ", ".join(sorted(unavailable))
)
_validate_enabled_dependencies(
enabled | set(current.forced_modules),
set(current.available_modules),
manifests,
)
updated = _write_document(
settings,
revision=current.revision + 1,
available_modules=current.available_modules,
forced_modules=current.forced_modules,
enabled_modules=enabled,
)
return updated, tenant_module_entitlement_state(
updated,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
def module_entitlement_payload(
tenant_id: str,
state: TenantModuleEntitlementState,
) -> dict[str, Any]:
return {
"tenant_id": tenant_id,
"revision": state.revision,
"configured": state.configured,
"available_modules": list(state.available_modules),
"forced_modules": list(state.forced_modules),
"selected_modules": list(state.selected_modules),
"effective_modules": list(state.effective_modules),
"derived_dependencies": list(state.derived_dependencies),
"modules": [
{
"id": item.id,
"name": item.name,
"dependencies": list(item.dependencies),
"runtime_active": item.runtime_active,
"availability": item.availability,
"selected": item.selected,
"effective": item.effective,
"forced": item.forced,
"derived_dependency": item.derived_dependency,
"tenant_can_toggle": item.tenant_can_toggle,
"reason": item.reason,
}
for item in state.modules
],
"diagnostics": [dict(item) for item in state.diagnostics],
}
def _write_document(
settings: Mapping[str, object] | None,
*,
revision: int,
available_modules: Iterable[str],
forced_modules: Iterable[str],
enabled_modules: Iterable[str],
) -> dict[str, object]:
updated = dict(settings or {})
updated[MODULE_ENTITLEMENTS_KEY] = {
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
"revision": revision,
"system_policy": {
"available_modules": sorted(set(available_modules)),
"forced_modules": sorted(set(forced_modules)),
},
"tenant_selection": {
"enabled_modules": sorted(set(enabled_modules)),
},
}
return updated
def _validate_enabled_dependencies(
enabled: set[str],
available: set[str],
manifests: Mapping[str, ModuleManifest],
) -> None:
closure, missing = _dependency_closure(enabled, manifests)
if missing:
raise ModuleEntitlementError(
"Selected modules require dependencies that are not installed: "
+ ", ".join(sorted(missing))
)
unavailable = closure - available
if unavailable:
raise ModuleEntitlementError(
"Selected modules require dependencies unavailable by system policy: "
+ ", ".join(sorted(unavailable))
)
def _dependency_closure(
requested: Iterable[str],
manifests: Mapping[str, ModuleManifest],
) -> tuple[set[str], set[str]]:
closure: set[str] = set()
missing: set[str] = set()
pending = list(_normalized_ids(requested))
while pending:
module_id = pending.pop()
if module_id in closure:
continue
manifest = manifests.get(module_id)
if manifest is None:
missing.add(module_id)
continue
closure.add(module_id)
pending.extend(manifest.dependencies)
return closure, missing
def _configured_ids(
value: object,
*,
field: str,
known: set[str],
fallback: Iterable[str],
diagnostics: list[dict[str, str]],
) -> set[str]:
if not isinstance(value, list | tuple):
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_field",
f"{field} is malformed and was evaluated with a restrictive fallback.",
)
)
return set(fallback)
values = _normalized_ids(value)
unknown = values - known
if unknown:
diagnostics.append(
_diagnostic(
"module_entitlements.unknown_module",
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
)
)
return values.intersection(known)
def _validated_requested_ids(
values: Iterable[str],
*,
known: set[str],
field: str,
) -> set[str]:
normalized = _normalized_ids(values)
unknown = normalized - known
if unknown:
raise ModuleEntitlementError(
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
)
return normalized
def _normalized_ids(values: Iterable[object]) -> set[str]:
return {
clean
for value in values
if (clean := str(value).strip())
}
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
if isinstance(value, int) and value >= 0:
return value
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_revision",
"The module entitlement revision is invalid; concurrent updates will require a reload.",
)
)
return 0
def _check_revision(current: int, expected: int | None) -> None:
if expected is not None and expected != current:
raise ModuleEntitlementConflict(
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
)
def _diagnostic(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message, "severity": "warning"}
__all__ = [
"MODULE_ENTITLEMENTS_KEY",
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
"TENANT_PROTECTED_MODULES",
"ModuleEntitlementConflict",
"ModuleEntitlementError",
"TenantModuleEntitlementState",
"TenantModuleItem",
"module_entitlement_payload",
"tenant_module_entitlement_state",
"update_system_tenant_module_policy",
"update_tenant_module_selection",
]
@@ -0,0 +1,303 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
import hashlib
import json
import re
from typing import Any, Literal, Mapping, Sequence
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.views import (
navigation_view_surface_id,
route_view_surface_id,
)
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
PlatformInterfaceKind = Literal[
"backend_capability",
"frontend_route",
"navigation",
"permission",
"provided_interface",
"public_route",
"search_provider",
"search_source",
"settings_route",
"view_surface",
]
@dataclass(frozen=True, slots=True)
class PlatformInterfaceDeclaration:
"""A sanitized, stable declaration from a module manifest.
The declaration contains identifiers and authorization metadata only. It
deliberately excludes factories, executable callbacks, credentials, and
mutable module state.
"""
id: str
module_id: str
kind: PlatformInterfaceKind
label: str | None = None
path: str | None = None
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
metadata: Mapping[str, Any] = field(default_factory=dict)
@property
def key(self) -> str:
return f"{self.kind}:{self.id}"
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"id": self.id,
"module_id": self.module_id,
"kind": self.kind,
"label": self.label,
"path": self.path,
"required_all": list(self.required_all),
"required_any": list(self.required_any),
"metadata": dict(self.metadata),
}
def manifest_interface_declarations(
manifest: ModuleManifest,
) -> tuple[PlatformInterfaceDeclaration, ...]:
"""Normalize the typed public declarations owned by one module manifest."""
declarations: list[PlatformInterfaceDeclaration] = []
for capability_name in sorted(manifest.capability_factories):
documentation = manifest.capability_documentation.get(capability_name)
declarations.append(
PlatformInterfaceDeclaration(
id=capability_name,
module_id=manifest.id,
kind="backend_capability",
label=documentation.label if documentation is not None else None,
metadata={
"contract_version": (
documentation.contract_version
if documentation is not None
else None
),
},
)
)
for interface in manifest.provides_interfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=interface.name,
module_id=manifest.id,
kind="provided_interface",
metadata={"version": interface.version},
)
)
for permission in manifest.permissions:
declarations.append(
PlatformInterfaceDeclaration(
id=permission.scope,
module_id=manifest.id,
kind="permission",
label=permission.label,
metadata={
"category": permission.category,
"level": permission.level,
"deprecated": permission.deprecated,
},
)
)
for registration in manifest.search_providers:
declarations.append(
PlatformInterfaceDeclaration(
id=registration.id,
module_id=manifest.id,
kind="search_provider",
metadata={
"role": "provider",
"resource_types": list(registration.resource_types),
},
)
)
for registration in manifest.search_sources:
declarations.append(
PlatformInterfaceDeclaration(
id=registration.id,
module_id=manifest.id,
kind="search_source",
metadata={"role": "source"},
)
)
frontend = manifest.frontend
if frontend is not None:
for route in frontend.routes:
declarations.append(
PlatformInterfaceDeclaration(
id=route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="frontend_route",
path=route.path,
required_all=route.required_all,
required_any=route.required_any,
metadata={
"component": route.component,
"order": route.order,
"surface_id": route.surface_id,
},
)
)
for route in frontend.public_routes:
declarations.append(
PlatformInterfaceDeclaration(
id=f"{manifest.id}.public.{_path_slug(route.path)}",
module_id=manifest.id,
kind="public_route",
path=route.path,
metadata={"component": route.component, "order": route.order},
)
)
for route in frontend.settings_routes:
declarations.append(
PlatformInterfaceDeclaration(
id=route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="settings_route",
path=route.path,
required_all=route.required_all,
required_any=route.required_any,
metadata={
"component": route.component,
"order": route.order,
"surface_id": route.surface_id,
},
)
)
for item in frontend.nav_items:
declarations.append(
PlatformInterfaceDeclaration(
id=navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
path=item.path,
required_all=item.required_all,
required_any=item.required_any,
metadata={
"icon": item.icon,
"section": item.section,
"order": item.order,
"surface_id": item.surface_id,
},
)
)
for surface in frontend.view_surfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=surface.id,
module_id=manifest.id,
kind="view_surface",
label=surface.label,
metadata={
"surface_kind": surface.kind,
"parent_id": surface.parent_id,
"description": surface.description,
"order": surface.order,
"default_visible": surface.default_visible,
"required": surface.required,
},
)
)
frontend_navigation = {
declaration.id: declaration
for declaration in declarations
if declaration.kind == "navigation"
}
for item in manifest.nav_items:
declaration = PlatformInterfaceDeclaration(
id=navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
path=item.path,
required_all=item.required_all,
required_any=item.required_any,
metadata={
"icon": item.icon,
"section": item.section,
"order": item.order,
"surface_id": item.surface_id,
},
)
frontend_declaration = frontend_navigation.get(declaration.id)
if frontend_declaration is not None and frontend_declaration == declaration:
continue
declarations.append(declaration)
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
seen: set[str] = set()
for declaration in manifest_interface_declarations(manifest):
if declaration.key in seen:
raise ValueError(
f"Module {manifest.id!r} declares duplicate platform interface "
f"{declaration.key!r}"
)
seen.add(declaration.key)
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
declarations = manifest_interface_declarations(manifest)
serialized = [item.to_dict() for item in declarations]
canonical = json.dumps(
serialized,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return {
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
"module_id": manifest.id,
"module_version": manifest.version,
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
"declarations": serialized,
}
def platform_interface_catalog(
manifests: Sequence[ModuleManifest],
) -> dict[str, Any]:
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
return {
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
"modules": modules,
}
def _path_slug(path: str) -> str:
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
return slug or "root"
__all__ = [
"PLATFORM_INTERFACE_CONTRACT_VERSION",
"PlatformInterfaceDeclaration",
"PlatformInterfaceKind",
"manifest_interface_catalog",
"manifest_interface_declarations",
"platform_interface_catalog",
"validate_manifest_interface_declarations",
]
+7
View File
@@ -29,6 +29,9 @@ from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.platform_interfaces import (
validate_manifest_interface_declarations,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
@@ -646,6 +649,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
try:
validate_manifest_interface_declarations(manifest)
except ValueError as exc:
raise RegistryError(str(exc)) from exc
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation: