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
+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()