From f65b00ea8a99dd9a9119b8fb82c9683d37be2ffc Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Mon, 3 Mar 2025 13:43:11 +0530 Subject: [PATCH] mgr/dashboard: Improve sso role mapping Fixes https://tracker.ceph.com/issues/70366 - adds support for ISV - using jmespath expression for fetching roles from payload - added roles_path parameter in sso enable command as optional argument - modified roles mapper for sso login Signed-off-by: Afreen Misbah --- ceph.spec.in | 1 + debian/control | 1 + src/pybind/mgr/dashboard/requirements.txt | 1 + .../mgr/dashboard/services/access_control.py | 19 ++++-- .../mgr/dashboard/services/auth/oauth2.py | 64 +++++++++++++------ src/pybind/mgr/dashboard/services/sso.py | 17 ++++- src/pybind/mgr/tox.ini | 1 + 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index 6276dd0878a..d72b5abcc62 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -694,6 +694,7 @@ Requires: ceph-prometheus-alerts = %{_epoch_prefix}%{version}-%{release} %if 0%{?fedora} || 0%{?rhel} >= 9 Requires: python%{python3_pkgversion}-grpcio Requires: python%{python3_pkgversion}-grpcio-tools +Requires: python%{python3_pkgversion}-jmespath %endif %if 0%{?fedora} || 0%{?rhel} || 0%{?openEuler} Requires: python%{python3_pkgversion}-cherrypy diff --git a/debian/control b/debian/control index a8c79f7a731..88c4c92238f 100644 --- a/debian/control +++ b/debian/control @@ -99,6 +99,7 @@ Build-Depends: automake, python3-coverage , python3-dateutil , python3-grpcio , + python3-jmespath (>=0.10) , python3-openssl , python3-prettytable , python3-requests , diff --git a/src/pybind/mgr/dashboard/requirements.txt b/src/pybind/mgr/dashboard/requirements.txt index b5c78ac8bec..8cf24eb6ae9 100644 --- a/src/pybind/mgr/dashboard/requirements.txt +++ b/src/pybind/mgr/dashboard/requirements.txt @@ -13,3 +13,4 @@ setuptools jsonpatch grpcio==1.46.5 grpcio-tools==1.46.5 +jmespath diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py index 6319802b6cc..e6b714af9e7 100644 --- a/src/pybind/mgr/dashboard/services/access_control.py +++ b/src/pybind/mgr/dashboard/services/access_control.py @@ -194,12 +194,12 @@ class Role(object): r_dict['scopes_permissions']) @classmethod - def map_to_system_roles(cls, roles) -> List['Role']: + def map_to_system_roles(cls, roles: List[str]) -> List['Role']: matches = [] - for rn in SYSTEM_ROLES_NAMES: + for sys_role in ROLE_MAPPER: for role in roles: - if role in SYSTEM_ROLES_NAMES[rn]: - matches.append(rn) + if role in ROLE_MAPPER[sys_role]: + matches.append(sys_role) return matches @@ -304,9 +304,16 @@ SYSTEM_ROLES = { } # static name-like roles list for role mapping -SYSTEM_ROLES_NAMES = { +ROLE_MAPPER = { ADMIN_ROLE: [ADMIN_ROLE.name, 'admin'], - READ_ONLY_ROLE: [READ_ONLY_ROLE.name, 'read', 'guest', 'monitor'] + READ_ONLY_ROLE: [READ_ONLY_ROLE.name, 'read', 'guest', 'monitor'], + BLOCK_MGR_ROLE: [BLOCK_MGR_ROLE.name, 'block', 'rbd'], + RGW_MGR_ROLE: [RGW_MGR_ROLE.name, 'object', 'rgw'], + CLUSTER_MGR_ROLE: [CLUSTER_MGR_ROLE.name, 'cluster'], + POOL_MGR_ROLE: [POOL_MGR_ROLE.name, 'pool'], + CEPHFS_MGR_ROLE: [CEPHFS_MGR_ROLE.name, 'cephfs'], + GANESHA_MGR_ROLE: [GANESHA_MGR_ROLE.name, 'ganesha', 'nfs'], + SMB_MGR_ROLE: [SMB_MGR_ROLE.name, 'smb'] } diff --git a/src/pybind/mgr/dashboard/services/auth/oauth2.py b/src/pybind/mgr/dashboard/services/auth/oauth2.py index 5376107667e..26027adf603 100644 --- a/src/pybind/mgr/dashboard/services/auth/oauth2.py +++ b/src/pybind/mgr/dashboard/services/auth/oauth2.py @@ -1,4 +1,7 @@ + +import importlib import json +import logging from typing import Dict, List from urllib.parse import quote @@ -10,6 +13,13 @@ from ...services.auth import BaseAuth, SSOAuth, decode_jwt_segment from ...tools import prepare_url_prefix from ..access_control import Role, User, UserAlreadyExists +try: + jmespath = importlib.import_module("jmespath") +except ModuleNotFoundError: + logging.error("Module 'jmespath' is not installed.") + +logger = logging.getLogger('services.oauth2') + class OAuth2(SSOAuth): LOGIN_URL = 'auth/oauth2/login' @@ -17,19 +27,29 @@ class OAuth2(SSOAuth): sso = True class OAuth2Config(BaseAuth.Config): - pass + roles_path: str + + def __init__(self, roles_path=None): + self.roles_path = roles_path + + def get_roles_path(self): + return self.roles_path @staticmethod def enabled(): return mgr.get_module_option('sso_oauth2') - def to_dict(self) -> 'BaseAuth.Config': - return self.OAuth2Config() + def to_dict(self) -> 'OAuth2Config': + return { + 'roles_path': self.roles_path + } @classmethod def from_dict(cls, s_dict: OAuth2Config) -> 'OAuth2': - # pylint: disable=unused-argument - return OAuth2() + try: + return OAuth2(s_dict['roles_path']) + except KeyError: + return OAuth2({}) @classmethod def get_auth_name(cls): @@ -66,25 +86,30 @@ class OAuth2(SSOAuth): @classmethod def get_user_roles(cls): - roles: List[Role] = [] + roles: List[str] = [] user_roles: List[Role] = [] try: jwt_payload = cherrypy.request.jwt_payload except AttributeError: - raise cherrypy.HTTPError() - - # check for client roes - if 'resource_access' in jwt_payload: - # Find the first value where the key is not 'account' - roles = next((value['roles'] for key, value in jwt_payload['resource_access'].items() - if key != "account"), user_roles) - # check for global roles - elif 'realm_access' in jwt_payload: - roles = next((value['roles'] for _, value in jwt_payload['realm_access'].items()), - user_roles) + raise cherrypy.HTTPError(401) + + if jmespath and hasattr(mgr.SSO_DB.config, 'roles_path'): + logger.debug("Using 'roles_path' to fetch roles") + roles = jmespath.search(mgr.SSO_DB.config.roles_path, jwt_payload) + # e.g Keycloak + elif 'resource_access' in jwt_payload or 'realm_access' in jwt_payload: + logger.debug("Using 'resource_access' or 'realm_access' to fetch roles") + roles = jmespath.search( + "resource_access.*[?@!='account'].roles[] || realm_access.roles[]", + jwt_payload) + elif 'roles' in jwt_payload: + logger.debug("Using 'roles' to fetch roles") + roles = jwt_payload['roles'] + if isinstance(roles, str): + roles = [roles] else: - raise cherrypy.HTTPError() - user_roles = Role.map_to_system_roles(roles) + raise cherrypy.HTTPError(403) + user_roles = Role.map_to_system_roles(roles or []) return user_roles @classmethod @@ -106,6 +131,7 @@ class OAuth2(SSOAuth): user = mgr.ACCESS_CTRL_DB.create_user( jwt_payload['sub'], None, jwt_payload['name'], jwt_payload['email']) except UserAlreadyExists: + logger.debug("User already exists") user = mgr.ACCESS_CTRL_DB.get_user(jwt_payload['sub']) user.set_roles(cls.get_user_roles()) # set user last update to token time issued diff --git a/src/pybind/mgr/dashboard/services/sso.py b/src/pybind/mgr/dashboard/services/sso.py index 0b607e217df..29ec62d1699 100644 --- a/src/pybind/mgr/dashboard/services/sso.py +++ b/src/pybind/mgr/dashboard/services/sso.py @@ -2,12 +2,13 @@ # pylint: disable=too-many-return-statements,too-many-branches import errno +import importlib import json import logging import os import threading import warnings -from typing import Dict +from typing import Dict, Optional from urllib import parse from mgr_module import CLIWriteCommand, HandleCommandResult @@ -20,6 +21,12 @@ from ..tools import prepare_url_prefix logger = logging.getLogger('sso') +try: + jmespath = importlib.import_module('jmespath') + JMESPathError = getattr(jmespath.exceptions, "JMESPathError") +except ModuleNotFoundError: + logger.error("Module 'jmespath' is not installed.") + try: from onelogin.saml2.errors import OneLogin_Saml2_Error as Saml2Error from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser as Saml2Parser @@ -88,8 +95,14 @@ def load_sso_db(): @CLIWriteCommand("dashboard sso enable oauth2") -def enable_sso(_): +def enable_sso(_, roles_path: Optional[str] = None): mgr.SSO_DB.protocol = AuthType.OAUTH2 + if jmespath and roles_path: + try: + jmespath.compile(roles_path) + mgr.SSO_DB.config.roles_path = roles_path + except (JMESPathError, SyntaxError): + return HandleCommandResult(stdout='Syntax invalid for "roles_path"') mgr.SSO_DB.save() mgr.set_module_option('sso_oauth2', True) return HandleCommandResult(stdout='SSO is "enabled" with "OAuth2" protocol.') diff --git a/src/pybind/mgr/tox.ini b/src/pybind/mgr/tox.ini index 5afbe93ace0..366dc1ff871 100644 --- a/src/pybind/mgr/tox.ini +++ b/src/pybind/mgr/tox.ini @@ -81,6 +81,7 @@ deps = types-requests types-PyYAML types-jwt + types-jmespath commands = mypy --config-file=../../mypy.ini \ -m alerts \ -- 2.39.5