Add governed module and interface controls
This commit is contained in:
@@ -135,6 +135,9 @@ def get_api_principal(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||
if isinstance(cached, ApiPrincipal):
|
||||
return cached
|
||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
@@ -143,6 +146,7 @@ def get_api_principal(
|
||||
)
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||
request.state.govoplan_api_principal = principal
|
||||
return principal
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
|
||||
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
|
||||
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
|
||||
PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"),
|
||||
PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"),
|
||||
)
|
||||
|
||||
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
|
||||
@@ -5,9 +5,17 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
module_entitlement_payload,
|
||||
tenant_module_entitlement_state,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
manifest_interface_catalog,
|
||||
platform_interface_catalog,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||
from govoplan_core.core.views import (
|
||||
VIEW_SURFACE_CONTRACT_VERSION,
|
||||
@@ -17,6 +25,7 @@ from govoplan_core.core.views import (
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
def _registry(request: Request) -> PlatformRegistry:
|
||||
@@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _effective_manifest_state(
|
||||
request: Request,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]:
|
||||
"""Resolve only manifests available in the principal's active context."""
|
||||
|
||||
registry = _registry(request)
|
||||
manifests = tuple(registry.manifests())
|
||||
entitlement = None
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
if tenant_id is not None:
|
||||
try:
|
||||
with get_database().session() as session:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="The active tenant is unavailable.",
|
||||
)
|
||||
manifest_map = {manifest.id: manifest for manifest in manifests}
|
||||
entitlement = tenant_module_entitlement_state(
|
||||
tenant.settings or {},
|
||||
manifest_map,
|
||||
runtime_active_modules=manifest_map,
|
||||
)
|
||||
except (RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
effective_ids = (
|
||||
set(entitlement.effective_modules)
|
||||
if entitlement is not None
|
||||
else {manifest.id for manifest in manifests}
|
||||
)
|
||||
return (
|
||||
registry,
|
||||
tuple(manifest for manifest in manifests if manifest.id in effective_ids),
|
||||
entitlement,
|
||||
)
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
@@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
@router.get("/modules")
|
||||
def modules(
|
||||
request: Request,
|
||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
registry = _registry(request)
|
||||
registry, manifests, entitlement = _effective_manifest_state(
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
@@ -178,13 +235,36 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
for declaration in manifest.external_providers
|
||||
],
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"interface_catalog": {
|
||||
key: value
|
||||
for key, value in manifest_interface_catalog(manifest).items()
|
||||
if key != "declarations"
|
||||
},
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
for manifest in manifests
|
||||
],
|
||||
"module_entitlement": (
|
||||
module_entitlement_payload(tenant_id, entitlement)
|
||||
if tenant_id is not None and entitlement is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
@router.get("/interface-catalog")
|
||||
def interface_catalog(
|
||||
request: Request,
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope("admin:module:read", "system:settings:read")
|
||||
),
|
||||
):
|
||||
_registry_item, manifests, _entitlement = _effective_manifest_state(
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
return platform_interface_catalog(manifests)
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
|
||||
Reference in New Issue
Block a user