Make tenancy an optional access integration
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
59
tests/test_optional_tenancy_contract.py
Normal file
59
tests/test_optional_tenancy_contract.py
Normal 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()
|
||||||
Reference in New Issue
Block a user