Enforce tenant module entitlements beyond requests

This commit is contained in:
2026-08-04 09:29:36 +02:00
parent d6e7c8b0b1
commit 40c10089ab
28 changed files with 1692 additions and 286 deletions
+4
View File
@@ -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}
+5
View File
@@ -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)
+15 -1
View File
@@ -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"])
+10 -1
View File
@@ -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
View File
@@ -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:
+213 -1
View File
@@ -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()
+3
View File
@@ -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=(
+94 -23
View File
@@ -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
+5
View File
@@ -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)
+10 -1
View File
@@ -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"])
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from govoplan_core.core.module_entitlements import (
TenantModuleAdmission,
TenantWorkState,
)
def allowed_worker_admissions(
_registry,
_session,
*,
capability_name: str,
tenant_id: str | None,
work_state: TenantWorkState = "accepted",
) -> tuple[TenantModuleAdmission, ...]:
return (
TenantModuleAdmission(
tenant_id=tenant_id or "tenant-1",
module_id=capability_name.split(".", 1)[0],
revision=1,
work_state=work_state,
allowed=True,
disposition="allowed",
reason="Test tenant permits the worker capability.",
),
)