feat(core): add authenticated baseline role templates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -45,6 +45,7 @@ class RoleTemplate:
|
||||
level: PermissionLevel = "tenant"
|
||||
managed: bool = True
|
||||
protected: bool = False
|
||||
default_authenticated: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user