Enforce tenant module entitlements beyond requests

This commit is contained in:
2026-08-04 09:29:36 +02:00
parent d6e7c8b0b1
commit 40c10089ab
28 changed files with 1692 additions and 286 deletions
File diff suppressed because it is too large Load Diff
+3
View File
@@ -95,6 +95,9 @@ class CampaignPolicyContextProvider(Protocol):
@runtime_checkable
class CampaignDeliveryTaskProvider(Protocol):
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
...
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
...
+3
View File
@@ -178,6 +178,7 @@ class DataflowTriggerDispatcher(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
@@ -204,6 +205,7 @@ class DataflowRunWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 10,
worker_id: str | None = None,
@@ -214,6 +216,7 @@ class DataflowRunWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 500,
) -> Mapping[str, object]:
+4
View File
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None,
limit: int = 100,
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
tenantless_only: bool = False,
before: datetime,
limit: int = 500,
) -> Mapping[str, int]:
+59 -42
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from threading import RLock
@@ -10,7 +10,11 @@ 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_entitlements import (
ModuleEntitlementResolutionError,
TenantModuleUnavailable,
tenant_execution_scope,
)
from govoplan_core.core.module_lifecycle_recovery import (
ModuleLifecycleRecovery,
begin_runtime_graph_recovery,
@@ -24,7 +28,6 @@ from govoplan_core.core.workflows import (
)
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)
@@ -37,60 +40,74 @@ class ModuleLifecycleResult:
def require_module_active(module_id: str):
def dependency(
async 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:
) -> AsyncIterator[None]:
registry = getattr(request.app.state, "govoplan_registry", None)
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.
tenant_id: str | None = None
if not authorization and not x_api_key and not request.cookies:
return
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,
}:
public_resolver = registry.public_tenant_resolver(module_id)
if public_resolver is not None:
tenant_id = public_resolver(request, session)
if tenant_id is None:
yield
return
raise
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None:
return
else:
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,
}:
yield
return
raise
if (
not isinstance(principal, ApiPrincipal)
or principal.principal.tenant_id is None
):
yield
return
tenant_id = principal.principal.tenant_id
resolver = registry.tenant_entitlement_resolver()
try:
tenant = session.get(Tenant, principal.principal.tenant_id)
except (RuntimeError, SQLAlchemyError) as exc:
admission = resolver.require(
session,
tenant_id=tenant_id,
module_id=module_id,
work_state="interactive",
)
except TenantModuleUnavailable as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Module is unavailable in the active tenant: {module_id}",
) from exc
except (ModuleEntitlementResolutionError, 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}",
)
request.state.govoplan_module_admission = admission
with tenant_execution_scope(
resolver,
session,
tenant_id=tenant_id,
work_state="interactive",
):
yield
return dependency
+1
View File
@@ -56,6 +56,7 @@ class MailDeliveryOutboxProvider(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 250,
) -> Mapping[str, object]:
...
+365 -2
View File
@@ -1,8 +1,13 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from collections import OrderedDict
from collections.abc import Iterable, Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
from threading import RLock
from time import monotonic
from typing import Any, Literal
from govoplan_core.core.modules import ModuleManifest
@@ -20,6 +25,30 @@ class ModuleEntitlementConflict(ModuleEntitlementError):
pass
class ModuleEntitlementResolutionError(ModuleEntitlementError):
pass
class TenantModuleUnavailable(ModuleEntitlementError):
def __init__(self, admission: "TenantModuleAdmission") -> None:
self.admission = admission
super().__init__(admission.reason)
class TenantModuleOperatorActionRequired(ModuleEntitlementError):
def __init__(self, admission: "TenantModuleAdmission") -> None:
self.admission = admission
super().__init__(admission.reason)
TenantWorkState = Literal["interactive", "new", "accepted"]
TenantAdmissionDisposition = Literal[
"allowed",
"rejected",
"operator_action_required",
]
@dataclass(frozen=True, slots=True)
class TenantModuleItem:
id: str
@@ -48,6 +77,331 @@ class TenantModuleEntitlementState:
diagnostics: tuple[dict[str, str], ...] = ()
@dataclass(frozen=True, slots=True)
class TenantModuleAdmission:
tenant_id: str
module_id: str
revision: int
work_state: TenantWorkState
allowed: bool
disposition: TenantAdmissionDisposition
reason: str
def payload(self) -> dict[str, object]:
return {
"tenant_id": self.tenant_id,
"module_id": self.module_id,
"entitlement_revision": self.revision,
"work_state": self.work_state,
"allowed": self.allowed,
"disposition": self.disposition,
"reason": self.reason,
}
@dataclass(frozen=True, slots=True)
class _CachedTenantEntitlement:
expires_at: float
tenant_active: bool
state: TenantModuleEntitlementState
class TenantModuleEntitlementResolver:
"""Resolve tenant-effective modules with bounded process-local caching.
Cache entries are explicitly invalidated by local mutations and expire
quickly so changes made on another application node become authoritative
without requiring a database lookup for every capability call.
"""
def __init__(
self,
registry: object,
*,
ttl_seconds: float = 5.0,
max_entries: int = 2048,
) -> None:
self._registry = registry
self._ttl_seconds = max(0.0, min(float(ttl_seconds), 300.0))
self._max_entries = max(1, int(max_entries))
self._cache: OrderedDict[str, _CachedTenantEntitlement] = OrderedDict()
self._lock = RLock()
def resolve(
self,
session: object,
tenant_id: str,
) -> TenantModuleEntitlementState:
normalized_tenant_id = str(tenant_id or "").strip()
if not normalized_tenant_id:
raise ModuleEntitlementResolutionError("Tenant id is required")
cached = self._cached(normalized_tenant_id)
if cached is not None:
if not cached.tenant_active:
raise ModuleEntitlementResolutionError(
f"Tenant is inactive: {normalized_tenant_id}"
)
return cached.state
from govoplan_core.tenancy.scope import Tenant
getter = getattr(session, "get", None)
if not callable(getter):
raise ModuleEntitlementResolutionError(
"Tenant module entitlement resolution requires a database session"
)
tenant = getter(Tenant, normalized_tenant_id)
if tenant is None:
raise ModuleEntitlementResolutionError(
f"Tenant is unavailable: {normalized_tenant_id}"
)
state = self._state_from_settings(getattr(tenant, "settings", None))
tenant_active = bool(getattr(tenant, "is_active", False))
self._store(normalized_tenant_id, tenant_active=tenant_active, state=state)
if not tenant_active:
raise ModuleEntitlementResolutionError(
f"Tenant is inactive: {normalized_tenant_id}"
)
return state
def admission(
self,
session: object,
*,
tenant_id: str,
module_id: str,
work_state: TenantWorkState = "interactive",
) -> TenantModuleAdmission:
if work_state not in {"interactive", "new", "accepted"}:
raise ModuleEntitlementError(f"Unsupported tenant work state: {work_state}")
normalized_module_id = str(module_id or "").strip()
if not normalized_module_id:
raise ModuleEntitlementError("Module id is required")
state = self.resolve(session, tenant_id)
allowed = normalized_module_id in state.effective_modules
if allowed:
return TenantModuleAdmission(
tenant_id=str(tenant_id),
module_id=normalized_module_id,
revision=state.revision,
work_state=work_state,
allowed=True,
disposition="allowed",
reason="The module is effective for this tenant.",
)
accepted = work_state == "accepted"
return TenantModuleAdmission(
tenant_id=str(tenant_id),
module_id=normalized_module_id,
revision=state.revision,
work_state=work_state,
allowed=False,
disposition=(
"operator_action_required" if accepted else "rejected"
),
reason=(
"Accepted durable work was preserved because the owning module "
"is no longer effective for this tenant; an operator must resume "
"the module or resolve the work explicitly."
if accepted
else "The module is not effective for this tenant."
),
)
def require(
self,
session: object,
*,
tenant_id: str,
module_id: str,
work_state: TenantWorkState = "interactive",
) -> TenantModuleAdmission:
admission = self.admission(
session,
tenant_id=tenant_id,
module_id=module_id,
work_state=work_state,
)
if admission.allowed:
return admission
if admission.disposition == "operator_action_required":
raise TenantModuleOperatorActionRequired(admission)
raise TenantModuleUnavailable(admission)
def effective_tenant_ids(
self,
session: object,
*,
module_id: str,
) -> tuple[str, ...]:
"""Return active tenants that may admit new work for one module."""
return tuple(
admission.tenant_id
for admission in self.active_tenant_admissions(
session,
module_id=module_id,
work_state="new",
)
if admission.allowed
)
def active_tenant_admissions(
self,
session: object,
*,
module_id: str,
work_state: TenantWorkState = "new",
) -> tuple[TenantModuleAdmission, ...]:
"""Resolve one admission per active tenant with a single DB query."""
from govoplan_core.tenancy.scope import Tenant
query = getattr(session, "query", None)
if not callable(query):
raise ModuleEntitlementResolutionError(
"Tenant module entitlement resolution requires a database session"
)
tenants = (
query(Tenant)
.filter(Tenant.is_active.is_(True))
.order_by(Tenant.id.asc())
.all()
)
admissions: list[TenantModuleAdmission] = []
for tenant in tenants:
state = self._state_from_settings(getattr(tenant, "settings", None))
self._store(tenant.id, tenant_active=True, state=state)
allowed = module_id in state.effective_modules
accepted = work_state == "accepted"
admissions.append(
TenantModuleAdmission(
tenant_id=tenant.id,
module_id=module_id,
revision=state.revision,
work_state=work_state,
allowed=allowed,
disposition=(
"allowed"
if allowed
else "operator_action_required"
if accepted
else "rejected"
),
reason=(
"The module is effective for this tenant."
if allowed
else "Accepted durable work was preserved because the owning module is no longer effective for this tenant; an operator must resume the module or resolve the work explicitly."
if accepted
else "The module is not effective for this tenant."
),
)
)
return tuple(admissions)
def invalidate(self, tenant_id: str | None = None) -> None:
with self._lock:
if tenant_id is None:
self._cache.clear()
else:
self._cache.pop(str(tenant_id), None)
def _state_from_settings(
self,
settings: Mapping[str, object] | None,
) -> TenantModuleEntitlementState:
manifests_method = getattr(self._registry, "manifests", None)
if not callable(manifests_method):
raise ModuleEntitlementResolutionError(
"Tenant module entitlement resolver has no platform registry"
)
manifests = {manifest.id: manifest for manifest in manifests_method()}
return tenant_module_entitlement_state(
settings,
manifests,
runtime_active_modules=manifests,
)
def _cached(self, tenant_id: str) -> _CachedTenantEntitlement | None:
now = monotonic()
with self._lock:
cached = self._cache.get(tenant_id)
if cached is None:
return None
if cached.expires_at <= now:
self._cache.pop(tenant_id, None)
return None
self._cache.move_to_end(tenant_id)
return cached
def _store(
self,
tenant_id: str,
*,
tenant_active: bool,
state: TenantModuleEntitlementState,
) -> None:
if self._ttl_seconds <= 0:
return
with self._lock:
self._cache[str(tenant_id)] = _CachedTenantEntitlement(
expires_at=monotonic() + self._ttl_seconds,
tenant_active=tenant_active,
state=state,
)
self._cache.move_to_end(str(tenant_id))
while len(self._cache) > self._max_entries:
self._cache.popitem(last=False)
@dataclass(frozen=True, slots=True)
class TenantExecutionContext:
resolver: TenantModuleEntitlementResolver
session: object
tenant_id: str
work_state: TenantWorkState
def require_module(self, module_id: str) -> TenantModuleAdmission:
return self.resolver.require(
self.session,
tenant_id=self.tenant_id,
module_id=module_id,
work_state=self.work_state,
)
_TENANT_EXECUTION_CONTEXT: ContextVar[TenantExecutionContext | None] = ContextVar(
"govoplan_tenant_execution_context",
default=None,
)
def current_tenant_execution_context() -> TenantExecutionContext | None:
return _TENANT_EXECUTION_CONTEXT.get()
@contextmanager
def tenant_execution_scope(
resolver: TenantModuleEntitlementResolver,
session: object,
*,
tenant_id: str,
work_state: TenantWorkState = "interactive",
) -> Iterator[TenantExecutionContext]:
context = TenantExecutionContext(
resolver=resolver,
session=session,
tenant_id=str(tenant_id),
work_state=work_state,
)
token = _TENANT_EXECUTION_CONTEXT.set(context)
try:
yield context
finally:
_TENANT_EXECUTION_CONTEXT.reset(token)
def tenant_module_entitlement_state(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
@@ -474,9 +828,18 @@ __all__ = [
"TENANT_PROTECTED_MODULES",
"ModuleEntitlementConflict",
"ModuleEntitlementError",
"ModuleEntitlementResolutionError",
"TenantExecutionContext",
"TenantModuleAdmission",
"TenantModuleEntitlementResolver",
"TenantModuleEntitlementState",
"TenantModuleItem",
"TenantModuleOperatorActionRequired",
"TenantModuleUnavailable",
"TenantWorkState",
"current_tenant_execution_context",
"module_entitlement_payload",
"tenant_execution_scope",
"tenant_module_entitlement_state",
"update_system_tenant_module_policy",
"update_tenant_module_selection",
+2
View File
@@ -408,6 +408,7 @@ class DeleteVetoProviderRegistration:
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
PublicTenantResolver = Callable[[object, object], str | None]
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
LifecycleHook = Callable[[ModuleContext], None]
@@ -426,6 +427,7 @@ class ModuleManifest:
permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None
public_tenant_resolver: PublicTenantResolver | None = None
migration_spec: MigrationSpec | None = None
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
+8
View File
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
@runtime_checkable
class NotificationDispatchProvider(Protocol):
def tenant_id_for_notification(
self,
session: object,
*,
notification_id: str,
) -> str | None:
...
def enqueue_notification(
self,
session: object,
@@ -129,6 +129,16 @@ class PollParticipationContextRef:
response: PollGovernedResponseRef | None = None
@dataclass(frozen=True, slots=True)
class PollPublicInvitationRef:
"""Non-sensitive routing identity for one valid governed invitation."""
invitation_id: str
tenant_id: str
poll_id: str
gateway: PollResponseGatewayRef
@runtime_checkable
class PollParticipationGatewayProvider(Protocol):
def create_governed_invitation(
@@ -156,6 +166,17 @@ class PollParticipationGatewayProvider(Protocol):
...
def resolve_public_invitation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
) -> PollPublicInvitationRef:
"""Resolve tenant routing without disclosing participant details."""
...
def submit_governed_response(
self,
session: object,
@@ -266,6 +287,7 @@ __all__ = [
"PollParticipationContextRef",
"PollParticipationGatewayProvider",
"PollParticipationPolicy",
"PollPublicInvitationRef",
"PollResponseGatewayRef",
"participation_token_fingerprint",
"poll_participation_gateway_provider",
+110
View File
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.module_entitlements import (
TenantModuleEntitlementResolver,
TenantModuleUnavailable,
TenantWorkState,
current_tenant_execution_context,
tenant_execution_scope,
)
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
@@ -83,8 +90,10 @@ class PlatformRegistry:
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capability_factory_owners: dict[str, str] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
@@ -142,6 +151,9 @@ class PlatformRegistry:
})
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._capability_factory_owners = dict(
replacement._capability_factory_owners
)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
@@ -151,6 +163,7 @@ class PlatformRegistry:
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
self._tenant_entitlement_resolver.invalidate()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -258,6 +271,23 @@ class PlatformRegistry:
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(
self,
ttl_seconds=float(
getattr(
context.settings,
"tenant_module_entitlement_cache_ttl_seconds",
5.0,
)
),
max_entries=int(
getattr(
context.settings,
"tenant_module_entitlement_cache_max_entries",
2048,
)
),
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
@@ -266,6 +296,7 @@ class PlatformRegistry:
if name in self._capability_factories:
raise RegistryError(f"Duplicate capability: {name}")
self._capability_factories[name] = factory
self._capability_factory_owners[name] = module_id
def has_capability(self, name: str) -> bool:
return name in self._capability_factories
@@ -273,7 +304,69 @@ class PlatformRegistry:
def capability_names(self) -> tuple[str, ...]:
return tuple(sorted(self._capability_factories))
def capability_owner(self, name: str) -> str | None:
return self._capability_factory_owners.get(name)
def public_tenant_resolver(self, module_id: str):
manifest = self.get(module_id)
return manifest.public_tenant_resolver if manifest is not None else None
def tenant_entitlement_resolver(self) -> TenantModuleEntitlementResolver:
return self._tenant_entitlement_resolver
def invalidate_tenant_entitlement(self, tenant_id: str | None = None) -> None:
self._tenant_entitlement_resolver.invalidate(tenant_id)
def tenant_capability(
self,
name: str,
session: object,
*,
tenant_id: str,
work_state: TenantWorkState = "interactive",
) -> object | None:
with tenant_execution_scope(
self._tenant_entitlement_resolver,
session,
tenant_id=tenant_id,
work_state=work_state,
):
return self.capability(name)
def require_tenant_capability(
self,
name: str,
session: object,
*,
tenant_id: str,
work_state: TenantWorkState = "interactive",
) -> object:
owner = self.capability_owner(name)
if owner is not None:
self._tenant_entitlement_resolver.require(
session,
tenant_id=tenant_id,
module_id=owner,
work_state=work_state,
)
capability = self.tenant_capability(
name,
session,
tenant_id=tenant_id,
work_state=work_state,
)
if capability is None:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def capability(self, name: str) -> object | None:
execution = current_tenant_execution_context()
owner = self._capability_factory_owners.get(name)
if execution is not None and owner is not None:
try:
execution.require_module(owner)
except TenantModuleUnavailable:
return None
if name in self._capabilities:
return self._capabilities[name]
factory = self._capability_factories.get(name)
@@ -314,6 +407,12 @@ class PlatformRegistry:
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
execution = current_tenant_execution_context()
if execution is not None:
try:
execution.require_module(registered.module_id)
except TenantModuleUnavailable:
continue
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
@@ -352,6 +451,12 @@ class PlatformRegistry:
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
execution = current_tenant_execution_context()
if execution is not None:
try:
execution.require_module(registered.module_id)
except TenantModuleUnavailable:
continue
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
@@ -1020,6 +1125,11 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
if frontend.public_routes and manifest.public_tenant_resolver is None:
raise RegistryError(
f"Module {manifest.id!r} exposes public frontend routes without a "
"public tenant resolver"
)
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes):
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
def build_worker_platform_registry(settings: object) -> PlatformRegistry:
"""Build the active capability graph used by an out-of-process worker."""
configured_modules = getattr(settings, "enabled_modules", "")
raw_enabled_modules = load_startup_enabled_modules(configured_modules)
candidate_modules = startup_candidate_module_ids(
configured_modules,
raw_enabled_modules,
)
available_modules = available_module_manifests(
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
enabled_modules = load_startup_enabled_modules(
configured_modules,
available=available_modules,
)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
registry.configure_capability_context(context)
return registry
__all__ = ["build_worker_platform_registry"]
+2
View File
@@ -127,6 +127,7 @@ class WorkflowRuntimeWorker(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
@@ -140,6 +141,7 @@ class WorkflowTriggerDispatcher(Protocol):
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]: ...
+12
View File
@@ -198,6 +198,18 @@ class Settings(BaseSettings):
le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
)
tenant_module_entitlement_cache_ttl_seconds: int = Field(
default=5,
ge=0,
le=300,
alias="TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS",
)
tenant_module_entitlement_cache_max_entries: int = Field(
default=2048,
ge=1,
le=100_000,
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,