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
|
||||
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
|
||||
already contributes a root `<module>.module` View surface, so personal and
|
||||
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
|
||||
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]:
|
||||
...
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ class DataflowTriggerDispatcher(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
@@ -204,6 +205,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 10,
|
||||
worker_id: str | None = None,
|
||||
@@ -214,6 +216,7 @@ class DataflowRunWorker(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, object]:
|
||||
|
||||
@@ -182,6 +182,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
consumers: Sequence[DurableEventConsumer] = (),
|
||||
observer: EventHandler | None = None,
|
||||
limit: int = 100,
|
||||
@@ -203,6 +205,8 @@ class PlatformEventOutbox(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
before: datetime,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, int]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
@@ -10,7 +10,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
||||
from govoplan_core.core.module_entitlements import tenant_module_entitlement_state
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementResolutionError,
|
||||
TenantModuleUnavailable,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
begin_runtime_graph_recovery,
|
||||
@@ -24,7 +28,6 @@ from govoplan_core.core.workflows import (
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -37,60 +40,74 @@ class ModuleLifecycleResult:
|
||||
|
||||
|
||||
def require_module_active(module_id: str):
|
||||
def dependency(
|
||||
async def dependency(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> None:
|
||||
) -> AsyncIterator[None]:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||
|
||||
# Public module routes must remain reachable without Access. When an
|
||||
# authenticated request is present, cache its principal and apply the
|
||||
# active tenant's module entitlement before the owning route executes.
|
||||
tenant_id: str | None = None
|
||||
if not authorization and not x_api_key and not request.cookies:
|
||||
return
|
||||
try:
|
||||
principal = get_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in {
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
}:
|
||||
public_resolver = registry.public_tenant_resolver(module_id)
|
||||
if public_resolver is not None:
|
||||
tenant_id = public_resolver(request, session)
|
||||
if tenant_id is None:
|
||||
yield
|
||||
return
|
||||
raise
|
||||
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
principal = get_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in {
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
}:
|
||||
yield
|
||||
return
|
||||
raise
|
||||
if (
|
||||
not isinstance(principal, ApiPrincipal)
|
||||
or principal.principal.tenant_id is None
|
||||
):
|
||||
yield
|
||||
return
|
||||
tenant_id = principal.principal.tenant_id
|
||||
|
||||
resolver = registry.tenant_entitlement_resolver()
|
||||
try:
|
||||
tenant = session.get(Tenant, principal.principal.tenant_id)
|
||||
except (RuntimeError, SQLAlchemyError) as exc:
|
||||
admission = resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state="interactive",
|
||||
)
|
||||
except TenantModuleUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||
) from exc
|
||||
except (ModuleEntitlementResolutionError, RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="The active tenant is unavailable.",
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
||||
entitlement = tenant_module_entitlement_state(
|
||||
tenant.settings or {},
|
||||
manifests,
|
||||
runtime_active_modules=manifests,
|
||||
)
|
||||
if module_id not in entitlement.effective_modules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||
)
|
||||
request.state.govoplan_module_admission = admission
|
||||
with tenant_execution_scope(
|
||||
resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state="interactive",
|
||||
):
|
||||
yield
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ class MailDeliveryOutboxProvider(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 250,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
from time import monotonic
|
||||
from typing import Any, Literal
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
|
||||
@@ -20,6 +25,30 @@ class ModuleEntitlementConflict(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementResolutionError(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class TenantModuleUnavailable(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
class TenantModuleOperatorActionRequired(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
TenantWorkState = Literal["interactive", "new", "accepted"]
|
||||
TenantAdmissionDisposition = Literal[
|
||||
"allowed",
|
||||
"rejected",
|
||||
"operator_action_required",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleItem:
|
||||
id: str
|
||||
@@ -48,6 +77,331 @@ class TenantModuleEntitlementState:
|
||||
diagnostics: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleAdmission:
|
||||
tenant_id: str
|
||||
module_id: str
|
||||
revision: int
|
||||
work_state: TenantWorkState
|
||||
allowed: bool
|
||||
disposition: TenantAdmissionDisposition
|
||||
reason: str
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"module_id": self.module_id,
|
||||
"entitlement_revision": self.revision,
|
||||
"work_state": self.work_state,
|
||||
"allowed": self.allowed,
|
||||
"disposition": self.disposition,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CachedTenantEntitlement:
|
||||
expires_at: float
|
||||
tenant_active: bool
|
||||
state: TenantModuleEntitlementState
|
||||
|
||||
|
||||
class TenantModuleEntitlementResolver:
|
||||
"""Resolve tenant-effective modules with bounded process-local caching.
|
||||
|
||||
Cache entries are explicitly invalidated by local mutations and expire
|
||||
quickly so changes made on another application node become authoritative
|
||||
without requiring a database lookup for every capability call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: object,
|
||||
*,
|
||||
ttl_seconds: float = 5.0,
|
||||
max_entries: int = 2048,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._ttl_seconds = max(0.0, min(float(ttl_seconds), 300.0))
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._cache: OrderedDict[str, _CachedTenantEntitlement] = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantModuleEntitlementState:
|
||||
normalized_tenant_id = str(tenant_id or "").strip()
|
||||
if not normalized_tenant_id:
|
||||
raise ModuleEntitlementResolutionError("Tenant id is required")
|
||||
|
||||
cached = self._cached(normalized_tenant_id)
|
||||
if cached is not None:
|
||||
if not cached.tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return cached.state
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
getter = getattr(session, "get", None)
|
||||
if not callable(getter):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenant = getter(Tenant, normalized_tenant_id)
|
||||
if tenant is None:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is unavailable: {normalized_tenant_id}"
|
||||
)
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
tenant_active = bool(getattr(tenant, "is_active", False))
|
||||
self._store(normalized_tenant_id, tenant_active=tenant_active, state=state)
|
||||
if not tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return state
|
||||
|
||||
def admission(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
if work_state not in {"interactive", "new", "accepted"}:
|
||||
raise ModuleEntitlementError(f"Unsupported tenant work state: {work_state}")
|
||||
normalized_module_id = str(module_id or "").strip()
|
||||
if not normalized_module_id:
|
||||
raise ModuleEntitlementError("Module id is required")
|
||||
state = self.resolve(session, tenant_id)
|
||||
allowed = normalized_module_id in state.effective_modules
|
||||
if allowed:
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=True,
|
||||
disposition="allowed",
|
||||
reason="The module is effective for this tenant.",
|
||||
)
|
||||
accepted = work_state == "accepted"
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=False,
|
||||
disposition=(
|
||||
"operator_action_required" if accepted else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"Accepted durable work was preserved because the owning module "
|
||||
"is no longer effective for this tenant; an operator must resume "
|
||||
"the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
|
||||
def require(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
admission = self.admission(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if admission.allowed:
|
||||
return admission
|
||||
if admission.disposition == "operator_action_required":
|
||||
raise TenantModuleOperatorActionRequired(admission)
|
||||
raise TenantModuleUnavailable(admission)
|
||||
|
||||
def effective_tenant_ids(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return active tenants that may admit new work for one module."""
|
||||
|
||||
return tuple(
|
||||
admission.tenant_id
|
||||
for admission in self.active_tenant_admissions(
|
||||
session,
|
||||
module_id=module_id,
|
||||
work_state="new",
|
||||
)
|
||||
if admission.allowed
|
||||
)
|
||||
|
||||
def active_tenant_admissions(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "new",
|
||||
) -> tuple[TenantModuleAdmission, ...]:
|
||||
"""Resolve one admission per active tenant with a single DB query."""
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
query = getattr(session, "query", None)
|
||||
if not callable(query):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenants = (
|
||||
query(Tenant)
|
||||
.filter(Tenant.is_active.is_(True))
|
||||
.order_by(Tenant.id.asc())
|
||||
.all()
|
||||
)
|
||||
admissions: list[TenantModuleAdmission] = []
|
||||
for tenant in tenants:
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
self._store(tenant.id, tenant_active=True, state=state)
|
||||
allowed = module_id in state.effective_modules
|
||||
accepted = work_state == "accepted"
|
||||
admissions.append(
|
||||
TenantModuleAdmission(
|
||||
tenant_id=tenant.id,
|
||||
module_id=module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=allowed,
|
||||
disposition=(
|
||||
"allowed"
|
||||
if allowed
|
||||
else "operator_action_required"
|
||||
if accepted
|
||||
else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"The module is effective for this tenant."
|
||||
if allowed
|
||||
else "Accepted durable work was preserved because the owning module is no longer effective for this tenant; an operator must resume the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(admissions)
|
||||
|
||||
def invalidate(self, tenant_id: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if tenant_id is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(str(tenant_id), None)
|
||||
|
||||
def _state_from_settings(
|
||||
self,
|
||||
settings: Mapping[str, object] | None,
|
||||
) -> TenantModuleEntitlementState:
|
||||
manifests_method = getattr(self._registry, "manifests", None)
|
||||
if not callable(manifests_method):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolver has no platform registry"
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in manifests_method()}
|
||||
return tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=manifests,
|
||||
)
|
||||
|
||||
def _cached(self, tenant_id: str) -> _CachedTenantEntitlement | None:
|
||||
now = monotonic()
|
||||
with self._lock:
|
||||
cached = self._cache.get(tenant_id)
|
||||
if cached is None:
|
||||
return None
|
||||
if cached.expires_at <= now:
|
||||
self._cache.pop(tenant_id, None)
|
||||
return None
|
||||
self._cache.move_to_end(tenant_id)
|
||||
return cached
|
||||
|
||||
def _store(
|
||||
self,
|
||||
tenant_id: str,
|
||||
*,
|
||||
tenant_active: bool,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> None:
|
||||
if self._ttl_seconds <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._cache[str(tenant_id)] = _CachedTenantEntitlement(
|
||||
expires_at=monotonic() + self._ttl_seconds,
|
||||
tenant_active=tenant_active,
|
||||
state=state,
|
||||
)
|
||||
self._cache.move_to_end(str(tenant_id))
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantExecutionContext:
|
||||
resolver: TenantModuleEntitlementResolver
|
||||
session: object
|
||||
tenant_id: str
|
||||
work_state: TenantWorkState
|
||||
|
||||
def require_module(self, module_id: str) -> TenantModuleAdmission:
|
||||
return self.resolver.require(
|
||||
self.session,
|
||||
tenant_id=self.tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=self.work_state,
|
||||
)
|
||||
|
||||
|
||||
_TENANT_EXECUTION_CONTEXT: ContextVar[TenantExecutionContext | None] = ContextVar(
|
||||
"govoplan_tenant_execution_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_tenant_execution_context() -> TenantExecutionContext | None:
|
||||
return _TENANT_EXECUTION_CONTEXT.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tenant_execution_scope(
|
||||
resolver: TenantModuleEntitlementResolver,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> Iterator[TenantExecutionContext]:
|
||||
context = TenantExecutionContext(
|
||||
resolver=resolver,
|
||||
session=session,
|
||||
tenant_id=str(tenant_id),
|
||||
work_state=work_state,
|
||||
)
|
||||
token = _TENANT_EXECUTION_CONTEXT.set(context)
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
_TENANT_EXECUTION_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def tenant_module_entitlement_state(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
@@ -474,9 +828,18 @@ __all__ = [
|
||||
"TENANT_PROTECTED_MODULES",
|
||||
"ModuleEntitlementConflict",
|
||||
"ModuleEntitlementError",
|
||||
"ModuleEntitlementResolutionError",
|
||||
"TenantExecutionContext",
|
||||
"TenantModuleAdmission",
|
||||
"TenantModuleEntitlementResolver",
|
||||
"TenantModuleEntitlementState",
|
||||
"TenantModuleItem",
|
||||
"TenantModuleOperatorActionRequired",
|
||||
"TenantModuleUnavailable",
|
||||
"TenantWorkState",
|
||||
"current_tenant_execution_context",
|
||||
"module_entitlement_payload",
|
||||
"tenant_execution_scope",
|
||||
"tenant_module_entitlement_state",
|
||||
"update_system_tenant_module_policy",
|
||||
"update_tenant_module_selection",
|
||||
|
||||
@@ -408,6 +408,7 @@ class DeleteVetoProviderRegistration:
|
||||
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
PublicTenantResolver = Callable[[object, object], str | None]
|
||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||
LifecycleHook = Callable[[ModuleContext], None]
|
||||
|
||||
@@ -426,6 +427,7 @@ class ModuleManifest:
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
public_tenant_resolver: PublicTenantResolver | None = None
|
||||
migration_spec: MigrationSpec | None = None
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
frontend: FrontendModule | None = None
|
||||
|
||||
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationDispatchProvider(Protocol):
|
||||
def tenant_id_for_notification(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
notification_id: str,
|
||||
) -> str | None:
|
||||
...
|
||||
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -129,6 +129,16 @@ class PollParticipationContextRef:
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollPublicInvitationRef:
|
||||
"""Non-sensitive routing identity for one valid governed invitation."""
|
||||
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
@@ -156,6 +166,17 @@ class PollParticipationGatewayProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def resolve_public_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
) -> PollPublicInvitationRef:
|
||||
"""Resolve tenant routing without disclosing participant details."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
@@ -266,6 +287,7 @@ __all__ = [
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollPublicInvitationRef",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
|
||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleEntitlementResolver,
|
||||
TenantModuleUnavailable,
|
||||
TenantWorkState,
|
||||
current_tenant_execution_context,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.ownership import (
|
||||
OwnershipProviderRegistration,
|
||||
ResourceOwnershipProvider,
|
||||
@@ -83,8 +90,10 @@ class PlatformRegistry:
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capability_factory_owners: dict[str, str] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
|
||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||
self._search_providers: dict[str, SearchProvider] = {}
|
||||
self._search_source_registrations: list[
|
||||
@@ -142,6 +151,9 @@ class PlatformRegistry:
|
||||
})
|
||||
self._ownership_providers = dict(replacement._ownership_providers)
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._capability_factory_owners = dict(
|
||||
replacement._capability_factory_owners
|
||||
)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
)
|
||||
@@ -151,6 +163,7 @@ class PlatformRegistry:
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
self._tenant_entitlement_resolver.invalidate()
|
||||
return snapshot
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -258,6 +271,23 @@ class PlatformRegistry:
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(
|
||||
self,
|
||||
ttl_seconds=float(
|
||||
getattr(
|
||||
context.settings,
|
||||
"tenant_module_entitlement_cache_ttl_seconds",
|
||||
5.0,
|
||||
)
|
||||
),
|
||||
max_entries=int(
|
||||
getattr(
|
||||
context.settings,
|
||||
"tenant_module_entitlement_cache_max_entries",
|
||||
2048,
|
||||
)
|
||||
),
|
||||
)
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
@@ -266,6 +296,7 @@ class PlatformRegistry:
|
||||
if name in self._capability_factories:
|
||||
raise RegistryError(f"Duplicate capability: {name}")
|
||||
self._capability_factories[name] = factory
|
||||
self._capability_factory_owners[name] = module_id
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capability_factories
|
||||
@@ -273,7 +304,69 @@ class PlatformRegistry:
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._capability_factories))
|
||||
|
||||
def capability_owner(self, name: str) -> str | None:
|
||||
return self._capability_factory_owners.get(name)
|
||||
|
||||
def public_tenant_resolver(self, module_id: str):
|
||||
manifest = self.get(module_id)
|
||||
return manifest.public_tenant_resolver if manifest is not None else None
|
||||
|
||||
def tenant_entitlement_resolver(self) -> TenantModuleEntitlementResolver:
|
||||
return self._tenant_entitlement_resolver
|
||||
|
||||
def invalidate_tenant_entitlement(self, tenant_id: str | None = None) -> None:
|
||||
self._tenant_entitlement_resolver.invalidate(tenant_id)
|
||||
|
||||
def tenant_capability(
|
||||
self,
|
||||
name: str,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> object | None:
|
||||
with tenant_execution_scope(
|
||||
self._tenant_entitlement_resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state=work_state,
|
||||
):
|
||||
return self.capability(name)
|
||||
|
||||
def require_tenant_capability(
|
||||
self,
|
||||
name: str,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> object:
|
||||
owner = self.capability_owner(name)
|
||||
if owner is not None:
|
||||
self._tenant_entitlement_resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=owner,
|
||||
work_state=work_state,
|
||||
)
|
||||
capability = self.tenant_capability(
|
||||
name,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if capability is None:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
execution = current_tenant_execution_context()
|
||||
owner = self._capability_factory_owners.get(name)
|
||||
if execution is not None and owner is not None:
|
||||
try:
|
||||
execution.require_module(owner)
|
||||
except TenantModuleUnavailable:
|
||||
return None
|
||||
if name in self._capabilities:
|
||||
return self._capabilities[name]
|
||||
factory = self._capability_factories.get(name)
|
||||
@@ -314,6 +407,12 @@ class PlatformRegistry:
|
||||
return ()
|
||||
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||
for registered in self.search_provider_registrations():
|
||||
execution = current_tenant_execution_context()
|
||||
if execution is not None:
|
||||
try:
|
||||
execution.require_module(registered.module_id)
|
||||
except TenantModuleUnavailable:
|
||||
continue
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_providers.get(key)
|
||||
if provider is None:
|
||||
@@ -352,6 +451,12 @@ class PlatformRegistry:
|
||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||
] = []
|
||||
for registered in self.search_source_registrations():
|
||||
execution = current_tenant_execution_context()
|
||||
if execution is not None:
|
||||
try:
|
||||
execution.require_module(registered.module_id)
|
||||
except TenantModuleUnavailable:
|
||||
continue
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_sources.get(key)
|
||||
if provider is None:
|
||||
@@ -1020,6 +1125,11 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
if frontend.public_routes and manifest.public_tenant_resolver is None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} exposes public frontend routes without a "
|
||||
"public tenant resolver"
|
||||
)
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
|
||||
@@ -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,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]: ...
|
||||
@@ -140,6 +141,7 @@ class WorkflowTriggerDispatcher(Protocol):
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
@@ -198,6 +198,18 @@ class Settings(BaseSettings):
|
||||
le=100_000,
|
||||
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
tenant_module_entitlement_cache_ttl_seconds: int = Field(
|
||||
default=5,
|
||||
ge=0,
|
||||
le=300,
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS",
|
||||
)
|
||||
tenant_module_entitlement_cache_max_entries: int = Field(
|
||||
default=2048,
|
||||
ge=1,
|
||||
le=100_000,
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
|
||||
@@ -426,6 +426,10 @@ class _FakeCampaignPolicyContextProvider:
|
||||
|
||||
|
||||
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):
|
||||
del session
|
||||
return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task}
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
@@ -22,6 +23,10 @@ class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
|
||||
with (
|
||||
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),
|
||||
):
|
||||
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.celery_app import (
|
||||
dispatch_dataflow_runs,
|
||||
purge_dataflow_runs,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class DataflowRunWorkerTests(unittest.TestCase):
|
||||
@@ -30,11 +31,16 @@ class DataflowRunWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_runs.run(7)
|
||||
|
||||
provider.dispatch_pending.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=7,
|
||||
worker_id=ANY,
|
||||
)
|
||||
@@ -57,10 +63,18 @@ class DataflowRunWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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()
|
||||
self.assertEqual(2, result["purged"])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
@@ -30,10 +31,18 @@ class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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()
|
||||
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.registry import PlatformRegistry
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Lifecycle:
|
||||
@@ -70,6 +71,10 @@ class IdmAssignmentLifecycleWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = expire_idm_assignments.run("tenant-1", 25)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from govoplan_core.celery_app import (
|
||||
dispatch_mail_outbox,
|
||||
purge_mail_outbox,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Provider:
|
||||
@@ -35,6 +36,10 @@ class MailDeliveryWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.celery_app._mail_delivery_outbox",
|
||||
return_value=_Provider(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_mail_outbox.run("tenant-1", 7)
|
||||
|
||||
@@ -56,10 +61,15 @@ class MailDeliveryWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.celery_app._mail_delivery_outbox",
|
||||
return_value=_Provider(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = purge_mail_outbox.run(19)
|
||||
|
||||
self.assertIs(result["session"], session)
|
||||
self.assertEqual(result["tenant_id"], "tenant-1")
|
||||
self.assertEqual(result["limit"], 19)
|
||||
|
||||
def test_worker_routes_and_schedules_are_declared(self) -> None:
|
||||
|
||||
@@ -3,22 +3,27 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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.lifecycle import require_module_active
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementConflict,
|
||||
ModuleEntitlementError,
|
||||
TenantModuleEntitlementResolver,
|
||||
TenantModuleOperatorActionRequired,
|
||||
TenantModuleUnavailable,
|
||||
tenant_module_entitlement_state,
|
||||
update_system_tenant_module_policy,
|
||||
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.db.session import configure_database, get_database
|
||||
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.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):
|
||||
def setUp(self) -> None:
|
||||
@@ -288,6 +362,144 @@ class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -520,6 +520,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
@@ -536,6 +537,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
@@ -584,6 +586,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
public_tenant_resolver=lambda _request, _session: "tenant-1",
|
||||
frontend=FrontendModule(
|
||||
module_id=module_id,
|
||||
public_routes=(
|
||||
|
||||
@@ -10,6 +10,10 @@ from govoplan_core.celery_app import (
|
||||
purge_platform_events,
|
||||
)
|
||||
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):
|
||||
@@ -18,16 +22,30 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.dispatch_pending.return_value = {
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
}
|
||||
outbox.dispatch_pending.side_effect = (
|
||||
{
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
{
|
||||
"selected": 0,
|
||||
"delivered": 0,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 0,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
)
|
||||
dataflow = MagicMock()
|
||||
registry = MagicMock()
|
||||
registry.has_capability.side_effect = lambda name: name in {
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -54,12 +72,21 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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(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]
|
||||
self.assertEqual(
|
||||
"dataflow.event-triggers.v1",
|
||||
@@ -86,14 +113,24 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.dispatch_pending.return_value = {
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
}
|
||||
outbox.dispatch_pending.side_effect = (
|
||||
{
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
{
|
||||
"selected": 0,
|
||||
"delivered": 0,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 0,
|
||||
"observer_failed": 0,
|
||||
},
|
||||
)
|
||||
search = MagicMock()
|
||||
search.process_changes.return_value = {
|
||||
"selected": 1,
|
||||
@@ -101,11 +138,16 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
}
|
||||
registry = MagicMock()
|
||||
registry.has_capability.side_effect = lambda name: name in {
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||
CAPABILITY_SEARCH_INDEX_WRITER,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_registry",
|
||||
return_value=MagicMock(),
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
@@ -127,10 +169,16 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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(frozenset({"*"}), consumer.event_types)
|
||||
event = PlatformEvent(type="files.file.updated", module_id="files")
|
||||
@@ -141,7 +189,11 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
event=event,
|
||||
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"])
|
||||
session.commit.assert_called_once_with()
|
||||
|
||||
@@ -150,9 +202,20 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
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 (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
return_value=outbox,
|
||||
@@ -166,17 +229,25 @@ class PlatformEventWorkerTests(unittest.TestCase):
|
||||
"platform_event_outbox_terminal_retention_days",
|
||||
30,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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(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"]
|
||||
self.assertIsInstance(before, datetime)
|
||||
self.assertEqual(timezone.utc, before.tzinfo)
|
||||
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:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -18,6 +18,9 @@ class _CompleteGateway:
|
||||
def resolve_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_public_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_governed_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.core.postbox import (
|
||||
postbox_routing_provider,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _RoutingProvider:
|
||||
@@ -68,6 +69,10 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
result = dispatch_postbox_routes.run("tenant-1", 25)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from govoplan_core.core.workflows import (
|
||||
workflow_runtime_worker,
|
||||
workflow_trigger_dispatcher,
|
||||
)
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class _Worker:
|
||||
@@ -141,10 +142,18 @@ class WorkflowRuntimeWorkerTests(unittest.TestCase):
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
):
|
||||
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()
|
||||
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