Files
govoplan-access/src/govoplan_access/backend/permissions/catalog.py
T
2026-09-08 01:32:20 +02:00

251 lines
10 KiB
Python

from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
from govoplan_core.security.module_permissions import compatible_required_scopes
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
parts = permission.scope.split(":", 2)
if len(parts) == 2:
module_id, action = parts
resource = module_id
else:
module_id, resource, action = parts
return PermissionDefinition(
scope=permission.scope,
label=permission.label,
description=permission.description,
category=permission.category,
level=permission.level, # type: ignore[arg-type]
module_id=module_id,
resource=resource,
action=action,
deprecated=True,
)
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
_legacy_permission(permission)
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
)
def normalize_email(value: str) -> str:
return value.strip().casefold()
def permission_catalog(*, include_legacy: bool = True) -> tuple[PermissionDefinition, ...]:
catalog: dict[str, PermissionDefinition] = {}
for permission in _active_permission_definitions():
catalog[permission.scope] = permission
if include_legacy:
for permission in LEGACY_PERMISSION_DEFINITIONS:
catalog.setdefault(permission.scope, permission)
return tuple(catalog.values())
def permission_map(*, include_legacy: bool = True) -> dict[str, PermissionDefinition]:
return {permission.scope: permission for permission in permission_catalog(include_legacy=include_legacy)}
def role_templates() -> tuple[RoleTemplate, ...]:
registry = _registry()
if registry is not None and hasattr(registry, "role_templates"):
return tuple(registry.role_templates())
from govoplan_access.backend.manifest import ACCESS_ROLE_TEMPLATES
return ACCESS_ROLE_TEMPLATES
def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]:
return tuple(template for template in role_templates() if template.level == level)
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
if _scope_grants(granted, required, catalog=catalog):
return True
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
if scope_grants(alias, required, catalog=catalog):
return True
return any(
_scope_grants(granted, alias, catalog=catalog)
for alias in compatible_required_scopes(required)
if alias != required
)
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
catalog = permission_map(include_legacy=True)
raw = {str(scope) for scope in scopes if scope}
expanded: set[str] = set()
for scope in raw:
if scope in {"*", "tenant:*", "system:*"} or scope.endswith(":*"):
expanded.add(scope)
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
expanded.add(alias)
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
expanded.update(matched)
for alias in compatible_required_scopes(scope):
if alias != scope:
expanded.add(alias)
# Preserve explicitly granted scopes in the presentation set. A
# canonical module scope can still match its legacy compatibility
# alias when the providing module is not loaded; treating that match
# as proof that the original scope is known would otherwise replace
# the canonical grant with only the deprecated alias.
if include_unknown or scope in catalog:
expanded.add(scope)
return sorted(expanded)
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
candidates = _effective_permission_candidates(level=level)
granted = list(scopes)
catalog = permission_map(include_legacy=True)
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
return len(effective_permission_scopes(scopes, level=level))
def _effective_permission_candidates(*, level: PermissionLevel | None = None) -> set[str]:
active_catalog = permission_map(include_legacy=False)
full_catalog = permission_map(include_legacy=True)
active_scopes = {
scope
for scope, definition in active_catalog.items()
if level is None or definition.level == level
}
candidates = set(active_scopes)
for scope, definition in full_catalog.items():
if scope in active_catalog:
continue
if level is not None and definition.level != level:
continue
if any(
scope_grants(active_scope, scope, catalog=full_catalog)
or scope_grants(scope, active_scope, catalog=full_catalog)
for active_scope in active_scopes
):
continue
candidates.add(scope)
return candidates
def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> list[str]:
normalized = {str(scope) for scope in scopes if scope}
wildcard = "system:*" if level == "system" else "tenant:*"
if "*" in normalized or wildcard in normalized:
return [wildcard]
catalog = permission_map(include_legacy=True)
expanded: set[str] = set()
invalid: list[str] = []
for scope in sorted(normalized):
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
if aliases:
for alias in aliases:
definition = catalog.get(alias)
if definition is not None and definition.level == level:
expanded.add(alias)
continue
if scope.endswith(":*"):
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
if matching:
expanded.add(scope)
continue
definition = catalog.get(scope)
if definition is not None and definition.level == level:
expanded.add(scope)
continue
invalid.append(scope)
if invalid:
raise ValueError(f"Unsupported {level} permissions: {', '.join(invalid)}")
return sorted(expanded)
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
return validate_permissions(scopes, level="tenant")
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
return validate_permissions(scopes, level="system")
def delegateable_scopes(scopes: Iterable[str], *, level: PermissionLevel) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
wildcard = "system:*" if level == "system" else "tenant:*"
if "*" in expanded or wildcard in expanded:
return {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == level}
return effective_permission_scopes(expanded, level=level)
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
return delegateable_scopes(scopes, level="tenant")
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
return delegateable_scopes(scopes, level="system")
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes)
key = list(key_scopes)
catalog = permission_map(include_legacy=True)
tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key))
allowed.update(
scope
for scope in user_raw.intersection(key_raw)
if _is_concrete_tenant_credential_scope(scope, catalog)
)
return sorted(allowed)
def _is_concrete_tenant_credential_scope(
scope: str,
catalog: Mapping[str, PermissionDefinition],
) -> bool:
# Wildcards are expanded against the tenant catalogue above. Returning the
# wildcard itself could grant system permissions sharing the module prefix,
# or permissions outside the currently known tenant catalogue.
if scope == "*" or scope.endswith(":*"):
return False
# System permissions can use module-native names (e.g. access:tenant:create),
# so excluding only the historical system: prefix is not sufficient.
return all(
not alias.startswith("system:")
and (alias not in catalog or catalog[alias].level == "tenant")
for alias in compatible_required_scopes(scope)
)
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
registry = _registry()
if registry is not None and hasattr(registry, "permissions"):
return tuple(registry.permissions())
from govoplan_access.backend.manifest import ACCESS_PERMISSIONS
return ACCESS_PERMISSIONS
def _registry() -> object | None:
from govoplan_access.backend.runtime import get_registry
return get_registry()