Decouple scopes from tenancy schema
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 17:33:43 +02:00
parent 635d25c74c
commit 79af252e88
27 changed files with 529 additions and 75 deletions

View File

@@ -219,11 +219,15 @@ class ModuleSystemTests(unittest.TestCase):
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
manifests = available_module_manifests()
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
self.assertEqual(manifests["dashboard"].dependencies, ("access",))
self.assertEqual(manifests["docs"].dependencies, ("access",))
self.assertEqual(manifests["ops"].dependencies, ("access",))
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
self.assertTrue(manifests["docs"].required_capabilities)
self.assertEqual(manifests["ops"].dependencies, ())
self.assertTrue(manifests["ops"].required_capabilities)
def test_access_manifest_contributes_admin_frontend_metadata(self) -> None:
manifests = available_module_manifests()
@@ -266,11 +270,12 @@ class ModuleSystemTests(unittest.TestCase):
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import scope_registry
from govoplan_tenancy.backend.db.models import Tenant
self.assertIs(AccessBase, Base)
self.assertEqual("access_accounts", Account.__tablename__)
self.assertEqual("tenancy_tenants", Tenant.__tablename__)
self.assertEqual("core_scopes", Tenant.__tablename__)
self.assertEqual("access_users", User.__tablename__)
self.assertEqual("access_api_keys", ApiKey.__tablename__)
self.assertEqual("access_auth_sessions", AuthSession.__tablename__)
@@ -278,7 +283,8 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual("admin_governance_templates", GovernanceTemplate.__tablename__)
self.assertEqual("core_system_settings", SystemSettings.__tablename__)
self.assertIn("access_accounts", Base.metadata.tables)
self.assertIn("tenancy_tenants", Base.metadata.tables)
self.assertIn("core_scopes", scope_registry.metadata.tables)
self.assertNotIn("core_scopes", Base.metadata.tables)
self.assertNotIn("accounts", Base.metadata.tables)
self.assertNotIn("auth_sessions", Base.metadata.tables)
@@ -408,15 +414,15 @@ class ModuleSystemTests(unittest.TestCase):
def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = (
("core_only", (), {"tenancy", "access"}, set()),
("files_only", ("files",), {"tenancy", "access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"tenancy", "access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"tenancy", "access", "campaigns"}, {"/api/v1/campaigns"}),
("campaign_without_files", ("campaigns", "mail"), {"tenancy", "access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"tenancy", "access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
("dashboard_only", ("dashboard",), {"tenancy", "access", "dashboard"}, set()),
("docs_and_ops", ("docs", "ops"), {"tenancy", "access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
("full_product", ("dashboard", "campaigns", "files", "mail", "docs", "ops"), {"tenancy", "access", "dashboard", "campaigns", "files", "mail", "docs", "ops"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"}),
("core_only", (), {"access"}, set()),
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
("campaign_without_files", ("campaigns", "mail"), {"access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
("dashboard_only", ("dashboard",), {"access", "dashboard"}, set()),
("docs_and_ops", ("docs", "ops"), {"access", "docs", "ops"}, {"/api/v1/docs", "/api/v1/ops"}),
("full_product", ("dashboard", "campaigns", "files", "mail", "docs", "ops"), {"access", "dashboard", "campaigns", "files", "mail", "docs", "ops"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"}),
)
for name, enabled_modules, expected_modules, expected_prefixes in cases:
with self.subTest(name=name):
@@ -524,12 +530,32 @@ finally:
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
self.assertEqual(mail_enabled, registry.integration_enabled("campaigns", "mail"))
def test_registry_rejects_missing_required_capability(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="needs_auth",
name="Needs Auth",
version="test",
required_capabilities=("auth.principalResolver",),
))
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate()
def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None:
manifests = available_module_manifests()
self.assertNotIn("tenancy", manifests["access"].dependencies)
self.assertIn("tenancy", manifests["access"].optional_dependencies)
self.assertNotIn("tenancy", manifests["organizations"].dependencies)
self.assertIn("tenancy", manifests["organizations"].optional_dependencies)
def test_module_management_plan_adds_protected_modules_and_required_dependencies(self) -> None:
manifests = available_module_manifests()
plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("tenancy", "access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), plan.added_dependencies)
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
@@ -2032,7 +2058,7 @@ finally:
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual({"tenancy", "access", "files"}, payload_modules)
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-stale-", dir=_TEST_ROOT))
@@ -2065,7 +2091,7 @@ finally:
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual({"tenancy", "access", "files"}, payload_modules)
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-state-admin-", dir=_TEST_ROOT))
@@ -2098,7 +2124,7 @@ finally:
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual({"tenancy", "access", "admin", "files"}, payload_modules)
self.assertEqual({"access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
app, _settings_obj = self._app_for_modules(())
@@ -2108,14 +2134,14 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertFalse("/api/v1/files" in _route_paths(app))
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertTrue("/api/v1/files" in _route_paths(app))
self.assertEqual(401, client.get("/api/v1/files").status_code)
@@ -2123,7 +2149,7 @@ finally:
self.assertEqual(("files",), result.deactivated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"tenancy", "access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
disabled_response = client.get("/api/v1/files")
self.assertEqual(404, disabled_response.status_code)
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
@@ -2131,12 +2157,12 @@ finally:
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = (
((), ("govoplan_files", "govoplan_mail", "govoplan_campaign")),
(("files",), ("govoplan_mail", "govoplan_campaign")),
(("mail",), ("govoplan_files", "govoplan_campaign")),
(("campaigns",), ("govoplan_files", "govoplan_mail")),
(("campaigns", "files"), ("govoplan_mail",)),
(("campaigns", "mail"), ("govoplan_files",)),
((), ("govoplan_tenancy", "govoplan_files", "govoplan_mail", "govoplan_campaign")),
(("files",), ("govoplan_tenancy", "govoplan_mail", "govoplan_campaign")),
(("mail",), ("govoplan_tenancy", "govoplan_files", "govoplan_campaign")),
(("campaigns",), ("govoplan_tenancy", "govoplan_files", "govoplan_mail")),
(("campaigns", "files"), ("govoplan_tenancy", "govoplan_mail")),
(("campaigns", "mail"), ("govoplan_tenancy", "govoplan_files")),
(("campaigns", "files", "mail"), ()),
)
for enabled_modules, blocked_modules in cases: