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
This commit is contained in:
David Aaron Suddjian 2022-03-30 12:34:05 -07:00 committed by GitHub
parent a4c261d72c
commit 8e29ec5a66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1020 additions and 125 deletions

View File

@ -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": [

View File

@ -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')
});

View File

@ -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>
);
};

View File

@ -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')}

View File

@ -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}

View File

@ -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 } =

View File

@ -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;

View File

@ -152,3 +152,9 @@ export type DashboardPermalinkValue = {
hash: string;
};
};
export type EmbeddedDashboard = {
uuid: string;
dashboard_id: string;
allowed_domains: string[];
};

View File

@ -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

View File

@ -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`);

View File

@ -37,6 +37,9 @@ export let bootstrapData: {
user?: User | undefined;
common?: any;
config?: any;
embedded?: {
dashboard_id: string;
};
} = {};
// Configure translation
if (typeof window !== 'undefined') {

View File

@ -57,10 +57,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(
@ -112,7 +112,7 @@ export const routes: Routes = [
},
{
path: '/superset/dashboard/:idOrSlug/',
Component: DashboardPage,
Component: DashboardRoute,
},
{
path: '/chart/list/',

View File

@ -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,

View File

@ -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()

View File

@ -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")

View File

@ -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

View File

@ -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_(

View File

@ -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)

View File

@ -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 = (

View File

@ -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.

53
superset/embedded/dao.py Normal file
View File

@ -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.")

80
superset/embedded/view.py Normal file
View File

@ -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
),
)

View File

@ -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
@ -292,6 +293,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)

View File

@ -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"
)

View File

@ -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 []

View File

@ -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 = {
@ -1268,10 +1269,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()
@ -1410,15 +1409,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

View File

@ -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

View File

@ -161,6 +161,7 @@ class Dashboard(BaseSupersetView):
bootstrap_data = {
"common": common_bootstrap_payload(),
"embedded": {"dashboard_id": dashboard_id_or_slug},
}
return self.render_template(

View File

@ -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):
@ -1710,3 +1717,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)

View File

@ -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.

View File

@ -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)

View File

@ -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,60 +79,6 @@ class TestGuestUserSecurity(SupersetTestCase):
guest_user = security_manager.get_current_guest_user_if_guest()
self.assertEqual(guest_user, g.user)
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
)
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
)
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
)
self.assertTrue(has_guest_access)
def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
guest = self.authorized_guest()
guest.resources = [
{"type": "dashboard", "id": self.resource_id - 1}
] + guest.resources
g.user = guest
has_guest_access = security_manager.has_guest_access(
GuestTokenResourceType.DASHBOARD, self.resource_id
)
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}],
}
)
has_guest_access = security_manager.has_guest_access(
GuestTokenResourceType.DASHBOARD, self.resource_id
)
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
)
self.assertFalse(has_guest_access)
def test_get_guest_user_roles_explicit(self):
guest = self.authorized_guest()
roles = security_manager.get_user_roles(guest)
@ -158,13 +100,65 @@ class TestGuestUserSecurity(SupersetTestCase):
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": self.dash.id}]}
{
"user": {},
"resources": [{"type": "dashboard", "id": str(self.embedded.uuid)}],
}
)
self.unauthorized_guest = security_manager.get_guest_user_from_token(
{"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id + 1}]}
{
"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(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(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(self.dash)
self.assertTrue(has_guest_access)
def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
# set up a user who has authorized access, plus another resource
guest = self.authorized_guest
guest.resources = [
{"type": "dashboard", "id": "not-a-real-id"}
] + guest.resources
g.user = guest
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": "not-a-real-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.embedded.uuid}]}
)
has_guest_access = security_manager.has_guest_access(self.dash)
self.assertFalse(has_guest_access)
def test_chart_raise_for_access_as_guest(self):
chart = self.dash.slices[0]
g.user = self.authorized_guest

View File

@ -888,7 +888,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"],