feat(core): add authenticated baseline role templates

This commit is contained in:
2026-07-21 20:47:54 +02:00
parent c4b90181e0
commit bf0729eb59
4 changed files with 121 additions and 1 deletions

View File

@@ -453,6 +453,18 @@ The manifest should declare:
- navigation metadata using serializable icon names - navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes - 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: Backend nav metadata must use icon-name strings, not frontend components:
```python ```python

View File

@@ -45,6 +45,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant" level: PermissionLevel = "tenant"
managed: bool = True managed: bool = True
protected: bool = False protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View File

@@ -320,9 +320,34 @@ def _validate_role_template_scopes(
*, *,
known_scopes: set[str], known_scopes: set[str],
) -> None: ) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests: for manifest in manifests:
for template in manifest.role_templates: 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: 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): if _role_template_scope_known(scope, known_scopes):
continue continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}") raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")

View File

@@ -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.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.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.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -359,6 +359,88 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"): with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
registry.validate() 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: def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(ModuleManifest( registry.register(ModuleManifest(