From bf0729eb59e3b6ce1a13a6af239decc55f4c7bc7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 20:47:54 +0200 Subject: [PATCH] feat(core): add authenticated baseline role templates --- docs/MODULE_ARCHITECTURE.md | 12 +++++ src/govoplan_core/core/modules.py | 1 + src/govoplan_core/core/registry.py | 25 +++++++++ tests/test_module_system.py | 84 +++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index c27a472..8117b84 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -453,6 +453,18 @@ The manifest should declare: - navigation metadata using serializable icon names - uninstall guard providers for data, migration, worker, or scheduler vetoes +A tenant-level managed `RoleTemplate` may set `default_authenticated=True` +only when every authenticated tenant member must receive that narrow baseline +while the contributing module is installed. Access derives the explicit grant +from the active manifest set during authorization without mutating the request +transaction. It may materialize a non-assignable role row for administration, +but no per-user assignment is required and role edits cannot remove the +baseline. +This is not a shortcut for feature authorization: keep the template narrow and +continue to enforce each domain action's own permission and resource policy. +System-level, unmanaged, wildcard-bearing, or slug-colliding automatic +templates are rejected by registry validation. + Backend nav metadata must use icon-name strings, not frontend components: ```python diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 31f6298..d9c8ba1 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -45,6 +45,7 @@ class RoleTemplate: level: PermissionLevel = "tenant" managed: bool = True protected: bool = False + default_authenticated: bool = False @dataclass(frozen=True, slots=True) diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 297086b..c160727 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -320,9 +320,34 @@ def _validate_role_template_scopes( *, known_scopes: set[str], ) -> None: + seen_templates: dict[tuple[str, str], str] = {} for manifest in manifests: for template in manifest.role_templates: + template_key = (template.level, template.slug) + previous_module = seen_templates.get(template_key) + if previous_module is not None: + raise RegistryError( + f"Duplicate {template.level} role template slug {template.slug!r} " + f"in modules {previous_module!r} and {manifest.id!r}" + ) + seen_templates[template_key] = manifest.id + if template.default_authenticated and template.level != "tenant": + raise RegistryError( + f"Default authenticated role template {template.slug!r} must be tenant-level" + ) + if template.default_authenticated and not template.managed: + raise RegistryError( + f"Default authenticated role template {template.slug!r} must be managed" + ) for scope in template.permissions: + if template.default_authenticated and ( + scope in {"*", "tenant:*", "system:*"} + or _WILDCARD_RE.match(scope) + ): + raise RegistryError( + f"Default authenticated role template {template.slug!r} " + "must use explicit permissions, not wildcard scopes" + ) if _role_template_scope_known(scope, known_scopes): continue raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}") diff --git a/tests/test_module_system.py b/tests/test_module_system.py index c1cff6a..59d3bc4 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -101,7 +101,7 @@ from govoplan_core.core.module_package_catalog import ( ) from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult from govoplan_core.core.module_guards import drop_table_retirement_provider -from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest +from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.admin.models import SystemSettings from govoplan_core.db.base import Base @@ -359,6 +359,88 @@ class ModuleSystemTests(unittest.TestCase): with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"): registry.validate() + def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None: + for template, message in ( + ( + RoleTemplate( + slug="invalid-system-default", + name="Invalid", + description="Invalid system default.", + permissions=("system:*",), + level="system", + default_authenticated=True, + ), + "must be tenant-level", + ), + ( + RoleTemplate( + slug="invalid-unmanaged-default", + name="Invalid", + description="Invalid unmanaged default.", + permissions=("tenant:*",), + managed=False, + default_authenticated=True, + ), + "must be managed", + ), + ): + with self.subTest(template=template.slug): + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="example", + name="Example", + version="test", + role_templates=(template,), + ) + ) + with self.assertRaisesRegex(RegistryError, message): + registry.validate() + + def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="example", + name="Example", + version="test", + role_templates=( + RoleTemplate( + slug="unsafe-default", + name="Unsafe default", + description="Must not grant broad authority automatically.", + permissions=("tenant:*",), + default_authenticated=True, + ), + ), + ) + ) + + with self.assertRaisesRegex(RegistryError, "must use explicit permissions"): + registry.validate() + + def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None: + registry = PlatformRegistry() + for module_id in ("first", "second"): + registry.register( + ModuleManifest( + id=module_id, + name=module_id.title(), + version="test", + role_templates=( + RoleTemplate( + slug="shared-reader", + name="Shared reader", + description="A colliding role template.", + permissions=(), + ), + ), + ) + ) + + with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"): + registry.validate() + def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None: registry = PlatformRegistry() registry.register(ModuleManifest(