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/common/permissions/dto.py b/pcs/common/permissions/dto.py index 6fc4772cf..a181bbc93 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 PermissionMetadataTargetTypeDto(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[PermissionMetadataTargetTypeDto] + permission_types: list[PermissionMetadataPermissionTypeDto] + permissions_dependencies: PermissionMetadataDependenciesDto diff --git a/pcs/daemon/app/api_v0.py b/pcs/daemon/app/api_v0.py index b331d56f1..2ce05951f 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,42 @@ 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({}) + ) + ) + + # 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), + "users_permissions": [ + to_dict(cast(PermissionEntryDto, entry)) + for entry in user_permissions + ], + }, + ) + + class KnownHostsChangeHandler(_BaseApiV0Handler): """ Input format: @@ -757,6 +798,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/lib/commands/cluster/__init__.py b/pcs/lib/commands/cluster/__init__.py index 80eca876b..b4e906144 100644 --- a/pcs/lib/commands/cluster/__init__.py +++ b/pcs/lib/commands/cluster/__init__.py @@ -7,13 +7,7 @@ set_corosync_conf, ) from .link import add_link, remove_links, update_link -from .misc import ( - corosync_authkey_change, - 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, @@ -22,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 @@ -31,10 +30,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..c0b28e6b3 100644 --- a/pcs/lib/commands/cluster/misc.py +++ b/pcs/lib/commands/cluster/misc.py @@ -1,13 +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 -from pcs.common.permissions.types import PermissionGrantedType 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 @@ -42,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 @@ -289,95 +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() diff --git a/pcs/lib/commands/cluster/permissions.py b/pcs/lib/commands/cluster/permissions.py new file mode 100644 index 000000000..fed0e33f3 --- /dev/null +++ b/pcs/lib/commands/cluster/permissions.py @@ -0,0 +1,166 @@ +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 +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 import const +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 pcs_settings.config.permissions.local_cluster + ] + + +def get_permissions_metadata(env: LibraryEnvironment) -> PermissionMetadataDto: + """ + Return metadata about cluster permissions + """ + del env + + return PermissionMetadataDto( + user_types=[ + PermissionMetadataTargetTypeDto( + target_type, metadata.label, metadata.description + ) + for target_type, metadata in const.PERMISSION_TARGET_TYPE_METADATA.items() + ], + permission_types=[ + PermissionMetadataPermissionTypeDto( + permission_type, metadata.label, metadata.description + ) + for permission_type, metadata in const.PERMISSION_GRANTED_TYPE_METADATA.items() + ], + permissions_dependencies=PermissionMetadataDependenciesDto( + { + permission_type: dependencies + for permission_type, dependencies in const.PERMISSION_DEPENDENCIES.items() + if 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/const.py b/pcs/lib/permissions/const.py index dc1fc2291..389d0a275 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,48 @@ ), ) ] + + +@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", + ), +} + + +# Must list all transitive dependencies, because the evaluation is non-recursive +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 89a67bb5c..d2153aff7 100644 --- a/pcs/lib/permissions/tools.py +++ b/pcs/lib/permissions/tools.py @@ -7,20 +7,16 @@ 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( 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[permission]) + return final_permission_set def read_pcs_settings_conf() -> tuple[ diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am index b7873e89e..cb0776d08 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 \ @@ -435,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/daemon/app/test_api_v0.py b/pcs_test/tier0/daemon/app/test_api_v0.py index 9a8d0c130..03b9b71f5 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, + PermissionMetadataTargetTypeDto, +) +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,185 @@ def test_failure(self): ) +class GetPermissionsHandler(ApiV0HandlerTest): + url = "/remote/get_permissions" + + _PERMISSION_ENTRY = PermissionEntryDto( + "alice", PermissionTargetType.USER, [PermissionGrantedType.READ] + ) + _PERMISSION_METADATA = PermissionMetadataDto( + user_types=[ + PermissionMetadataTargetTypeDto( + 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": [], + } + ), + ) + + 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" command = "auth.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 new file mode 100644 index 000000000..dce7550fa --- /dev/null +++ b/pcs_test/tier0/lib/commands/cluster/test_get_permissions.py @@ -0,0 +1,212 @@ +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, + PermissionMetadataTargetTypeDto, +) +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_multiple_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=[ + PermissionEntry( + "bob", + PermissionTargetType.USER, + allow=[PermissionGrantedType.FULL], + ), + PermissionEntry( + "wheel", + PermissionTargetType.GROUP, + allow=[PermissionGrantedType.READ], + ), + ] + ), + ) + + result = cluster.get_permissions(self.env_assist.get_env()) + + self.assertEqual( + result, + [ + PermissionEntryDto( + "bob", + PermissionTargetType.USER, + [PermissionGrantedType.FULL], + ), + PermissionEntryDto( + "wheel", + PermissionTargetType.GROUP, + [PermissionGrantedType.READ], + ), + ], + ) + + 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=[ + PermissionMetadataTargetTypeDto( + PermissionTargetType.USER, "User", "" + ), + PermissionMetadataTargetTypeDto( + 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, + ], + } + ), + ), + ) 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 + ) 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'