Move access permissions to access module

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent a7c486788e
commit e32841077c
13 changed files with 1319 additions and 170 deletions

View File

@@ -0,0 +1,299 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
from govoplan_core.security.module_permissions import compatible_required_scopes
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=category,
level=level,
module_id=module_id,
resource=resource,
action=action,
deprecated=True,
)
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = (
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"),
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"),
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
)
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) -> bool:
catalog = 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):
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) -> bool:
return any(scope_grants(scope, required) 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)}
expanded.update(matched)
for alias in compatible_required_scopes(scope):
if alias != scope:
expanded.add(alias)
if include_unknown and not matched:
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)
return {scope for scope in candidates if scopes_grant(granted, scope)}
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) or scope_grants(scope, active_scope) 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)}
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)
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"}
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)}
user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key))
allowed.update(
scope
for scope in user_raw.intersection(key_raw)
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
)
return sorted(allowed)
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()