diff --git a/superset-frontend/src/chart/chartAction.js b/superset-frontend/src/chart/chartAction.js index 906a41c4f36a1..d87c7099e44d8 100644 --- a/superset-frontend/src/chart/chartAction.js +++ b/superset-frontend/src/chart/chartAction.js @@ -521,8 +521,8 @@ export function redirectSQLLab(formData) { export function refreshChart(chartKey, force, dashboardId) { return (dispatch, getState) => { const chart = (getState().charts || {})[chartKey]; - const timeout = getState().dashboardInfo.common.conf - .SUPERSET_WEBSERVER_TIMEOUT; + const { dashboardInfo } = getState(); + const timeout = dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT; if ( !chart.latestQueryFormData || diff --git a/superset-frontend/src/dashboard/containers/Chart.jsx b/superset-frontend/src/dashboard/containers/Chart.jsx index 94f88d3a9ef7f..712ac4e8aafc4 100644 --- a/superset-frontend/src/dashboard/containers/Chart.jsx +++ b/superset-frontend/src/dashboard/containers/Chart.jsx @@ -73,6 +73,7 @@ function mapStateToProps( }); formData.dashboardId = dashboardInfo.id; + formData.extra_jwt = dashboardInfo.extraJwt; return { chart, diff --git a/superset-frontend/src/dashboard/reducers/getInitialState.js b/superset-frontend/src/dashboard/reducers/getInitialState.js index 19ea54e789f1f..d065daf89fab9 100644 --- a/superset-frontend/src/dashboard/reducers/getInitialState.js +++ b/superset-frontend/src/dashboard/reducers/getInitialState.js @@ -49,7 +49,14 @@ import newComponentFactory from '../util/newComponentFactory'; import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; export default function getInitialState(bootstrapData) { - const { user_id, datasources, common, editMode, urlParams } = bootstrapData; + const { + user_id, + datasources, + common, + editMode, + urlParams, + extra_jwt, + } = bootstrapData; const dashboard = { ...bootstrapData.dashboard_data }; let preselectFilters = {}; @@ -283,6 +290,7 @@ export default function getInitialState(bootstrapData) { conf: common.conf, }, lastModifiedTime: dashboard.last_modified_time, + extraJwt: extra_jwt, }, dashboardFilters, nativeFilters, diff --git a/superset/app.py b/superset/app.py index f0f5dc1510689..4bd839adbad2c 100644 --- a/superset/app.py +++ b/superset/app.py @@ -34,6 +34,7 @@ cache_manager, celery_app, csrf, + dashboard_jwt_manager, db, feature_flag_manager, machine_auth_provider_factory, @@ -69,13 +70,13 @@ def create_app() -> Flask: raise ex -class SupersetIndexView(IndexView): +class SupersetIndexView(IndexView): # pylint: disable=too-few-public-methods @expose("/") def index(self) -> FlaskResponse: return redirect("/superset/welcome/") -class SupersetAppInitializer: +class SupersetAppInitializer: # pylint: disable=too-many-public-methods def __init__(self, app: Flask) -> None: super().__init__() @@ -534,6 +535,7 @@ def init_app_in_ctx(self) -> None: self.configure_data_sources() self.configure_auth_provider() self.configure_async_queries() + self.configure_dashboard_jwt() # Hook that provides administrators a handle on the Flask APP # after initialization @@ -698,3 +700,7 @@ def register_blueprints(self) -> None: def setup_bundle_manifest(self) -> None: manifest_processor.init_app(self.flask_app) + + def configure_dashboard_jwt(self) -> None: + if feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC"): + dashboard_jwt_manager.init_app(self.flask_app) diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 7703df7fcd433..1f44e47354042 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1066,6 +1066,14 @@ class ChartDataQueryContextSchema(Schema): result_type = EnumField(ChartDataResultType, by_value=True) result_format = EnumField(ChartDataResultFormat, by_value=True) + extra_jwt = fields.String( + required=False, + allow_none=True, + description="represents a security jwt that was " + "originally generated by the backend in " + "order to allow temporary access for that " + "chart data", + ) # pylint: disable=no-self-use,unused-argument @post_load diff --git a/superset/common/query_context.py b/superset/common/query_context.py index 16520add7783c..e96730b60a8bd 100644 --- a/superset/common/query_context.py +++ b/superset/common/query_context.py @@ -55,7 +55,7 @@ logger = logging.getLogger(__name__) -class QueryContext: +class QueryContext: # pylint: disable=too-many-instance-attributes """ The query context contains the query object and additional fields necessary to retrieve the data payload for a given viz. @@ -70,6 +70,7 @@ class QueryContext: custom_cache_timeout: Optional[int] result_type: ChartDataResultType result_format: ChartDataResultFormat + extra_jwt: Optional[str] # TODO: Type datasource and query_object dictionary with TypedDict when it becomes # a vanilla python type https://github.com/python/mypy/issues/5288 @@ -81,6 +82,7 @@ def __init__( # pylint: disable=too-many-arguments custom_cache_timeout: Optional[int] = None, result_type: Optional[ChartDataResultType] = None, result_format: Optional[ChartDataResultFormat] = None, + extra_jwt: Optional[str] = None, ) -> None: self.datasource = ConnectorRegistry.get_datasource( str(datasource["type"]), int(datasource["id"]), db.session @@ -96,6 +98,7 @@ def __init__( # pylint: disable=too-many-arguments "result_type": self.result_type, "result_format": self.result_format, } + self.extra_jwt = extra_jwt def get_query_result(self, query_object: QueryObject) -> Dict[str, Any]: """Returns a pandas dataframe based on the query object""" diff --git a/superset/config.py b/superset/config.py index 57a1838b898b1..977873f2358b4 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1133,6 +1133,10 @@ class CeleryConfig: # pylint: disable=too-few-public-methods SQLALCHEMY_DOCS_URL = "https://docs.sqlalchemy.org/en/13/core/engines.html" SQLALCHEMY_DISPLAY_TEXT = "SQLAlchemy docs" +# This secret is used to sign dashboard context in order to manage data access when +# using DASHBOARD_RBAC feature flag +DASHBOARD_JWT_SECRET = None + # ------------------------------------------------------------------- # * WARNING: STOP EDITING HERE * # ------------------------------------------------------------------- diff --git a/superset/extensions.py b/superset/extensions.py index 8f5bc6d5a39f2..098471fe8f9c7 100644 --- a/superset/extensions.py +++ b/superset/extensions.py @@ -29,6 +29,7 @@ from superset.utils.async_query_manager import AsyncQueryManager from superset.utils.cache_manager import CacheManager +from superset.utils.dashboard_jwt_manager import DashboardJwtManager from superset.utils.feature_flag_manager import FeatureFlagManager from superset.utils.machine_auth import MachineAuthProviderFactory @@ -100,6 +101,7 @@ def get_manifest_files(self, bundle: str, asset_type: str) -> List[str]: appbuilder = AppBuilder(update_perms=False) async_query_manager = AsyncQueryManager() cache_manager = CacheManager() +dashboard_jwt_manager = DashboardJwtManager() celery_app = celery.Celery() csrf = CSRFProtect() db = SQLA() diff --git a/superset/security/manager.py b/superset/security/manager.py index 1c4419cfc5241..4b54e3f28e56d 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -49,6 +49,7 @@ from superset.constants import RouteMethod from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import SupersetSecurityException +from superset.extensions import dashboard_jwt_manager, feature_flag_manager from superset.utils.core import DatasourceName, RowLevelSecurityFilterType if TYPE_CHECKING: @@ -923,7 +924,8 @@ def set_perm( # pylint: disable=no-self-use,unused-argument ) ) - def raise_for_access( # pylint: disable=too-many-arguments,too-many-branches + def raise_for_access( # pylint: disable=too-many-arguments,too-many-branches, + # pylint: disable=too-many-locals self, database: Optional["Database"] = None, datasource: Optional["BaseDatasource"] = None, @@ -987,15 +989,28 @@ def raise_for_access( # pylint: disable=too-many-arguments,too-many-branches ) if datasource or query_context or viz: + extra_jwt = None if query_context: datasource = query_context.datasource + extra_jwt = query_context.extra_jwt elif viz: datasource = viz.datasource + extra_jwt = viz.extra_jwt assert datasource + ds_allowed_in_dashboard = False + if feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC"): + dashboard_data_context = dashboard_jwt_manager.parse_jwt(extra_jwt) + + if dashboard_data_context: + ds_allowed_in_dashboard = ( + datasource.id in dashboard_data_context.dataset_ids + ) + if not ( - self.can_access_schema(datasource) + ds_allowed_in_dashboard + or self.can_access_schema(datasource) or self.can_access("datasource_access", datasource.perm or "") ): raise SupersetSecurityException( diff --git a/superset/utils/dashboard_jwt_manager.py b/superset/utils/dashboard_jwt_manager.py new file mode 100644 index 0000000000000..e24d2d8997a77 --- /dev/null +++ b/superset/utils/dashboard_jwt_manager.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import List, Optional + +import jwt +from flask import Flask + + +class DashboardJwtDataObject: # pylint: disable=too-few-public-methods + dashboard_id: int + dataset_ids: List[int] + + def __init__(self, dashboard_id: int, dataset_ids: List[int]) -> None: + super().__init__() + self.dashboard_id = dashboard_id + self.dataset_ids = dataset_ids + + +class DashboardJwtManager: + def __init__(self) -> None: + super().__init__() + self._jwt_secret: str + + def init_app(self, app: Flask) -> None: + config = app.config + + self._jwt_secret = config["DASHBOARD_JWT_SECRET"] + + def generate_jwt(self, data: DashboardJwtDataObject) -> str: + encoded_jwt = jwt.encode(data.__dict__, self._jwt_secret, algorithm="HS256") + return encoded_jwt.decode("utf-8") + + def parse_jwt(self, token: Optional[str]) -> Optional[DashboardJwtDataObject]: + if token: + data = jwt.decode(token, self._jwt_secret, algorithms=["HS256"]) + return DashboardJwtDataObject( + dashboard_id=data["id"], dataset_ids=data["dataset_ids"] + ) + return None diff --git a/superset/views/core.py b/superset/views/core.py index 647537598583b..37cde86cc97d1 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -84,7 +84,12 @@ SupersetTemplateParamsErrorException, SupersetTimeoutException, ) -from superset.extensions import async_query_manager, cache_manager +from superset.extensions import ( + async_query_manager, + cache_manager, + dashboard_jwt_manager, + feature_flag_manager, +) from superset.jinja_context import get_template_processor from superset.models.core import Database, FavStar, Log from superset.models.dashboard import Dashboard @@ -102,6 +107,7 @@ from superset.utils.async_query_manager import AsyncQueryTokenException from superset.utils.cache import etag_cache from superset.utils.core import ReservedUrlParameters +from superset.utils.dashboard_jwt_manager import DashboardJwtDataObject from superset.utils.dates import now_as_float from superset.utils.decorators import check_dashboard_access from superset.views.base import ( @@ -1869,6 +1875,19 @@ def dashboard( # pylint: disable=too-many-locals if key not in [param.value for param in utils.ReservedUrlParameters] } + extra_jwt = {} + if feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC"): + extra_jwt = { + "extra_jwt": dashboard_jwt_manager.generate_jwt( + DashboardJwtDataObject( + dashboard.id, + list( + map(lambda datasource: datasource.id, dashboard.datasources) + ), + ) + ) + } + bootstrap_data = { "user_id": g.user.get_id(), "common": common_bootstrap_payload(), @@ -1883,6 +1902,7 @@ def dashboard( # pylint: disable=too-many-locals "superset_can_csv": superset_can_csv, "slice_can_edit": slice_can_edit, }, + **extra_jwt, "datasources": data["datasources"], } diff --git a/superset/viz.py b/superset/viz.py index 3f20bb3b1ca43..e1a38dbe24ed4 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -127,6 +127,7 @@ def __init__( force: bool = False, force_cached: bool = False, ) -> None: + self.extra_jwt = form_data.get("extra_jwt") or None if not datasource: raise QueryObjectValidationError(_("Viz is missing a datasource")) diff --git a/tests/dashboards/security/security_rbac_tests.py b/tests/dashboards/security/security_rbac_tests.py index 19885d9582320..1d604fdda0f85 100644 --- a/tests/dashboards/security/security_rbac_tests.py +++ b/tests/dashboards/security/security_rbac_tests.py @@ -19,6 +19,7 @@ import pytest +from superset.extensions import dashboard_jwt_manager from tests.dashboards.dashboard_test_utils import * from tests.dashboards.security.base_case import BaseTestDashboardSecurity from tests.dashboards.superset_factory_util import ( @@ -28,6 +29,7 @@ create_slice_to_db, ) from tests.fixtures.public_role import public_role_like_gamma +from tests.test_app import app @mock.patch.dict( @@ -36,6 +38,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity): def test_get_dashboard_view__admin_can_access(self): # arrange + self.init_dashboard_jwt_manager() dashboard_to_access = create_dashboard_to_db( owners=[], slices=[create_slice_to_db()], published=False ) @@ -47,8 +50,13 @@ def test_get_dashboard_view__admin_can_access(self): # assert self.assert_dashboard_view_response(response, dashboard_to_access) + def init_dashboard_jwt_manager(self): + app.config["DASHBOARD_JWT_SECRET"] = "this is my secret" + dashboard_jwt_manager.init_app(app) + def test_get_dashboard_view__owner_can_access(self): # arrange + self.init_dashboard_jwt_manager() username = random_str() new_role = f"role_{random_str()}" owner = self.create_user_with_roles( @@ -100,7 +108,7 @@ def test_get_dashboard_view__user_with_dashboard_permission_can_not_access_draft def test_get_dashboard_view__user_access_with_dashboard_permission(self): # arrange - + self.init_dashboard_jwt_manager() username = random_str() new_role = f"role_{random_str()}" self.create_user_with_roles(username, [new_role], should_create_roles=True) @@ -136,6 +144,7 @@ def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_acces self, ): # arrange + self.init_dashboard_jwt_manager() dashboard_to_access = create_dashboard_to_db(published=False) grant_access_to_dashboard(dashboard_to_access, "Public") self.logout()