Move access permissions to access module

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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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]

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,299 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
from govoplan_core.security.module_permissions import compatible_required_scopes
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=category,
level=level,
module_id=module_id,
resource=resource,
action=action,
deprecated=True,
)
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = (
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"),
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"),
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
)
def normalize_email(value: str) -> str:
return value.strip().casefold()
def permission_catalog(*, include_legacy: bool = True) -> tuple[PermissionDefinition, ...]:
catalog: dict[str, PermissionDefinition] = {}
for permission in _active_permission_definitions():
catalog[permission.scope] = permission
if include_legacy:
for permission in LEGACY_PERMISSION_DEFINITIONS:
catalog.setdefault(permission.scope, permission)
return tuple(catalog.values())
def permission_map(*, include_legacy: bool = True) -> dict[str, PermissionDefinition]:
return {permission.scope: permission for permission in permission_catalog(include_legacy=include_legacy)}
def role_templates() -> tuple[RoleTemplate, ...]:
registry = _registry()
if registry is not None and hasattr(registry, "role_templates"):
return tuple(registry.role_templates())
from govoplan_access.backend.manifest import ACCESS_ROLE_TEMPLATES
return ACCESS_ROLE_TEMPLATES
def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]:
return tuple(template for template in role_templates() if template.level == level)
def scope_grants(granted: str, required: str) -> bool:
catalog = permission_map(include_legacy=True)
if _scope_grants(granted, required, catalog=catalog):
return True
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
if scope_grants(alias, required):
return True
return any(
_scope_grants(granted, alias, catalog=catalog)
for alias in compatible_required_scopes(required)
if alias != required
)
def scopes_grant(scopes: Iterable[str], required: str) -> bool:
return any(scope_grants(scope, required) for scope in scopes)
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
catalog = permission_map(include_legacy=True)
raw = {str(scope) for scope in scopes if scope}
expanded: set[str] = set()
for scope in raw:
if scope in {"*", "tenant:*", "system:*"} or scope.endswith(":*"):
expanded.add(scope)
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
expanded.add(alias)
matched = {candidate for candidate in catalog if scope_grants(scope, candidate)}
expanded.update(matched)
for alias in compatible_required_scopes(scope):
if alias != scope:
expanded.add(alias)
if include_unknown and not matched:
expanded.add(scope)
return sorted(expanded)
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
candidates = _effective_permission_candidates(level=level)
granted = list(scopes)
return {scope for scope in candidates if scopes_grant(granted, scope)}
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
return len(effective_permission_scopes(scopes, level=level))
def _effective_permission_candidates(*, level: PermissionLevel | None = None) -> set[str]:
active_catalog = permission_map(include_legacy=False)
full_catalog = permission_map(include_legacy=True)
active_scopes = {
scope
for scope, definition in active_catalog.items()
if level is None or definition.level == level
}
candidates = set(active_scopes)
for scope, definition in full_catalog.items():
if scope in active_catalog:
continue
if level is not None and definition.level != level:
continue
if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes):
continue
candidates.add(scope)
return candidates
def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> list[str]:
normalized = {str(scope) for scope in scopes if scope}
wildcard = "system:*" if level == "system" else "tenant:*"
if "*" in normalized or wildcard in normalized:
return [wildcard]
catalog = permission_map(include_legacy=True)
expanded: set[str] = set()
invalid: list[str] = []
for scope in sorted(normalized):
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
if aliases:
for alias in aliases:
definition = catalog.get(alias)
if definition is not None and definition.level == level:
expanded.add(alias)
continue
if scope.endswith(":*"):
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)}
if matching:
expanded.add(scope)
continue
definition = catalog.get(scope)
if definition is not None and definition.level == level:
expanded.add(scope)
continue
invalid.append(scope)
if invalid:
raise ValueError(f"Unsupported {level} permissions: {', '.join(invalid)}")
return sorted(expanded)
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
return validate_permissions(scopes, level="tenant")
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
return validate_permissions(scopes, level="system")
def delegateable_scopes(scopes: Iterable[str], *, level: PermissionLevel) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
wildcard = "system:*" if level == "system" else "tenant:*"
if "*" in expanded or wildcard in expanded:
return {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == level}
return effective_permission_scopes(expanded, level=level)
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
return delegateable_scopes(scopes, level="tenant")
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
return delegateable_scopes(scopes, level="system")
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes)
key = list(key_scopes)
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"}
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)}
user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key))
allowed.update(
scope
for scope in user_raw.intersection(key_raw)
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
)
return sorted(allowed)
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
registry = _registry()
if registry is not None and hasattr(registry, "permissions"):
return tuple(registry.permissions())
from govoplan_access.backend.manifest import ACCESS_PERMISSIONS
return ACCESS_PERMISSIONS
def _registry() -> object | None:
from govoplan_access.backend.runtime import get_registry
return get_registry()

View File

@@ -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

View File

@@ -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;

View File

@@ -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";
}

View File

@@ -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",