You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2022/04/04 08:12:07 UTC
[superset] 07/23: feat: Embedded dashboard configuration (#19364)
This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to tag 1.5.0rc1
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 795ed3c7195227e259209ef07e3d893db2e9c4ba
Author: David Aaron Suddjian <18...@users.noreply.github.com>
AuthorDate: Wed Mar 30 12:34:05 2022 -0700
feat: Embedded dashboard configuration (#19364)
* embedded dashboard model
* embedded dashboard endpoints
* DRY up using the with_dashboard decorator elsewhere
* wip
* check feature flags and permissions
* wip
* sdk
* urls
* dao option for id column
* got it working
* Update superset/embedded/view.py
* use the curator check
* put back old endpoint, for now
* allow access by either embedded.uuid or dashboard.id
* keep the old endpoint around, for the time being
* openapi
* lint
* lint
* lint
* test stuff
* lint, test
* typo
* Update superset-frontend/src/embedded/index.tsx
* Update superset-frontend/src/embedded/index.tsx
* fix tests
* bump sdk
(cherry picked from commit 8e29ec5a6685867ffc035d20999c54c2abe36fb1)
---
superset-embedded-sdk/package.json | 2 +-
superset-embedded-sdk/src/index.ts | 2 +-
.../components/DashboardEmbedControls.tsx | 228 +++++++++++++++++++++
.../Header/HeaderActionsDropdown/index.jsx | 14 ++
.../src/dashboard/components/Header/index.jsx | 23 +++
.../src/dashboard/containers/DashboardPage.tsx | 8 +-
.../src/dashboard/containers/DashboardRoute.tsx | 28 +++
superset-frontend/src/dashboard/types.ts | 6 +
superset-frontend/src/embedded/index.tsx | 28 +--
.../src/hooks/apiResources/dashboards.ts | 5 +-
superset-frontend/src/preamble.ts | 3 +
superset-frontend/src/views/routes.tsx | 6 +-
superset/common/query_context_processor.py | 2 +-
superset/dao/base.py | 28 ++-
superset/dashboards/api.py | 217 ++++++++++++++++++--
superset/dashboards/commands/export.py | 7 +-
superset/dashboards/filters.py | 26 ++-
superset/dashboards/schemas.py | 12 ++
superset/databases/dao.py | 3 +-
superset/embedded/__init__.py | 16 ++
superset/embedded/dao.py | 53 +++++
superset/embedded/view.py | 80 ++++++++
superset/initialization/__init__.py | 2 +
superset/models/dashboard.py | 5 +
superset/models/embedded_dashboard.py | 57 ++++++
superset/security/manager.py | 30 ++-
superset/sqllab/command.py | 2 +-
superset/views/dashboard/views.py | 1 +
tests/integration_tests/dashboards/api_tests.py | 64 +++++-
tests/integration_tests/embedded/__init__.py | 16 ++
tests/integration_tests/embedded/dao_tests.py | 51 +++++
.../security/guest_token_security_tests.py | 108 +++++-----
tests/integration_tests/security_tests.py | 2 +
33 files changed, 1015 insertions(+), 120 deletions(-)
diff --git a/superset-embedded-sdk/package.json b/superset-embedded-sdk/package.json
index 88642e7232..49debb4ad3 100644
--- a/superset-embedded-sdk/package.json
+++ b/superset-embedded-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@superset-ui/embedded-sdk",
- "version": "0.1.0-alpha.6",
+ "version": "0.1.0-alpha.7",
"description": "SDK for embedding resources from Superset into your own application",
"access": "public",
"keywords": [
diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts
index 34932bd625..32b02641e0 100644
--- a/superset-embedded-sdk/src/index.ts
+++ b/superset-embedded-sdk/src/index.ts
@@ -131,7 +131,7 @@ export async function embedDashboard({
resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug }));
});
- iframe.src = `${supersetDomain}/dashboard/${id}/embedded${dashboardConfig}`;
+ iframe.src = `${supersetDomain}/embedded/${id}${dashboardConfig}`;
mountPoint.replaceChildren(iframe);
log('placed the iframe')
});
diff --git a/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx b/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx
new file mode 100644
index 0000000000..d20cc24600
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx
@@ -0,0 +1,228 @@
+/**
+ * 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 React, { useCallback, useEffect, useState } from 'react';
+import { makeApi, styled, SupersetApiError, t } from '@superset-ui/core';
+import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
+import Modal from 'src/components/Modal';
+import Loading from 'src/components/Loading';
+import Button from 'src/components/Button';
+import { Input } from 'src/components/Input';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { FormItem } from 'src/components/Form';
+import { EmbeddedDashboard } from '../types';
+
+type Props = {
+ dashboardId: string;
+ show: boolean;
+ onHide: () => void;
+};
+
+type EmbeddedApiPayload = { allowed_domains: string[] };
+
+const stringToList = (stringyList: string): string[] =>
+ stringyList.split(/(?:\s|,)+/).filter(x => x);
+
+const ButtonRow = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+`;
+
+export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
+ const { addInfoToast, addDangerToast } = useToasts();
+ const [ready, setReady] = useState(true); // whether we have initialized yet
+ const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
+ const [embedded, setEmbedded] = useState<EmbeddedDashboard | null>(null); // the embedded dashboard config
+ const [allowedDomains, setAllowedDomains] = useState<string>('');
+
+ const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
+ // whether saveable changes have been made to the config
+ const isDirty =
+ !embedded ||
+ stringToList(allowedDomains).join() !== embedded.allowed_domains.join();
+
+ const enableEmbedded = useCallback(() => {
+ setLoading(true);
+ makeApi<EmbeddedApiPayload, { result: EmbeddedDashboard }>({
+ method: 'POST',
+ endpoint,
+ })({
+ allowed_domains: stringToList(allowedDomains),
+ })
+ .then(
+ ({ result }) => {
+ setEmbedded(result);
+ setAllowedDomains(result.allowed_domains.join(', '));
+ addInfoToast(t('Changes saved.'));
+ },
+ err => {
+ console.error(err);
+ addDangerToast(
+ t(
+ t('Sorry, something went wrong. The changes could not be saved.'),
+ ),
+ );
+ },
+ )
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [endpoint, allowedDomains]);
+
+ const disableEmbedded = useCallback(() => {
+ Modal.confirm({
+ title: t('Disable embedding?'),
+ content: t('This will remove your current embed configuration.'),
+ okType: 'danger',
+ onOk: () => {
+ setLoading(true);
+ makeApi<{}>({ method: 'DELETE', endpoint })({})
+ .then(
+ () => {
+ setEmbedded(null);
+ setAllowedDomains('');
+ addInfoToast(t('Embedding deactivated.'));
+ onHide();
+ },
+ err => {
+ console.error(err);
+ addDangerToast(
+ t(
+ 'Sorry, something went wrong. Embedding could not be deactivated.',
+ ),
+ );
+ },
+ )
+ .finally(() => {
+ setLoading(false);
+ });
+ },
+ });
+ }, [endpoint]);
+
+ useEffect(() => {
+ setReady(false);
+ makeApi<{}, { result: EmbeddedDashboard }>({
+ method: 'GET',
+ endpoint,
+ })({})
+ .catch(err => {
+ if ((err as SupersetApiError).status === 404) {
+ // 404 just means the dashboard isn't currently embedded
+ return { result: null };
+ }
+ throw err;
+ })
+ .then(({ result }) => {
+ setReady(true);
+ setEmbedded(result);
+ setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
+ });
+ }, [dashboardId]);
+
+ if (!ready) {
+ return <Loading />;
+ }
+
+ return (
+ <>
+ <p>
+ {embedded ? (
+ <>
+ {t(
+ 'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
+ )}
+ <br />
+ <code>{embedded.uuid}</code>
+ </>
+ ) : (
+ t(
+ 'Configure this dashboard to embed it into an external web application.',
+ )
+ )}
+ </p>
+ <p>
+ {t('For further instructions, consult the')}{' '}
+ <a
+ href="https://www.npmjs.com/package/@superset-ui/embedded-sdk"
+ target="_blank"
+ rel="noreferrer"
+ >
+ {t('Superset Embedded SDK documentation.')}
+ </a>
+ </p>
+ <h3>Settings</h3>
+ <FormItem>
+ <label htmlFor="allowed-domains">
+ {t('Allowed Domains (comma separated)')}{' '}
+ <InfoTooltipWithTrigger
+ tooltip={t(
+ 'A list of domain names that can embed this dashboard. Leaving this field empty will allow embedding from any domain.',
+ )}
+ />
+ </label>
+ <Input
+ name="allowed-domains"
+ value={allowedDomains}
+ placeholder="superset.example.com"
+ onChange={event => setAllowedDomains(event.target.value)}
+ />
+ </FormItem>
+ <ButtonRow>
+ {embedded ? (
+ <>
+ <Button
+ onClick={disableEmbedded}
+ buttonStyle="secondary"
+ loading={loading}
+ >
+ {t('Deactivate')}
+ </Button>
+ <Button
+ onClick={enableEmbedded}
+ buttonStyle="primary"
+ disabled={!isDirty}
+ loading={loading}
+ >
+ {t('Save changes')}
+ </Button>
+ </>
+ ) : (
+ <Button
+ onClick={enableEmbedded}
+ buttonStyle="primary"
+ loading={loading}
+ >
+ {t('Enable embedding')}
+ </Button>
+ )}
+ </ButtonRow>
+ </>
+ );
+};
+
+export const DashboardEmbedModal = (props: Props) => {
+ const { show, onHide } = props;
+
+ return (
+ <Modal show={show} onHide={onHide} title={t('Embed')} hideFooter>
+ <DashboardEmbedControls {...props} />
+ </Modal>
+ );
+};
diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
index 9375c684af..619e10ea22 100644
--- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
@@ -59,11 +59,13 @@ const propTypes = {
userCanEdit: PropTypes.bool.isRequired,
userCanShare: PropTypes.bool.isRequired,
userCanSave: PropTypes.bool.isRequired,
+ userCanCurate: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
layout: PropTypes.object.isRequired,
expandedSlices: PropTypes.object.isRequired,
onSave: PropTypes.func.isRequired,
showPropertiesModal: PropTypes.func.isRequired,
+ manageEmbedded: PropTypes.func.isRequired,
refreshLimit: PropTypes.number,
refreshWarning: PropTypes.string,
lastModifiedTime: PropTypes.number.isRequired,
@@ -88,6 +90,7 @@ const MENU_KEYS = {
EDIT_CSS: 'edit-css',
DOWNLOAD_AS_IMAGE: 'download-as-image',
TOGGLE_FULLSCREEN: 'toggle-fullscreen',
+ MANAGE_EMBEDDED: 'manage-embedded',
};
const DropdownButton = styled.div`
@@ -182,6 +185,10 @@ class HeaderActionsDropdown extends React.PureComponent {
window.location.replace(url);
break;
}
+ case MENU_KEYS.MANAGE_EMBEDDED: {
+ this.props.manageEmbedded();
+ break;
+ }
default:
break;
}
@@ -204,6 +211,7 @@ class HeaderActionsDropdown extends React.PureComponent {
userCanEdit,
userCanShare,
userCanSave,
+ userCanCurate,
isLoading,
refreshLimit,
refreshWarning,
@@ -313,6 +321,12 @@ class HeaderActionsDropdown extends React.PureComponent {
</Menu.Item>
)}
+ {!editMode && userCanCurate && (
+ <Menu.Item key={MENU_KEYS.MANAGE_EMBEDDED}>
+ {t('Embed dashboard')}
+ </Menu.Item>
+ )}
+
{!editMode && (
<Menu.Item key={MENU_KEYS.DOWNLOAD_AS_IMAGE}>
{t('Download as image')}
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx
index 7fd1afc82e..ad668daf79 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -52,7 +52,9 @@ import setPeriodicRunner, {
stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner';
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
+import findPermission from 'src/dashboard/util/findPermission';
import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
+import { DashboardEmbedModal } from '../DashboardEmbedControls';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@@ -420,6 +422,14 @@ class Header extends React.PureComponent {
this.setState({ showingReportModal: false });
}
+ showEmbedModal = () => {
+ this.setState({ showingEmbedModal: true });
+ };
+
+ hideEmbedModal = () => {
+ this.setState({ showingEmbedModal: false });
+ };
+
renderReportModal() {
const attachedReportExists = !!Object.keys(this.props.reports).length;
return attachedReportExists ? (
@@ -498,6 +508,9 @@ class Header extends React.PureComponent {
const userCanSaveAs =
dashboardInfo.dash_save_perm &&
filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.REVIEWING;
+ const userCanCurate =
+ isFeatureEnabled(FeatureFlag.EMBEDDED_SUPERSET) &&
+ findPermission('can_set_embedded', 'Dashboard', user.roles);
const shouldShowReport = !editMode && this.canAddReports();
const refreshLimit =
dashboardInfo.common?.conf?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
@@ -658,6 +671,14 @@ class Header extends React.PureComponent {
/>
)}
+ {userCanCurate && (
+ <DashboardEmbedModal
+ show={this.state.showingEmbedModal}
+ onHide={this.hideEmbedModal}
+ dashboardId={dashboardInfo.id}
+ />
+ )}
+
<HeaderActionsDropdown
addSuccessToast={this.props.addSuccessToast}
addDangerToast={this.props.addDangerToast}
@@ -683,8 +704,10 @@ class Header extends React.PureComponent {
userCanEdit={userCanEdit}
userCanShare={userCanShare}
userCanSave={userCanSaveAs}
+ userCanCurate={userCanCurate}
isLoading={isLoading}
showPropertiesModal={this.showPropertiesModal}
+ manageEmbedded={this.showEmbedModal}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
lastModifiedTime={lastModifiedTime}
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index be0ba91772..97a4dc2283 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -27,7 +27,6 @@ import {
} from '@superset-ui/core';
import { useDispatch, useSelector } from 'react-redux';
import { Global } from '@emotion/react';
-import { useParams } from 'react-router-dom';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import Loading from 'src/components/Loading';
import FilterBoxMigrationModal from 'src/dashboard/components/FilterBoxMigrationModal';
@@ -79,14 +78,17 @@ const DashboardContainer = React.lazy(
const originalDocumentTitle = document.title;
-const DashboardPage: FC = () => {
+type PageProps = {
+ idOrSlug: string;
+};
+
+export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
const dispatch = useDispatch();
const theme = useTheme();
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
const { addDangerToast } = useToasts();
- const { idOrSlug } = useParams<{ idOrSlug: string }>();
const { result: dashboard, error: dashboardApiError } =
useDashboard(idOrSlug);
const { result: charts, error: chartsApiError } =
diff --git a/superset-frontend/src/dashboard/containers/DashboardRoute.tsx b/superset-frontend/src/dashboard/containers/DashboardRoute.tsx
new file mode 100644
index 0000000000..a382a28d45
--- /dev/null
+++ b/superset-frontend/src/dashboard/containers/DashboardRoute.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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 React, { FC } from 'react';
+import { useParams } from 'react-router-dom';
+import { DashboardPage } from './DashboardPage';
+
+const DashboardRoute: FC = () => {
+ const { idOrSlug } = useParams<{ idOrSlug: string }>();
+ return <DashboardPage idOrSlug={idOrSlug} />;
+};
+
+export default DashboardRoute;
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index dffbd9fbe0..c0b312d434 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -152,3 +152,9 @@ export type DashboardPermalinkValue = {
hash: string;
};
};
+
+export type EmbeddedDashboard = {
+ uuid: string;
+ dashboard_id: string;
+ allowed_domains: string[];
+};
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
index 3cd89a88be..afea2fd8bb 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -45,18 +45,22 @@ const LazyDashboardPage = lazy(
),
);
+const EmbeddedRoute = () => (
+ <Suspense fallback={<Loading />}>
+ <RootContextProviders>
+ <ErrorBoundary>
+ <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />
+ </ErrorBoundary>
+ <ToastContainer position="top" />
+ </RootContextProviders>
+ </Suspense>
+);
+
const EmbeddedApp = () => (
<Router>
- <Route path="/dashboard/:idOrSlug/embedded">
- <Suspense fallback={<Loading />}>
- <RootContextProviders>
- <ErrorBoundary>
- <LazyDashboardPage />
- </ErrorBoundary>
- <ToastContainer position="top" />
- </RootContextProviders>
- </Suspense>
- </Route>
+ {/* todo (embedded) remove this line after uuids are deployed */}
+ <Route path="/dashboard/:idOrSlug/embedded/" component={EmbeddedRoute} />
+ <Route path="/embedded/:uuid/" component={EmbeddedRoute} />
</Router>
);
@@ -64,9 +68,9 @@ const appMountPoint = document.getElementById('app')!;
const MESSAGE_TYPE = '__embedded_comms__';
-if (!window.parent) {
+if (!window.parent || window.parent === window) {
appMountPoint.innerHTML =
- 'This page is intended to be embedded in an iframe, but no window.parent was found.';
+ 'This page is intended to be embedded in an iframe, but it looks like that is not the case.';
}
// if the page is embedded in an origin that hasn't
diff --git a/superset-frontend/src/hooks/apiResources/dashboards.ts b/superset-frontend/src/hooks/apiResources/dashboards.ts
index b5b59d4ef4..9f512d5b15 100644
--- a/superset-frontend/src/hooks/apiResources/dashboards.ts
+++ b/superset-frontend/src/hooks/apiResources/dashboards.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Dashboard, Datasource } from 'src/dashboard/types';
+import { Dashboard, Datasource, EmbeddedDashboard } from 'src/dashboard/types';
import { Chart } from 'src/types/Chart';
import { useApiV1Resource, useTransformedResource } from './apiResources';
@@ -42,3 +42,6 @@ export const useDashboardCharts = (idOrSlug: string | number) =>
// that are necessary for rendering the given dashboard
export const useDashboardDatasets = (idOrSlug: string | number) =>
useApiV1Resource<Datasource[]>(`/api/v1/dashboard/${idOrSlug}/datasets`);
+
+export const useEmbeddedDashboard = (idOrSlug: string | number) =>
+ useApiV1Resource<EmbeddedDashboard>(`/api/v1/dashboard/${idOrSlug}/embedded`);
diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts
index 7bd6dbe29b..8d89104bf2 100644
--- a/superset-frontend/src/preamble.ts
+++ b/superset-frontend/src/preamble.ts
@@ -37,6 +37,9 @@ export let bootstrapData: {
user?: User | undefined;
common?: any;
config?: any;
+ embedded?: {
+ dashboard_id: string;
+ };
} = {};
// Configure translation
if (typeof window !== 'undefined') {
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index ba92d46b61..e7b189291f 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -58,10 +58,10 @@ const DashboardList = lazy(
/* webpackChunkName: "DashboardList" */ 'src/views/CRUD/dashboard/DashboardList'
),
);
-const DashboardPage = lazy(
+const DashboardRoute = lazy(
() =>
import(
- /* webpackChunkName: "DashboardPage" */ 'src/dashboard/containers/DashboardPage'
+ /* webpackChunkName: "DashboardRoute" */ 'src/dashboard/containers/DashboardRoute'
),
);
const DatabaseList = lazy(
@@ -113,7 +113,7 @@ export const routes: Routes = [
},
{
path: '/superset/dashboard/:idOrSlug/',
- Component: DashboardPage,
+ Component: DashboardRoute,
},
{
path: '/chart/list/',
diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py
index 202088c853..c87e878fdd 100644
--- a/superset/common/query_context_processor.py
+++ b/superset/common/query_context_processor.py
@@ -471,9 +471,9 @@ class QueryContextProcessor:
annotation_layer: Dict[str, Any], force: bool
) -> Dict[str, Any]:
chart = ChartDAO.find_by_id(annotation_layer["value"])
- form_data = chart.form_data.copy()
if not chart:
raise QueryObjectValidationError(_("The chart does not exist"))
+ form_data = chart.form_data.copy()
try:
viz_obj = get_viz(
datasource_type=chart.datasource.type,
diff --git a/superset/dao/base.py b/superset/dao/base.py
index ebd6a89088..607967e304 100644
--- a/superset/dao/base.py
+++ b/superset/dao/base.py
@@ -15,12 +15,12 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=isinstance-second-argument-not-valid-type
-from typing import Any, Dict, List, Optional, Type
+from typing import Any, Dict, List, Optional, Type, Union
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
-from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.exc import SQLAlchemyError, StatementError
from sqlalchemy.orm import Session
from superset.dao.exceptions import (
@@ -46,9 +46,12 @@ class BaseDAO:
"""
Child classes can register base filtering to be aplied to all filter methods
"""
+ id_column_name = "id"
@classmethod
- def find_by_id(cls, model_id: int, session: Session = None) -> Model:
+ def find_by_id(
+ cls, model_id: Union[str, int], session: Session = None
+ ) -> Optional[Model]:
"""
Find a model by id, if defined applies `base_filter`
"""
@@ -57,23 +60,28 @@ class BaseDAO:
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, session)
query = cls.base_filter( # pylint: disable=not-callable
- "id", data_model
+ cls.id_column_name, data_model
).apply(query, None)
- return query.filter_by(id=model_id).one_or_none()
+ id_filter = {cls.id_column_name: model_id}
+ try:
+ return query.filter_by(**id_filter).one_or_none()
+ except StatementError:
+ # can happen if int is passed instead of a string or similar
+ return None
@classmethod
- def find_by_ids(cls, model_ids: List[int]) -> List[Model]:
+ def find_by_ids(cls, model_ids: Union[List[str], List[int]]) -> List[Model]:
"""
Find a List of models by a list of ids, if defined applies `base_filter`
"""
- id_col = getattr(cls.model_cls, "id", None)
+ id_col = getattr(cls.model_cls, cls.id_column_name, None)
if id_col is None:
return []
query = db.session.query(cls.model_cls).filter(id_col.in_(model_ids))
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
- "id", data_model
+ cls.id_column_name, data_model
).apply(query, None)
return query.all()
@@ -86,7 +94,7 @@ class BaseDAO:
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
- "id", data_model
+ cls.id_column_name, data_model
).apply(query, None)
return query.all()
@@ -99,7 +107,7 @@ class BaseDAO:
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
- "id", data_model
+ cls.id_column_name, data_model
).apply(query, None)
return query.filter_by(**filter_by).one_or_none()
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 5e761cdd1f..d97b5f78e3 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -15,14 +15,16 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=too-many-lines
+import functools
import json
import logging
from datetime import datetime
from io import BytesIO
-from typing import Any, Optional
+from typing import Any, Callable, Optional
from zipfile import is_zipfile, ZipFile
from flask import g, make_response, redirect, request, Response, send_file, url_for
+from flask_appbuilder import permission_name
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -65,6 +67,8 @@ from superset.dashboards.schemas import (
DashboardGetResponseSchema,
DashboardPostSchema,
DashboardPutSchema,
+ EmbeddedDashboardConfigSchema,
+ EmbeddedDashboardResponseSchema,
get_delete_ids_schema,
get_export_ids_schema,
get_fav_star_ids_schema,
@@ -72,8 +76,10 @@ from superset.dashboards.schemas import (
openapi_spec_methods_override,
thumbnail_query_schema,
)
+from superset.embedded.dao import EmbeddedDAO
from superset.extensions import event_logger
from superset.models.dashboard import Dashboard
+from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.tasks.thumbnails import cache_dashboard_thumbnail
from superset.utils.cache import etag_cache
from superset.utils.screenshots import DashboardScreenshot
@@ -91,6 +97,27 @@ from superset.views.filters import FilterRelatedOwners
logger = logging.getLogger(__name__)
+def with_dashboard(
+ f: Callable[[BaseSupersetModelRestApi, Dashboard], Response]
+) -> Callable[[BaseSupersetModelRestApi, str], Response]:
+ """
+ A decorator that looks up the dashboard by id or slug and passes it to the api.
+ Route must include an <id_or_slug> parameter.
+ Responds with 403 or 404 without calling the route, if necessary.
+ """
+
+ def wraps(self: BaseSupersetModelRestApi, id_or_slug: str) -> Response:
+ try:
+ dash = DashboardDAO.get_by_id_or_slug(id_or_slug)
+ return f(self, dash)
+ except DashboardAccessDeniedError:
+ return self.response_403()
+ except DashboardNotFoundError:
+ return self.response_404()
+
+ return functools.update_wrapper(wraps, f)
+
+
class DashboardRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Dashboard)
@@ -108,6 +135,9 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"favorite_status",
"get_charts",
"get_datasets",
+ "get_embedded",
+ "set_embedded",
+ "delete_embedded",
"thumbnail",
}
resource_name = "dashboard"
@@ -193,6 +223,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
chart_entity_response_schema = ChartEntityResponseSchema()
dashboard_get_response_schema = DashboardGetResponseSchema()
dashboard_dataset_schema = DashboardDatasetSchema()
+ embedded_response_schema = EmbeddedDashboardResponseSchema()
+ embedded_config_schema = EmbeddedDashboardConfigSchema()
base_filters = [["id", DashboardAccessFilter, lambda: []]]
@@ -215,6 +247,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
DashboardGetResponseSchema,
DashboardDatasetSchema,
GetFavStarIdsSchema,
+ EmbeddedDashboardResponseSchema,
)
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,
@@ -248,9 +281,11 @@ class DashboardRestApi(BaseSupersetModelRestApi):
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
- log_to_statsd=False, # pylint: disable=arguments-renamed
+ log_to_statsd=False,
)
- def get(self, id_or_slug: str) -> Response:
+ @with_dashboard
+ # pylint: disable=arguments-renamed, arguments-differ
+ def get(self, dash: Dashboard) -> Response:
"""Gets a dashboard
---
get:
@@ -283,15 +318,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
404:
$ref: '#/components/responses/404'
"""
- # pylint: disable=arguments-differ
- try:
- dash = DashboardDAO.get_by_id_or_slug(id_or_slug)
- result = self.dashboard_get_response_schema.dump(dash)
- return self.response(200, result=result)
- except DashboardAccessDeniedError:
- return self.response_403()
- except DashboardNotFoundError:
- return self.response_404()
+ result = self.dashboard_get_response_schema.dump(dash)
+ return self.response(200, result=result)
@etag_cache(
get_last_modified=lambda _self, id_or_slug: DashboardDAO.get_dashboard_and_datasets_changed_on( # pylint: disable=line-too-long,useless-suppression
@@ -1001,3 +1029,168 @@ class DashboardRestApi(BaseSupersetModelRestApi):
)
command.run()
return self.response(200, message="OK")
+
+ @expose("/<id_or_slug>/embedded", methods=["GET"])
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded",
+ log_to_statsd=False,
+ )
+ @with_dashboard
+ def get_embedded(self, dashboard: Dashboard) -> Response:
+ """Response
+ Returns the dashboard's embedded configuration
+ ---
+ get:
+ description: >-
+ Returns the dashboard's embedded configuration
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ description: The dashboard id or slug
+ responses:
+ 200:
+ description: Result contains the embedded dashboard config
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ $ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not dashboard.embedded:
+ return self.response(404)
+ embedded: EmbeddedDashboard = dashboard.embedded[0]
+ result = self.embedded_response_schema.dump(embedded)
+ return self.response(200, result=result)
+
+ @expose("/<id_or_slug>/embedded", methods=["POST", "PUT"])
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded",
+ log_to_statsd=False,
+ )
+ @with_dashboard
+ def set_embedded(self, dashboard: Dashboard) -> Response:
+ """Response
+ Sets a dashboard's embedded configuration.
+ ---
+ post:
+ description: >-
+ Sets a dashboard's embedded configuration.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ description: The dashboard id or slug
+ requestBody:
+ description: The embedded configuration to set
+ required: true
+ content:
+ application/json:
+ schema: EmbeddedDashboardConfigSchema
+ responses:
+ 200:
+ description: Successfully set the configuration
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ $ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ put:
+ description: >-
+ Sets a dashboard's embedded configuration.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ description: The dashboard id or slug
+ requestBody:
+ description: The embedded configuration to set
+ required: true
+ content:
+ application/json:
+ schema: EmbeddedDashboardConfigSchema
+ responses:
+ 200:
+ description: Successfully set the configuration
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ $ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ body = self.embedded_config_schema.load(request.json)
+ embedded = EmbeddedDAO.upsert(dashboard, body["allowed_domains"])
+ result = self.embedded_response_schema.dump(embedded)
+ return self.response(200, result=result)
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+
+ @expose("/<id_or_slug>/embedded", methods=["DELETE"])
+ @protect()
+ @safe
+ @permission_name("set_embedded")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_embedded",
+ log_to_statsd=False,
+ )
+ @with_dashboard
+ def delete_embedded(self, dashboard: Dashboard) -> Response:
+ """Response
+ Removes a dashboard's embedded configuration.
+ ---
+ delete:
+ description: >-
+ Removes a dashboard's embedded configuration.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ description: The dashboard id or slug
+ responses:
+ 200:
+ description: Successfully removed the configuration
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ dashboard.embedded = []
+ return self.response(200, message="OK")
diff --git a/superset/dashboards/commands/export.py b/superset/dashboards/commands/export.py
index 87408bab37..c7aa8b6e5c 100644
--- a/superset/dashboards/commands/export.py
+++ b/superset/dashboards/commands/export.py
@@ -140,9 +140,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
dataset_id = target.pop("datasetId", None)
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
- target["datasetUuid"] = str(dataset.uuid)
- if export_related:
- yield from ExportDatasetsCommand([dataset_id]).run()
+ if dataset:
+ target["datasetUuid"] = str(dataset.uuid)
+ if export_related:
+ yield from ExportDatasetsCommand([dataset_id]).run()
# the mapping between dashboard -> charts is inferred from the position
# attribute, so if it's not present we need to add a default config
diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py
index 5f79392e71..52a945ca41 100644
--- a/superset/dashboards/filters.py
+++ b/superset/dashboards/filters.py
@@ -14,7 +14,8 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from typing import Any, Optional
+import uuid
+from typing import Any, Optional, Union
from flask import g
from flask_appbuilder.security.sqla.models import Role
@@ -25,6 +26,7 @@ from sqlalchemy.orm.query import Query
from superset import db, is_feature_enabled, security_manager
from superset.models.core import FavStar
from superset.models.dashboard import Dashboard
+from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.models.slice import Slice
from superset.security.guest_token import GuestTokenResourceType, GuestUser
from superset.views.base import BaseFilter, is_user_admin
@@ -59,6 +61,14 @@ class DashboardFavoriteFilter( # pylint: disable=too-few-public-methods
model = Dashboard
+def is_uuid(value: Union[str, int]) -> bool:
+ try:
+ uuid.UUID(str(value))
+ return True
+ except ValueError:
+ return False
+
+
class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-methods
"""
List dashboards with the following criteria:
@@ -133,14 +143,24 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
g.user
):
+
guest_user: GuestUser = g.user
embedded_dashboard_ids = [
r["id"]
for r in guest_user.resources
if r["type"] == GuestTokenResourceType.DASHBOARD.value
]
- if len(embedded_dashboard_ids) != 0:
- feature_flagged_filters.append(Dashboard.id.in_(embedded_dashboard_ids))
+
+ # TODO (embedded): only use uuid filter once uuids are rolled out
+ condition = (
+ Dashboard.embedded.any(
+ EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
+ )
+ if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
+ else Dashboard.id.in_(embedded_dashboard_ids)
+ )
+
+ feature_flagged_filters.append(condition)
query = query.filter(
or_(
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 9b668df721..d91879f0d8 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -309,3 +309,15 @@ class ImportV1DashboardSchema(Schema):
version = fields.String(required=True)
is_managed_externally = fields.Boolean(allow_none=True, default=False)
external_url = fields.String(allow_none=True)
+
+
+class EmbeddedDashboardConfigSchema(Schema):
+ allowed_domains = fields.List(fields.String(), required=True)
+
+
+class EmbeddedDashboardResponseSchema(Schema):
+ uuid = fields.String()
+ allowed_domains = fields.List(fields.String())
+ dashboard_id = fields.String()
+ changed_on = fields.DateTime()
+ changed_by = fields.Nested(UserSchema)
diff --git a/superset/databases/dao.py b/superset/databases/dao.py
index d8813dc8eb..5e47772cfc 100644
--- a/superset/databases/dao.py
+++ b/superset/databases/dao.py
@@ -68,7 +68,8 @@ class DatabaseDAO(BaseDAO):
@classmethod
def get_related_objects(cls, database_id: int) -> Dict[str, Any]:
- datasets = cls.find_by_id(database_id).tables
+ database: Any = cls.find_by_id(database_id)
+ datasets = database.tables
dataset_ids = [dataset.id for dataset in datasets]
charts = (
diff --git a/superset/embedded/__init__.py b/superset/embedded/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/superset/embedded/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/embedded/dao.py b/superset/embedded/dao.py
new file mode 100644
index 0000000000..957a7242a7
--- /dev/null
+++ b/superset/embedded/dao.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.
+import logging
+from typing import Any, Dict, List
+
+from superset.dao.base import BaseDAO
+from superset.extensions import db
+from superset.models.dashboard import Dashboard
+from superset.models.embedded_dashboard import EmbeddedDashboard
+
+logger = logging.getLogger(__name__)
+
+
+class EmbeddedDAO(BaseDAO):
+ model_cls = EmbeddedDashboard
+ # There isn't really a regular scenario where we would rather get Embedded by id
+ id_column_name = "uuid"
+
+ @staticmethod
+ def upsert(dashboard: Dashboard, allowed_domains: List[str]) -> EmbeddedDashboard:
+ """
+ Sets up a dashboard to be embeddable.
+ Upsert is used to preserve the embedded_dashboard uuid across updates.
+ """
+ embedded: EmbeddedDashboard = (
+ dashboard.embedded[0] if dashboard.embedded else EmbeddedDashboard()
+ )
+ embedded.allow_domain_list = ",".join(allowed_domains)
+ dashboard.embedded = [embedded]
+ db.session.commit()
+ return embedded
+
+ @classmethod
+ def create(cls, properties: Dict[str, Any], commit: bool = True) -> Any:
+ """
+ Use EmbeddedDAO.upsert() instead.
+ At least, until we are ok with more than one embedded instance per dashboard.
+ """
+ raise NotImplementedError("Use EmbeddedDAO.upsert() instead.")
diff --git a/superset/embedded/view.py b/superset/embedded/view.py
new file mode 100644
index 0000000000..487850b728
--- /dev/null
+++ b/superset/embedded/view.py
@@ -0,0 +1,80 @@
+# 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 json
+from typing import Callable
+
+from flask import abort
+from flask_appbuilder import expose
+from flask_login import AnonymousUserMixin, LoginManager
+
+from superset import event_logger, is_feature_enabled, security_manager
+from superset.embedded.dao import EmbeddedDAO
+from superset.superset_typing import FlaskResponse
+from superset.utils import core as utils
+from superset.views.base import BaseSupersetView, common_bootstrap_payload
+
+
+class EmbeddedView(BaseSupersetView):
+ """The views for embedded resources to be rendered in an iframe"""
+
+ route_base = "/embedded"
+
+ @expose("/<uuid>")
+ @event_logger.log_this_with_extra_payload
+ def embedded(
+ self,
+ uuid: str,
+ add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
+ ) -> FlaskResponse:
+ """
+ Server side rendering for the embedded dashboard page
+ :param uuid: identifier for embedded dashboard
+ :param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
+ default value to appease pylint
+ """
+ if not is_feature_enabled("EMBEDDED_SUPERSET"):
+ abort(404)
+
+ embedded = EmbeddedDAO.find_by_id(uuid)
+ if not embedded:
+ abort(404)
+
+ # Log in as an anonymous user, just for this view.
+ # This view needs to be visible to all users,
+ # and building the page fails if g.user and/or ctx.user aren't present.
+ login_manager: LoginManager = security_manager.lm
+ login_manager.reload_user(AnonymousUserMixin())
+
+ add_extra_log_payload(
+ embedded_dashboard_id=uuid,
+ dashboard_version="v2",
+ )
+
+ bootstrap_data = {
+ "common": common_bootstrap_payload(),
+ "embedded": {
+ "dashboard_id": embedded.dashboard_id,
+ },
+ }
+
+ return self.render_template(
+ "superset/spa.html",
+ entry="embedded",
+ bootstrap_data=json.dumps(
+ bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
+ ),
+ )
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index f6ffd3ec3a..349990a0cd 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -141,6 +141,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.datasets.api import DatasetRestApi
from superset.datasets.columns.api import DatasetColumnsRestApi
from superset.datasets.metrics.api import DatasetMetricRestApi
+ from superset.embedded.view import EmbeddedView
from superset.explore.form_data.api import ExploreFormDataRestApi
from superset.explore.permalink.api import ExplorePermalinkRestApi
from superset.importexport.api import ImportExportRestApi
@@ -302,6 +303,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view_no_menu(Dashboard)
appbuilder.add_view_no_menu(DashboardModelViewAsync)
appbuilder.add_view_no_menu(Datasource)
+ appbuilder.add_view_no_menu(EmbeddedView)
appbuilder.add_view_no_menu(KV)
appbuilder.add_view_no_menu(R)
appbuilder.add_view_no_menu(SavedQueryView)
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index 7a8710af0d..50619829c2 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -152,6 +152,11 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
is_managed_externally = Column(Boolean, nullable=False, default=False)
external_url = Column(Text, nullable=True)
roles = relationship(security_manager.role_model, secondary=DashboardRoles)
+ embedded = relationship(
+ "EmbeddedDashboard",
+ back_populates="dashboard",
+ cascade="all, delete-orphan",
+ )
_filter_sets = relationship(
"FilterSet", back_populates="dashboard", cascade="all, delete"
)
diff --git a/superset/models/embedded_dashboard.py b/superset/models/embedded_dashboard.py
new file mode 100644
index 0000000000..7718bc886f
--- /dev/null
+++ b/superset/models/embedded_dashboard.py
@@ -0,0 +1,57 @@
+# 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 uuid
+from typing import List
+
+from flask_appbuilder import Model
+from sqlalchemy import Column, ForeignKey, Integer, Text
+from sqlalchemy.orm import relationship
+from sqlalchemy_utils import UUIDType
+
+from superset.models.helpers import AuditMixinNullable
+
+
+class EmbeddedDashboard(Model, AuditMixinNullable):
+ """
+ A configuration of embedding for a dashboard.
+ Currently, the only embeddable resource is the Dashboard.
+ If we add new embeddable resource types, this model should probably be renamed.
+
+ References the dashboard, and contains a config for embedding that dashboard.
+
+ This data model allows multiple configurations for a given dashboard,
+ but at this time the API only allows setting one.
+ """
+
+ __tablename__ = "embedded_dashboards"
+
+ uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
+ allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
+ dashboard_id = Column(Integer, ForeignKey("dashboards.id"), nullable=False)
+ dashboard = relationship(
+ "Dashboard",
+ back_populates="embedded",
+ foreign_keys=[dashboard_id],
+ )
+
+ @property
+ def allowed_domains(self) -> List[str]:
+ """
+ A list of domains which are allowed to embed the dashboard.
+ An empty list means any domain can embed.
+ """
+ return self.allow_domain_list.split(",") if self.allow_domain_list else []
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 9f13bf6134..2ad8baa5e1 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -189,6 +189,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"can_update_role",
"all_query_access",
"can_grant_guest_token",
+ "can_set_embedded",
}
READ_ONLY_PERMISSION = {
@@ -1274,10 +1275,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
for dashboard_role in dashboard.roles
)
- if self.is_guest_user():
- can_access = self.has_guest_access(
- GuestTokenResourceType.DASHBOARD, dashboard.id
- )
+ if self.is_guest_user() and dashboard.embedded:
+ can_access = self.has_guest_access(dashboard)
else:
can_access = (
is_user_admin()
@@ -1416,15 +1415,26 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return g.user
return None
- def has_guest_access(
- self, resource_type: GuestTokenResourceType, resource_id: Union[str, int]
- ) -> bool:
+ def has_guest_access(self, dashboard: "Dashboard") -> bool:
user = self.get_current_guest_user_if_guest()
if not user:
return False
- strid = str(resource_id)
- for resource in user.resources:
- if resource["type"] == resource_type.value and str(resource["id"]) == strid:
+ dashboards = [
+ r
+ for r in user.resources
+ if r["type"] == GuestTokenResourceType.DASHBOARD.value
+ ]
+
+ # TODO (embedded): remove this check once uuids are rolled out
+ for resource in dashboards:
+ if str(resource["id"]) == str(dashboard.id):
+ return True
+
+ if not dashboard.embedded:
+ return False
+
+ for resource in dashboards:
+ if str(resource["id"]) == str(dashboard.embedded[0].uuid):
return True
return False
diff --git a/superset/sqllab/command.py b/superset/sqllab/command.py
index bdc570b603..ff50e18eda 100644
--- a/superset/sqllab/command.py
+++ b/superset/sqllab/command.py
@@ -138,7 +138,7 @@ class ExecuteSqlCommand(BaseCommand):
raise ex
def _get_the_query_db(self) -> Database:
- mydb = self._database_dao.find_by_id(self._execution_context.database_id)
+ mydb: Any = self._database_dao.find_by_id(self._execution_context.database_id)
self._validate_query_db(mydb)
return mydb
diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py
index 84a25c2cea..57d9b78092 100644
--- a/superset/views/dashboard/views.py
+++ b/superset/views/dashboard/views.py
@@ -164,6 +164,7 @@ class Dashboard(BaseSupersetView):
bootstrap_data = {
"common": common_bootstrap_payload(),
+ "embedded": {"dashboard_id": dashboard_id_or_slug},
}
return self.render_template(
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index 4c8a207438..b6029676a9 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -388,7 +388,14 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
rv = self.get_assert_metric(uri, "info")
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
- assert set(data["permissions"]) == {"can_read", "can_write", "can_export"}
+ assert set(data["permissions"]) == {
+ "can_read",
+ "can_write",
+ "can_export",
+ "can_get_embedded",
+ "can_delete_embedded",
+ "can_set_embedded",
+ }
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_get_dashboard_not_found(self):
@@ -1722,3 +1729,58 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
response_roles = [result["text"] for result in response["result"]]
assert "Alpha" in response_roles
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_embedded_dashboards(self):
+ self.login(username="admin")
+ uri = "api/v1/dashboard/world_health/embedded"
+
+ # initial get should return 404
+ resp = self.get_assert_metric(uri, "get_embedded")
+ self.assertEqual(resp.status_code, 404)
+
+ # post succeeds and returns value
+ allowed_domains = ["test.example", "embedded.example"]
+ resp = self.post_assert_metric(
+ uri,
+ {"allowed_domains": allowed_domains},
+ "set_embedded",
+ )
+ self.assertEqual(resp.status_code, 200)
+ result = json.loads(resp.data.decode("utf-8"))["result"]
+ self.assertIsNotNone(result["uuid"])
+ self.assertNotEqual(result["uuid"], "")
+ self.assertEqual(result["allowed_domains"], allowed_domains)
+
+ # get returns value
+ resp = self.get_assert_metric(uri, "get_embedded")
+ self.assertEqual(resp.status_code, 200)
+ result = json.loads(resp.data.decode("utf-8"))["result"]
+ self.assertIsNotNone(result["uuid"])
+ self.assertNotEqual(result["uuid"], "")
+ self.assertEqual(result["allowed_domains"], allowed_domains)
+
+ # save uuid for later
+ original_uuid = result["uuid"]
+
+ # put succeeds and returns value
+ resp = self.post_assert_metric(uri, {"allowed_domains": []}, "set_embedded")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIsNotNone(result["uuid"])
+ self.assertNotEqual(result["uuid"], "")
+ self.assertEqual(result["allowed_domains"], allowed_domains)
+
+ # get returns changed value
+ resp = self.get_assert_metric(uri, "get_embedded")
+ self.assertEqual(resp.status_code, 200)
+ result = json.loads(resp.data.decode("utf-8"))["result"]
+ self.assertEqual(result["uuid"], original_uuid)
+ self.assertEqual(result["allowed_domains"], [])
+
+ # delete succeeds
+ resp = self.delete_assert_metric(uri, "delete_embedded")
+ self.assertEqual(resp.status_code, 200)
+
+ # get returns 404
+ resp = self.get_assert_metric(uri, "get_embedded")
+ self.assertEqual(resp.status_code, 404)
diff --git a/tests/integration_tests/embedded/__init__.py b/tests/integration_tests/embedded/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/tests/integration_tests/embedded/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/integration_tests/embedded/dao_tests.py b/tests/integration_tests/embedded/dao_tests.py
new file mode 100644
index 0000000000..8160144a25
--- /dev/null
+++ b/tests/integration_tests/embedded/dao_tests.py
@@ -0,0 +1,51 @@
+# 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
+import pytest
+
+import tests.integration_tests.test_app # pylint: disable=unused-import
+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.world_bank_dashboard import (
+ load_world_bank_dashboard_with_slices,
+ load_world_bank_data,
+)
+
+
+class TestEmbeddedDAO(SupersetTestCase):
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_upsert(self):
+ dash = db.session.query(Dashboard).filter_by(slug="world_health").first()
+ assert not dash.embedded
+ EmbeddedDAO.upsert(dash, ["test.example.com"])
+ assert dash.embedded
+ self.assertEqual(dash.embedded[0].allowed_domains, ["test.example.com"])
+ original_uuid = dash.embedded[0].uuid
+ self.assertIsNotNone(original_uuid)
+ EmbeddedDAO.upsert(dash, [])
+ self.assertEqual(dash.embedded[0].allowed_domains, [])
+ self.assertEqual(dash.embedded[0].uuid, original_uuid)
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_get_by_uuid(self):
+ dash = db.session.query(Dashboard).filter_by(slug="world_health").first()
+ uuid = str(EmbeddedDAO.upsert(dash, ["test.example.com"]).uuid)
+ db.session.expire_all()
+ embedded = EmbeddedDAO.find_by_id(uuid)
+ self.assertIsNotNone(embedded)
diff --git a/tests/integration_tests/security/guest_token_security_tests.py b/tests/integration_tests/security/guest_token_security_tests.py
index e4d55d9747..78bd8bde86 100644
--- a/tests/integration_tests/security/guest_token_security_tests.py
+++ b/tests/integration_tests/security/guest_token_security_tests.py
@@ -22,6 +22,7 @@ from flask import g
from superset import db, security_manager
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
+from superset.embedded.dao import EmbeddedDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.security.guest_token import GuestTokenResourceType
@@ -38,14 +39,9 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
EMBEDDED_SUPERSET=True,
)
class TestGuestUserSecurity(SupersetTestCase):
- # This test doesn't use a dashboard fixture, the next test does.
- # That way tests are faster.
-
- resource_id = 42
-
def authorized_guest(self):
return security_manager.get_guest_user_from_token(
- {"user": {}, "resources": [{"type": "dashboard", "id": self.resource_id}]}
+ {"user": {}, "resources": [{"type": "dashboard", "id": "some-uuid"}]}
)
def test_is_guest_user__regular_user(self):
@@ -83,88 +79,86 @@ class TestGuestUserSecurity(SupersetTestCase):
guest_user = security_manager.get_current_guest_user_if_guest()
self.assertEqual(guest_user, g.user)
+ def test_get_guest_user_roles_explicit(self):
+ guest = self.authorized_guest()
+ roles = security_manager.get_user_roles(guest)
+ self.assertEqual(guest.roles, roles)
+
+ def test_get_guest_user_roles_implicit(self):
+ guest = self.authorized_guest()
+ g.user = guest
+
+ roles = security_manager.get_user_roles()
+ self.assertEqual(guest.roles, roles)
+
+
+@mock.patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags",
+ EMBEDDED_SUPERSET=True,
+)
+@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+class TestGuestUserDashboardAccess(SupersetTestCase):
+ def setUp(self) -> None:
+ self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
+ self.embedded = EmbeddedDAO.upsert(self.dash, [])
+ self.authorized_guest = security_manager.get_guest_user_from_token(
+ {
+ "user": {},
+ "resources": [{"type": "dashboard", "id": str(self.embedded.uuid)}],
+ }
+ )
+ self.unauthorized_guest = security_manager.get_guest_user_from_token(
+ {
+ "user": {},
+ "resources": [
+ {"type": "dashboard", "id": "06383667-3e02-4e5e-843f-44e9c5896b6c"}
+ ],
+ }
+ )
+
def test_has_guest_access__regular_user(self):
g.user = security_manager.find_user("admin")
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
- )
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertFalse(has_guest_access)
def test_has_guest_access__anonymous_user(self):
g.user = security_manager.get_anonymous_user()
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
- )
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertFalse(has_guest_access)
def test_has_guest_access__authorized_guest_user(self):
- g.user = self.authorized_guest()
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
- )
+ g.user = self.authorized_guest
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertTrue(has_guest_access)
def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
- guest = self.authorized_guest()
+ # set up a user who has authorized access, plus another resource
+ guest = self.authorized_guest
guest.resources = [
- {"type": "dashboard", "id": self.resource_id - 1}
+ {"type": "dashboard", "id": "not-a-real-id"}
] + guest.resources
g.user = guest
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
- )
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertTrue(has_guest_access)
def test_has_guest_access__unauthorized_guest_user__different_resource_id(self):
g.user = security_manager.get_guest_user_from_token(
{
"user": {},
- "resources": [{"type": "dashboard", "id": self.resource_id - 1}],
+ "resources": [{"type": "dashboard", "id": "not-a-real-id"}],
}
)
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
- )
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertFalse(has_guest_access)
def test_has_guest_access__unauthorized_guest_user__different_resource_type(self):
g.user = security_manager.get_guest_user_from_token(
- {"user": {}, "resources": [{"type": "dirt", "id": self.resource_id}]}
- )
- has_guest_access = security_manager.has_guest_access(
- GuestTokenResourceType.DASHBOARD, self.resource_id
+ {"user": {}, "resources": [{"type": "dirt", "id": self.embedded.uuid}]}
)
+ has_guest_access = security_manager.has_guest_access(self.dash)
self.assertFalse(has_guest_access)
- def test_get_guest_user_roles_explicit(self):
- guest = self.authorized_guest()
- roles = security_manager.get_user_roles(guest)
- self.assertEqual(guest.roles, roles)
-
- def test_get_guest_user_roles_implicit(self):
- guest = self.authorized_guest()
- g.user = guest
-
- roles = security_manager.get_user_roles()
- self.assertEqual(guest.roles, roles)
-
-
-@mock.patch.dict(
- "superset.extensions.feature_flag_manager._feature_flags",
- EMBEDDED_SUPERSET=True,
-)
-@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
-class TestGuestUserDashboardAccess(SupersetTestCase):
- def setUp(self) -> None:
- self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
- self.authorized_guest = security_manager.get_guest_user_from_token(
- {"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id}]}
- )
- self.unauthorized_guest = security_manager.get_guest_user_from_token(
- {"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id + 1}]}
- )
-
def test_chart_raise_for_access_as_guest(self):
chart = self.dash.slices[0]
g.user = self.authorized_guest
diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py
index d54e400817..48017f69d1 100644
--- a/tests/integration_tests/security_tests.py
+++ b/tests/integration_tests/security_tests.py
@@ -906,7 +906,9 @@ class TestRolePermission(SupersetTestCase):
["AuthDBView", "login"],
["AuthDBView", "logout"],
["CurrentUserRestApi", "get_me"],
+ # TODO (embedded) remove Dashboard:embedded after uuids have been shipped
["Dashboard", "embedded"],
+ ["EmbeddedView", "embedded"],
["R", "index"],
["Superset", "log"],
["Superset", "theme"],