From 224769bd452b831ae4ab4d7fc658b61805970b62 Mon Sep 17 00:00:00 2001 From: Lily Kuang Date: Tue, 12 Apr 2022 15:14:08 -0700 Subject: [PATCH] feat(embedded): API get embedded dashboard config by uuid (#19650) * feat(embedded): get embedded dashboard config by uuid * add tests and validation * remove accidentally commit * fix tests --- superset/embedded/api.py | 105 ++++++++++++++++++ .../embedded_dashboard/commands/exceptions.py | 34 ++++++ superset/initialization/__init__.py | 2 + superset/security/api.py | 8 +- superset/security/manager.py | 18 +++ tests/integration_tests/embedded/api_tests.py | 53 +++++++++ tests/integration_tests/security/api_tests.py | 29 ++++- 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 superset/embedded/api.py create mode 100644 superset/embedded_dashboard/commands/exceptions.py create mode 100644 tests/integration_tests/embedded/api_tests.py diff --git a/superset/embedded/api.py b/superset/embedded/api.py new file mode 100644 index 0000000000000..f7278d910a079 --- /dev/null +++ b/superset/embedded/api.py @@ -0,0 +1,105 @@ +# 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. +import logging +from typing import Optional + +from flask import Response +from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.hooks import before_request +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset import is_feature_enabled +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.dashboards.schemas import EmbeddedDashboardResponseSchema +from superset.embedded.dao import EmbeddedDAO +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) +from superset.extensions import event_logger +from superset.models.embedded_dashboard import EmbeddedDashboard +from superset.reports.logs.schemas import openapi_spec_methods_override +from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class EmbeddedDashboardRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(EmbeddedDashboard) + + @before_request + def ensure_embedded_enabled(self) -> Optional[Response]: + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return self.response_404() + return None + + include_route_methods = RouteMethod.GET + class_permission_name = "EmbeddedDashboard" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + resource_name = "embedded_dashboard" + allow_browser_login = True + + openapi_spec_tag = "Embedded Dashboard" + openapi_spec_methods = openapi_spec_methods_override + + embedded_response_schema = EmbeddedDashboardResponseSchema() + + @expose("/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded", + log_to_statsd=False, + ) + # pylint: disable=arguments-differ, arguments-renamed) + def get(self, uuid: str) -> Response: + """Response + Returns the dashboard's embedded configuration + --- + get: + description: >- + Returns the dashboard's embedded configuration + parameters: + - in: path + schema: + type: string + name: uuid + description: The embedded configuration uuid + responses: + 200: + description: Result contains the embedded dashboard configuration + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' + 401: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + embedded = EmbeddedDAO.find_by_id(uuid) + if not embedded: + raise EmbeddedDashboardNotFoundError() + result = self.embedded_response_schema.dump(embedded) + return self.response(200, result=result) + except EmbeddedDashboardNotFoundError: + return self.response_404() diff --git a/superset/embedded_dashboard/commands/exceptions.py b/superset/embedded_dashboard/commands/exceptions.py new file mode 100644 index 0000000000000..e99dfa807cf49 --- /dev/null +++ b/superset/embedded_dashboard/commands/exceptions.py @@ -0,0 +1,34 @@ +# 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 Optional + +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ForbiddenError, ObjectNotFoundError + + +class EmbeddedDashboardNotFoundError(ObjectNotFoundError): + def __init__( + self, + embedded_dashboard_uuid: Optional[str] = None, + exception: Optional[Exception] = None, + ) -> None: + super().__init__("EmbeddedDashboard", embedded_dashboard_uuid, exception) + + +class EmbeddedDashboardAccessDeniedError(ForbiddenError): + message = _("You don't have access to this embedded dashboard config.") diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 74b05e168804e..1bc6b0b82417d 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -141,6 +141,7 @@ def init_views(self) -> None: from superset.datasets.api import DatasetRestApi from superset.datasets.columns.api import DatasetColumnsRestApi from superset.datasets.metrics.api import DatasetMetricRestApi + from superset.embedded.api import EmbeddedDashboardRestApi from superset.embedded.view import EmbeddedView from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi @@ -208,6 +209,7 @@ def init_views(self) -> None: appbuilder.add_api(DatasetRestApi) appbuilder.add_api(DatasetColumnsRestApi) appbuilder.add_api(DatasetMetricRestApi) + appbuilder.add_api(EmbeddedDashboardRestApi) appbuilder.add_api(ExploreFormDataRestApi) appbuilder.add_api(ExplorePermalinkRestApi) appbuilder.add_api(FilterSetRestApi) diff --git a/superset/security/api.py b/superset/security/api.py index b919e29f78ddd..6411ccf7be56b 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -25,6 +25,9 @@ from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError from marshmallow_enum import EnumField +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) from superset.extensions import event_logger from superset.security.guest_token import GuestTokenResourceType @@ -142,13 +145,16 @@ def guest_token(self) -> Response: """ try: body = guest_token_create_schema.load(request.json) + self.appbuilder.sm.validate_guest_token_resources(body["resources"]) + # todo validate stuff: - # make sure the resource ids are valid # make sure username doesn't reference an existing user # check rls rules for validity? token = self.appbuilder.sm.create_guest_access_token( body["user"], body["resources"], body["rls"] ) return self.response(200, token=token) + except EmbeddedDashboardNotFoundError as error: + return self.response_400(message=error.message) except ValidationError as error: return self.response_400(message=error.messages) diff --git a/superset/security/manager.py b/superset/security/manager.py index f57f1166ce394..48d43d01d0f76 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -1313,6 +1313,24 @@ def _get_guest_token_jwt_audience() -> str: audience = audience() return audience + @staticmethod + def validate_guest_token_resources(resources: GuestTokenResources) -> None: + # pylint: disable=import-outside-toplevel + from superset.embedded.dao import EmbeddedDAO + from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, + ) + from superset.models.dashboard import Dashboard + + for resource in resources: + if resource["type"] == GuestTokenResourceType.DASHBOARD.value: + # TODO (embedded): remove this check once uuids are rolled out + dashboard = Dashboard.get(str(resource["id"])) + if not dashboard: + embedded = EmbeddedDAO.find_by_id(str(resource["id"])) + if not embedded: + raise EmbeddedDashboardNotFoundError() + def create_guest_access_token( self, user: GuestTokenUser, diff --git a/tests/integration_tests/embedded/api_tests.py b/tests/integration_tests/embedded/api_tests.py new file mode 100644 index 0000000000000..8f3950fcf5462 --- /dev/null +++ b/tests/integration_tests/embedded/api_tests.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. +# isort:skip_file +"""Tests for security api methods""" +from unittest import mock + +import pytest + +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) + + +class TestEmbeddedDashboardApi(SupersetTestCase): + resource_name = "embedded_dashboard" + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @mock.patch.dict( + "superset.extensions.feature_flag_manager._feature_flags", + EMBEDDED_SUPERSET=True, + ) + def test_get_embedded_dashboard(self): + self.login("admin") + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) + uri = f"api/v1/{self.resource_name}/{self.embedded.uuid}" + response = self.client.get(uri) + self.assert200(response) + + def test_get_embedded_dashboard_non_found(self): + self.login("admin") + uri = f"api/v1/{self.resource_name}/bad-uuid" + response = self.client.get(uri) + self.assert404(response) diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py index f936219971517..9a5a085c81c34 100644 --- a/tests/integration_tests/security/api_tests.py +++ b/tests/integration_tests/security/api_tests.py @@ -19,10 +19,18 @@ import json import jwt +import pytest -from tests.integration_tests.base_tests import SupersetTestCase from flask_wtf.csrf import generate_csrf +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard from superset.utils.urls import get_url_host +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) class TestSecurityCsrfApi(SupersetTestCase): @@ -78,10 +86,13 @@ def test_post_guest_token_unauthorized(self): response = self.client.post(self.uri) self.assert403(response) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_post_guest_token_authorized(self): + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) self.login(username="admin") user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} - resource = {"type": "dashboard", "id": "blah"} + resource = {"type": "dashboard", "id": str(self.embedded.uuid)} rls_rule = {"dataset": 1, "clause": "1=1"} params = {"user": user, "resources": [resource], "rls": [rls_rule]} @@ -99,3 +110,17 @@ def test_post_guest_token_authorized(self): ) self.assertEqual(user, decoded_token["user"]) self.assertEqual(resource, decoded_token["resources"][0]) + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_post_guest_token_bad_resources(self): + self.login(username="admin") + user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} + resource = {"type": "dashboard", "id": "bad-id"} + rls_rule = {"dataset": 1, "clause": "1=1"} + params = {"user": user, "resources": [resource], "rls": [rls_rule]} + + response = self.client.post( + self.uri, data=json.dumps(params), content_type="application/json" + ) + + self.assert400(response)