Make tenancy an optional access integration

This commit is contained in:
2026-07-11 00:40:09 +02:00
parent d6a0d6241d
commit 5b8baa6cde
3 changed files with 81 additions and 2 deletions

View File

@@ -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 core-owned `core_scopes` table as the scope table, but it must not import
`govoplan_tenancy` or require the tenancy package to start. `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 ## Principal Context Contract
The stable runtime principal is `govoplan_core.core.access.PrincipalRef`. The stable runtime principal is `govoplan_core.core.access.PrincipalRef`.

View File

@@ -5,14 +5,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.6" 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" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.6",
"govoplan-tenancy>=0.1.6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]

View File

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