From acce930cede44aa70b55d7dc9fa48f5c7c2ff546 Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Thu, 14 May 2026 17:21:34 +0200 Subject: [PATCH 1/8] add lib commands for getting cluster permissions --- pcs/common/permissions/dto.py | 26 ++ pcs/common/permissions/types.py | 45 ++++ pcs/lib/commands/cluster/__init__.py | 6 +- pcs/lib/commands/cluster/misc.py | 63 ++++- pcs/lib/permissions/config/types.py | 9 +- pcs/lib/permissions/tools.py | 12 +- pcs_test/Makefile.am | 1 + .../commands/cluster/test_get_permissions.py | 223 ++++++++++++++++++ 8 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 pcs_test/tier0/lib/commands/cluster/test_get_permissions.py diff --git a/pcs/common/permissions/dto.py b/pcs/common/permissions/dto.py index 6fc4772cf..5356dd349 100644 --- a/pcs/common/permissions/dto.py +++ b/pcs/common/permissions/dto.py @@ -10,3 +10,29 @@ class PermissionEntryDto(DataTransferObject): name: str type: PermissionTargetType allow: list[PermissionGrantedType] + + +@dataclass(frozen=True) +class PermissionMetadataUserTypeDto(DataTransferObject): + code: PermissionTargetType + label: str + description: str + + +@dataclass(frozen=True) +class PermissionMetadataPermissionTypeDto(DataTransferObject): + code: PermissionGrantedType + label: str + description: str + + +@dataclass(frozen=True) +class PermissionMetadataDependenciesDto(DataTransferObject): + also_allows: dict[PermissionGrantedType, list[PermissionGrantedType]] + + +@dataclass(frozen=True) +class PermissionMetadataDto(DataTransferObject): + user_types: list[PermissionMetadataUserTypeDto] + permission_types: list[PermissionMetadataPermissionTypeDto] + permissions_dependencies: PermissionMetadataDependenciesDto diff --git a/pcs/common/permissions/types.py b/pcs/common/permissions/types.py index 8cc4f8b7c..32d05f1de 100644 --- a/pcs/common/permissions/types.py +++ b/pcs/common/permissions/types.py @@ -5,9 +5,54 @@ class PermissionTargetType(StrEnum): USER = "user" GROUP = "group" + @property + def label(self) -> str: + return self.value.capitalize() + + @property + def description(self) -> str: + return "" + class PermissionGrantedType(StrEnum): READ = "read" WRITE = "write" GRANT = "grant" FULL = "full" + + @property + def label(self) -> str: + return self.value.capitalize() + + @property + def description(self) -> str: + return _PERMISSION_TYPE_DESCRIPTIONS[self] + + @property + def dependencies(self) -> list["PermissionGrantedType"]: + return _PERMISSION_DEPENDENCIES[self] + + +_PERMISSION_TYPE_DESCRIPTIONS = { + PermissionGrantedType.READ: "Allows to view cluster settings", + PermissionGrantedType.WRITE: ( + "Allows to modify cluster settings except permissions and ACLs" + ), + PermissionGrantedType.GRANT: ( + "Allows to modify cluster permissions and ACLs" + ), + PermissionGrantedType.FULL: ( + "Allows unrestricted access to a cluster except for adding nodes" + ), +} + +_PERMISSION_DEPENDENCIES = { + PermissionGrantedType.READ: [], + PermissionGrantedType.WRITE: [PermissionGrantedType.READ], + PermissionGrantedType.GRANT: [], + PermissionGrantedType.FULL: [ + PermissionGrantedType.READ, + PermissionGrantedType.WRITE, + PermissionGrantedType.GRANT, + ], +} diff --git a/pcs/lib/commands/cluster/__init__.py b/pcs/lib/commands/cluster/__init__.py index 80eca876b..098f19309 100644 --- a/pcs/lib/commands/cluster/__init__.py +++ b/pcs/lib/commands/cluster/__init__.py @@ -9,6 +9,8 @@ from .link import add_link, remove_links, update_link from .misc import ( corosync_authkey_change, + get_permissions, + get_permissions_metadata, rename, set_permissions, verify, @@ -31,10 +33,12 @@ "config_update", "config_update_local", "corosync_authkey_change", - "get_host_daemons_info", "generate_cluster_uuid", "generate_cluster_uuid_local", "get_corosync_conf_struct", + "get_host_daemons_info", + "get_permissions", + "get_permissions_metadata", "node_clear", "remove_links", "remove_nodes", diff --git a/pcs/lib/commands/cluster/misc.py b/pcs/lib/commands/cluster/misc.py index 004ca4a62..9ee5b1e9e 100644 --- a/pcs/lib/commands/cluster/misc.py +++ b/pcs/lib/commands/cluster/misc.py @@ -4,8 +4,17 @@ from pcs import settings from pcs.common import reports -from pcs.common.permissions.dto import PermissionEntryDto -from pcs.common.permissions.types import PermissionGrantedType +from pcs.common.permissions.dto import ( + PermissionEntryDto, + PermissionMetadataDependenciesDto, + PermissionMetadataDto, + PermissionMetadataPermissionTypeDto, + PermissionMetadataUserTypeDto, +) +from pcs.common.permissions.types import ( + PermissionGrantedType, + PermissionTargetType, +) from pcs.lib import node_communication_format from pcs.lib.auth.types import AuthUser from pcs.lib.cib import fencing_topology @@ -381,3 +390,53 @@ def set_permissions( ) if env.report_processor.has_errors: raise LibraryError() + + +def get_permissions(env: LibraryEnvironment) -> list[PermissionEntryDto]: + """ + Return local cluster permissions + """ + ensure_live_env(env) + + pcs_settings, report_list = read_pcs_settings_conf() + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + return [ + entry.to_dto() + for entry in sorted( + pcs_settings.config.permissions.local_cluster, + key=lambda p: (p.type, p.name), + ) + ] + + +def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: + """ + Return metadata about cluster permissions + """ + del env + + return PermissionMetadataDto( + user_types=[ + PermissionMetadataUserTypeDto( + user_type, user_type.label, user_type.description + ) + for user_type in PermissionTargetType + ], + permission_types=[ + PermissionMetadataPermissionTypeDto( + permission_type, + permission_type.label, + permission_type.description, + ) + for permission_type in PermissionGrantedType + ], + permissions_dependencies=PermissionMetadataDependenciesDto( + { + permission: permission.dependencies + for permission in PermissionGrantedType + if permission.dependencies + } + ), + ) diff --git a/pcs/lib/permissions/config/types.py b/pcs/lib/permissions/config/types.py index fe65f4676..b1b2abdf6 100644 --- a/pcs/lib/permissions/config/types.py +++ b/pcs/lib/permissions/config/types.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Collection, Sequence +from pcs.common.interface.dto import ImplementsToDto +from pcs.common.permissions.dto import PermissionEntryDto from pcs.common.permissions.types import ( PermissionGrantedType, PermissionTargetType, @@ -14,11 +16,16 @@ class ClusterEntry: @dataclass(frozen=True) -class PermissionEntry: +class PermissionEntry(ImplementsToDto): name: str type: PermissionTargetType allow: Collection[PermissionGrantedType] + def to_dto(self) -> PermissionEntryDto: + return PermissionEntryDto( + name=self.name, type=self.type, allow=list(self.allow) + ) + @dataclass(frozen=True) class ClusterPermissions: diff --git a/pcs/lib/permissions/tools.py b/pcs/lib/permissions/tools.py index 89a67bb5c..600b3ae61 100644 --- a/pcs/lib/permissions/tools.py +++ b/pcs/lib/permissions/tools.py @@ -13,14 +13,10 @@ def complete_access_list( access_list: Collection[PermissionGrantedType], ) -> set[PermissionGrantedType]: - permission_set = set(access_list) - if PermissionGrantedType.FULL in permission_set: - permission_set.update( - (PermissionGrantedType.WRITE, PermissionGrantedType.GRANT) - ) - if PermissionGrantedType.WRITE in permission_set: - permission_set.add(PermissionGrantedType.READ) - return permission_set + final_permission_set = set(access_list) + for permission in set(access_list): + final_permission_set.update(permission.dependencies) + return final_permission_set def read_pcs_settings_conf() -> tuple[ diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am index b7873e89e..bf627bd8a 100644 --- a/pcs_test/Makefile.am +++ b/pcs_test/Makefile.am @@ -283,6 +283,7 @@ EXTRA_DIST = \ tier0/lib/commands/cluster/test_config_update.py \ tier0/lib/commands/cluster/test_get_corosync_conf_struct.py \ tier0/lib/commands/cluster/test_get_host_daemons_info.py \ + tier0/lib/commands/cluster/test_get_permissions.py \ tier0/lib/commands/cluster/test_node_clear.py \ tier0/lib/commands/cluster/test_node_rename_cib.py \ tier0/lib/commands/cluster/test_node_rename_corosync.py \ diff --git a/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py new file mode 100644 index 000000000..1b72042e0 --- /dev/null +++ b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py @@ -0,0 +1,223 @@ +from unittest import TestCase + +from pcs import settings +from pcs.common import file_type_codes, reports +from pcs.common.permissions.dto import ( + PermissionEntryDto, + PermissionMetadataDependenciesDto, + PermissionMetadataDto, + PermissionMetadataPermissionTypeDto, + PermissionMetadataUserTypeDto, +) +from pcs.common.permissions.types import ( + PermissionGrantedType, + PermissionTargetType, +) +from pcs.lib.auth.const import ADMIN_GROUP +from pcs.lib.commands import cluster +from pcs.lib.permissions.config.types import PermissionEntry + +from pcs_test.tools import fixture +from pcs_test.tools.command_env import get_env_tools +from pcs_test.tools.fixture_pcs_cfgsync import fixture_pcs_settings_file_content + + +class GetPermissions(TestCase): + def setUp(self): + self.env_assist, self.config = get_env_tools(test_case=self) + + def test_not_live_environment(self): + self.config.env.set_corosync_conf_data("") + self.env_assist.assert_raise_library_error( + lambda: cluster.get_permissions(self.env_assist.get_env()), + [ + fixture.error( + reports.codes.LIVE_ENVIRONMENT_REQUIRED, + forbidden_options=[file_type_codes.COROSYNC_CONF], + ), + ], + expected_in_processor=False, + ) + + def test_success_file_does_not_exist(self): + self.config.raw_file.exists( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + exists=False, + ) + + result = cluster.get_permissions(self.env_assist.get_env()) + + self.assertEqual( + result, + [ + PermissionEntryDto( + name=ADMIN_GROUP, + type=PermissionTargetType.GROUP, + allow=[ + PermissionGrantedType.READ, + PermissionGrantedType.WRITE, + PermissionGrantedType.GRANT, + ], + ) + ], + ) + self.env_assist.assert_reports( + [ + fixture.debug( + reports.codes.FILE_DOES_NOT_EXIST_USING_DEFAULT, + file_type_code=file_type_codes.PCS_SETTINGS_CONF, + file_path=settings.pcsd_settings_conf_location, + ) + ] + ) + + def test_success_empty_permissions(self): + self.config.raw_file.exists( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + ) + self.config.raw_file.read( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + content=fixture_pcs_settings_file_content(permissions=[]), + ) + + result = cluster.get_permissions(self.env_assist.get_env()) + + self.assertEqual(result, []) + + def test_success_permissions_sorted(self): + self.config.raw_file.exists( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + ) + self.config.raw_file.read( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + content=fixture_pcs_settings_file_content( + permissions=[ + PermissionEntry( + "bob", + PermissionTargetType.USER, + allow=[PermissionGrantedType.FULL], + ), + PermissionEntry( + "alice", + PermissionTargetType.USER, + allow=[PermissionGrantedType.WRITE], + ), + PermissionEntry( + "wheel", + PermissionTargetType.GROUP, + allow=[PermissionGrantedType.READ], + ), + ] + ), + ) + + result = cluster.get_permissions(self.env_assist.get_env()) + + # sorted by (type, name): GROUP < USER alphabetically, then by name + self.assertEqual( + result, + [ + PermissionEntryDto( + "wheel", + PermissionTargetType.GROUP, + [PermissionGrantedType.READ], + ), + PermissionEntryDto( + "alice", + PermissionTargetType.USER, + [PermissionGrantedType.WRITE], + ), + PermissionEntryDto( + "bob", + PermissionTargetType.USER, + [PermissionGrantedType.FULL], + ), + ], + ) + + def test_error_reading_file(self): + self.config.raw_file.exists( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + ) + self.config.raw_file.read( + file_type_codes.PCS_SETTINGS_CONF, + settings.pcsd_settings_conf_location, + exception_msg="Something bad", + ) + + self.env_assist.assert_raise_library_error( + lambda: cluster.get_permissions(self.env_assist.get_env()) + ) + self.env_assist.assert_reports( + [ + fixture.error( + reports.codes.FILE_IO_ERROR, + file_type_code=file_type_codes.PCS_SETTINGS_CONF, + operation="read", + reason="Something bad", + file_path=settings.pcsd_settings_conf_location, + ) + ] + ) + + +class GetPermissionsMetadata(TestCase): + def setUp(self): + self.env_assist, self.config = get_env_tools(test_case=self) + + def test_success(self): + result = cluster.get_permissions_metadata(self.env_assist.get_env()) + + self.assertEqual( + result, + PermissionMetadataDto( + user_types=[ + PermissionMetadataUserTypeDto( + PermissionTargetType.USER, "User", "" + ), + PermissionMetadataUserTypeDto( + PermissionTargetType.GROUP, "Group", "" + ), + ], + permission_types=[ + PermissionMetadataPermissionTypeDto( + PermissionGrantedType.READ, + "Read", + "Allows to view cluster settings", + ), + PermissionMetadataPermissionTypeDto( + PermissionGrantedType.WRITE, + "Write", + "Allows to modify cluster settings except permissions and ACLs", + ), + PermissionMetadataPermissionTypeDto( + PermissionGrantedType.GRANT, + "Grant", + "Allows to modify cluster permissions and ACLs", + ), + PermissionMetadataPermissionTypeDto( + PermissionGrantedType.FULL, + "Full", + "Allows unrestricted access to a cluster except for adding nodes", + ), + ], + permissions_dependencies=PermissionMetadataDependenciesDto( + { + PermissionGrantedType.WRITE: [ + PermissionGrantedType.READ + ], + PermissionGrantedType.FULL: [ + PermissionGrantedType.READ, + PermissionGrantedType.WRITE, + PermissionGrantedType.GRANT, + ], + } + ), + ), + ) From 1450748e6067a71666f8834ab0563a6573e81713 Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Thu, 14 May 2026 17:22:31 +0200 Subject: [PATCH 2/8] add Python handler for /remote/get_permissions --- pcs/daemon/app/api_v0.py | 37 +++++ .../async_tasks/worker/command_mapping.py | 10 ++ pcs_test/tier0/daemon/app/test_api_v0.py | 144 ++++++++++++++++++ 3 files changed, 191 insertions(+) diff --git a/pcs/daemon/app/api_v0.py b/pcs/daemon/app/api_v0.py index b331d56f1..f4842876c 100644 --- a/pcs/daemon/app/api_v0.py +++ b/pcs/daemon/app/api_v0.py @@ -11,6 +11,11 @@ from pcs.common.cluster_dto import ClusterDaemonsInfoDto from pcs.common.interface.dto import to_dict from pcs.common.pcs_cfgsync_dto import SyncConfigsDto +from pcs.common.permissions.dto import ( + PermissionEntryDto, + PermissionMetadataDependenciesDto, + PermissionMetadataDto, +) from pcs.common.str_tools import format_list from pcs.daemon import log from pcs.daemon.app.api_v0_tools import ( @@ -598,6 +603,37 @@ async def _handle_request(self) -> None: self.write("Permissions saved") +class GetPermissionsHandler(_BaseApiV0Handler): + async def _handle_request(self) -> None: + result = await self._run_library_command("cluster.get_permissions", {}) + # Ruby compatibility: ignore errors, return empty permissions + # + # 403 is already returned in _run_library_command if the user does not + # have the needed permissions to run this command, so this is safe + user_permissions = result.result if result.success else [] + + result = await self._run_library_command( + "cluster.get_permissions_metadata", {} + ) + permission_metadata = ( + cast(PermissionMetadataDto, result.result) + if result.success + else PermissionMetadataDto( + [], [], PermissionMetadataDependenciesDto({}) + ) + ) + + self.write( + { + **to_dict(permission_metadata), + "users_permissions": [ + to_dict(cast(PermissionEntryDto, entry)) + for entry in user_permissions + ], + }, + ) + + class KnownHostsChangeHandler(_BaseApiV0Handler): """ Input format: @@ -757,6 +793,7 @@ def r(url: str) -> str: ), # permissions (r("set_permissions"), SetPermissionsHandler, params), + (r("get_permissions"), GetPermissionsHandler, params), # known hosts (r("known_hosts_change"), KnownHostsChangeHandler, params), # check_host diff --git a/pcs/daemon/async_tasks/worker/command_mapping.py b/pcs/daemon/async_tasks/worker/command_mapping.py index fcf39a6dc..f1bf54a2f 100644 --- a/pcs/daemon/async_tasks/worker/command_mapping.py +++ b/pcs/daemon/async_tasks/worker/command_mapping.py @@ -157,6 +157,14 @@ class _Cmd: cmd=cluster.get_host_daemons_info, required_permission=p.READ, ), + "cluster.get_permissions": _Cmd( + cmd=cluster.get_permissions, + required_permission=p.GRANT, + ), + "cluster.get_permissions_metadata": _Cmd( + cmd=cluster.get_permissions_metadata, + required_permission=p.GRANT, + ), "cluster.node_clear": _Cmd( cmd=cluster.node_clear, required_permission=p.WRITE, @@ -546,6 +554,8 @@ class _Cmd: LEGACY_API_COMMANDS = ( "auth.known_hosts_change", "booth.get_config", + "cluster.get_permissions", + "cluster.get_permissions_metadata", "cluster.set_corosync_conf", "cluster.set_permissions", "manage_clusters.add_cluster", diff --git a/pcs_test/tier0/daemon/app/test_api_v0.py b/pcs_test/tier0/daemon/app/test_api_v0.py index 9a8d0c130..73f81070c 100644 --- a/pcs_test/tier0/daemon/app/test_api_v0.py +++ b/pcs_test/tier0/daemon/app/test_api_v0.py @@ -31,7 +31,19 @@ ClusterDaemonsInfoDto, ) from pcs.common.file import RawFileError +from pcs.common.interface.dto import to_dict from pcs.common.pcs_cfgsync_dto import SyncConfigsDto +from pcs.common.permissions.dto import ( + PermissionEntryDto, + PermissionMetadataDependenciesDto, + PermissionMetadataDto, + PermissionMetadataPermissionTypeDto, + PermissionMetadataUserTypeDto, +) +from pcs.common.permissions.types import ( + PermissionGrantedType, + PermissionTargetType, +) from pcs.common.services_dto import ServiceStatusDto from pcs.common.version_dto import VersionDto from pcs.daemon.app import api_v0 @@ -1607,6 +1619,138 @@ def test_failure(self): ) +class GetPermissionsHandler(ApiV0HandlerTest): + url = "/remote/get_permissions" + + _PERMISSION_ENTRY = PermissionEntryDto( + "alice", PermissionTargetType.USER, [PermissionGrantedType.READ] + ) + _PERMISSION_METADATA = PermissionMetadataDto( + user_types=[ + PermissionMetadataUserTypeDto( + PermissionTargetType.USER, "User", "" + ), + ], + permission_types=[ + PermissionMetadataPermissionTypeDto( + PermissionGrantedType.READ, + "Read", + "Allows to view cluster settings", + ), + ], + permissions_dependencies=PermissionMetadataDependenciesDto( + {PermissionGrantedType.WRITE: [PermissionGrantedType.READ]} + ), + ) + + def test_success(self): + self.mock_run_library_command.side_effect = [ + self.result_success([self._PERMISSION_ENTRY]), + self.result_success(self._PERMISSION_METADATA), + ] + + response = self.fetch(self.url) + + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "user_types": [ + { + "code": "user", + "label": "User", + "description": "", + } + ], + "permission_types": [ + { + "code": "read", + "label": "Read", + "description": "Allows to view cluster settings", + } + ], + "permissions_dependencies": { + "also_allows": {"write": ["read"]} + }, + "users_permissions": [ + {"name": "alice", "type": "user", "allow": ["read"]} + ], + } + ), + ) + mock_calls = [ + mock.call("cluster.get_permissions", {}), + mock.call("cluster.get_permissions_metadata", {}), + ] + self.mock_run_library_command.assert_has_calls(mock_calls) + self.assertEqual( + self.mock_run_library_command.call_count, len(mock_calls) + ) + + def test_get_permissions_failure_returns_empty_list(self): + # Ruby compatibility: errors from get_permissions are silently ignored + self.mock_run_library_command.side_effect = [ + self.result_failure(), + self.result_success(self._PERMISSION_METADATA), + ] + + response = self.fetch(self.url) + + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + **to_dict(self._PERMISSION_METADATA), + "users_permissions": [], + } + ), + ) + + def test_get_permissions_metadata_failure_returns_empty_metadata(self): + self.mock_run_library_command.side_effect = [ + self.result_success([self._PERMISSION_ENTRY]), + self.result_failure(), + ] + + response = self.fetch(self.url) + + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "user_types": [], + "permission_types": [], + "permissions_dependencies": {"also_allows": {}}, + "users_permissions": [to_dict(self._PERMISSION_ENTRY)], + } + ), + ) + + def test_both_commands_fail_returns_empty_response(self): + self.mock_run_library_command.side_effect = [ + self.result_failure(), + self.result_failure(), + ] + + response = self.fetch(self.url) + + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "user_types": [], + "permission_types": [], + "permissions_dependencies": {"also_allows": {}}, + "users_permissions": [], + } + ), + ) + + class KnownHostsChange(ApiV0HandlerTest): url = "/remote/known_hosts_change" command = "auth.known_hosts_change" From 60127cb23e6aecd9635ea8879cb8f6d85cd9f7fb Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Thu, 14 May 2026 17:22:44 +0200 Subject: [PATCH 3/8] remove unused Ruby code --- pcsd/permissions.rb | 41 ----------------------------------------- pcsd/remote.rb | 16 ---------------- 2 files changed, 57 deletions(-) diff --git a/pcsd/permissions.rb b/pcsd/permissions.rb index 41ce560cb..7df16a867 100644 --- a/pcsd/permissions.rb +++ b/pcsd/permissions.rb @@ -8,47 +8,6 @@ module Permissions GRANT = 'grant' FULL = 'full' - def self.get_user_types() - return [ - { - 'code' => TYPE_USER, - 'label' => 'User', - 'description' => '', - }, - { - 'code' => TYPE_GROUP, - 'label' => 'Group', - 'description' => '', - } - ] - end - - def self.get_permission_types() - return [ - { - 'code' => READ, - 'label' => 'Read', - 'description' => 'Allows to view cluster settings', - }, - { - 'code' => WRITE, - 'label' => 'Write', - 'description' => 'Allows to modify cluster settings except permissions and ACLs', - }, - { - 'code' => GRANT, - 'label' => 'Grant', - 'description' => 'Allows to modify cluster permissions and ACLs', - }, - { - 'code' => FULL, - 'label' => 'Full', - 'description' => ('Allows unrestricted access to a cluster except for'\ - + ' adding nodes'), - } - ] - end - def self.permissions_dependencies() return { 'also_allows' => { diff --git a/pcsd/remote.rb b/pcsd/remote.rb index a66226bb3..0a6337b73 100644 --- a/pcsd/remote.rb +++ b/pcsd/remote.rb @@ -26,7 +26,6 @@ def remote(params, request, auth_user) :get_quorum_info => method(:get_quorum_info), :get_corosync_conf => method(:get_corosync_conf_remote), :set_certs => method(:set_certs), - :get_permissions => method(:get_permissions_remote), :cluster_start => method(:cluster_start), :cluster_stop => method(:cluster_stop), :config_restore => method(:config_restore), @@ -426,21 +425,6 @@ def set_certs(params, request, auth_user) return [200, 'success'] end -def get_permissions_remote(params, request, auth_user) - if not allowed_for_local_cluster(auth_user, Permissions::GRANT) - return 403, 'Permission denied' - end - - pcs_config = PCSConfig.new(get_pcs_settings_conf()) - data = { - 'user_types' => Permissions::get_user_types(), - 'permission_types' => Permissions::get_permission_types(), - 'permissions_dependencies' => Permissions::permissions_dependencies(), - 'users_permissions' => pcs_config.permissions_local.to_hash(), - } - return [200, JSON.generate(data)] -end - def remote_pacemaker_node_status(params, request, auth_user) if not allowed_for_local_cluster(auth_user, Permissions::READ) return 403, 'Permission denied' From c65f16876aad968c883a9fcfff9115678e6afb2a Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Mon, 25 May 2026 10:17:56 +0200 Subject: [PATCH 4/8] rename DTO --- pcs/common/permissions/dto.py | 4 ++-- pcs/lib/commands/cluster/misc.py | 4 ++-- pcs_test/tier0/daemon/app/test_api_v0.py | 4 ++-- pcs_test/tier0/lib/commands/cluster/test_get_permissions.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pcs/common/permissions/dto.py b/pcs/common/permissions/dto.py index 5356dd349..a181bbc93 100644 --- a/pcs/common/permissions/dto.py +++ b/pcs/common/permissions/dto.py @@ -13,7 +13,7 @@ class PermissionEntryDto(DataTransferObject): @dataclass(frozen=True) -class PermissionMetadataUserTypeDto(DataTransferObject): +class PermissionMetadataTargetTypeDto(DataTransferObject): code: PermissionTargetType label: str description: str @@ -33,6 +33,6 @@ class PermissionMetadataDependenciesDto(DataTransferObject): @dataclass(frozen=True) class PermissionMetadataDto(DataTransferObject): - user_types: list[PermissionMetadataUserTypeDto] + user_types: list[PermissionMetadataTargetTypeDto] permission_types: list[PermissionMetadataPermissionTypeDto] permissions_dependencies: PermissionMetadataDependenciesDto diff --git a/pcs/lib/commands/cluster/misc.py b/pcs/lib/commands/cluster/misc.py index 9ee5b1e9e..9e7b07614 100644 --- a/pcs/lib/commands/cluster/misc.py +++ b/pcs/lib/commands/cluster/misc.py @@ -9,7 +9,7 @@ PermissionMetadataDependenciesDto, PermissionMetadataDto, PermissionMetadataPermissionTypeDto, - PermissionMetadataUserTypeDto, + PermissionMetadataTargetTypeDto, ) from pcs.common.permissions.types import ( PermissionGrantedType, @@ -419,7 +419,7 @@ def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: return PermissionMetadataDto( user_types=[ - PermissionMetadataUserTypeDto( + PermissionMetadataTargetTypeDto( user_type, user_type.label, user_type.description ) for user_type in PermissionTargetType diff --git a/pcs_test/tier0/daemon/app/test_api_v0.py b/pcs_test/tier0/daemon/app/test_api_v0.py index 73f81070c..164201230 100644 --- a/pcs_test/tier0/daemon/app/test_api_v0.py +++ b/pcs_test/tier0/daemon/app/test_api_v0.py @@ -38,7 +38,7 @@ PermissionMetadataDependenciesDto, PermissionMetadataDto, PermissionMetadataPermissionTypeDto, - PermissionMetadataUserTypeDto, + PermissionMetadataTargetTypeDto, ) from pcs.common.permissions.types import ( PermissionGrantedType, @@ -1627,7 +1627,7 @@ class GetPermissionsHandler(ApiV0HandlerTest): ) _PERMISSION_METADATA = PermissionMetadataDto( user_types=[ - PermissionMetadataUserTypeDto( + PermissionMetadataTargetTypeDto( PermissionTargetType.USER, "User", "" ), ], diff --git a/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py index 1b72042e0..b09658383 100644 --- a/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py +++ b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py @@ -7,7 +7,7 @@ PermissionMetadataDependenciesDto, PermissionMetadataDto, PermissionMetadataPermissionTypeDto, - PermissionMetadataUserTypeDto, + PermissionMetadataTargetTypeDto, ) from pcs.common.permissions.types import ( PermissionGrantedType, @@ -178,10 +178,10 @@ def test_success(self): result, PermissionMetadataDto( user_types=[ - PermissionMetadataUserTypeDto( + PermissionMetadataTargetTypeDto( PermissionTargetType.USER, "User", "" ), - PermissionMetadataUserTypeDto( + PermissionMetadataTargetTypeDto( PermissionTargetType.GROUP, "Group", "" ), ], From 785d59effae97d75f343a8f7fa89c0985d69f6e4 Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Mon, 25 May 2026 10:25:59 +0200 Subject: [PATCH 5/8] extract permission commands into separate module --- pcs/Makefile.am | 1 + pcs/lib/commands/cluster/__init__.py | 15 +- pcs/lib/commands/cluster/misc.py | 168 +---------------------- pcs/lib/commands/cluster/permissions.py | 173 ++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 176 deletions(-) create mode 100644 pcs/lib/commands/cluster/permissions.py diff --git a/pcs/Makefile.am b/pcs/Makefile.am index 0fb30d2b1..d338c40e4 100644 --- a/pcs/Makefile.am +++ b/pcs/Makefile.am @@ -352,6 +352,7 @@ EXTRA_DIST = \ lib/commands/cluster/link.py \ lib/commands/cluster/misc.py \ lib/commands/cluster/node.py \ + lib/commands/cluster/permissions.py \ lib/commands/cluster_property.py \ lib/commands/cluster/setup_cluster.py \ lib/commands/cluster/setup_node.py \ diff --git a/pcs/lib/commands/cluster/__init__.py b/pcs/lib/commands/cluster/__init__.py index 098f19309..b4e906144 100644 --- a/pcs/lib/commands/cluster/__init__.py +++ b/pcs/lib/commands/cluster/__init__.py @@ -7,15 +7,7 @@ set_corosync_conf, ) from .link import add_link, remove_links, update_link -from .misc import ( - corosync_authkey_change, - get_permissions, - get_permissions_metadata, - rename, - set_permissions, - verify, - wait_for_pcmk_idle, -) +from .misc import corosync_authkey_change, rename, verify, wait_for_pcmk_idle from .node import ( get_host_daemons_info, node_clear, @@ -24,6 +16,11 @@ rename_node_cib, rename_node_corosync, ) +from .permissions import ( + get_permissions, + get_permissions_metadata, + set_permissions, +) from .setup_cluster import setup, setup_local from .setup_node import add_nodes diff --git a/pcs/lib/commands/cluster/misc.py b/pcs/lib/commands/cluster/misc.py index 9e7b07614..c0b28e6b3 100644 --- a/pcs/lib/commands/cluster/misc.py +++ b/pcs/lib/commands/cluster/misc.py @@ -1,22 +1,10 @@ -from typing import Optional, Sequence +from typing import Optional from lxml.etree import _Element from pcs import settings from pcs.common import reports -from pcs.common.permissions.dto import ( - PermissionEntryDto, - PermissionMetadataDependenciesDto, - PermissionMetadataDto, - PermissionMetadataPermissionTypeDto, - PermissionMetadataTargetTypeDto, -) -from pcs.common.permissions.types import ( - PermissionGrantedType, - PermissionTargetType, -) from pcs.lib import node_communication_format -from pcs.lib.auth.types import AuthUser from pcs.lib.cib import fencing_topology from pcs.lib.cib.nvpair_multi import NVSET_INSTANCE, find_nvsets from pcs.lib.cib.resource.bundle import verify as verify_bundles @@ -51,18 +39,6 @@ ) from pcs.lib.pacemaker.live import verify as verify_cmd from pcs.lib.pacemaker.state import ClusterState -from pcs.lib.pcs_cfgsync.sync_files import ( - sync_pcs_settings_in_cluster, - update_pcs_settings_locally, -) -from pcs.lib.permissions.checker import PermissionsChecker -from pcs.lib.permissions.config.types import PermissionEntry -from pcs.lib.permissions.tools import ( - complete_access_list, - read_pcs_settings_conf, -) -from pcs.lib.permissions.types import PermissionRequiredType -from pcs.lib.permissions.validations import validate_set_permissions from pcs.lib.resource_agent.types import ResourceAgentName from pcs.lib.tools import generate_binary_key @@ -298,145 +274,3 @@ def warn_gfs2_resources(resources: _Element) -> reports.ReportItemList: corosync_conf.set_cluster_name(new_name) env.push_corosync_conf(corosync_conf, skip_offline) - - -def set_permissions( - env: LibraryEnvironment, permissions: Sequence[PermissionEntryDto] -) -> None: - """ - Replace the current local cluster permissions with provided permissions. If - local node is in cluster, synchronize the updated pcs_settings file. - - permissions -- new permissions for the local cluster - """ - # TODO - # Checking user permissions is done in daemon command executor when calling - # lib commands through API - GRANT in this case. If the user has GRANT, - # then this command is called, and the command itself does only "extra" - # permission checks in case the user is trying to change users with FULL - # permissions. - # - # We need to properly check that user has permissions to call this command - # when we eventually want to use this command from CLI through lib_wrapper! - - if env.report_processor.report_list( - validate_set_permissions(permissions) - ).has_errors: - raise LibraryError() - - ensure_live_env(env) - - pcs_settings, report_list = read_pcs_settings_conf() - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - - # TODO: The user_login and user_groups are None when calling - # this command from cli through lib_wrapper -> this will break - auth_user = AuthUser(env.user_login or "", env.user_groups or []) - permissions_checker = PermissionsChecker(env.logger) - - new_full_users = set() - new_permission_list = [] - for perm in permissions: - if PermissionGrantedType.FULL in perm.allow: - new_full_users.add((perm.name, perm.type)) - # Explicitly save dependant permissions. That way if the dependency is - # changed in the future, it won't revoke permissions which were once - # granted - allow = complete_access_list(set(perm.allow)) - new_permission_list.append( - PermissionEntry(name=perm.name, type=perm.type, allow=sorted(allow)) - ) - - current_full_users = { - (perm.name, perm.type) - for perm in pcs_settings.get_entries_with_allow_full() - } - if new_full_users != current_full_users: - if not permissions_checker.is_authorized( - auth_user, PermissionRequiredType.FULL - ): - env.report_processor.report( - reports.ReportItem.error( - reports.messages.NotAuthorizedToChangeFullPermission() - ) - ) - raise LibraryError() - - # replace all of the the current permissions - pcs_settings.set_permissions(new_permission_list) - - if not env.has_corosync_conf: - update_pcs_settings_locally(pcs_settings, env.report_processor) - if env.report_processor.has_errors: - raise LibraryError() - return - - # the node is in cluster, sync the updated config to cluster nodes - corosync_conf = env.get_corosync_conf() - local_cluster_name = corosync_conf.get_cluster_name() - local_corosync_nodes, _ = get_existing_nodes_names(corosync_conf) - request_targets = env.get_node_target_factory().get_target_list( - local_corosync_nodes - ) - node_communicator = env.get_node_communicator_no_privilege_transition() - - sync_pcs_settings_in_cluster( - pcs_settings, - local_cluster_name, - request_targets, - node_communicator, - env.report_processor, - ) - if env.report_processor.has_errors: - raise LibraryError() - - -def get_permissions(env: LibraryEnvironment) -> list[PermissionEntryDto]: - """ - Return local cluster permissions - """ - ensure_live_env(env) - - pcs_settings, report_list = read_pcs_settings_conf() - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - - return [ - entry.to_dto() - for entry in sorted( - pcs_settings.config.permissions.local_cluster, - key=lambda p: (p.type, p.name), - ) - ] - - -def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: - """ - Return metadata about cluster permissions - """ - del env - - return PermissionMetadataDto( - user_types=[ - PermissionMetadataTargetTypeDto( - user_type, user_type.label, user_type.description - ) - for user_type in PermissionTargetType - ], - permission_types=[ - PermissionMetadataPermissionTypeDto( - permission_type, - permission_type.label, - permission_type.description, - ) - for permission_type in PermissionGrantedType - ], - permissions_dependencies=PermissionMetadataDependenciesDto( - { - permission: permission.dependencies - for permission in PermissionGrantedType - if permission.dependencies - } - ), - ) diff --git a/pcs/lib/commands/cluster/permissions.py b/pcs/lib/commands/cluster/permissions.py new file mode 100644 index 000000000..9b8e5cf5d --- /dev/null +++ b/pcs/lib/commands/cluster/permissions.py @@ -0,0 +1,173 @@ +from typing import Sequence + +from pcs.common import reports +from pcs.common.permissions.dto import ( + PermissionEntryDto, + PermissionMetadataDependenciesDto, + PermissionMetadataDto, + PermissionMetadataPermissionTypeDto, + PermissionMetadataTargetTypeDto, +) +from pcs.common.permissions.types import ( + PermissionGrantedType, + PermissionTargetType, +) +from pcs.lib.auth.types import AuthUser +from pcs.lib.commands.cluster.utils import ensure_live_env +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError +from pcs.lib.node import get_existing_nodes_names +from pcs.lib.pcs_cfgsync.sync_files import ( + sync_pcs_settings_in_cluster, + update_pcs_settings_locally, +) +from pcs.lib.permissions.checker import PermissionsChecker +from pcs.lib.permissions.config.types import PermissionEntry +from pcs.lib.permissions.tools import ( + complete_access_list, + read_pcs_settings_conf, +) +from pcs.lib.permissions.types import PermissionRequiredType +from pcs.lib.permissions.validations import validate_set_permissions + + +def set_permissions( + env: LibraryEnvironment, permissions: Sequence[PermissionEntryDto] +) -> None: + """ + Replace the current local cluster permissions with provided permissions. If + local node is in cluster, synchronize the updated pcs_settings file. + + permissions -- new permissions for the local cluster + """ + # TODO + # Checking user permissions is done in daemon command executor when calling + # lib commands through API - GRANT in this case. If the user has GRANT, + # then this command is called, and the command itself does only "extra" + # permission checks in case the user is trying to change users with FULL + # permissions. + # + # We need to properly check that user has permissions to call this command + # when we eventually want to use this command from CLI through lib_wrapper! + + if env.report_processor.report_list( + validate_set_permissions(permissions) + ).has_errors: + raise LibraryError() + + ensure_live_env(env) + + pcs_settings, report_list = read_pcs_settings_conf() + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + # TODO: The user_login and user_groups are None when calling + # this command from cli through lib_wrapper -> this will break + auth_user = AuthUser(env.user_login or "", env.user_groups or []) + permissions_checker = PermissionsChecker(env.logger) + + new_full_users = set() + new_permission_list = [] + for perm in permissions: + if PermissionGrantedType.FULL in perm.allow: + new_full_users.add((perm.name, perm.type)) + # Explicitly save dependant permissions. That way if the dependency is + # changed in the future, it won't revoke permissions which were once + # granted + allow = complete_access_list(set(perm.allow)) + new_permission_list.append( + PermissionEntry(name=perm.name, type=perm.type, allow=sorted(allow)) + ) + + current_full_users = { + (perm.name, perm.type) + for perm in pcs_settings.get_entries_with_allow_full() + } + if new_full_users != current_full_users: + if not permissions_checker.is_authorized( + auth_user, PermissionRequiredType.FULL + ): + env.report_processor.report( + reports.ReportItem.error( + reports.messages.NotAuthorizedToChangeFullPermission() + ) + ) + raise LibraryError() + + # replace all of the the current permissions + pcs_settings.set_permissions(new_permission_list) + + if not env.has_corosync_conf: + update_pcs_settings_locally(pcs_settings, env.report_processor) + if env.report_processor.has_errors: + raise LibraryError() + return + + # the node is in cluster, sync the updated config to cluster nodes + corosync_conf = env.get_corosync_conf() + local_cluster_name = corosync_conf.get_cluster_name() + local_corosync_nodes, _ = get_existing_nodes_names(corosync_conf) + request_targets = env.get_node_target_factory().get_target_list( + local_corosync_nodes + ) + node_communicator = env.get_node_communicator_no_privilege_transition() + + sync_pcs_settings_in_cluster( + pcs_settings, + local_cluster_name, + request_targets, + node_communicator, + env.report_processor, + ) + if env.report_processor.has_errors: + raise LibraryError() + + +def get_permissions(env: LibraryEnvironment) -> list[PermissionEntryDto]: + """ + Return local cluster permissions + """ + ensure_live_env(env) + + pcs_settings, report_list = read_pcs_settings_conf() + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + return [ + entry.to_dto() + for entry in sorted( + pcs_settings.config.permissions.local_cluster, + key=lambda p: (p.type, p.name), + ) + ] + + +def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: + """ + Return metadata about cluster permissions + """ + del env + + return PermissionMetadataDto( + user_types=[ + PermissionMetadataTargetTypeDto( + user_type, user_type.label, user_type.description + ) + for user_type in PermissionTargetType + ], + permission_types=[ + PermissionMetadataPermissionTypeDto( + permission_type, + permission_type.label, + permission_type.description, + ) + for permission_type in PermissionGrantedType + ], + permissions_dependencies=PermissionMetadataDependenciesDto( + { + permission: permission.dependencies + for permission in PermissionGrantedType + if permission.dependencies + } + ), + ) From 23cf081a6c83a67258e9139f5b4e32e65662f9bd Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Mon, 25 May 2026 11:24:55 +0200 Subject: [PATCH 6/8] use constants for permission metadata --- pcs/common/permissions/types.py | 45 ------------------- pcs/lib/commands/cluster/permissions.py | 22 ++++------ pcs/lib/permissions/const.py | 46 ++++++++++++++++++++ pcs/lib/permissions/tools.py | 4 +- pcs_test/Makefile.am | 1 + pcs_test/tier0/lib/permissions/test_const.py | 37 ++++++++++++++++ 6 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 pcs_test/tier0/lib/permissions/test_const.py diff --git a/pcs/common/permissions/types.py b/pcs/common/permissions/types.py index 32d05f1de..8cc4f8b7c 100644 --- a/pcs/common/permissions/types.py +++ b/pcs/common/permissions/types.py @@ -5,54 +5,9 @@ class PermissionTargetType(StrEnum): USER = "user" GROUP = "group" - @property - def label(self) -> str: - return self.value.capitalize() - - @property - def description(self) -> str: - return "" - class PermissionGrantedType(StrEnum): READ = "read" WRITE = "write" GRANT = "grant" FULL = "full" - - @property - def label(self) -> str: - return self.value.capitalize() - - @property - def description(self) -> str: - return _PERMISSION_TYPE_DESCRIPTIONS[self] - - @property - def dependencies(self) -> list["PermissionGrantedType"]: - return _PERMISSION_DEPENDENCIES[self] - - -_PERMISSION_TYPE_DESCRIPTIONS = { - PermissionGrantedType.READ: "Allows to view cluster settings", - PermissionGrantedType.WRITE: ( - "Allows to modify cluster settings except permissions and ACLs" - ), - PermissionGrantedType.GRANT: ( - "Allows to modify cluster permissions and ACLs" - ), - PermissionGrantedType.FULL: ( - "Allows unrestricted access to a cluster except for adding nodes" - ), -} - -_PERMISSION_DEPENDENCIES = { - PermissionGrantedType.READ: [], - PermissionGrantedType.WRITE: [PermissionGrantedType.READ], - PermissionGrantedType.GRANT: [], - PermissionGrantedType.FULL: [ - PermissionGrantedType.READ, - PermissionGrantedType.WRITE, - PermissionGrantedType.GRANT, - ], -} diff --git a/pcs/lib/commands/cluster/permissions.py b/pcs/lib/commands/cluster/permissions.py index 9b8e5cf5d..8cd94b3f8 100644 --- a/pcs/lib/commands/cluster/permissions.py +++ b/pcs/lib/commands/cluster/permissions.py @@ -8,10 +8,7 @@ PermissionMetadataPermissionTypeDto, PermissionMetadataTargetTypeDto, ) -from pcs.common.permissions.types import ( - PermissionGrantedType, - PermissionTargetType, -) +from pcs.common.permissions.types import PermissionGrantedType from pcs.lib.auth.types import AuthUser from pcs.lib.commands.cluster.utils import ensure_live_env from pcs.lib.env import LibraryEnvironment @@ -21,6 +18,7 @@ sync_pcs_settings_in_cluster, update_pcs_settings_locally, ) +from pcs.lib.permissions import const from pcs.lib.permissions.checker import PermissionsChecker from pcs.lib.permissions.config.types import PermissionEntry from pcs.lib.permissions.tools import ( @@ -151,23 +149,21 @@ def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: return PermissionMetadataDto( user_types=[ PermissionMetadataTargetTypeDto( - user_type, user_type.label, user_type.description + target_type, metadata.label, metadata.description ) - for user_type in PermissionTargetType + for target_type, metadata in const.PERMISSION_TARGET_TYPE_METADATA.items() ], permission_types=[ PermissionMetadataPermissionTypeDto( - permission_type, - permission_type.label, - permission_type.description, + permission_type, metadata.label, metadata.description ) - for permission_type in PermissionGrantedType + for permission_type, metadata in const.PERMISSION_GRANTED_TYPE_METADATA.items() ], permissions_dependencies=PermissionMetadataDependenciesDto( { - permission: permission.dependencies - for permission in PermissionGrantedType - if permission.dependencies + permission_type: dependencies + for permission_type, dependencies in const.PERMISSION_DEPENDENCIES.items() + if dependencies } ), ) diff --git a/pcs/lib/permissions/const.py b/pcs/lib/permissions/const.py index dc1fc2291..1a00e5ec1 100644 --- a/pcs/lib/permissions/const.py +++ b/pcs/lib/permissions/const.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from pcs.common.permissions.types import ( PermissionGrantedType, PermissionTargetType, @@ -18,3 +20,47 @@ ), ) ] + + +@dataclass(frozen=True) +class _Metadata: + label: str + description: str + + +PERMISSION_TARGET_TYPE_METADATA = { + target_type: _Metadata(target_type.value.capitalize(), "") + for target_type in PermissionTargetType +} + + +PERMISSION_GRANTED_TYPE_METADATA = { + PermissionGrantedType.READ: _Metadata( + "Read", + "Allows to view cluster settings", + ), + PermissionGrantedType.WRITE: _Metadata( + "Write", + "Allows to modify cluster settings except permissions and ACLs", + ), + PermissionGrantedType.GRANT: _Metadata( + "Grant", + "Allows to modify cluster permissions and ACLs", + ), + PermissionGrantedType.FULL: _Metadata( + "Full", + "Allows unrestricted access to a cluster except for adding nodes", + ), +} + + +PERMISSION_DEPENDENCIES = { + PermissionGrantedType.READ: [], + PermissionGrantedType.WRITE: [PermissionGrantedType.READ], + PermissionGrantedType.GRANT: [], + PermissionGrantedType.FULL: [ + PermissionGrantedType.READ, + PermissionGrantedType.WRITE, + PermissionGrantedType.GRANT, + ], +} diff --git a/pcs/lib/permissions/tools.py b/pcs/lib/permissions/tools.py index 600b3ae61..d2153aff7 100644 --- a/pcs/lib/permissions/tools.py +++ b/pcs/lib/permissions/tools.py @@ -7,7 +7,7 @@ from pcs.lib.interface.config import ParserErrorException from pcs.lib.permissions.config.facade import FacadeV2 as PcsSettingsFacade -from .const import DEFAULT_PERMISSIONS +from .const import DEFAULT_PERMISSIONS, PERMISSION_DEPENDENCIES def complete_access_list( @@ -15,7 +15,7 @@ def complete_access_list( ) -> set[PermissionGrantedType]: final_permission_set = set(access_list) for permission in set(access_list): - final_permission_set.update(permission.dependencies) + final_permission_set.update(PERMISSION_DEPENDENCIES[permission]) return final_permission_set diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am index bf627bd8a..cb0776d08 100644 --- a/pcs_test/Makefile.am +++ b/pcs_test/Makefile.am @@ -436,6 +436,7 @@ EXTRA_DIST = \ tier0/lib/permissions/config/test_parser.py \ tier0/lib/permissions/__init__.py \ tier0/lib/permissions/test_checker.py \ + tier0/lib/permissions/test_const.py \ tier0/lib/permissions/test_tools.py \ tier0/lib/permissions/test_validations.py \ tier0/lib/resource_agent/__init__.py \ diff --git a/pcs_test/tier0/lib/permissions/test_const.py b/pcs_test/tier0/lib/permissions/test_const.py new file mode 100644 index 000000000..192afc768 --- /dev/null +++ b/pcs_test/tier0/lib/permissions/test_const.py @@ -0,0 +1,37 @@ +from enum import EnumType +from typing import Any +from unittest import TestCase + +from pcs.common.permissions.types import ( + PermissionGrantedType, + PermissionTargetType, +) +from pcs.lib.permissions import const + + +class ConstMetadataForAllEnumMembers(TestCase): + def assert_all_members_in_const( + self, metadata_const: dict[EnumType, Any], enum_type: EnumType + ) -> None: + enum_members = set(enum_type) + metadata_keys = set(metadata_const.keys()) + self.assertEqual( + enum_members, + metadata_keys, + msg="Make sure constant defines metadata for all enum members", + ) + + def test_target_type_metadata(self): + self.assert_all_members_in_const( + const.PERMISSION_TARGET_TYPE_METADATA, PermissionTargetType + ) + + def test_permission_type_metadata(self): + self.assert_all_members_in_const( + const.PERMISSION_GRANTED_TYPE_METADATA, PermissionGrantedType + ) + + def test_permission_dependencies(self): + self.assert_all_members_in_const( + const.PERMISSION_DEPENDENCIES, PermissionGrantedType + ) From 967ab46a8e6add7d66474904f858509b8931c0dc Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Mon, 25 May 2026 11:52:35 +0200 Subject: [PATCH 7/8] do not sort permissions in lib command --- pcs/daemon/app/api_v0.py | 5 ++ pcs/lib/commands/cluster/permissions.py | 5 +- pcs_test/tier0/daemon/app/test_api_v0.py | 47 +++++++++++++++++++ .../commands/cluster/test_get_permissions.py | 23 +++------ 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/pcs/daemon/app/api_v0.py b/pcs/daemon/app/api_v0.py index f4842876c..2ce05951f 100644 --- a/pcs/daemon/app/api_v0.py +++ b/pcs/daemon/app/api_v0.py @@ -623,6 +623,11 @@ async def _handle_request(self) -> None: ) ) + # Ruby compatibility: sort the permissions + # sorted by (type, name): GROUP < USER alphabetically, then by name + # + # WebUI displays the permissions in the order they were sent from pcsd + user_permissions.sort(key=lambda p: (p.type, p.name)) self.write( { **to_dict(permission_metadata), diff --git a/pcs/lib/commands/cluster/permissions.py b/pcs/lib/commands/cluster/permissions.py index 8cd94b3f8..fed0e33f3 100644 --- a/pcs/lib/commands/cluster/permissions.py +++ b/pcs/lib/commands/cluster/permissions.py @@ -133,10 +133,7 @@ def get_permissions(env: LibraryEnvironment) -> list[PermissionEntryDto]: return [ entry.to_dto() - for entry in sorted( - pcs_settings.config.permissions.local_cluster, - key=lambda p: (p.type, p.name), - ) + for entry in pcs_settings.config.permissions.local_cluster ] diff --git a/pcs_test/tier0/daemon/app/test_api_v0.py b/pcs_test/tier0/daemon/app/test_api_v0.py index 164201230..03b9b71f5 100644 --- a/pcs_test/tier0/daemon/app/test_api_v0.py +++ b/pcs_test/tier0/daemon/app/test_api_v0.py @@ -1750,6 +1750,53 @@ def test_both_commands_fail_returns_empty_response(self): ), ) + def test_success_permissions_sorted(self): + self.mock_run_library_command.side_effect = [ + self.result_success( + [ + PermissionEntryDto( + "bob", + PermissionTargetType.USER, + [PermissionGrantedType.READ], + ), + PermissionEntryDto( + "alice", + PermissionTargetType.USER, + [PermissionGrantedType.READ], + ), + PermissionEntryDto( + "wheel", + PermissionTargetType.GROUP, + [PermissionGrantedType.READ], + ), + ] + ), + self.result_success( + PermissionMetadataDto( + [], [], PermissionMetadataDependenciesDto({}) + ) + ), + ] + + response = self.fetch(self.url) + + self.assertEqual(response.code, 200) + self.assert_body( + response.body, + json.dumps( + { + "user_types": [], + "permission_types": [], + "permissions_dependencies": {"also_allows": {}}, + "users_permissions": [ + {"name": "wheel", "type": "group", "allow": ["read"]}, + {"name": "alice", "type": "user", "allow": ["read"]}, + {"name": "bob", "type": "user", "allow": ["read"]}, + ], + } + ), + ) + class KnownHostsChange(ApiV0HandlerTest): url = "/remote/known_hosts_change" diff --git a/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py index b09658383..dce7550fa 100644 --- a/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py +++ b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py @@ -87,7 +87,7 @@ def test_success_empty_permissions(self): self.assertEqual(result, []) - def test_success_permissions_sorted(self): + def test_success_multiple_permissions(self): self.config.raw_file.exists( file_type_codes.PCS_SETTINGS_CONF, settings.pcsd_settings_conf_location, @@ -102,11 +102,6 @@ def test_success_permissions_sorted(self): PermissionTargetType.USER, allow=[PermissionGrantedType.FULL], ), - PermissionEntry( - "alice", - PermissionTargetType.USER, - allow=[PermissionGrantedType.WRITE], - ), PermissionEntry( "wheel", PermissionTargetType.GROUP, @@ -118,25 +113,19 @@ def test_success_permissions_sorted(self): result = cluster.get_permissions(self.env_assist.get_env()) - # sorted by (type, name): GROUP < USER alphabetically, then by name self.assertEqual( result, [ - PermissionEntryDto( - "wheel", - PermissionTargetType.GROUP, - [PermissionGrantedType.READ], - ), - PermissionEntryDto( - "alice", - PermissionTargetType.USER, - [PermissionGrantedType.WRITE], - ), PermissionEntryDto( "bob", PermissionTargetType.USER, [PermissionGrantedType.FULL], ), + PermissionEntryDto( + "wheel", + PermissionTargetType.GROUP, + [PermissionGrantedType.READ], + ), ], ) From afa884cc225976e34bde95e7d21f79f8c31586c9 Mon Sep 17 00:00:00 2001 From: Peter Romancik Date: Wed, 27 May 2026 13:09:31 +0200 Subject: [PATCH 8/8] add comment about permission dependencies --- pcs/lib/permissions/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pcs/lib/permissions/const.py b/pcs/lib/permissions/const.py index 1a00e5ec1..389d0a275 100644 --- a/pcs/lib/permissions/const.py +++ b/pcs/lib/permissions/const.py @@ -54,6 +54,7 @@ class _Metadata: } +# Must list all transitive dependencies, because the evaluation is non-recursive PERMISSION_DEPENDENCIES = { PermissionGrantedType.READ: [], PermissionGrantedType.WRITE: [PermissionGrantedType.READ],