Enforce tenant module entitlements beyond requests
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+636
-215
File diff suppressed because it is too large
Load Diff
@@ -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]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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]: ...
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user