From 5b8baa6cde704fb9cafa7b95f8250529beb6ea95 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 00:40:09 +0200 Subject: [PATCH] Make tenancy an optional access integration --- docs/ACCESS_MODULE_BOUNDARY.md | 21 +++++++++ pyproject.toml | 3 +- tests/test_optional_tenancy_contract.py | 59 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_optional_tenancy_contract.py diff --git a/docs/ACCESS_MODULE_BOUNDARY.md b/docs/ACCESS_MODULE_BOUNDARY.md index c136858..68eed16 100644 --- a/docs/ACCESS_MODULE_BOUNDARY.md +++ b/docs/ACCESS_MODULE_BOUNDARY.md @@ -64,6 +64,27 @@ Access declares tenancy as an optional module integration. It uses the core-owned `core_scopes` table as the scope table, but it must not import `govoplan_tenancy` or require the tenancy package to start. +## Core-Only Startup Contract + +A core-only installation must be able to start far enough to expose process +health, module metadata, and the unauthenticated shell needed for installation +or recovery work. It is not a usable authenticated product installation. + +Authenticated product use requires the `access` module or another module that +provides the same kernel auth capabilities: + +- `auth.apiPrincipalProvider` +- `auth.principalResolver` +- `auth.permissionEvaluator` +- `auth.tenantContextSwitcher` + +Access contributes the default implementations for those capabilities plus the +interactive `/api/v1/auth/*` routes. Product modules should express auth needs +as required capabilities or route permission requirements instead of importing +access internals. Runtime configurations that intentionally omit access should +hide authenticated navigation and return capability errors for authenticated +product routes rather than failing process startup. + ## Principal Context Contract The stable runtime principal is `govoplan_core.core.access.PrincipalRef`. diff --git a/pyproject.toml b/pyproject.toml index ce9857c..1b078ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-access" version = "0.1.6" -description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives." +description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ "govoplan-core>=0.1.6", - "govoplan-tenancy>=0.1.6", "SQLAlchemy>=2,<3", ] diff --git a/tests/test_optional_tenancy_contract.py b/tests/test_optional_tenancy_contract.py new file mode 100644 index 0000000..2cce6f8 --- /dev/null +++ b/tests/test_optional_tenancy_contract.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import ast +import pathlib +import tomllib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class OptionalTenancyContractTests(unittest.TestCase): + def test_access_package_does_not_require_tenancy_to_install(self) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] + + dependencies = tuple(project["dependencies"]) + + self.assertIn("govoplan-core>=0.1.6", dependencies) + self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies) + self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) + + def test_tenancy_is_declared_as_optional_module_integration(self) -> None: + manifest_path = ROOT / "src" / "govoplan_access" / "backend" / "manifest.py" + tree = ast.parse(manifest_path.read_text(encoding="utf-8")) + manifest_call = next( + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "manifest" for target in node.targets) + and isinstance(node.value, ast.Call) + ) + optional_dependencies = next( + keyword.value + for keyword in manifest_call.keywords + if keyword.arg == "optional_dependencies" + ) + + self.assertIsInstance(optional_dependencies, ast.Tuple) + self.assertIn( + "tenancy", + { + item.value + for item in optional_dependencies.elts + if isinstance(item, ast.Constant) and isinstance(item.value, str) + }, + ) + + def test_access_source_does_not_import_tenancy_module_internals(self) -> None: + offenders: list[str] = [] + for path in (ROOT / "src" / "govoplan_access").rglob("*.py"): + source = path.read_text(encoding="utf-8") + if "govoplan_tenancy" in source: + offenders.append(str(path.relative_to(ROOT))) + + self.assertEqual([], offenders) + + +if __name__ == "__main__": + unittest.main()