diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
index b5820255a33b8..0f4c123ca974a 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
@@ -20,6 +20,7 @@
import SupersetClientClass from './SupersetClientClass';
import { SupersetClientInterface } from './types';
+// this is local to this file, don't expose it
let singletonClient: SupersetClientClass | undefined;
function getInstance(): SupersetClientClass {
@@ -39,7 +40,6 @@ const SupersetClient: SupersetClientInterface = {
reset: () => {
singletonClient = undefined;
},
- getInstance,
delete: request => getInstance().delete(request),
get: request => getInstance().get(request),
init: force => getInstance().init(force),
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
index ef52134f31376..39d5022be8a0b 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
@@ -40,6 +40,10 @@ export default class SupersetClientClass {
csrfPromise?: CsrfPromise;
+ guestToken?: string;
+
+ guestTokenHeaderName: string;
+
fetchRetryOptions?: FetchRetryOptions;
baseUrl: string;
@@ -64,6 +68,8 @@ export default class SupersetClientClass {
timeout,
credentials = undefined,
csrfToken = undefined,
+ guestToken = undefined,
+ guestTokenHeaderName = 'X-GuestToken',
}: ClientConfig = {}) {
const url = new URL(
host || protocol
@@ -81,6 +87,8 @@ export default class SupersetClientClass {
this.timeout = timeout;
this.credentials = credentials;
this.csrfToken = csrfToken;
+ this.guestToken = guestToken;
+ this.guestTokenHeaderName = guestTokenHeaderName;
this.fetchRetryOptions = {
...DEFAULT_FETCH_RETRY_OPTIONS,
...fetchRetryOptions,
@@ -89,6 +97,9 @@ export default class SupersetClientClass {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
this.csrfPromise = Promise.resolve(this.csrfToken);
}
+ if (guestToken) {
+ this.headers[guestTokenHeaderName] = guestToken;
+ }
}
async init(force = false): CsrfPromise {
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts
index 3f02f1c61d0c2..b8df5a95be136 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts
@@ -130,6 +130,8 @@ export interface ClientConfig {
protocol?: Protocol;
credentials?: Credentials;
csrfToken?: CsrfToken;
+ guestToken?: string;
+ guestTokenHeaderName?: string;
fetchRetryOptions?: FetchRetryOptions;
headers?: Headers;
mode?: Mode;
@@ -149,7 +151,6 @@ export interface SupersetClientInterface
| 'reAuthenticate'
> {
configure: (config?: ClientConfig) => SupersetClientClass;
- getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass;
reset: () => void;
}
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index a09f9b4f9f1e1..56135a7c3df30 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -40,6 +40,7 @@ export enum FeatureFlag {
DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS',
DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET',
DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL',
+ EMBEDDED_SUPERSET = 'EMBEDDED_SUPERSET',
ENABLE_FILTER_BOX_MIGRATION = 'ENABLE_FILTER_BOX_MIGRATION',
VERSIONED_EXPORT = 'VERSIONED_EXPORT',
GLOBAL_ASYNC_QUERIES = 'GLOBAL_ASYNC_QUERIES',
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
new file mode 100644
index 0000000000000..146f4ee83e728
--- /dev/null
+++ b/superset-frontend/src/embedded/index.tsx
@@ -0,0 +1,117 @@
+/**
+ * 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, { lazy, Suspense } from 'react';
+import ReactDOM from 'react-dom';
+import { BrowserRouter as Router, Route } from 'react-router-dom';
+import { bootstrapData } from 'src/preamble';
+import setupClient from 'src/setup/setupClient';
+import { RootContextProviders } from 'src/views/RootContextProviders';
+import ErrorBoundary from 'src/components/ErrorBoundary';
+import Loading from 'src/components/Loading';
+
+const LazyDashboardPage = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "DashboardPage" */ 'src/dashboard/containers/DashboardPage'
+ ),
+);
+
+const EmbeddedApp = () => (
+
+
+ }>
+
+
+
+
+
+
+
+
+);
+
+const appMountPoint = document.getElementById('app')!;
+
+const MESSAGE_TYPE = '__embedded_comms__';
+
+if (!window.parent) {
+ appMountPoint.innerHTML =
+ 'This page is intended to be embedded in an iframe, but no window.parent was found.';
+}
+
+// if the page is embedded in an origin that hasn't
+// been authorized by the curator, we forbid access entirely.
+// todo: check the referrer on the route serving this page instead
+// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
+// const parentOrigin = new URL(document.referrer).origin;
+// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
+// throw new Error(
+// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
+// );
+// }
+
+async function start(guestToken: string) {
+ // the preamble configures a client, but we need to configure a new one
+ // now that we have the guest token
+ setupClient({
+ guestToken,
+ guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
+ });
+ ReactDOM.render(, appMountPoint);
+}
+
+function validateMessageEvent(event: MessageEvent) {
+ if (
+ event.data?.type === 'webpackClose' ||
+ event.data?.source === '@devtools-page'
+ ) {
+ // sometimes devtools use the messaging api and we want to ignore those
+ throw new Error("Sir, this is a Wendy's");
+ }
+
+ // if (!ALLOW_ORIGINS.includes(event.origin)) {
+ // throw new Error('Message origin is not in the allowed list');
+ // }
+
+ if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
+ throw new Error(`Message type does not match type used for embedded comms`);
+ }
+}
+
+window.addEventListener('message', function (event) {
+ try {
+ validateMessageEvent(event);
+ } catch (err) {
+ console.info('[superset] ignoring message', err, event);
+ return;
+ }
+
+ console.info('[superset] received message', event);
+ const hostAppPort = event.ports?.[0];
+ if (hostAppPort) {
+ hostAppPort.onmessage = function receiveMessage(event) {
+ console.info('[superset] received message event', event.data);
+ if (event.data.guestToken) {
+ start(event.data.guestToken);
+ }
+ };
+ }
+});
+
+console.info('[superset] embed page is ready to receive messages');
diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts
index da2fac3f75fec..5380fe269861d 100644
--- a/superset-frontend/src/preamble.ts
+++ b/superset-frontend/src/preamble.ts
@@ -30,11 +30,11 @@ if (process.env.WEBPACK_MODE === 'development') {
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
}
-let bootstrapData: any;
+// eslint-disable-next-line import/no-mutable-exports
+export let bootstrapData: any;
// Configure translation
if (typeof window !== 'undefined') {
const root = document.getElementById('app');
-
bootstrapData = root
? JSON.parse(root.getAttribute('data-bootstrap') || '{}')
: {};
diff --git a/superset-frontend/src/setup/setupClient.ts b/superset-frontend/src/setup/setupClient.ts
index 11f3f6a1a2864..8802ae47227d1 100644
--- a/superset-frontend/src/setup/setupClient.ts
+++ b/superset-frontend/src/setup/setupClient.ts
@@ -16,22 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { SupersetClient, logging } from '@superset-ui/core';
+import { SupersetClient, logging, ClientConfig } from '@superset-ui/core';
import parseCookie from 'src/utils/parseCookie';
-export default function setupClient() {
+function getDefaultConfiguration(): ClientConfig {
const csrfNode = document.querySelector('#csrf_token');
const csrfToken = csrfNode?.value;
// when using flask-jwt-extended csrf is set in cookies
const cookieCSRFToken = parseCookie().csrf_access_token || '';
- SupersetClient.configure({
+ return {
protocol: ['http:', 'https:'].includes(window?.location?.protocol)
? (window?.location?.protocol as 'http:' | 'https:')
: undefined,
host: (window.location && window.location.host) || '',
csrfToken: csrfToken || cookieCSRFToken,
+ };
+}
+
+export default function setupClient(customConfig: Partial = {}) {
+ SupersetClient.configure({
+ ...getDefaultConfiguration(),
+ ...customConfig,
})
.init()
.catch(error => {
diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx
index 2d751cb2b24a9..4e193bbf776aa 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -18,42 +18,31 @@
*/
import React, { Suspense, useEffect } from 'react';
import { hot } from 'react-hot-loader/root';
-import { Provider as ReduxProvider } from 'react-redux';
import {
BrowserRouter as Router,
Switch,
Route,
useLocation,
} from 'react-router-dom';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
-import { QueryParamProvider } from 'use-query-params';
import { initFeatureFlags } from 'src/featureFlags';
-import { ThemeProvider } from '@superset-ui/core';
-import { DynamicPluginProvider } from 'src/components/DynamicPlugins';
-import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import ErrorBoundary from 'src/components/ErrorBoundary';
import Loading from 'src/components/Loading';
import Menu from 'src/views/components/Menu';
-import FlashProvider from 'src/components/FlashProvider';
-import { theme } from 'src/preamble';
+import { bootstrapData } from 'src/preamble';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import setupApp from 'src/setup/setupApp';
import { routes, isFrontendRoute } from 'src/views/routes';
import { Logger } from 'src/logger/LogUtils';
-import { store } from './store';
+import { RootContextProviders } from './RootContextProviders';
setupApp();
-const container = document.getElementById('app');
-const bootstrap = JSON.parse(container?.getAttribute('data-bootstrap') ?? '{}');
-const user = { ...bootstrap.user };
-const menu = { ...bootstrap.common.menu_data };
-const common = { ...bootstrap.common };
+const user = { ...bootstrapData.user };
+const menu = { ...bootstrapData.common.menu_data };
let lastLocationPathname: string;
-initFeatureFlags(bootstrap.common.feature_flags);
+initFeatureFlags(bootstrapData.common.feature_flags);
-const RootContextProviders: React.FC = ({ children }) => {
+const LocationPathnameLogger = () => {
const location = useLocation();
useEffect(() => {
// reset performance logger timer start point to avoid soft navigation
@@ -63,31 +52,12 @@ const RootContextProviders: React.FC = ({ children }) => {
}
lastLocationPathname = location.pathname;
}, [location.pathname]);
-
- return (
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
- );
+ return <>>;
};
const App = () => (
+
diff --git a/superset-frontend/src/views/RootContextProviders.tsx b/superset-frontend/src/views/RootContextProviders.tsx
new file mode 100644
index 0000000000000..f40f228bb8c13
--- /dev/null
+++ b/superset-frontend/src/views/RootContextProviders.tsx
@@ -0,0 +1,55 @@
+/**
+ * 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 from 'react';
+import { Route } from 'react-router-dom';
+import { ThemeProvider } from '@superset-ui/core';
+import { Provider as ReduxProvider } from 'react-redux';
+import { QueryParamProvider } from 'use-query-params';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+
+import { store } from './store';
+import FlashProvider from '../components/FlashProvider';
+import { bootstrapData, theme } from '../preamble';
+import { EmbeddedUiConfigProvider } from '../components/UiConfigContext';
+import { DynamicPluginProvider } from '../components/DynamicPlugins';
+
+const common = { ...bootstrapData.common };
+
+export const RootContextProviders: React.FC = ({ children }) => (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+);
diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js
index c482706cd6c2f..16c0bccdf63d5 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -208,6 +208,7 @@ const config = {
theme: path.join(APP_DIR, '/src/theme.ts'),
menu: addPreamble('src/views/menu.tsx'),
spa: addPreamble('/src/views/index.tsx'),
+ embedded: addPreamble('/src/embedded/index.tsx'),
addSlice: addPreamble('/src/addSlice/index.tsx'),
explore: addPreamble('/src/explore/index.jsx'),
sqllab: addPreamble('/src/SqlLab/index.tsx'),
diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html
index 1e38cb2e75be7..6a0312f4f0955 100644
--- a/superset/templates/superset/spa.html
+++ b/superset/templates/superset/spa.html
@@ -22,6 +22,6 @@
{% endblock %}
{% block tail_js %}
- {{ js_bundle("spa") }}
+ {{ js_bundle(entry) }}
{% include "tail_js_custom_extra.html" %}
{% endblock %}
diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py
index 5e03d24d13fd4..99782def38a68 100644
--- a/superset/views/dashboard/views.py
+++ b/superset/views/dashboard/views.py
@@ -16,7 +16,7 @@
# under the License.
import json
import re
-from typing import List, Union
+from typing import Callable, List, Union
from flask import g, redirect, request, Response
from flask_appbuilder import expose
@@ -24,8 +24,9 @@
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.decorators import has_access
from flask_babel import gettext as __, lazy_gettext as _
+from flask_login import AnonymousUserMixin, LoginManager
-from superset import db, event_logger, is_feature_enabled
+from superset import db, event_logger, is_feature_enabled, security_manager
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.models.dashboard import Dashboard as DashboardModel
from superset.typing import FlaskResponse
@@ -33,6 +34,7 @@
from superset.views.base import (
BaseSupersetView,
check_ownership,
+ common_bootstrap_payload,
DeleteMixin,
generate_download_headers,
SupersetModelView,
@@ -133,6 +135,44 @@ def new(self) -> FlaskResponse: # pylint: disable=no-self-use
db.session.commit()
return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true")
+ @expose("//embedded")
+ @event_logger.log_this_with_extra_payload
+ def embedded(
+ self,
+ dashboard_id_or_slug: str,
+ add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
+ ) -> FlaskResponse:
+ """
+ Server side rendering for a dashboard
+ :param dashboard_id_or_slug: identifier for dashboard. used in the decorators
+ :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"):
+ return Response(status=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(
+ dashboard_id=dashboard_id_or_slug, dashboard_version="v2",
+ )
+
+ bootstrap_data = {
+ "common": common_bootstrap_payload(),
+ }
+
+ return self.render_template(
+ "superset/spa.html",
+ entry="embedded",
+ bootstrap_data=json.dumps(
+ bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
+ ),
+ )
+
class DashboardModelViewAsync(DashboardModelView): # pylint: disable=too-many-ancestors
route_base = "/dashboardasync"
diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py
index 232caea284816..a8af64e8b5bd1 100644
--- a/tests/integration_tests/security_tests.py
+++ b/tests/integration_tests/security_tests.py
@@ -920,6 +920,7 @@ def test_views_are_secured(self):
["LocaleView", "index"],
["AuthDBView", "login"],
["AuthDBView", "logout"],
+ ["Dashboard", "embedded"],
["R", "index"],
["Superset", "log"],
["Superset", "theme"],