From 1e91d404745d5bb1c1e32a629613f90493cacb9f Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Fri, 27 Apr 2018 15:05:21 +0100 Subject: [PATCH] mgr/dashboard: configured security info for each controller Signed-off-by: Ricardo Dias --- .../mgr/dashboard/controllers/__init__.py | 89 ++++++++++++++++++- .../mgr/dashboard/controllers/cephfs.py | 3 +- .../mgr/dashboard/controllers/dashboard.py | 50 ++++++----- .../controllers/erasure_code_profile.py | 3 +- .../mgr/dashboard/controllers/grafana.py | 9 +- src/pybind/mgr/dashboard/controllers/host.py | 3 +- .../mgr/dashboard/controllers/monitor.py | 6 +- src/pybind/mgr/dashboard/controllers/osd.py | 8 +- .../dashboard/controllers/perf_counters.py | 13 +-- src/pybind/mgr/dashboard/controllers/pool.py | 6 +- src/pybind/mgr/dashboard/controllers/rbd.py | 9 +- .../dashboard/controllers/rbd_mirroring.py | 6 +- src/pybind/mgr/dashboard/controllers/rgw.py | 13 +-- .../mgr/dashboard/controllers/summary.py | 9 +- .../mgr/dashboard/controllers/tcmu_iscsi.py | 3 +- .../mgr/dashboard/tests/test_rbd_mirroring.py | 4 +- 16 files changed, 176 insertions(+), 58 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index 328eb271d892c..23eb6d81eb423 100644 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -14,16 +14,24 @@ import cherrypy from six import add_metaclass from .. import logger +from ..security import Scope, Permission from ..settings import Settings from ..tools import Session, wraps, getargspec, TaskManager -from ..exceptions import ViewCacheNoDataException, DashboardException +from ..exceptions import ViewCacheNoDataException, DashboardException, \ + ScopeNotValid, PermissionNotValid from ..services.exception import serialize_dashboard_exception +from ..services.auth import AuthManager class Controller(object): - def __init__(self, path, base_url=None, secure=True): + def __init__(self, path, base_url=None, security_scope=None, secure=True): + if security_scope and not Scope.valid_scope(security_scope): + logger.debug("Invalid security scope name: %s\n Possible values: " + "%s", security_scope, Scope.all_scopes()) + raise ScopeNotValid(security_scope) self.path = path self.base_url = base_url + self.security_scope = security_scope self.secure = secure if self.path and self.path[0] != "/": @@ -40,6 +48,7 @@ class Controller(object): def __call__(self, cls): cls._cp_controller_ = True cls._cp_path_ = "{}{}".format(self.base_url, self.path) + cls._security_scope = self.security_scope config = { 'tools.sessions.on': True, @@ -55,8 +64,9 @@ class Controller(object): class ApiController(Controller): - def __init__(self, path, secure=True): + def __init__(self, path, security_scope=None, secure=True): super(ApiController, self).__init__(path, base_url="/api", + security_scope=security_scope, secure=secure) def __call__(self, cls): @@ -444,6 +454,22 @@ class BaseController(object): logger.info('Initializing controller: %s -> %s', self.__class__.__name__, self._cp_path_) + def _has_permissions(self, permissions, scope=None): + if not self._cp_config['tools.authenticate.on']: + raise Exception("Cannot verify permission in non secured " + "controllers") + + if not isinstance(permissions, list): + permissions = [permissions] + + if scope is None: + scope = getattr(self, '_security_scope', None) + if scope is None: + raise Exception("Cannot verify permissions without scope security" + " defined") + username = cherrypy.session.get(Session.USERNAME) + return AuthManager.authorize(username, scope, permissions) + @classmethod def get_path_param_names(cls, path_extension=None): if path_extension is None: @@ -544,6 +570,13 @@ class RESTController(BaseController): # of the resourse ID. RESOURCE_ID = None + _permission_map = { + 'GET': Permission.READ, + 'POST': Permission.CREATE, + 'PUT': Permission.UPDATE, + 'DELETE': Permission.DELETE + } + _method_mapping = collections.OrderedDict([ ('list', {'method': 'GET', 'resource': False, 'status': 200}), ('create', {'method': 'POST', 'resource': False, 'status': 201}), @@ -580,6 +613,8 @@ class RESTController(BaseController): method = None query_params = None path = "" + sec_permissions = hasattr(func, '_security_permissions') + permission = None if func.__name__ in cls._method_mapping: meth = cls._method_mapping[func.__name__] @@ -593,6 +628,8 @@ class RESTController(BaseController): status = meth['status'] method = meth['method'] + if not sec_permissions: + permission = cls._permission_map[method] elif hasattr(func, "_collection_method_"): if func._collection_method_['path']: @@ -602,6 +639,8 @@ class RESTController(BaseController): status = func._collection_method_['status'] method = func._collection_method_['method'] query_params = func._collection_method_['query_params'] + if not sec_permissions: + permission = cls._permission_map[method] elif hasattr(func, "_resource_method_"): if not res_id_params: @@ -616,6 +655,8 @@ class RESTController(BaseController): status = func._resource_method_['status'] method = func._resource_method_['method'] query_params = func._resource_method_['query_params'] + if not sec_permissions: + permission = cls._permission_map[method] else: continue @@ -638,6 +679,8 @@ class RESTController(BaseController): func = cls._status_code_wrapper(func, status) endp_func = Endpoint(method, path=path, query_params=query_params)(func) + if permission: + _set_func_permissions(endp_func, [permission]) result.append(cls.Endpoint(cls, endp_func)) return result @@ -686,3 +729,43 @@ class RESTController(BaseController): } return func return _wrapper + + +# Role-based access permissions decorators + +def _set_func_permissions(func, permissions): + if not isinstance(permissions, list): + permissions = [permissions] + + for perm in permissions: + if not Permission.valid_permission(perm): + logger.debug("Invalid security permission: %s\n " + "Possible values: %s", perm, + Permission.all_permissions()) + raise PermissionNotValid(perm) + + if not hasattr(func, '_security_permissions'): + func._security_permissions = permissions + else: + permissions.extend(func._security_permissions) + func._security_permissions = list(set(permissions)) + + +def ReadPermission(func): + _set_func_permissions(func, Permission.READ) + return func + + +def CreatePermission(func): + _set_func_permissions(func, Permission.CREATE) + return func + + +def DeletePermission(func): + _set_func_permissions(func, Permission.DELETE) + return func + + +def UpdatePermission(func): + _set_func_permissions(func, Permission.UPDATE) + return func diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 90fa5c9c0b8c0..df31d4a4f36eb 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -8,11 +8,12 @@ import cherrypy from . import ApiController, RESTController from .. import mgr from ..exceptions import DashboardException +from ..security import Scope from ..services.ceph_service import CephService from ..tools import ViewCache -@ApiController('/cephfs') +@ApiController('/cephfs', Scope.CEPHFS) class CephFS(RESTController): def __init__(self): super(CephFS, self).__init__() diff --git a/src/pybind/mgr/dashboard/controllers/dashboard.py b/src/pybind/mgr/dashboard/controllers/dashboard.py index 87bf62089bdb6..d859c85567d9a 100644 --- a/src/pybind/mgr/dashboard/controllers/dashboard.py +++ b/src/pybind/mgr/dashboard/controllers/dashboard.py @@ -6,6 +6,7 @@ import json from . import ApiController, Endpoint, BaseController from .. import mgr +from ..security import Permission, Scope from ..services.ceph_service import CephService from ..tools import NotificationQueue @@ -45,32 +46,39 @@ class Dashboard(BaseController): NotificationQueue.register(self.append_log, 'clog') - # Fuse osdmap with pg_summary to get description of pools - # including their PG states + result = { + "health": self.health_data(), + } - osd_map = self.osd_map() + if self._has_permissions(Permission.READ, Scope.LOG): + result['clog'] = list(self.log_buffer) + result['audit_log'] = list(self.audit_buffer) - pools = CephService.get_pool_list_with_stats() + if self._has_permissions(Permission.READ, Scope.MONITOR): + result['mon_status'] = self.mon_status() - # Not needed, skip the effort of transmitting this - # to UI - del osd_map['pg_temp'] + if self._has_permissions(Permission.READ, Scope.CEPHFS): + result['fs_map'] = mgr.get('fs_map') - df = mgr.get("df") - df['stats']['total_objects'] = sum( - [p['stats']['objects'] for p in df['pools']]) + if self._has_permissions(Permission.READ, Scope.OSD): + osd_map = self.osd_map() + # Not needed, skip the effort of transmitting this to UI + del osd_map['pg_temp'] + result['osd_map'] = osd_map - return { - "health": self.health_data(), - "mon_status": self.mon_status(), - "fs_map": mgr.get('fs_map'), - "osd_map": osd_map, - "clog": list(self.log_buffer), - "audit_log": list(self.audit_buffer), - "pools": pools, - "mgr_map": mgr.get("mgr_map"), - "df": df - } + if self._has_permissions(Permission.READ, Scope.MANAGER): + result['mgr_map'] = mgr.get("mgr_map") + + if self._has_permissions(Permission.READ, Scope.POOL): + pools = CephService.get_pool_list_with_stats() + result['pools'] = pools + + df = mgr.get("df") + df['stats']['total_objects'] = sum( + [p['stats']['objects'] for p in df['pools']]) + result['df'] = df + + return result def mon_status(self): mon_status_data = mgr.get("mon_status") diff --git a/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py index 9c9f0d3f591f2..688fbf783547f 100644 --- a/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py +++ b/src/pybind/mgr/dashboard/controllers/erasure_code_profile.py @@ -4,6 +4,7 @@ from __future__ import absolute_import from cherrypy import NotFound from . import ApiController, RESTController +from ..security import Scope from ..services.ceph_service import CephService from .. import mgr @@ -15,7 +16,7 @@ def _serialize_ecp(name, ecp): return ecp -@ApiController('/erasure_code_profile') +@ApiController('/erasure_code_profile', Scope.POOL) class ErasureCodeProfile(RESTController): """ create() supports additional key-value arguments that are passed to the diff --git a/src/pybind/mgr/dashboard/controllers/grafana.py b/src/pybind/mgr/dashboard/controllers/grafana.py index e7ae55f3a02d4..03fdfbed4d36f 100644 --- a/src/pybind/mgr/dashboard/controllers/grafana.py +++ b/src/pybind/mgr/dashboard/controllers/grafana.py @@ -5,8 +5,9 @@ import cherrypy import requests from six.moves.urllib.parse import urlparse # pylint: disable=import-error -from . import ApiController, BaseController, Proxy, Endpoint +from . import ApiController, BaseController, Proxy, Endpoint, ReadPermission from .. import logger +from ..security import Scope from ..settings import Settings @@ -101,10 +102,11 @@ class GrafanaRestClient(object): return True, '' -@ApiController('/grafana') +@ApiController('/grafana', Scope.GRAFANA) class Grafana(BaseController): @Endpoint() + @ReadPermission def status(self): grafana = GrafanaRestClient.instance() available, msg = grafana.is_service_online() @@ -115,9 +117,10 @@ class Grafana(BaseController): return response -@ApiController('/grafana/proxy') +@ApiController('/grafana/proxy', Scope.GRAFANA) class GrafanaProxy(BaseController): @Proxy() + @ReadPermission def __call__(self, path, **params): grafana = GrafanaRestClient.instance() method = cherrypy.request.method diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index 76c2afea3b010..e8518a14c9218 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -3,9 +3,10 @@ from __future__ import absolute_import from . import ApiController, RESTController from .. import mgr +from ..security import Scope -@ApiController('/host') +@ApiController('/host', Scope.HOSTS) class Host(RESTController): def list(self): return mgr.list_servers() diff --git a/src/pybind/mgr/dashboard/controllers/monitor.py b/src/pybind/mgr/dashboard/controllers/monitor.py index be3a85fe24b95..d4512fcfe357f 100644 --- a/src/pybind/mgr/dashboard/controllers/monitor.py +++ b/src/pybind/mgr/dashboard/controllers/monitor.py @@ -3,13 +3,15 @@ from __future__ import absolute_import import json -from . import ApiController, Endpoint, BaseController +from . import ApiController, Endpoint, BaseController, ReadPermission from .. import mgr +from ..security import Scope -@ApiController('/monitor') +@ApiController('/monitor', Scope.MONITOR) class Monitor(BaseController): @Endpoint() + @ReadPermission def __call__(self): in_quorum, out_quorum = [], [] diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py index 0b462ef9c4008..43cd6c8ffba39 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from . import ApiController, RESTController +from . import ApiController, RESTController, UpdatePermission from .. import mgr, logger +from ..security import Scope from ..services.ceph_service import CephService from ..services.exception import handle_send_command_error from ..tools import str_to_bool -@ApiController('/osd') +@ApiController('/osd', Scope.OSD) class Osd(RESTController): def list(self): osds = self.get_osd_map() @@ -58,12 +59,13 @@ class Osd(RESTController): } @RESTController.Resource('POST', query_params=['deep']) + @UpdatePermission def scrub(self, svc_id, deep=False): api_scrub = "osd deep-scrub" if str_to_bool(deep) else "osd scrub" CephService.send_command("mon", api_scrub, who=svc_id) -@ApiController('/osd/flags') +@ApiController('/osd/flags', Scope.OSD) class OsdFlagsController(RESTController): @staticmethod def _osd_flags(): diff --git a/src/pybind/mgr/dashboard/controllers/perf_counters.py b/src/pybind/mgr/dashboard/controllers/perf_counters.py index 68a82dc6a831b..152d59b73e3c2 100644 --- a/src/pybind/mgr/dashboard/controllers/perf_counters.py +++ b/src/pybind/mgr/dashboard/controllers/perf_counters.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from . import ApiController, RESTController from .. import mgr +from ..security import Scope from ..services.ceph_service import CephService @@ -38,32 +39,32 @@ class PerfCounter(RESTController): } -@ApiController('perf_counters/mds') +@ApiController('perf_counters/mds', Scope.CEPHFS) class MdsPerfCounter(PerfCounter): service_type = 'mds' -@ApiController('perf_counters/mon') +@ApiController('perf_counters/mon', Scope.MONITOR) class MonPerfCounter(PerfCounter): service_type = 'mon' -@ApiController('perf_counters/osd') +@ApiController('perf_counters/osd', Scope.OSD) class OsdPerfCounter(PerfCounter): service_type = 'osd' -@ApiController('perf_counters/rgw') +@ApiController('perf_counters/rgw', Scope.RGW) class RgwPerfCounter(PerfCounter): service_type = 'rgw' -@ApiController('perf_counters/rbd-mirror') +@ApiController('perf_counters/rbd-mirror', Scope.RBD_MIRRORING) class RbdMirrorPerfCounter(PerfCounter): service_type = 'rbd-mirror' -@ApiController('perf_counters/mgr') +@ApiController('perf_counters/mgr', Scope.MANAGER) class MgrPerfCounter(PerfCounter): service_type = 'mgr' diff --git a/src/pybind/mgr/dashboard/controllers/pool.py b/src/pybind/mgr/dashboard/controllers/pool.py index 09f7c75d1ff83..6dd3d3618207b 100644 --- a/src/pybind/mgr/dashboard/controllers/pool.py +++ b/src/pybind/mgr/dashboard/controllers/pool.py @@ -3,14 +3,15 @@ from __future__ import absolute_import import cherrypy -from . import ApiController, RESTController, Endpoint +from . import ApiController, RESTController, Endpoint, ReadPermission from .. import mgr +from ..security import Scope from ..services.ceph_service import CephService from ..services.exception import handle_send_command_error from ..tools import str_to_bool -@ApiController('/pool') +@ApiController('/pool', Scope.POOL) class Pool(RESTController): @classmethod @@ -83,6 +84,7 @@ class Pool(RESTController): CephService.send_command('mon', 'osd pool set', pool=pool, var=key, val=value) @Endpoint() + @ReadPermission def _info(self): """Used by the create-pool dialog""" def rules(pool_type): diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index 3eca70a6ab105..d9b217fd6f75b 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -11,8 +11,9 @@ import six import rbd -from . import ApiController, RESTController, Task +from . import ApiController, RESTController, Task, UpdatePermission from .. import mgr +from ..security import Scope from ..services.ceph_service import CephService from ..tools import ViewCache from ..services.exception import handle_rados_error, handle_rbd_error, \ @@ -112,7 +113,7 @@ def _sort_features(features, enable=True): features.sort(key=key_func, reverse=not enable) -@ApiController('/block/image') +@ApiController('/block/image', Scope.RBD_IMAGE) class Rbd(RESTController): RESOURCE_ID = "pool_name/image_name" @@ -360,6 +361,7 @@ class Rbd(RESTController): @RbdTask('flatten', ['{pool_name}', '{image_name}'], 2.0) @RESTController.Resource('POST') + @UpdatePermission def flatten(self, pool_name, image_name): def _flatten(ioctx, image): @@ -373,7 +375,7 @@ class Rbd(RESTController): return _format_bitmask(int(rbd_default_features)) -@ApiController('/block/image/:pool_name/:image_name/snap') +@ApiController('/block/image/{pool_name}/{image_name}/snap', Scope.RBD_IMAGE) class RbdSnapshot(RESTController): RESOURCE_ID = "snapshot_name" @@ -416,6 +418,7 @@ class RbdSnapshot(RESTController): @RbdTask('snap/rollback', ['{pool_name}', '{image_name}', '{snapshot_name}'], 5.0) @RESTController.Resource('POST') + @UpdatePermission def rollback(self, pool_name, image_name, snapshot_name): def _rollback(ioctx, img, snapshot_name): img.rollback_to_snap(snapshot_name) diff --git a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py index a1823ed5e7d6a..40fa419b31821 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py @@ -8,8 +8,9 @@ from functools import partial import rbd -from . import ApiController, Endpoint, BaseController +from . import ApiController, Endpoint, BaseController, ReadPermission from .. import logger, mgr +from ..security import Scope from ..services.ceph_service import CephService from ..tools import ViewCache from ..services.exception import handle_rbd_error @@ -154,7 +155,7 @@ def get_daemons_and_pools(): # pylint: disable=R0915 } -@ApiController('/rbdmirror') +@ApiController('/rbdmirror', Scope.RBD_MIRRORING) class RbdMirror(BaseController): def __init__(self): @@ -163,6 +164,7 @@ class RbdMirror(BaseController): @Endpoint() @handle_rbd_error() + @ReadPermission def __call__(self): status, content_data = self._get_content_data() return {'status': status, 'content_data': content_data} diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index ee989a137fa2f..53d6265595961 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -4,18 +4,21 @@ from __future__ import absolute_import import json import cherrypy -from . import ApiController, BaseController, RESTController, Endpoint +from . import ApiController, BaseController, RESTController, Endpoint, \ + ReadPermission from .. import logger +from ..security import Scope from ..services.ceph_service import CephService from ..services.rgw_client import RgwClient from ..rest_client import RequestException from ..exceptions import DashboardException -@ApiController('/rgw') +@ApiController('/rgw', Scope.RGW) class Rgw(BaseController): @Endpoint() + @ReadPermission def status(self): status = {'available': False, 'message': None} try: @@ -35,7 +38,7 @@ class Rgw(BaseController): return status -@ApiController('/rgw/daemon') +@ApiController('/rgw/daemon', Scope.RGW) class RgwDaemon(RESTController): def list(self): @@ -94,7 +97,7 @@ class RgwRESTController(RESTController): raise DashboardException(e, http_status_code=500, component='rgw') -@ApiController('/rgw/bucket') +@ApiController('/rgw/bucket', Scope.RGW) class RgwBucket(RgwRESTController): def list(self): @@ -124,7 +127,7 @@ class RgwBucket(RgwRESTController): }, json_response=False) -@ApiController('/rgw/user') +@ApiController('/rgw/user', Scope.RGW) class RgwUser(RgwRESTController): def list(self): diff --git a/src/pybind/mgr/dashboard/controllers/summary.py b/src/pybind/mgr/dashboard/controllers/summary.py index 59ff0f4015cf2..b73d0fb4ea634 100644 --- a/src/pybind/mgr/dashboard/controllers/summary.py +++ b/src/pybind/mgr/dashboard/controllers/summary.py @@ -6,8 +6,9 @@ import json from . import ApiController, Endpoint, BaseController from .. import mgr +from ..security import Permission, Scope from ..controllers.rbd_mirroring import get_daemons_and_pools -from ..tools import ViewCacheNoDataException +from ..exceptions import ViewCacheNoDataException from ..tools import TaskManager @@ -43,12 +44,14 @@ class Summary(BaseController): @Endpoint() def __call__(self): executing_t, finished_t = TaskManager.list_serializable() - return { + result = { 'health_status': self._health_status(), - 'rbd_mirroring': self._rbd_mirroring(), 'mgr_id': mgr.get_mgr_id(), 'have_mon_connection': mgr.have_mon_connection(), 'executing_tasks': executing_t, 'finished_tasks': finished_t, 'version': mgr.version } + if self._has_permissions(Permission.READ, Scope.RBD_MIRRORING): + result['rbd_mirroring'] = self._rbd_mirroring() + return result diff --git a/src/pybind/mgr/dashboard/controllers/tcmu_iscsi.py b/src/pybind/mgr/dashboard/controllers/tcmu_iscsi.py index 4043b0deeb347..bcd3eb8bfd559 100644 --- a/src/pybind/mgr/dashboard/controllers/tcmu_iscsi.py +++ b/src/pybind/mgr/dashboard/controllers/tcmu_iscsi.py @@ -3,12 +3,13 @@ from __future__ import absolute_import from . import ApiController, RESTController from .. import mgr +from ..security import Scope from ..services.ceph_service import CephService SERVICE_TYPE = 'tcmu-runner' -@ApiController('/tcmuiscsi') +@ApiController('/tcmuiscsi', Scope.ISCSI) class TcmuIscsi(RESTController): # pylint: disable=too-many-nested-blocks def list(self): # pylint: disable=unused-argument diff --git a/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py b/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py index 99ca8521e7f0a..d8ec2ee85b41f 100644 --- a/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py @@ -78,9 +78,11 @@ class RbdMirroringControllerTest(ControllerTestCase): for k in ['daemons', 'pools', 'image_error', 'image_syncing', 'image_ready']: self.assertIn(k, result['content_data']) + @mock.patch('dashboard.controllers.BaseController._has_permissions') @mock.patch('dashboard.controllers.rbd_mirroring.rbd') - def test_summary(self, rbd_mock): # pylint: disable=W0613 + def test_summary(self, rbd_mock, has_perms_mock): # pylint: disable=W0613 """We're also testing `summary`, as it also uses code from `rbd_mirroring.py`""" + has_perms_mock.return_value = True self._get('/test/api/summary') self.assertStatus(200) -- 2.39.5