Move access permissions to access module
This commit is contained in:
@@ -22,13 +22,14 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_access.backend.semantic import ensure_identity_for_account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.security.permissions import (
|
||||
DEFAULT_SYSTEM_ROLES,
|
||||
DEFAULT_TENANT_ROLES,
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
delegateable_system_scopes,
|
||||
delegateable_tenant_scopes,
|
||||
normalize_email,
|
||||
scopes_grant,
|
||||
validate_system_permissions,
|
||||
validate_tenant_permissions,
|
||||
role_templates_for_level,
|
||||
)
|
||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
||||
|
||||
@@ -48,21 +49,23 @@ def generate_temporary_password(length: int = 20) -> str:
|
||||
|
||||
|
||||
def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]:
|
||||
definitions = DEFAULT_TENANT_ROLES if tenant is not None else DEFAULT_SYSTEM_ROLES
|
||||
level = "tenant" if tenant is not None else "system"
|
||||
roles: dict[str, Role] = {}
|
||||
for slug, definition in definitions.items():
|
||||
query = session.query(Role).filter(Role.slug == slug)
|
||||
for template in role_templates_for_level(level):
|
||||
query = session.query(Role).filter(Role.slug == template.slug)
|
||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||
role = query.one_or_none()
|
||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
is_assignable = True
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=tenant.id if tenant is not None else None,
|
||||
slug=slug,
|
||||
name=str(definition["name"]),
|
||||
description=str(definition.get("description") or "") or None,
|
||||
permissions=list(definition["permissions"]),
|
||||
is_builtin=bool(definition.get("is_builtin", True)),
|
||||
is_assignable=bool(definition.get("is_assignable", True)),
|
||||
slug=template.slug,
|
||||
name=template.name,
|
||||
description=template.description or None,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=is_builtin,
|
||||
is_assignable=is_assignable,
|
||||
)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
@@ -70,18 +73,30 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
|
||||
# Tenant built-ins and explicitly managed system roles remain
|
||||
# code-defined. Seeded, non-protected system roles are only created
|
||||
# here and can subsequently be administered in the System roles UI.
|
||||
managed = tenant is not None or bool(definition.get("managed", False))
|
||||
managed = _template_updates_existing(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
if managed:
|
||||
role.name = str(definition["name"])
|
||||
role.description = str(definition.get("description") or "") or None
|
||||
role.permissions = list(definition["permissions"])
|
||||
role.is_builtin = bool(definition.get("is_builtin", True))
|
||||
role.is_assignable = bool(definition.get("is_assignable", True))
|
||||
role.name = template.name
|
||||
role.description = template.description or None
|
||||
role.permissions = list(template.permissions)
|
||||
role.is_builtin = is_builtin
|
||||
role.is_assignable = is_assignable
|
||||
session.add(role)
|
||||
roles[slug] = role
|
||||
roles[template.slug] = role
|
||||
return roles
|
||||
|
||||
|
||||
def _template_is_builtin(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def _template_updates_existing(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def get_or_create_account(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -381,8 +396,6 @@ def delete_system_role(session: Session, role: Role) -> None:
|
||||
|
||||
def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent a system administrator from defining stronger roles than they hold."""
|
||||
from govoplan_core.security.permissions import delegateable_system_scopes
|
||||
|
||||
requested = set(validate_system_permissions(permissions))
|
||||
if "system:*" in requested:
|
||||
if not scopes_grant(actor_scopes, "system:*"):
|
||||
@@ -546,7 +559,6 @@ def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
|
||||
|
||||
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
|
||||
from govoplan_core.security.permissions import delegateable_tenant_scopes
|
||||
requested = set(validate_tenant_permissions(permissions))
|
||||
if "tenant:*" in requested:
|
||||
# Only a tenant owner-equivalent can delegate the wildcard.
|
||||
|
||||
@@ -25,7 +25,11 @@ from govoplan_access.backend.security.sessions import (
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids
|
||||
from govoplan_access.backend.semantic import (
|
||||
collect_external_function_roles,
|
||||
collect_function_assignment_ids,
|
||||
collect_function_delegation_ids,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
@@ -38,7 +42,8 @@ from govoplan_access.backend.db.models import (
|
||||
User,
|
||||
UserGroupMembership,
|
||||
)
|
||||
from govoplan_core.security.permissions import effective_permission_count
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
@@ -132,12 +137,24 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
)
|
||||
|
||||
|
||||
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
|
||||
def _user_item(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
) -> UserAdminItem:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = collect_user_groups(session, user)
|
||||
roles = collect_direct_user_roles(session, user)
|
||||
external_roles = collect_external_function_roles(session, user, idm_assignments) if idm_assignments else []
|
||||
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
|
||||
for role in external_roles:
|
||||
effective_scopes.update(role.permissions or [])
|
||||
function_assignment_ids = collect_function_assignment_ids(session, user)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
|
||||
return UserAdminItem(
|
||||
id=user.id,
|
||||
@@ -151,9 +168,9 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
function_assignment_ids=collect_function_assignment_ids(session, user),
|
||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=collect_user_scopes(session, user, include_system=False),
|
||||
effective_scopes=expand_scopes(effective_scopes),
|
||||
is_owner=user.id in effective_owner_ids,
|
||||
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
|
||||
created_at=user.created_at,
|
||||
|
||||
@@ -454,6 +454,64 @@ class UserAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal["direct_role", "group_role", "legacy_function_role", "idm_function_role", "system_role"]
|
||||
|
||||
|
||||
class AccessRoleSourceItem(BaseModel):
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
|
||||
class AccessScopeExplanationItem(BaseModel):
|
||||
scope: str
|
||||
sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FunctionFactExplanationItem(BaseModel):
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
function_id: str
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
role_sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
scopes: list[AccessScopeExplanationItem] = Field(default_factory=list)
|
||||
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from govoplan_core.i18n import (
|
||||
tenant_enabled_language_codes,
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
from govoplan_core.security.permissions import normalize_email, scopes_grant
|
||||
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
|
||||
@@ -120,16 +120,17 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
SystemAccountUpdateRequest,
|
||||
UserCreateRequest,
|
||||
UserCreateResponse,
|
||||
UserAccessExplanationResponse,
|
||||
UserAdminItem,
|
||||
UserListDeltaResponse,
|
||||
UserListResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_event, audit_from_principal
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
||||
from govoplan_access.backend.explanation import build_user_access_explanation
|
||||
from govoplan_access.backend.runtime import get_registry
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
CONFIGURATION_PROVIDER_CAPABILITY,
|
||||
@@ -153,6 +154,7 @@ from govoplan_core.core.configuration_control import (
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.core.change_sequence import (
|
||||
@@ -185,7 +187,11 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_access.backend.semantic import identity_id_for_account
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
normalize_email,
|
||||
permission_catalog as access_permission_catalog,
|
||||
scopes_grant,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
@@ -488,6 +494,35 @@ def _organization_directory_or_error() -> OrganizationDirectory:
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_organization_directory() -> OrganizationDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_idm_directory() -> IdmDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||
if not isinstance(capability, IdmDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _idm_assignments_for_user(
|
||||
idm_directory: IdmDirectory | None,
|
||||
user: User,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
if idm_directory is None:
|
||||
return ()
|
||||
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
|
||||
|
||||
|
||||
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
|
||||
if source_module != ORGANIZATIONS_MODULE_ID:
|
||||
raise HTTPException(
|
||||
@@ -660,7 +695,7 @@ def permission_catalog(
|
||||
category=item.category,
|
||||
level=item.level,
|
||||
)
|
||||
for item in ALL_PERMISSIONS
|
||||
for item in access_permission_catalog(include_legacy=True)
|
||||
if item.level == "tenant" or has_scope(principal, "system:roles:read") or has_scope(principal, "system:access:read") or has_scope(principal, "system:governance:read")
|
||||
]
|
||||
return PermissionCatalogResponse(permissions=items)
|
||||
@@ -1850,8 +1885,9 @@ def revoke_function_delegation(
|
||||
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse:
|
||||
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserListDeltaResponse(
|
||||
users=[_user_item(session, user, owner_ids=owner_ids) for user in users],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
|
||||
has_more=False,
|
||||
@@ -1872,13 +1908,14 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
|
||||
)
|
||||
}
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
deleted = [
|
||||
_delta_deleted_item(entry)
|
||||
for entry in entries
|
||||
if entry.resource_type == "access_user" and entry.resource_id not in visible
|
||||
]
|
||||
return UserListDeltaResponse(
|
||||
users=[_user_item(session, user, owner_ids=owner_ids) for user in visible.values()],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()],
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
@@ -1886,6 +1923,17 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
|
||||
)
|
||||
|
||||
|
||||
def _user_item_for_response(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
) -> UserAdminItem:
|
||||
idm_assignments = _idm_assignments_for_user(idm_directory, user)
|
||||
return _user_item(session, user, owner_ids=owner_ids, idm_assignments=idm_assignments)
|
||||
|
||||
|
||||
@router.get("/users/delta", response_model=UserListDeltaResponse)
|
||||
def list_users_delta(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
@@ -1909,7 +1957,35 @@ def list_users(
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
return UserListResponse(users=[_user_item(session, user, owner_ids=owner_ids) for user in users])
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users])
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
|
||||
def get_user_access_explanation(
|
||||
user_id: str,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
idm_directory = _optional_idm_directory()
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=_optional_organization_directory(),
|
||||
include_system=False,
|
||||
)
|
||||
return UserAccessExplanationResponse(
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory),
|
||||
role_sources=[source.to_dict() for source in explanation.role_sources],
|
||||
scopes=[scope.to_dict() for scope in explanation.scopes],
|
||||
function_facts=[fact.to_dict() for fact in explanation.function_facts],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1993,8 +2069,9 @@ def create_user(
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserCreateResponse(
|
||||
user=_user_item(session, result.user),
|
||||
user=_user_item_for_response(session, result.user, idm_directory=idm_directory),
|
||||
account_created=result.account_created,
|
||||
temporary_password=result.temporary_password,
|
||||
)
|
||||
@@ -2075,7 +2152,7 @@ def update_user(
|
||||
details=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
session.commit()
|
||||
return _user_item(session, user)
|
||||
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory())
|
||||
|
||||
|
||||
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
|
||||
@@ -3152,7 +3229,7 @@ def create_tenant_api_key(
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
||||
user_scopes = collect_user_scopes(session, user, include_system=False)
|
||||
user_scopes = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes
|
||||
requested = payload.scopes or ["campaign:read"]
|
||||
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
|
||||
if invalid:
|
||||
|
||||
@@ -29,7 +29,7 @@ from govoplan_access.backend.security.sessions import (
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import expand_scopes, intersect_api_key_scopes
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
@@ -22,18 +27,159 @@ from govoplan_access.backend.semantic import (
|
||||
identity_id_for_account,
|
||||
)
|
||||
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.permissions import scopes_grant
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal[
|
||||
"direct_role",
|
||||
"group_role",
|
||||
"legacy_function_role",
|
||||
"idm_function_role",
|
||||
"system_role",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessRoleSourceExplanation:
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: tuple[str, ...]
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_type": self.source_type,
|
||||
"role_id": self.role_id,
|
||||
"role_slug": self.role_slug,
|
||||
"role_name": self.role_name,
|
||||
"permissions": list(self.permissions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"group_id": self.group_id,
|
||||
"group_name": self.group_name,
|
||||
"function_assignment_id": self.function_assignment_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"source_module": self.source_module,
|
||||
"assignment_source": self.assignment_source,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"delegation_id": self.delegation_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessScopeExplanation:
|
||||
scope: str
|
||||
sources: tuple[AccessRoleSourceExplanation, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"scope": self.scope, "sources": [source.to_dict() for source in self.sources]}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionFactExplanation:
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
function_name: str | None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: tuple[str, ...] = ()
|
||||
role_names: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_module": self.source_module,
|
||||
"assignment_id": self.assignment_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"assignment_source": self.assignment_source,
|
||||
"status": self.status,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
"role_ids": list(self.role_ids),
|
||||
"role_names": list(self.role_names),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserAccessExplanation:
|
||||
role_sources: tuple[AccessRoleSourceExplanation, ...] = ()
|
||||
scopes: tuple[AccessScopeExplanation, ...] = ()
|
||||
function_facts: tuple[FunctionFactExplanation, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"role_sources": [source.to_dict() for source in self.role_sources],
|
||||
"scopes": [scope.to_dict() for scope in self.scopes],
|
||||
"function_facts": [fact.to_dict() for fact in self.function_facts],
|
||||
}
|
||||
|
||||
|
||||
class SqlAccessExplanationService(AccessExplanationService):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def explain_scope_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
return tuple(_scope_provenance(session, principal, required_scope))
|
||||
return tuple(
|
||||
_scope_provenance(
|
||||
session,
|
||||
principal,
|
||||
required_scope,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
)
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
@@ -47,7 +193,44 @@ class SqlAccessExplanationService(AccessExplanationService):
|
||||
return self.explain_scope_provenance(principal, action)
|
||||
|
||||
|
||||
def _scope_provenance(session: Session, principal: PrincipalRef, required_scope: str) -> list[AccessDecisionProvenance]:
|
||||
def build_user_access_explanation(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
include_system: bool = False,
|
||||
) -> UserAccessExplanation:
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
role_sources.extend(_direct_role_sources(session, user))
|
||||
role_sources.extend(_group_role_sources(session, user))
|
||||
role_sources.extend(_legacy_function_role_sources(session, user))
|
||||
idm_sources, function_facts = _idm_function_role_sources(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
role_sources.extend(idm_sources)
|
||||
if include_system:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is not None:
|
||||
role_sources.extend(_system_role_sources(session, account))
|
||||
return UserAccessExplanation(
|
||||
role_sources=tuple(role_sources),
|
||||
scopes=_scope_explanations(role_sources),
|
||||
function_facts=tuple(function_facts),
|
||||
)
|
||||
|
||||
|
||||
def _scope_provenance(
|
||||
session: Session,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> list[AccessDecisionProvenance]:
|
||||
items: list[AccessDecisionProvenance] = []
|
||||
account = session.get(Account, principal.account_id)
|
||||
identity_id = principal.identity_id or identity_id_for_account(session, principal.account_id)
|
||||
@@ -72,46 +255,46 @@ def _scope_provenance(session: Session, principal: PrincipalRef, required_scope:
|
||||
source="membership",
|
||||
)
|
||||
)
|
||||
_append_direct_role_provenance(session, items, user, required_scope)
|
||||
_append_group_role_provenance(session, items, user, required_scope)
|
||||
_append_function_role_provenance(session, items, user, required_scope)
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
include_system=False,
|
||||
)
|
||||
_append_source_provenance(
|
||||
items,
|
||||
[source for source in explanation.role_sources if scopes_grant(source.permissions, required_scope)],
|
||||
)
|
||||
if account is not None:
|
||||
_append_system_role_provenance(session, items, account, required_scope)
|
||||
for source in _system_role_sources(session, account):
|
||||
if scopes_grant(source.permissions, required_scope):
|
||||
_append_source_provenance(items, [source])
|
||||
items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope))
|
||||
return items
|
||||
return _dedupe_provenance(items)
|
||||
|
||||
|
||||
def _append_direct_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
def _direct_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="direct_user_role",
|
||||
)
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="direct_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _append_group_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
def _group_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
rows = (
|
||||
session.query(Group, Role)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
@@ -125,132 +308,296 @@ def _append_group_role_provenance(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for group, role in rows:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="group",
|
||||
id=group.id,
|
||||
label=group.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="group_membership",
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="group_role",
|
||||
details={"group_id": group.id},
|
||||
)
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="group_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
)
|
||||
for group, role in rows
|
||||
]
|
||||
|
||||
|
||||
def _append_function_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
def _legacy_function_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
sources: list[AccessRoleSourceExplanation] = []
|
||||
direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
for assignment in direct_assignments:
|
||||
_append_function_assignment_provenance(session, items, assignment, required_scope, source="function_assignment")
|
||||
sources.extend(_legacy_function_assignment_sources(session, assignment, assignment_source="function_assignment"))
|
||||
|
||||
delegations = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
for delegation in delegations:
|
||||
assignment = session.get(FunctionAssignment, delegation.function_assignment_id)
|
||||
if assignment is None:
|
||||
continue
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="delegation",
|
||||
id=delegation.id,
|
||||
label=delegation.mode,
|
||||
tenant_id=delegation.tenant_id,
|
||||
source="function_delegation",
|
||||
details={
|
||||
"function_assignment_id": delegation.function_assignment_id,
|
||||
"delegator_account_id": delegation.delegator_account_id,
|
||||
"delegate_account_id": delegation.delegate_account_id,
|
||||
},
|
||||
sources.extend(
|
||||
_legacy_function_assignment_sources(
|
||||
session,
|
||||
assignment,
|
||||
assignment_source="delegated_function",
|
||||
delegation=delegation,
|
||||
)
|
||||
)
|
||||
_append_function_assignment_provenance(session, items, assignment, required_scope, source="delegated_function")
|
||||
return sources
|
||||
|
||||
|
||||
def _append_function_assignment_provenance(
|
||||
def _legacy_function_assignment_sources(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
assignment: FunctionAssignment,
|
||||
required_scope: str,
|
||||
*,
|
||||
source: str,
|
||||
) -> None:
|
||||
assignment_source: str,
|
||||
delegation: FunctionDelegation | None = None,
|
||||
) -> list[AccessRoleSourceExplanation]:
|
||||
function = session.get(Function, assignment.function_id)
|
||||
if function is None:
|
||||
return
|
||||
return []
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(FunctionRoleAssignment.tenant_id == assignment.tenant_id, FunctionRoleAssignment.function_id == function.id)
|
||||
.all()
|
||||
)
|
||||
matching = [role for role in roles if scopes_grant(role.permissions or [], required_scope)]
|
||||
if not matching:
|
||||
return
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="organization_unit",
|
||||
id=assignment.organization_unit_id,
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="legacy_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"applies_to_subunits": assignment.applies_to_subunits},
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=function.id,
|
||||
function_name=function.name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
assignment_source=assignment_source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
delegation_id=delegation.id if delegation else None,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=assignment.id,
|
||||
label=function.name,
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"function_id": function.id, "organization_unit_id": assignment.organization_unit_id},
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _idm_function_role_sources(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> tuple[list[AccessRoleSourceExplanation], list[FunctionFactExplanation]]:
|
||||
if idm_directory is None:
|
||||
return [], []
|
||||
assignments = [
|
||||
assignment
|
||||
for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
]
|
||||
if not assignments:
|
||||
return [], []
|
||||
function_ids = sorted({assignment.function_id for assignment in assignments})
|
||||
rows = (
|
||||
session.query(ExternalFunctionRoleAssignment, Role)
|
||||
.join(Role, ExternalFunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for role in matching:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
mappings_by_function: dict[str, list[tuple[ExternalFunctionRoleAssignment, Role]]] = {}
|
||||
for mapping, role in rows:
|
||||
mappings_by_function.setdefault(mapping.function_id, []).append((mapping, role))
|
||||
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
function_facts: list[FunctionFactExplanation] = []
|
||||
for assignment in assignments:
|
||||
function_name, organization_unit_name = _organization_labels(organization_directory, assignment)
|
||||
mappings = mappings_by_function.get(assignment.function_id, [])
|
||||
function_facts.append(
|
||||
FunctionFactExplanation(
|
||||
source_module="organizations",
|
||||
assignment_id=assignment.id,
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"function_assignment_id": assignment.id, "function_id": function.id},
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
assignment_source=assignment.source,
|
||||
status=assignment.status,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
role_ids=tuple(role.id for _, role in mappings),
|
||||
role_names=tuple(role.name for _, role in mappings),
|
||||
)
|
||||
)
|
||||
for mapping, role in mappings:
|
||||
role_sources.append(
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="idm_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
source_module=mapping.source_module,
|
||||
assignment_source=assignment.source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
)
|
||||
return role_sources, function_facts
|
||||
|
||||
|
||||
def _append_system_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
account: Account,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
def _organization_labels(
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if organization_directory is None:
|
||||
return None, None
|
||||
function = organization_directory.get_function(assignment.function_id)
|
||||
unit = organization_directory.get_organization_unit(assignment.organization_unit_id)
|
||||
return (function.name if function is not None else None, unit.name if unit is not None else None)
|
||||
|
||||
|
||||
def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="system_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _scope_explanations(role_sources: Iterable[AccessRoleSourceExplanation]) -> tuple[AccessScopeExplanation, ...]:
|
||||
sources_by_scope: dict[str, list[AccessRoleSourceExplanation]] = {}
|
||||
for source in role_sources:
|
||||
for scope in expand_scopes(source.permissions):
|
||||
sources_by_scope.setdefault(scope, []).append(source)
|
||||
return tuple(
|
||||
AccessScopeExplanation(scope=scope, sources=tuple(sources))
|
||||
for scope, sources in sorted(sources_by_scope.items(), key=lambda item: item[0])
|
||||
)
|
||||
|
||||
|
||||
def _append_source_provenance(
|
||||
items: list[AccessDecisionProvenance],
|
||||
sources: Iterable[AccessRoleSourceExplanation],
|
||||
) -> None:
|
||||
for source in sources:
|
||||
if source.group_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
source="system_role",
|
||||
kind="group",
|
||||
id=source.group_id,
|
||||
label=source.group_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source="group_membership",
|
||||
)
|
||||
)
|
||||
if source.delegation_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="delegation",
|
||||
id=source.delegation_id,
|
||||
label=source.assignment_source,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"delegated_from_assignment_id": source.delegated_from_assignment_id,
|
||||
"acting_for_account_id": source.acting_for_account_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
if source.function_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="organization_unit",
|
||||
id=source.organization_unit_id,
|
||||
label=source.organization_unit_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={"applies_to_subunits": source.applies_to_subunits},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=source.function_assignment_id,
|
||||
label=source.function_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_id": source.function_id,
|
||||
"organization_unit_id": source.organization_unit_id,
|
||||
"identity_id": source.identity_id,
|
||||
"account_id": source.account_id,
|
||||
"source_module": source.source_module,
|
||||
"assignment_source": source.assignment_source,
|
||||
},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=source.role_id,
|
||||
label=source.role_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"role_slug": source.role_slug,
|
||||
"group_id": source.group_id,
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"function_id": source.function_id,
|
||||
"source_module": source.source_module,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_provenance(items: Iterable[AccessDecisionProvenance]) -> list[AccessDecisionProvenance]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
deduped: list[AccessDecisionProvenance] = []
|
||||
for item in items:
|
||||
key = (
|
||||
item.kind,
|
||||
item.id,
|
||||
item.tenant_id,
|
||||
item.source,
|
||||
tuple(sorted((str(key), str(value)) for key, value in item.details.items())),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
@@ -19,6 +19,7 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
)
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
@@ -54,9 +55,18 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"),
|
||||
_permission("access:tenant:create", "Create tenants", "Create tenant registry entries.", "Access", "system"),
|
||||
_permission("access:tenant:update", "Update tenants", "Update tenant metadata and activation state.", "Access", "system"),
|
||||
_permission("access:tenant:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "Access", "system"),
|
||||
_permission("access:account:read", "View accounts", "List and inspect global login accounts.", "Access", "system"),
|
||||
_permission("access:account:create", "Create accounts", "Create global login accounts.", "Access", "system"),
|
||||
_permission("access:account:update", "Update accounts", "Update or suspend global login accounts.", "Access", "system"),
|
||||
_permission("access:account:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "Access", "system"),
|
||||
_permission("access:system_role:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "Access", "system"),
|
||||
_permission("access:system_role:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "Access", "system"),
|
||||
_permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"),
|
||||
_permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"),
|
||||
_permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"),
|
||||
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"),
|
||||
@@ -75,6 +85,8 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"),
|
||||
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
|
||||
_permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"),
|
||||
)
|
||||
@@ -97,18 +109,50 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:tenant:read",
|
||||
"access:tenant:create",
|
||||
"access:tenant:update",
|
||||
"access:tenant:suspend",
|
||||
"access:account:read",
|
||||
"access:account:create",
|
||||
"access:account:update",
|
||||
"access:account:suspend",
|
||||
"access:system_role:read",
|
||||
"access:system_role:write",
|
||||
"access:system_role:assign",
|
||||
"access:system_setting:read",
|
||||
"access:system_setting:write",
|
||||
"access:governance:read",
|
||||
"access:governance:write",
|
||||
),
|
||||
level="system",
|
||||
managed=True,
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tenant_owner",
|
||||
slug="system_auditor",
|
||||
name="System auditor",
|
||||
description="Read tenant registry, accounts, system roles, settings, governance, and cross-tenant audit records.",
|
||||
permissions=(
|
||||
"access:tenant:read",
|
||||
"access:account:read",
|
||||
"access:system_role:read",
|
||||
"access:audit:read",
|
||||
"access:system_setting:read",
|
||||
"access:governance:read",
|
||||
),
|
||||
level="system",
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="maintenance_operator",
|
||||
name="Maintenance operator",
|
||||
description="Access the system while maintenance mode is active.",
|
||||
permissions=("access:system_setting:read", "access:maintenance:access"),
|
||||
level="system",
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="owner",
|
||||
name="Tenant owner",
|
||||
description="Protected full tenant administration and module access.",
|
||||
permissions=("tenant:*",),
|
||||
@@ -116,6 +160,41 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
managed=True,
|
||||
protected=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="tenant_admin",
|
||||
name="Tenant administrator",
|
||||
description="Manage tenant settings, policies, users, groups, roles and API keys.",
|
||||
permissions=(
|
||||
"access:membership:read",
|
||||
"access:membership:create",
|
||||
"access:membership:update",
|
||||
"access:group:read",
|
||||
"access:group:write",
|
||||
"access:group:manage_members",
|
||||
"access:role:read",
|
||||
"access:role:write",
|
||||
"access:role:assign",
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
"access:setting:read",
|
||||
"access:setting:write",
|
||||
"access:policy:read",
|
||||
"access:policy:write",
|
||||
),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="admin",
|
||||
name="Administrator (legacy)",
|
||||
description="Legacy broad tenant role retained for upgraded installations.",
|
||||
permissions=("tenant:*",),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="access_admin",
|
||||
name="Access administrator",
|
||||
@@ -323,14 +402,14 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.external-function-role-mappings",
|
||||
title="Function facts and access roles",
|
||||
summary="Access maps accepted organization function facts to roles and permissions. IDM assignment ownership remains separate from authorization.",
|
||||
title="Organization function facts and access roles",
|
||||
summary="Organizations defines function facts, IDM assigns them to identities, and Access maps accepted facts to roles and permissions.",
|
||||
body=(
|
||||
"When IDM is installed, Access can consume identity-to-organization-function assignments through the IDM directory capability. "
|
||||
"Organizations must be installed because Access validates mappings against the Organizations function directory instead of accepting arbitrary function identifiers. "
|
||||
"An assignment does not grant rights by itself. Access grants rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
|
||||
"Tenant administrators manage these mappings under Admin > Function role mappings, and the same records are exposed through the admin API. "
|
||||
"This keeps the audit trail clear: IDM records who holds a function, while Access records which function facts produce role-derived permissions."
|
||||
"Organizations owns the organization meta-model, concrete units, structures, and functions. IDM owns the fact that an identity, through one of its accounts, holds a function in an organization unit, including delegated and acting-for assignments. "
|
||||
"Access consumes those accepted IDM facts through the directory capability, validates function identifiers against Organizations, and turns them into rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
|
||||
"The assignment itself does not grant rights. Removing the IDM assignment, disabling the Organizations function, or removing the Access mapping stops the derived role source from contributing effective permissions. "
|
||||
"Tenant administrators inspect this from the user access explanation dialog: role sources link back to the Organizations function or unit that defines the fact and to the IDM assignment that produced it. "
|
||||
"The same explanation is exposed by the admin API, while mapping management remains under Admin > Function role mappings. This keeps the audit trail clear: Organizations records what can exist, IDM records who holds it, and Access records which accepted facts produce permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -345,13 +424,22 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
links=(
|
||||
DocumentationLink(label="Function role mappings", href="/admin?section=tenant-function-role-mappings", kind="runtime"),
|
||||
DocumentationLink(label="External function role mappings API", href="/api/v1/admin/external-function-role-mappings", kind="api"),
|
||||
DocumentationLink(label="Effective user access explanation API", href="/api/v1/admin/users/{user_id}/access-explanation", kind="api"),
|
||||
DocumentationLink(label="Organizations functions", href="/organizations?section=functions", kind="runtime"),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/admin",
|
||||
"api_path": "/api/v1/admin/external-function-role-mappings",
|
||||
"explanation_api_path": "/api/v1/admin/users/{user_id}/access-explanation",
|
||||
"runtime_routes": ["/admin?section=tenant-function-role-mappings", "/organizations?section=functions", "/idm"],
|
||||
"permission_scopes": ["access:function:write", "access:role:assign"],
|
||||
"responsibility_boundaries": {
|
||||
"organizations": "Defines organization units, structures, function types, and functions.",
|
||||
"idm": "Assigns organization functions to identities and accounts, including delegation and acting-for facts.",
|
||||
"access": "Maps accepted function facts to tenant roles and explains effective permissions.",
|
||||
},
|
||||
"related_topic_ids": [
|
||||
"idm.workflow.assign-function-to-identity",
|
||||
"docs.reference.organization-identity-idm-access-boundary",
|
||||
@@ -410,11 +498,22 @@ def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_organization_directory(context: ModuleContext) -> OrganizationDirectory | None:
|
||||
if not context.registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
return None
|
||||
capability = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _access_explanation_service(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.explanation import SqlAccessExplanationService
|
||||
|
||||
return SqlAccessExplanationService()
|
||||
return SqlAccessExplanationService(
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_provisioner(context: ModuleContext) -> object:
|
||||
|
||||
299
src/govoplan_access/backend/permissions/catalog.py
Normal file
299
src/govoplan_access/backend/permissions/catalog.py
Normal 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()
|
||||
@@ -19,7 +19,7 @@ from govoplan_access.backend.db.models import (
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_roles
|
||||
from govoplan_core.security.permissions import expand_scopes
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
SESSION_RANDOM_BYTES = 32
|
||||
|
||||
@@ -92,6 +92,8 @@ export type UserAdminItem = {
|
||||
last_login_at?: string | null;
|
||||
groups: GroupSummary[];
|
||||
roles: RoleSummary[];
|
||||
function_assignment_ids: string[];
|
||||
function_delegation_ids: string[];
|
||||
effective_scopes: string[];
|
||||
is_owner: boolean;
|
||||
is_last_active_owner: boolean;
|
||||
@@ -99,6 +101,63 @@ export type UserAdminItem = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AccessRoleSourceType = "direct_role" | "group_role" | "legacy_function_role" | "idm_function_role" | "system_role";
|
||||
|
||||
export type AccessRoleSourceItem = {
|
||||
source_type: AccessRoleSourceType;
|
||||
role_id: string;
|
||||
role_slug: string;
|
||||
role_name: string;
|
||||
permissions: string[];
|
||||
tenant_id?: string | null;
|
||||
group_id?: string | null;
|
||||
group_name?: string | null;
|
||||
function_assignment_id?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
source_module?: string | null;
|
||||
assignment_source?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
delegation_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
};
|
||||
|
||||
export type AccessScopeExplanationItem = {
|
||||
scope: string;
|
||||
sources: AccessRoleSourceItem[];
|
||||
};
|
||||
|
||||
export type FunctionFactExplanationItem = {
|
||||
source_module: string;
|
||||
assignment_id: string;
|
||||
tenant_id: string;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
function_id: string;
|
||||
function_name?: string | null;
|
||||
organization_unit_id: string;
|
||||
organization_unit_name?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
assignment_source: string;
|
||||
status: string;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
role_ids: string[];
|
||||
role_names: string[];
|
||||
};
|
||||
|
||||
export type UserAccessExplanationResponse = {
|
||||
user: UserAdminItem;
|
||||
role_sources: AccessRoleSourceItem[];
|
||||
scopes: AccessScopeExplanationItem[];
|
||||
function_facts: FunctionFactExplanationItem[];
|
||||
};
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
@@ -400,6 +459,10 @@ export function updateUser(settings: ApiSettings, userId: string, payload: Parti
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function fetchUserAccessExplanation(settings: ApiSettings, userId: string): Promise<UserAccessExplanationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}/access-explanation`);
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
||||
return response.groups;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
@@ -42,6 +42,9 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
|
||||
const [explaining, setExplaining] = useState<UserAdminItem | null>(null);
|
||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
@@ -161,6 +164,21 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function openAccessExplanation(user: UserAdminItem) {
|
||||
setExplaining(user);
|
||||
setAccessExplanation(null);
|
||||
setAccessExplanationLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setAccessExplanation(await fetchUserAccessExplanation(settings, user.id));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
setExplaining(null);
|
||||
} finally {
|
||||
setAccessExplanationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||
@@ -168,12 +186,13 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }],
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate]);
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,6 +234,48 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||
{accessExplanation && <>
|
||||
<dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
||||
</dl>
|
||||
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
||||
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid">
|
||||
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
||||
<strong>{source.role_name}</strong>
|
||||
<div className="muted small-note">
|
||||
<span>{sourceTypeLabel(source)}</span>
|
||||
{source.group_name && <> - <span>i18n:govoplan-access.group.171a0606</span>: {source.group_name}</>}
|
||||
{(source.function_name || source.function_id) && <> - <span>i18n:govoplan-access.function_fact.4f7435e4</span>: {source.function_name || source.function_id}</>}
|
||||
{(source.organization_unit_name || source.organization_unit_id) && <> - <span>i18n:govoplan-access.organization_unit.9832b383</span>: {source.organization_unit_name || source.organization_unit_id}</>}
|
||||
</div>
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
||||
</div>)}
|
||||
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
||||
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
||||
<dt>{fact.function_name || fact.function_id}</dt>
|
||||
<dd>
|
||||
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
||||
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
||||
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||
</dd>
|
||||
</div>)}
|
||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||
<div className="admin-scope-list">
|
||||
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
||||
</div>
|
||||
</>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
||||
</Dialog>
|
||||
@@ -241,3 +302,83 @@ function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sourceKey(source: AccessRoleSourceItem): string {
|
||||
return [
|
||||
source.source_type,
|
||||
source.role_id,
|
||||
source.group_id || "",
|
||||
source.function_assignment_id || "",
|
||||
source.function_id || ""
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function AccessExplanationLinks({ organizationHref, idmHref }: { organizationHref: string | null; idmHref: string | null }) {
|
||||
if (!organizationHref && !idmHref) return null;
|
||||
return (
|
||||
<div className="muted small-note">
|
||||
{organizationHref && <a href={organizationHref} title="i18n:govoplan-access.open_organizations">i18n:govoplan-access.open_organizations</a>}
|
||||
{organizationHref && idmHref && <span> | </span>}
|
||||
{idmHref && <a href={idmHref} title="i18n:govoplan-access.open_idm_assignment">i18n:govoplan-access.open_idm_assignment</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function organizationHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return organizationHref(source.function_id, source.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return organizationHref(fact.function_id, fact.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHref(functionId?: string | null, unitId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (functionId) {
|
||||
params.set("section", "functions");
|
||||
params.set("function_id", functionId);
|
||||
if (unitId) params.set("unit_id", unitId);
|
||||
} else if (unitId) {
|
||||
params.set("section", "units");
|
||||
params.set("unit_id", unitId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/organizations?${query}` : null;
|
||||
}
|
||||
|
||||
function idmHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return idmHref(source.function_assignment_id, source.function_id);
|
||||
}
|
||||
|
||||
function idmHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return idmHref(fact.assignment_id, fact.function_id);
|
||||
}
|
||||
|
||||
function idmHref(assignmentId?: string | null, functionId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (assignmentId) params.set("assignment_id", assignmentId);
|
||||
if (functionId) params.set("function_id", functionId);
|
||||
const query = params.toString();
|
||||
return query ? `/idm?${query}` : null;
|
||||
}
|
||||
|
||||
function sourceTypeLabel(source: AccessRoleSourceItem): string {
|
||||
switch (source.source_type) {
|
||||
case "direct_role":
|
||||
return "i18n:govoplan-access.direct_role_source.bf3e6a27";
|
||||
case "group_role":
|
||||
return "i18n:govoplan-access.group_role_source.0aa38904";
|
||||
case "legacy_function_role":
|
||||
return "i18n:govoplan-access.legacy_function_role_source.36f81c6b";
|
||||
case "idm_function_role":
|
||||
return "i18n:govoplan-access.idm_function_role_source.14a231fe";
|
||||
case "system_role":
|
||||
return "i18n:govoplan-access.system_role_source.c6e83223";
|
||||
default:
|
||||
return source.source_type;
|
||||
}
|
||||
}
|
||||
|
||||
function factRoles(fact: FunctionFactExplanationItem): string {
|
||||
return fact.role_names.length ? fact.role_names.join(", ") : "i18n:govoplan-access.no_mapped_roles.986bfb44";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Access explanation",
|
||||
"i18n:govoplan-access.access.2f81a22d": "Access",
|
||||
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
|
||||
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
|
||||
@@ -39,6 +40,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.assignable.a88debc5": "Assignable",
|
||||
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
|
||||
"i18n:govoplan-access.assignments.057d58c7": "Assignments",
|
||||
"i18n:govoplan-access.assignment_source.5fac1f72": "Assignment source",
|
||||
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
|
||||
"i18n:govoplan-access.audit.fa1703dd": "Audit",
|
||||
"i18n:govoplan-access.available.ce372771": ", available",
|
||||
@@ -87,6 +89,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
|
||||
"i18n:govoplan-access.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-access.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-access.direct_role_source.bf3e6a27": "Direct role",
|
||||
"i18n:govoplan-access.direct_roles.c4db7156": "Direct roles",
|
||||
"i18n:govoplan-access.display_name.c7874aaa": "Display name",
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Dry run",
|
||||
@@ -102,12 +105,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.email.84add5b2": "Email",
|
||||
"i18n:govoplan-access.expires.a99be3da": "Expires",
|
||||
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Files",
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Function facts",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Function ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function role mapping created.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function role mapping deleted.",
|
||||
@@ -130,6 +135,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
|
||||
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
|
||||
"i18n:govoplan-access.group_retention.57cdcdaa": "Group retention",
|
||||
"i18n:govoplan-access.group_role_source.0aa38904": "Group role",
|
||||
"i18n:govoplan-access.group_templates": "Group templates",
|
||||
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
|
||||
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
|
||||
@@ -144,6 +150,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.groups.07551586": "groups,",
|
||||
"i18n:govoplan-access.groups.ae9629f4": "Groups",
|
||||
"i18n:govoplan-access.i_have_recorded_it.7522da18": "I have recorded it",
|
||||
"i18n:govoplan-access.idm_function_role_source.14a231fe": "IDM function role",
|
||||
"i18n:govoplan-access.inactive.09af574c": "Inactive",
|
||||
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
|
||||
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
@@ -161,12 +168,15 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.last_login.43dab84f": "Last login",
|
||||
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
|
||||
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leave empty to generate",
|
||||
"i18n:govoplan-access.legacy_function_role_source.36f81c6b": "Legacy function role",
|
||||
"i18n:govoplan-access.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-access.loading_access_explanation.04a7c934": "Loading access explanation...",
|
||||
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
|
||||
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
|
||||
"i18n:govoplan-access.maintenance.94de303b": "Maintenance",
|
||||
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
|
||||
"i18n:govoplan-access.management.63cecca6": "Management",
|
||||
"i18n:govoplan-access.mapped_roles.504d9ff9": "Mapped roles",
|
||||
"i18n:govoplan-access.members.1cb449c1": "Members",
|
||||
"i18n:govoplan-access.membership_status.b77fc732": "Membership status",
|
||||
"i18n:govoplan-access.membership.53bc9670": "Membership",
|
||||
@@ -177,10 +187,13 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
|
||||
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function role mappings found.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
|
||||
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
|
||||
"i18n:govoplan-access.no_mapped_roles.986bfb44": "No mapped roles",
|
||||
"i18n:govoplan-access.no_roles_found.70f7c0c9": "No roles found.",
|
||||
"i18n:govoplan-access.no_role_sources_found.91013aa5": "No role sources found.",
|
||||
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
|
||||
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
|
||||
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "No tenant users found.",
|
||||
@@ -189,6 +202,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.none.6eef6648": "None",
|
||||
"i18n:govoplan-access.object.2883f191": "Object",
|
||||
"i18n:govoplan-access.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-access.organization_unit.9832b383": "Organization unit",
|
||||
"i18n:govoplan-access.open_idm_assignment": "Open IDM assignment",
|
||||
"i18n:govoplan-access.open_organizations": "Open Organizations",
|
||||
"i18n:govoplan-access.owner.89ff3122": "Owner",
|
||||
"i18n:govoplan-access.packages.0a999012": "Packages",
|
||||
"i18n:govoplan-access.permissions.d06d5557": "Permissions",
|
||||
@@ -210,6 +226,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
|
||||
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
|
||||
"i18n:govoplan-access.role_details.a16b5d9f": "Role details",
|
||||
"i18n:govoplan-access.role_sources.6f42a672": "Role sources",
|
||||
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
|
||||
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
|
||||
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
|
||||
@@ -258,6 +275,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
|
||||
"i18n:govoplan-access.system_role.91762640": "System role",
|
||||
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
|
||||
"i18n:govoplan-access.system_role_source.c6e83223": "System role",
|
||||
"i18n:govoplan-access.system.29d43743": "SYSTEM",
|
||||
"i18n:govoplan-access.system.bc0792d8": "System",
|
||||
"i18n:govoplan-access.temporary_password.62d60628": "Temporary password",
|
||||
@@ -334,6 +352,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"de": {
|
||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Zugriffserklaerung",
|
||||
"i18n:govoplan-access.access.2f81a22d": "Zugriff",
|
||||
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
|
||||
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
|
||||
@@ -369,6 +388,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.assignable.a88debc5": "Zuweisbar",
|
||||
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
|
||||
"i18n:govoplan-access.assignments.057d58c7": "Zuweisungen",
|
||||
"i18n:govoplan-access.assignment_source.5fac1f72": "Zuweisungsquelle",
|
||||
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
|
||||
"i18n:govoplan-access.audit.fa1703dd": "Audit",
|
||||
"i18n:govoplan-access.available.ce372771": ", available",
|
||||
@@ -417,6 +437,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
|
||||
"i18n:govoplan-access.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-access.description.55f8ebc8": "Beschreibung",
|
||||
"i18n:govoplan-access.direct_role_source.bf3e6a27": "Direkte Rolle",
|
||||
"i18n:govoplan-access.direct_roles.c4db7156": "Direkte Rollen",
|
||||
"i18n:govoplan-access.display_name.c7874aaa": "Anzeigename",
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
|
||||
@@ -432,12 +453,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.email.84add5b2": "E-Mail",
|
||||
"i18n:govoplan-access.expires.a99be3da": "Expires",
|
||||
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Funktions-ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktions-Rollenzuordnung erstellt.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktions-Rollenzuordnung gelöscht.",
|
||||
@@ -460,6 +483,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
|
||||
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
|
||||
"i18n:govoplan-access.group_retention.57cdcdaa": "Gruppenaufbewahrung",
|
||||
"i18n:govoplan-access.group_role_source.0aa38904": "Gruppenrolle",
|
||||
"i18n:govoplan-access.group_templates": "Gruppenvorlagen",
|
||||
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
|
||||
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
|
||||
@@ -474,6 +498,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.groups.07551586": "groups,",
|
||||
"i18n:govoplan-access.groups.ae9629f4": "Gruppen",
|
||||
"i18n:govoplan-access.i_have_recorded_it.7522da18": "Ich habe es notiert",
|
||||
"i18n:govoplan-access.idm_function_role_source.14a231fe": "IDM-Funktionsrolle",
|
||||
"i18n:govoplan-access.inactive.09af574c": "Inaktiv",
|
||||
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
|
||||
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
@@ -491,12 +516,15 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.last_login.43dab84f": "Letzte Anmeldung",
|
||||
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
|
||||
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leer lassen zum Generieren",
|
||||
"i18n:govoplan-access.legacy_function_role_source.36f81c6b": "Alte Funktionsrolle",
|
||||
"i18n:govoplan-access.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-access.loading_access_explanation.04a7c934": "Zugriffserklaerung wird geladen...",
|
||||
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
|
||||
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
|
||||
"i18n:govoplan-access.maintenance.94de303b": "Wartung",
|
||||
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
|
||||
"i18n:govoplan-access.management.63cecca6": "Management",
|
||||
"i18n:govoplan-access.mapped_roles.504d9ff9": "Zugeordnete Rollen",
|
||||
"i18n:govoplan-access.members.1cb449c1": "Members",
|
||||
"i18n:govoplan-access.membership_status.b77fc732": "Mitgliedschaftsstatus",
|
||||
"i18n:govoplan-access.membership.53bc9670": "Mitgliedschaft",
|
||||
@@ -507,10 +535,13 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
|
||||
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktions-Rollenzuordnungen gefunden.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
|
||||
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
|
||||
"i18n:govoplan-access.no_mapped_roles.986bfb44": "Keine zugeordneten Rollen",
|
||||
"i18n:govoplan-access.no_roles_found.70f7c0c9": "Keine Rollen gefunden.",
|
||||
"i18n:govoplan-access.no_role_sources_found.91013aa5": "Keine Rollenquellen gefunden.",
|
||||
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
|
||||
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
|
||||
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "Keine Mandantenbenutzer gefunden.",
|
||||
@@ -519,6 +550,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.none.6eef6648": "Keine",
|
||||
"i18n:govoplan-access.object.2883f191": "Object",
|
||||
"i18n:govoplan-access.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-access.organization_unit.9832b383": "Organisationseinheit",
|
||||
"i18n:govoplan-access.open_idm_assignment": "IDM-Zuordnung oeffnen",
|
||||
"i18n:govoplan-access.open_organizations": "Organisationen oeffnen",
|
||||
"i18n:govoplan-access.owner.89ff3122": "Eigentümer",
|
||||
"i18n:govoplan-access.packages.0a999012": "Pakete",
|
||||
"i18n:govoplan-access.permissions.d06d5557": "Berechtigungen",
|
||||
@@ -540,6 +574,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
|
||||
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
|
||||
"i18n:govoplan-access.role_details.a16b5d9f": "Rollendetails",
|
||||
"i18n:govoplan-access.role_sources.6f42a672": "Rollenquellen",
|
||||
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
|
||||
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
|
||||
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
|
||||
@@ -588,6 +623,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
|
||||
"i18n:govoplan-access.system_role.91762640": "System role",
|
||||
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
|
||||
"i18n:govoplan-access.system_role_source.c6e83223": "Systemrolle",
|
||||
"i18n:govoplan-access.system.29d43743": "SYSTEM",
|
||||
"i18n:govoplan-access.system.bc0792d8": "System",
|
||||
"i18n:govoplan-access.temporary_password.62d60628": "Temporäres Passwort",
|
||||
|
||||
Reference in New Issue
Block a user