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
+21
View File
@@ -1278,6 +1278,27 @@ runtime activation with the active tenant's effective entitlement. Entitlement
does not grant a permission. Access authorization must still allow every API does not grant a permission. Access authorization must still allow every API
operation and resource. operation and resource.
The same boundary applies outside authenticated request handling:
- capability factories retain their owning module, and tenant-scoped capability
lookup treats a provider that is unavailable to the tenant as absent;
- workers partition scheduled scans by tenant before claiming rows;
- new work is rejected while a module is unavailable, while already accepted
durable work remains in provider-owned storage and is reported as
`operator_action_required` instead of being dropped or executed;
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
outboxes run inside a tenant execution context, so their optional capability
calls inherit the same provider checks;
- public signed-link modules declare a `public_tenant_resolver`; valid token
context is resolved before the route runs and the module entitlement is then
enforced without requiring an authenticated principal.
Entitlement resolution uses a bounded process-local cache. A local policy
mutation invalidates its tenant entry immediately; changes made by another node
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
(five seconds by default). This is a bounded staleness optimization, not an
authorization grant: a cache miss or resolution failure fails closed.
Users and groups do not own another module-runtime state. Every WebUI module Users and groups do not own another module-runtime state. Every WebUI module
already contributes a root `<module>.module` View surface, so personal and already contributes a root `<module>.module` View surface, so personal and
group module visibility is expressed through Views. View policy controls who group module visibility is expressed through Views. View policy controls who
File diff suppressed because it is too large Load Diff
+3
View File
@@ -95,6 +95,9 @@ class CampaignPolicyContextProvider(Protocol):
@runtime_checkable @runtime_checkable
class CampaignDeliveryTaskProvider(Protocol): 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]: 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, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int = 50, limit: int = 50,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
@@ -204,6 +205,7 @@ class DataflowRunWorker(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int = 10, limit: int = 10,
worker_id: str | None = None, worker_id: str | None = None,
@@ -214,6 +216,7 @@ class DataflowRunWorker(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int = 500, limit: int = 500,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
+4
View File
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
tenantless_only: bool = False,
consumers: Sequence[DurableEventConsumer] = (), consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None, observer: EventHandler | None = None,
limit: int = 100, limit: int = 100,
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
tenantless_only: bool = False,
before: datetime, before: datetime,
limit: int = 500, limit: int = 500,
) -> Mapping[str, int]: ) -> Mapping[str, int]:
+59 -42
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from threading import RLock 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.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_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 ( from govoplan_core.core.module_lifecycle_recovery import (
ModuleLifecycleRecovery, ModuleLifecycleRecovery,
begin_runtime_graph_recovery, 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.db.session import get_session
from govoplan_core.server.route_validation import validate_router_can_mount from govoplan_core.server.route_validation import validate_router_can_mount
from govoplan_core.tenancy.scope import Tenant
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -37,60 +40,74 @@ class ModuleLifecycleResult:
def require_module_active(module_id: str): def require_module_active(module_id: str):
def dependency( async def dependency(
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> None: ) -> AsyncIterator[None]:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry) or not 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}") 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 tenant_id: str | None = None
# 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: if not authorization and not x_api_key and not request.cookies:
return public_resolver = registry.public_tenant_resolver(module_id)
try: if public_resolver is not None:
principal = get_api_principal( tenant_id = public_resolver(request, session)
request, if tenant_id is None:
session, yield
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 return
raise else:
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None: try:
return 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: try:
tenant = session.get(Tenant, principal.principal.tenant_id) admission = resolver.require(
except (RuntimeError, SQLAlchemyError) as exc: 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant module entitlement could not be resolved.", detail="Tenant module entitlement could not be resolved.",
) from exc ) from exc
if tenant is None: request.state.govoplan_module_admission = admission
raise HTTPException( with tenant_execution_scope(
status_code=status.HTTP_403_FORBIDDEN, resolver,
detail="The active tenant is unavailable.", session,
) tenant_id=tenant_id,
manifests = {manifest.id: manifest for manifest in registry.manifests()} work_state="interactive",
entitlement = tenant_module_entitlement_state( ):
tenant.settings or {}, yield
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 return dependency
+1
View File
@@ -56,6 +56,7 @@ class MailDeliveryOutboxProvider(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
limit: int = 250, limit: int = 250,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
... ...
+365 -2
View File
@@ -1,8 +1,13 @@
from __future__ import annotations 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 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 from govoplan_core.core.modules import ModuleManifest
@@ -20,6 +25,30 @@ class ModuleEntitlementConflict(ModuleEntitlementError):
pass 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) @dataclass(frozen=True, slots=True)
class TenantModuleItem: class TenantModuleItem:
id: str id: str
@@ -48,6 +77,331 @@ class TenantModuleEntitlementState:
diagnostics: tuple[dict[str, str], ...] = () 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( def tenant_module_entitlement_state(
settings: Mapping[str, object] | None, settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest], manifests: Mapping[str, ModuleManifest],
@@ -474,9 +828,18 @@ __all__ = [
"TENANT_PROTECTED_MODULES", "TENANT_PROTECTED_MODULES",
"ModuleEntitlementConflict", "ModuleEntitlementConflict",
"ModuleEntitlementError", "ModuleEntitlementError",
"ModuleEntitlementResolutionError",
"TenantExecutionContext",
"TenantModuleAdmission",
"TenantModuleEntitlementResolver",
"TenantModuleEntitlementState", "TenantModuleEntitlementState",
"TenantModuleItem", "TenantModuleItem",
"TenantModuleOperatorActionRequired",
"TenantModuleUnavailable",
"TenantWorkState",
"current_tenant_execution_context",
"module_entitlement_payload", "module_entitlement_payload",
"tenant_execution_scope",
"tenant_module_entitlement_state", "tenant_module_entitlement_state",
"update_system_tenant_module_policy", "update_system_tenant_module_policy",
"update_tenant_module_selection", "update_tenant_module_selection",
+2
View File
@@ -408,6 +408,7 @@ class DeleteVetoProviderRegistration:
RouteFactory = Callable[[ModuleContext], "APIRouter"] RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object] CapabilityFactory = Callable[[ModuleContext], object]
PublicTenantResolver = Callable[[object, object], str | None]
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]] DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
LifecycleHook = Callable[[ModuleContext], None] LifecycleHook = Callable[[ModuleContext], None]
@@ -426,6 +427,7 @@ class ModuleManifest:
permissions: tuple[PermissionDefinition, ...] = () permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = () role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None route_factory: RouteFactory | None = None
public_tenant_resolver: PublicTenantResolver | None = None
migration_spec: MigrationSpec | None = None migration_spec: MigrationSpec | None = None
nav_items: tuple[NavItem, ...] = () nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None frontend: FrontendModule | None = None
+8
View File
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
@runtime_checkable @runtime_checkable
class NotificationDispatchProvider(Protocol): class NotificationDispatchProvider(Protocol):
def tenant_id_for_notification(
self,
session: object,
*,
notification_id: str,
) -> str | None:
...
def enqueue_notification( def enqueue_notification(
self, self,
session: object, session: object,
@@ -129,6 +129,16 @@ class PollParticipationContextRef:
response: PollGovernedResponseRef | None = None 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 @runtime_checkable
class PollParticipationGatewayProvider(Protocol): class PollParticipationGatewayProvider(Protocol):
def create_governed_invitation( 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( def submit_governed_response(
self, self,
session: object, session: object,
@@ -266,6 +287,7 @@ __all__ = [
"PollParticipationContextRef", "PollParticipationContextRef",
"PollParticipationGatewayProvider", "PollParticipationGatewayProvider",
"PollParticipationPolicy", "PollParticipationPolicy",
"PollPublicInvitationRef",
"PollResponseGatewayRef", "PollResponseGatewayRef",
"participation_token_fingerprint", "participation_token_fingerprint",
"poll_participation_gateway_provider", "poll_participation_gateway_provider",
+110
View File
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
TenantSummaryProvider, TenantSummaryProvider,
user_workflow_scope_condition_issues, 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 ( from govoplan_core.core.ownership import (
OwnershipProviderRegistration, OwnershipProviderRegistration,
ResourceOwnershipProvider, ResourceOwnershipProvider,
@@ -83,8 +90,10 @@ class PlatformRegistry:
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list) self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {} self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {} self._capability_factories: dict[str, CapabilityFactory] = {}
self._capability_factory_owners: dict[str, str] = {}
self._capabilities: dict[str, object] = {} self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None self._capability_context: ModuleContext | None = None
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
self._search_provider_registrations: list[RegisteredSearchProvider] = [] self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {} self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[ self._search_source_registrations: list[
@@ -142,6 +151,9 @@ class PlatformRegistry:
}) })
self._ownership_providers = dict(replacement._ownership_providers) self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories) self._capability_factories = dict(replacement._capability_factories)
self._capability_factory_owners = dict(
replacement._capability_factory_owners
)
self._search_provider_registrations = list( self._search_provider_registrations = list(
replacement._search_provider_registrations replacement._search_provider_registrations
) )
@@ -151,6 +163,7 @@ class PlatformRegistry:
self._capabilities.clear() self._capabilities.clear()
self._search_providers.clear() self._search_providers.clear()
self._search_sources.clear() self._search_sources.clear()
self._tenant_entitlement_resolver.invalidate()
return snapshot return snapshot
def get(self, module_id: str) -> ModuleManifest | None: def get(self, module_id: str) -> ModuleManifest | None:
@@ -258,6 +271,23 @@ class PlatformRegistry:
def configure_capability_context(self, context: ModuleContext) -> None: def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context 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._capabilities.clear()
self._search_providers.clear() self._search_providers.clear()
self._search_sources.clear() self._search_sources.clear()
@@ -266,6 +296,7 @@ class PlatformRegistry:
if name in self._capability_factories: if name in self._capability_factories:
raise RegistryError(f"Duplicate capability: {name}") raise RegistryError(f"Duplicate capability: {name}")
self._capability_factories[name] = factory self._capability_factories[name] = factory
self._capability_factory_owners[name] = module_id
def has_capability(self, name: str) -> bool: def has_capability(self, name: str) -> bool:
return name in self._capability_factories return name in self._capability_factories
@@ -273,7 +304,69 @@ class PlatformRegistry:
def capability_names(self) -> tuple[str, ...]: def capability_names(self) -> tuple[str, ...]:
return tuple(sorted(self._capability_factories)) 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: 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: if name in self._capabilities:
return self._capabilities[name] return self._capabilities[name]
factory = self._capability_factories.get(name) factory = self._capability_factories.get(name)
@@ -314,6 +407,12 @@ class PlatformRegistry:
return () return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = [] providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations(): 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}" key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key) provider = self._search_providers.get(key)
if provider is None: if provider is None:
@@ -352,6 +451,12 @@ class PlatformRegistry:
tuple[RegisteredSearchSourceProvider, SearchSourceProvider] tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = [] ] = []
for registered in self.search_source_registrations(): 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}" key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key) provider = self._search_sources.get(key)
if provider is None: 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): 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}") 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): for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component) _validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes): 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, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int = 50, limit: int = 50,
) -> Mapping[str, object]: ... ) -> Mapping[str, object]: ...
@@ -140,6 +141,7 @@ class WorkflowTriggerDispatcher(Protocol):
self, self,
session: object, session: object,
*, *,
tenant_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int = 50, limit: int = 50,
) -> Mapping[str, object]: ... ) -> Mapping[str, object]: ...
+12
View File
@@ -198,6 +198,18 @@ class Settings(BaseSettings):
le=100_000, le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES", 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_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field( auth_login_throttle_identity_limit: int = Field(
default=10, default=10,
+4
View File
@@ -426,6 +426,10 @@ class _FakeCampaignPolicyContextProvider:
class _FakeCampaignDeliveryTaskProvider: class _FakeCampaignDeliveryTaskProvider:
def tenant_id_for_job(self, session: object, *, job_id: str):
del session, job_id
return "tenant-1"
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True): def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True):
del session del session
return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task} return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task}
+5
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_calendar_outbox from govoplan_core.celery_app import celery, dispatch_calendar_outbox
from tests.worker_test_support import allowed_worker_admissions
class CalendarOutboxWorkerTests(unittest.TestCase): class CalendarOutboxWorkerTests(unittest.TestCase):
@@ -22,6 +23,10 @@ class CalendarOutboxWorkerTests(unittest.TestCase):
with ( with (
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider), patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
patch("govoplan_core.db.session.get_database", return_value=database), patch("govoplan_core.db.session.get_database", return_value=database),
): ):
result = dispatch_calendar_outbox.run("tenant-1", 25) result = dispatch_calendar_outbox.run("tenant-1", 25)
+15 -1
View File
@@ -8,6 +8,7 @@ from govoplan_core.celery_app import (
dispatch_dataflow_runs, dispatch_dataflow_runs,
purge_dataflow_runs, purge_dataflow_runs,
) )
from tests.worker_test_support import allowed_worker_admissions
class DataflowRunWorkerTests(unittest.TestCase): class DataflowRunWorkerTests(unittest.TestCase):
@@ -30,11 +31,16 @@ class DataflowRunWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_dataflow_runs.run(7) result = dispatch_dataflow_runs.run(7)
provider.dispatch_pending.assert_called_once_with( provider.dispatch_pending.assert_called_once_with(
session, session,
tenant_id="tenant-1",
limit=7, limit=7,
worker_id=ANY, worker_id=ANY,
) )
@@ -57,10 +63,18 @@ class DataflowRunWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = purge_dataflow_runs.run(25) result = purge_dataflow_runs.run(25)
provider.purge_expired.assert_called_once_with(session, limit=25) provider.purge_expired.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
self.assertEqual(2, result["purged"]) self.assertEqual(2, result["purged"])
+10 -1
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
from tests.worker_test_support import allowed_worker_admissions
class DataflowTriggerWorkerTests(unittest.TestCase): class DataflowTriggerWorkerTests(unittest.TestCase):
@@ -30,10 +31,18 @@ class DataflowTriggerWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_dataflow_triggers.run(25) result = dispatch_dataflow_triggers.run(25)
provider.dispatch_due.assert_called_once_with(session, limit=25) provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1) self.assertEqual(result["succeeded"], 1)
@@ -11,6 +11,7 @@ from govoplan_core.core.idm import (
) )
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from tests.worker_test_support import allowed_worker_admissions
class _Lifecycle: class _Lifecycle:
@@ -70,6 +71,10 @@ class IdmAssignmentLifecycleWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = expire_idm_assignments.run("tenant-1", 25) result = expire_idm_assignments.run("tenant-1", 25)
+10
View File
@@ -10,6 +10,7 @@ from govoplan_core.celery_app import (
dispatch_mail_outbox, dispatch_mail_outbox,
purge_mail_outbox, purge_mail_outbox,
) )
from tests.worker_test_support import allowed_worker_admissions
class _Provider: class _Provider:
@@ -35,6 +36,10 @@ class MailDeliveryWorkerTests(unittest.TestCase):
"govoplan_core.celery_app._mail_delivery_outbox", "govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(), return_value=_Provider(),
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_mail_outbox.run("tenant-1", 7) result = dispatch_mail_outbox.run("tenant-1", 7)
@@ -56,10 +61,15 @@ class MailDeliveryWorkerTests(unittest.TestCase):
"govoplan_core.celery_app._mail_delivery_outbox", "govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(), return_value=_Provider(),
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = purge_mail_outbox.run(19) result = purge_mail_outbox.run(19)
self.assertIs(result["session"], session) self.assertIs(result["session"], session)
self.assertEqual(result["tenant_id"], "tenant-1")
self.assertEqual(result["limit"], 19) self.assertEqual(result["limit"], 19)
def test_worker_routes_and_schedules_are_declared(self) -> None: def test_worker_routes_and_schedules_are_declared(self) -> None:
+213 -1
View File
@@ -3,22 +3,27 @@ from __future__ import annotations
import unittest import unittest
from pathlib import Path from pathlib import Path
import tempfile import tempfile
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi import APIRouter, Depends, FastAPI from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.celery_app import _run_tenant_worker_batches
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.lifecycle import require_module_active from govoplan_core.core.lifecycle import require_module_active
from govoplan_core.core.module_entitlements import ( from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict, ModuleEntitlementConflict,
ModuleEntitlementError, ModuleEntitlementError,
TenantModuleEntitlementResolver,
TenantModuleOperatorActionRequired,
TenantModuleUnavailable,
tenant_module_entitlement_state, tenant_module_entitlement_state,
update_system_tenant_module_policy, update_system_tenant_module_policy,
update_tenant_module_selection, update_tenant_module_selection,
) )
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database, get_database from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.platform import create_platform_router from govoplan_core.server.platform import create_platform_router
@@ -177,6 +182,75 @@ class TenantModuleEntitlementTests(unittest.TestCase):
self.assertEqual({"access", "admin"}, set(state.effective_modules)) self.assertEqual({"access", "admin"}, set(state.effective_modules))
self.assertTrue(state.diagnostics) self.assertTrue(state.diagnostics)
def test_resolver_caches_and_invalidates_tenant_state(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests.values():
registry.register(manifest)
tenant = SimpleNamespace(id="tenant-1", is_active=True, settings={})
class CountingSession:
calls = 0
def get(self, _model, _tenant_id):
self.calls += 1
return tenant
session = CountingSession()
resolver = TenantModuleEntitlementResolver(
registry,
ttl_seconds=60,
max_entries=2,
)
resolver.resolve(session, "tenant-1")
resolver.resolve(session, "tenant-1")
self.assertEqual(1, session.calls)
resolver.invalidate("tenant-1")
resolver.resolve(session, "tenant-1")
self.assertEqual(2, session.calls)
def test_new_and_accepted_work_have_distinct_disable_semantics(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests.values():
registry.register(manifest)
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=(),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
tenant = SimpleNamespace(
id="tenant-1",
is_active=True,
settings=settings,
)
session = SimpleNamespace(get=lambda _model, _tenant_id: tenant)
resolver = TenantModuleEntitlementResolver(registry, ttl_seconds=0)
with self.assertRaises(TenantModuleUnavailable) as rejected:
resolver.require(
session,
tenant_id="tenant-1",
module_id="files",
work_state="new",
)
self.assertEqual("rejected", rejected.exception.admission.disposition)
with self.assertRaises(TenantModuleOperatorActionRequired) as preserved:
resolver.require(
session,
tenant_id="tenant-1",
module_id="files",
work_state="accepted",
)
self.assertEqual(
"operator_action_required",
preserved.exception.admission.disposition,
)
class TenantModuleEntitlementRouteTests(unittest.TestCase): class TenantModuleEntitlementRouteTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -288,6 +362,144 @@ class TenantModuleEntitlementRouteTests(unittest.TestCase):
self.assertEqual(200, response.status_code, response.text) self.assertEqual(200, response.status_code, response.text)
def test_public_tenant_route_enforces_module_entitlement(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
public_tenant_resolver=(
(lambda _request, _session: "tenant-1")
if manifest.id == "files"
else None
),
)
)
app = FastAPI()
app.state.govoplan_registry = registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/public-files/{token}")
def public_files_route(token: str):
return {"token": token}
app.include_router(guarded)
with TestClient(app) as client:
response = client.get("/public-files/example")
self.assertEqual(404, response.status_code, response.text)
self.assertEqual(
"Module is unavailable in the active tenant: files",
response.json()["detail"],
)
def test_tenant_capability_rejects_unavailable_provider(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.example": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
with get_database().session() as session:
with self.assertRaises(TenantModuleUnavailable):
registry.require_tenant_capability(
"files.example",
session,
tenant_id="tenant-1",
)
def test_request_context_treats_unavailable_optional_capability_as_absent(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.example": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
app = FastAPI()
app.state.govoplan_registry = registry
guarded = APIRouter(dependencies=[Depends(require_module_active("admin"))])
@guarded.get("/admin-capability")
def admin_capability_route():
return {"files_available": registry.capability("files.example") is not None}
app.include_router(guarded)
with patch(
"govoplan_core.core.lifecycle.get_api_principal",
return_value=self.principal,
), TestClient(app) as client:
response = client.get(
"/admin-capability",
headers={"Authorization": "Bearer test"},
)
self.assertEqual(200, response.status_code, response.text)
self.assertFalse(response.json()["files_available"])
def test_worker_preserves_accepted_work_for_operator_when_disabled(self) -> None:
registry = PlatformRegistry()
for manifest in self.manifests:
registry.register(
ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=manifest.dependencies,
capability_factories=(
{"files.worker": lambda _context: object()}
if manifest.id == "files"
else {}
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=SimpleNamespace())
)
invoked: list[str] = []
with get_database().session() as session:
result = _run_tenant_worker_batches(
registry,
session,
capability_name="files.worker",
tenant_id="tenant-1",
operation=lambda tenant_id: invoked.append(tenant_id) or {},
defaults={"processed": 0},
)
self.assertEqual([], invoked)
self.assertEqual(1, result["operator_action_required"])
self.assertEqual(
"operator_action_required",
result["operator_actions"][0]["disposition"],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+3
View File
@@ -520,6 +520,7 @@ class ModuleSystemTests(unittest.TestCase):
id="example", id="example",
name="Example", name="Example",
version="test", version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
frontend=FrontendModule( frontend=FrontendModule(
module_id="example", module_id="example",
package_name="@govoplan/example-webui", package_name="@govoplan/example-webui",
@@ -536,6 +537,7 @@ class ModuleSystemTests(unittest.TestCase):
id="example", id="example",
name="Example", name="Example",
version="test", version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
frontend=FrontendModule( frontend=FrontendModule(
module_id="example", module_id="example",
package_name="@govoplan/example-webui", package_name="@govoplan/example-webui",
@@ -584,6 +586,7 @@ class ModuleSystemTests(unittest.TestCase):
id=module_id, id=module_id,
name=module_id.title(), name=module_id.title(),
version="test", version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1",
frontend=FrontendModule( frontend=FrontendModule(
module_id=module_id, module_id=module_id,
public_routes=( public_routes=(
+94 -23
View File
@@ -10,6 +10,10 @@ from govoplan_core.celery_app import (
purge_platform_events, purge_platform_events,
) )
from govoplan_core.core.events import PlatformEvent from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
from govoplan_core.core.search import CAPABILITY_SEARCH_INDEX_WRITER
from tests.worker_test_support import allowed_worker_admissions
class PlatformEventWorkerTests(unittest.TestCase): class PlatformEventWorkerTests(unittest.TestCase):
@@ -18,16 +22,30 @@ class PlatformEventWorkerTests(unittest.TestCase):
database = MagicMock() database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock() outbox = MagicMock()
outbox.dispatch_pending.return_value = { outbox.dispatch_pending.side_effect = (
"selected": 1, {
"delivered": 1, "selected": 1,
"retrying": 0, "delivered": 1,
"quarantined": 0, "retrying": 0,
"dispatched": 1, "quarantined": 0,
"observer_failed": 0, "dispatched": 1,
} "observer_failed": 0,
},
{
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
},
)
dataflow = MagicMock() dataflow = MagicMock()
registry = MagicMock() registry = MagicMock()
registry.has_capability.side_effect = lambda name: name in {
CAPABILITY_PLATFORM_EVENT_OUTBOX,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
}
with ( with (
patch( patch(
@@ -54,12 +72,21 @@ class PlatformEventWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_platform_events.run(25) result = dispatch_platform_events.run(25)
call = outbox.dispatch_pending.call_args self.assertEqual(2, outbox.dispatch_pending.call_count)
call = outbox.dispatch_pending.call_args_list[0]
self.assertEqual(session, call.args[0]) self.assertEqual(session, call.args[0])
self.assertEqual(25, call.kwargs["limit"]) self.assertEqual(25, call.kwargs["limit"])
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
system_call = outbox.dispatch_pending.call_args_list[1]
self.assertTrue(system_call.kwargs["tenantless_only"])
self.assertIsNone(system_call.kwargs["tenant_id"])
consumer = call.kwargs["consumers"][0] consumer = call.kwargs["consumers"][0]
self.assertEqual( self.assertEqual(
"dataflow.event-triggers.v1", "dataflow.event-triggers.v1",
@@ -86,14 +113,24 @@ class PlatformEventWorkerTests(unittest.TestCase):
database = MagicMock() database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock() outbox = MagicMock()
outbox.dispatch_pending.return_value = { outbox.dispatch_pending.side_effect = (
"selected": 1, {
"delivered": 1, "selected": 1,
"retrying": 0, "delivered": 1,
"quarantined": 0, "retrying": 0,
"dispatched": 1, "quarantined": 0,
"observer_failed": 0, "dispatched": 1,
} "observer_failed": 0,
},
{
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
},
)
search = MagicMock() search = MagicMock()
search.process_changes.return_value = { search.process_changes.return_value = {
"selected": 1, "selected": 1,
@@ -101,11 +138,16 @@ class PlatformEventWorkerTests(unittest.TestCase):
"retrying": 0, "retrying": 0,
"quarantined": 0, "quarantined": 0,
} }
registry = MagicMock()
registry.has_capability.side_effect = lambda name: name in {
CAPABILITY_PLATFORM_EVENT_OUTBOX,
CAPABILITY_SEARCH_INDEX_WRITER,
}
with ( with (
patch( patch(
"govoplan_core.celery_app._platform_registry", "govoplan_core.celery_app._platform_registry",
return_value=MagicMock(), return_value=registry,
), ),
patch( patch(
"govoplan_core.celery_app._platform_event_outbox", "govoplan_core.celery_app._platform_event_outbox",
@@ -127,10 +169,16 @@ class PlatformEventWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_platform_events.run(25) result = dispatch_platform_events.run(25)
consumer = outbox.dispatch_pending.call_args.kwargs["consumers"][0] consumer = outbox.dispatch_pending.call_args_list[0].kwargs[
"consumers"
][0]
self.assertEqual("search.indexing.v1", consumer.consumer_id) self.assertEqual("search.indexing.v1", consumer.consumer_id)
self.assertEqual(frozenset({"*"}), consumer.event_types) self.assertEqual(frozenset({"*"}), consumer.event_types)
event = PlatformEvent(type="files.file.updated", module_id="files") event = PlatformEvent(type="files.file.updated", module_id="files")
@@ -141,7 +189,11 @@ class PlatformEventWorkerTests(unittest.TestCase):
event=event, event=event,
delivery_key=delivery_key, delivery_key=delivery_key,
) )
search.process_changes.assert_called_once_with(session, limit=25) search.process_changes.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
self.assertEqual(1, result["search_changes"]["applied"]) self.assertEqual(1, result["search_changes"]["applied"])
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
@@ -150,9 +202,20 @@ class PlatformEventWorkerTests(unittest.TestCase):
database = MagicMock() database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock() outbox = MagicMock()
outbox.purge_terminal.return_value = {"deleted": 2} outbox.purge_terminal.side_effect = (
{"deleted": 2},
{"deleted": 1},
)
registry = MagicMock()
registry.has_capability.side_effect = lambda name: (
name == CAPABILITY_PLATFORM_EVENT_OUTBOX
)
with ( with (
patch(
"govoplan_core.celery_app._platform_registry",
return_value=registry,
),
patch( patch(
"govoplan_core.celery_app._platform_event_outbox", "govoplan_core.celery_app._platform_event_outbox",
return_value=outbox, return_value=outbox,
@@ -166,17 +229,25 @@ class PlatformEventWorkerTests(unittest.TestCase):
"platform_event_outbox_terminal_retention_days", "platform_event_outbox_terminal_retention_days",
30, 30,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = purge_platform_events.run(75) result = purge_platform_events.run(75)
call = outbox.purge_terminal.call_args self.assertEqual(2, outbox.purge_terminal.call_count)
call = outbox.purge_terminal.call_args_list[0]
self.assertEqual(session, call.args[0]) self.assertEqual(session, call.args[0])
self.assertEqual(75, call.kwargs["limit"]) self.assertEqual(75, call.kwargs["limit"])
self.assertEqual("tenant-1", call.kwargs["tenant_id"])
system_call = outbox.purge_terminal.call_args_list[1]
self.assertTrue(system_call.kwargs["tenantless_only"])
before = call.kwargs["before"] before = call.kwargs["before"]
self.assertIsInstance(before, datetime) self.assertIsInstance(before, datetime)
self.assertEqual(timezone.utc, before.tzinfo) self.assertEqual(timezone.utc, before.tzinfo)
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
self.assertEqual({"deleted": 2}, result) self.assertEqual(3, result["deleted"])
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None: def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
self.assertEqual( self.assertEqual(
@@ -18,6 +18,9 @@ class _CompleteGateway:
def resolve_participation(self, *args, **kwargs): def resolve_participation(self, *args, **kwargs):
raise NotImplementedError raise NotImplementedError
def resolve_public_invitation(self, *args, **kwargs):
raise NotImplementedError
def submit_governed_response(self, *args, **kwargs): def submit_governed_response(self, *args, **kwargs):
raise NotImplementedError raise NotImplementedError
+5
View File
@@ -11,6 +11,7 @@ from govoplan_core.core.postbox import (
postbox_routing_provider, postbox_routing_provider,
) )
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from tests.worker_test_support import allowed_worker_admissions
class _RoutingProvider: class _RoutingProvider:
@@ -68,6 +69,10 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = dispatch_postbox_routes.run("tenant-1", 25) result = dispatch_postbox_routes.run("tenant-1", 25)
+10 -1
View File
@@ -22,6 +22,7 @@ from govoplan_core.core.workflows import (
workflow_runtime_worker, workflow_runtime_worker,
workflow_trigger_dispatcher, workflow_trigger_dispatcher,
) )
from tests.worker_test_support import allowed_worker_admissions
class _Worker: class _Worker:
@@ -141,10 +142,18 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase):
"govoplan_core.db.session.get_database", "govoplan_core.db.session.get_database",
return_value=database, return_value=database,
), ),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
): ):
result = reconcile_workflow_instances.run(25) result = reconcile_workflow_instances.run(25)
worker.reconcile_pending.assert_called_once_with(session, limit=25) worker.reconcile_pending.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
self.assertEqual(1, result["advanced"]) self.assertEqual(1, result["advanced"])
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from govoplan_core.core.module_entitlements import (
TenantModuleAdmission,
TenantWorkState,
)
def allowed_worker_admissions(
_registry,
_session,
*,
capability_name: str,
tenant_id: str | None,
work_state: TenantWorkState = "accepted",
) -> tuple[TenantModuleAdmission, ...]:
return (
TenantModuleAdmission(
tenant_id=tenant_id or "tenant-1",
module_id=capability_name.split(".", 1)[0],
revision=1,
work_state=work_state,
allowed=True,
disposition="allowed",
reason="Test tenant permits the worker capability.",
),
)