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
|
- 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user