diff --git a/embed-demo.html b/embed-demo.html new file mode 100644 index 000000000000..1a6e46e9118e --- /dev/null +++ b/embed-demo.html @@ -0,0 +1,110 @@ + + + + Superset Embedded Chart Demo + + + +

Superset Embedded Chart Demo

+ +
+ + + +
+ +
+

Chart will appear here

+
+ + + + diff --git a/superset-frontend/src/dashboard/components/EmbeddedChartModal/index.tsx b/superset-frontend/src/dashboard/components/EmbeddedChartModal/index.tsx new file mode 100644 index 000000000000..7e2efbd0b014 --- /dev/null +++ b/superset-frontend/src/dashboard/components/EmbeddedChartModal/index.tsx @@ -0,0 +1,292 @@ +/** + * 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 { useCallback, useEffect, useState } from 'react'; +import { makeApi, SupersetApiError } from '@superset-ui/core'; +import { logging } from '@apache-superset/core'; +import { styled, css, Alert, t } from '@apache-superset/core/ui'; +import { + Button, + FormItem, + InfoTooltip, + Input, + Modal, + Loading, + Form, + Space, +} from '@superset-ui/core/components'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { Typography } from '@superset-ui/core/components/Typography'; +import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon'; + +type Props = { + chartId: number; + formData: Record; + show: boolean; + onHide: () => void; +}; + +type EmbeddedChart = { + uuid: string; + allowed_domains: string[]; + chart_id: number; + changed_on: string; +}; + +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 ChartEmbedControls = ({ chartId, onHide }: Props) => { + const { addInfoToast, addDangerToast } = useToasts(); + const [ready, setReady] = useState(true); + const [loading, setLoading] = useState(false); + const [embedded, setEmbedded] = useState(null); + const [allowedDomains, setAllowedDomains] = useState(''); + const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false); + + const endpoint = `/api/v1/chart/${chartId}/embedded`; + const isDirty = + !embedded || + stringToList(allowedDomains).join() !== embedded.allowed_domains.join(); + + const enableEmbedded = useCallback(() => { + setLoading(true); + makeApi({ + method: 'POST', + endpoint, + })({ + allowed_domains: stringToList(allowedDomains), + }) + .then( + ({ result }) => { + setEmbedded(result); + setAllowedDomains(result.allowed_domains.join(', ')); + addInfoToast(t('Changes saved.')); + }, + err => { + logging.error(err); + addDangerToast( + t('Sorry, something went wrong. The changes could not be saved.'), + ); + }, + ) + .finally(() => { + setLoading(false); + }); + }, [endpoint, allowedDomains, addInfoToast, addDangerToast]); + + const disableEmbedded = useCallback(() => { + setShowDeactivateConfirm(true); + }, []); + + const confirmDeactivate = useCallback(() => { + setLoading(true); + makeApi({ method: 'DELETE', endpoint })({}) + .then( + () => { + setEmbedded(null); + setAllowedDomains(''); + setShowDeactivateConfirm(false); + addInfoToast(t('Embedding deactivated.')); + onHide(); + }, + err => { + logging.error(err); + addDangerToast( + t( + 'Sorry, something went wrong. Embedding could not be deactivated.', + ), + ); + }, + ) + .finally(() => { + setLoading(false); + }); + }, [endpoint, addInfoToast, addDangerToast, onHide]); + + useEffect(() => { + setReady(false); + makeApi({ + method: 'GET', + endpoint, + })({}) + .catch(err => { + if ((err as SupersetApiError).status === 404) { + return { result: null }; + } + addDangerToast(t('Sorry, something went wrong. Please try again.')); + throw err; + }) + .then(({ result }) => { + setEmbedded(result); + setAllowedDomains(result ? result.allowed_domains.join(', ') : ''); + }) + .finally(() => { + setReady(true); + }); + }, [chartId, addDangerToast, endpoint]); + + if (!ready) { + return ; + } + + return ( + <> + {embedded ? ( +

+ {t( + 'This chart is ready to embed. In your application, pass the following id to the SDK:', + )} +
+ {embedded.uuid} +

+ ) : ( +

+ {t( + 'Configure this chart to embed it into an external web application.', + )} +

+ )} +

+ {t('For further instructions, consult the')}{' '} + + {t('Superset Embedded SDK documentation.')} + +

+

{t('Settings')}

+
+ + {t('Allowed Domains (comma separated)')}{' '} + + + } + > + setAllowedDomains(event.target.value)} + /> + +
+ {showDeactivateConfirm ? ( + + + + + } + /> + ) : ( + css` + margin-top: ${theme.margin}px; + `} + > + {embedded ? ( + <> + + + + ) : ( + + )} + + )} + + ); +}; + +const EmbeddedChartModal = (props: Props) => { + const { show, onHide } = props; + + return ( + } + > + + + ); +}; + +export default EmbeddedChartModal; diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 142e7f8e65bc..f0564d557cb3 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -61,6 +61,7 @@ import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import { ViewResultsModalTrigger } from './ViewResultsModalTrigger'; +import EmbeddedChartModal from '../EmbeddedChartModal'; const RefreshTooltip = styled.div` ${({ theme }) => css` @@ -154,6 +155,7 @@ const SliceHeaderControls = ( props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps, ) => { const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); + const [embedModalIsOpen, setEmbedModalIsOpen] = useState(false); // setting openKeys undefined falls back to uncontrolled behaviour const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [openScopingModal, scopingModal] = useCrossFiltersScopingModal( @@ -292,6 +294,10 @@ const SliceHeaderControls = ( setDrillModalIsOpen(!drillModalIsOpen); break; } + case MenuKeys.EmbedChart: { + setEmbedModalIsOpen(true); + break; + } case MenuKeys.ViewQuery: { if (queryMenuRef.current && !queryMenuRef.current.showModal) { queryMenuRef.current.open(domEvent); @@ -498,6 +504,15 @@ const SliceHeaderControls = ( newMenuItems.push(shareMenuItems); } + // Add "Embed chart" option - available when user can share + if (supersetCanShare) { + newMenuItems.push({ + key: MenuKeys.EmbedChart, + label: t('Embed chart'), + icon: , + }); + } + if (props.supersetCanCSV) { newMenuItems.push({ type: 'submenu', @@ -603,6 +618,13 @@ const SliceHeaderControls = ( dataset={datasetWithVerboseMap} /> + setEmbedModalIsOpen(false)} + /> + {canEditCrossFilters && scopingModal} ); diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index b2bde9683681..65e305bc2241 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -310,4 +310,5 @@ export enum MenuKeys { ManageEmailReports = 'manage_email_reports', ExportPivotXlsx = 'export_pivot_xlsx', EmbedCode = 'embed_code', + EmbedChart = 'embed_chart', } diff --git a/superset-frontend/src/embeddedChart/index.tsx b/superset-frontend/src/embeddedChart/index.tsx new file mode 100644 index 000000000000..f6c29381941d --- /dev/null +++ b/superset-frontend/src/embeddedChart/index.tsx @@ -0,0 +1,338 @@ +/** + * 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 'src/public-path'; + +import { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { + makeApi, + QueryFormData, + StatefulChart, +} from '@superset-ui/core'; +import { logging } from '@apache-superset/core'; +import { t } from '@apache-superset/core/ui'; +import Switchboard from '@superset-ui/switchboard'; +import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData'; +import setupClient from 'src/setup/setupClient'; +import setupPlugins from 'src/setup/setupPlugins'; +import { store, USER_LOADED } from 'src/views/store'; +import { Loading } from '@superset-ui/core/components'; +import { ErrorBoundary, DynamicPluginProvider } from 'src/components'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import ToastContainer from 'src/components/MessageToasts/ToastContainer'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { SupersetThemeProvider } from 'src/theme/ThemeProvider'; +import { ThemeController } from 'src/theme/ThemeController'; +import type { ThemeStorage } from '@apache-superset/core/ui'; +import { Provider as ReduxProvider } from 'react-redux'; + +setupPlugins(); + +const debugMode = process.env.WEBPACK_MODE === 'development'; +const bootstrapData = getBootstrapData(); + +function log(...info: unknown[]) { + if (debugMode) logging.debug(`[superset-embedded-chart]`, ...info); +} + +/** + * In-memory implementation of ThemeStorage interface for embedded contexts. + */ +class ThemeMemoryStorageAdapter implements ThemeStorage { + private storage = new Map(); + + getItem(key: string): string | null { + return this.storage.get(key) || null; + } + + setItem(key: string, value: string): void { + this.storage.set(key, value); + } + + removeItem(key: string): void { + this.storage.delete(key); + } +} + +const themeController = new ThemeController({ + storage: new ThemeMemoryStorageAdapter(), +}); + +interface PermalinkState { + formData: QueryFormData; +} + +const appMountPoint = document.getElementById('app')!; +const MESSAGE_TYPE = '__embedded_comms__'; + +function showFailureMessage(message: string) { + appMountPoint.innerHTML = `
${message}
`; +} + +if (!window.parent || window.parent === window) { + showFailureMessage( + t( + 'This page is intended to be embedded in an iframe, but it looks like that is not the case.', + ), + ); +} + +let displayedUnauthorizedToast = false; + +/** + * Handle unauthorized errors from the API. + */ +function guestUnauthorizedHandler() { + if (displayedUnauthorizedToast) return; + displayedUnauthorizedToast = true; + store.dispatch( + addDangerToast( + t( + 'This session has encountered an interruption. The embedded chart may not load correctly.', + ), + { + duration: -1, + noDuplicate: true, + }, + ), + ); +} + +/** + * Configures SupersetClient with the correct settings for the embedded chart page. + */ +function setupGuestClient(guestToken: string) { + setupClient({ + appRoot: applicationRoot(), + guestToken, + guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME, + unauthorizedHandler: guestUnauthorizedHandler, + }); +} + +function validateMessageEvent(event: MessageEvent) { + const { data } = event; + if (data == null || typeof data !== 'object' || data.type !== MESSAGE_TYPE) { + throw new Error(`Message type does not match type used for embedded comms`); + } +} + +/** + * Embedded Chart component that fetches formData from permalink and renders StatefulChart. + */ +function EmbeddedChartApp() { + const [formData, setFormData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + async function fetchPermalinkData() { + const urlParams = new URLSearchParams(window.location.search); + const permalinkKey = urlParams.get('permalink_key'); + + if (!permalinkKey) { + if (isMounted) { + setError(t('Missing permalink_key parameter')); + setLoading(false); + } + return; + } + + try { + const getPermalinkData = makeApi({ + method: 'GET', + endpoint: `/api/v1/embedded_chart/${permalinkKey}`, + }); + + const response = await getPermalinkData(); + const { state } = response; + + if (!state?.formData) { + if (isMounted) { + setError(t('Invalid permalink data: missing formData')); + setLoading(false); + } + return; + } + + log('Loaded formData from permalink:', state.formData); + if (isMounted) { + setFormData(state.formData); + setLoading(false); + } + } catch (err) { + logging.error('Failed to load permalink data:', err); + if (isMounted) { + setError( + t('Failed to load chart data. The permalink may have expired.'), + ); + setLoading(false); + } + } + } + + fetchPermalinkData(); + + return () => { + isMounted = false; + }; + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!formData) { + return null; + } + + return ( +
+ +
+ ); +} + +/** + * Context providers wrapper for the embedded chart. + */ +function EmbeddedChartWithProviders() { + return ( + + + + + + + + + + + ); +} + +function start() { + const getMeWithRole = makeApi({ + method: 'GET', + endpoint: '/api/v1/me/roles/', + }); + return getMeWithRole().then( + ({ result }) => { + bootstrapData.user = result; + store.dispatch({ + type: USER_LOADED, + user: result, + }); + ReactDOM.render(, appMountPoint); + }, + err => { + logging.error(err); + showFailureMessage( + t( + 'Something went wrong with embedded authentication. Check the dev console for details.', + ), + ); + }, + ); +} + +let started = false; + +window.addEventListener('message', function embeddedChartInitializer(event) { + try { + validateMessageEvent(event); + } catch (err) { + log('ignoring message unrelated to embedded comms', err, event); + return; + } + + // Simple direct guestToken message (no Switchboard required) + if (event.data.guestToken && !event.data.handshake) { + log('received direct guestToken message'); + setupGuestClient(event.data.guestToken); + if (!started) { + started = true; + start().catch(err => { + logging.error('Failed to start after receiving guestToken', err); + started = false; + }); + } + return; + } + + // Switchboard-based communication (with MessagePort) + const port = event.ports?.[0]; + if (event.data.handshake === 'port transfer' && port) { + log('message port received', event); + + Switchboard.init({ + port, + name: 'superset-embedded-chart', + debug: debugMode, + }); + + Switchboard.defineMethod( + 'guestToken', + ({ guestToken }: { guestToken: string }) => { + setupGuestClient(guestToken); + if (!started) { + started = true; + start().catch(err => { + logging.error('Failed to start after receiving guestToken', err); + started = false; + }); + } + }, + ); + + Switchboard.start(); + } +}); + +log('embedded chart page is ready to receive messages'); diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.jsx b/superset-frontend/src/explore/components/EmbedCodeContent.jsx index 5668611d7854..c232f21b9c73 100644 --- a/superset-frontend/src/explore/components/EmbedCodeContent.jsx +++ b/superset-frontend/src/explore/components/EmbedCodeContent.jsx @@ -18,16 +18,15 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { css, t } from '@apache-superset/core/ui'; +import { makeApi } from '@superset-ui/core'; import { Input, Space, Typography } from '@superset-ui/core/components'; import { CopyToClipboard } from 'src/components'; -import { URL_PARAMS } from 'src/constants'; -import { getChartPermalink } from 'src/utils/urlUtils'; -import { Icons } from '@superset-ui/core/components/Icons'; const EmbedCodeContent = ({ formData, addDangerToast }) => { const [height, setHeight] = useState('400'); const [width, setWidth] = useState('600'); - const [url, setUrl] = useState(''); + const [embedData, setEmbedData] = useState(null); + const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const handleInputChange = useCallback(e => { @@ -40,42 +39,66 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { } }, []); - const updateUrl = useCallback(() => { - setUrl(''); - getChartPermalink(formData) - .then(result => { - if (result?.url) { - setUrl(result.url); - setErrorMessage(''); - } - }) - .catch(() => { - setErrorMessage(t('Error')); - addDangerToast(t('Sorry, something went wrong. Try again later.')); + const generateEmbedCode = useCallback(async () => { + if (!formData) return; + + setLoading(true); + setErrorMessage(''); + + try { + const createEmbeddedChart = makeApi({ + method: 'POST', + endpoint: '/api/v1/embedded_chart/', + }); + + const response = await createEmbeddedChart({ + form_data: formData, + allowed_domains: [], + ttl_minutes: 60, }); + + setEmbedData(response); + } catch (err) { + setErrorMessage(t('Error generating embed code')); + addDangerToast(t('Sorry, something went wrong. Try again later.')); + } finally { + setLoading(false); + } }, [addDangerToast, formData]); useEffect(() => { - updateUrl(); + generateEmbedCode(); }, []); const html = useMemo(() => { - if (!url) return ''; - const srcLink = `${url}?${URL_PARAMS.standalone.name}=1&height=${height}`; - return ( - '\n' + - '' + if (!embedData?.iframe_url || !embedData?.guest_token) return ''; + + const origin = new URL(embedData.iframe_url).origin; + + return ` + +`; + }, [height, width, embedData]); + + const text = loading + ? t('Generating embed code...') + : errorMessage || html || t('No embed data available'); - const text = errorMessage || html || t('Generating link, please wait..'); return (
{ text={html} copyNode={ - + 📋 } /> @@ -98,7 +121,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { name="embedCode" disabled={!html} value={text} - rows="4" + rows="10" readOnly css={theme => css` resize: vertical; @@ -107,6 +130,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { font-size: ${theme.fontSizeSM}px; border-radius: 4px; background-color: ${theme.colorBgElevated}; + font-family: monospace; `} />
@@ -138,6 +162,19 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { />
+ {embedData?.expires_at && ( + css` + display: block; + margin-top: ${theme.margin}px; + font-size: ${theme.fontSizeSM}px; + `} + > + {t('Token expires')}:{' '} + {new Date(embedData.expires_at).toLocaleString()} + + )} ); }; diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 8be2bea51bc5..e8c44d43d526 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -175,18 +175,23 @@ export const ExploreChartHeader = ({ [redirectSQLLab, history], ); - const [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState] = - useExploreAdditionalActionsMenu( - latestQueryFormData, - canDownload, - slice, - redirectToSQLLab, - openPropertiesModal, - ownState, - metadata?.dashboards, - showReportModal, - setCurrentReportDeleting, - ); + const [ + menu, + isDropdownVisible, + setIsDropdownVisible, + streamingExportState, + embedChartModal, + ] = useExploreAdditionalActionsMenu( + latestQueryFormData, + canDownload, + slice, + redirectToSQLLab, + openPropertiesModal, + ownState, + metadata?.dashboards, + showReportModal, + setCurrentReportDeleting, + ); const metadataBar = useExploreMetadataBar(metadata, slice); const oldSliceName = slice?.slice_name; @@ -356,6 +361,8 @@ export const ExploreChartHeader = ({ onDownload={streamingExportState.onDownload} progress={streamingExportState.progress} /> + + {embedChartModal} ); }; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 05c9e1997295..e122a97e9fdd 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -48,6 +48,7 @@ import exportPivotExcel from 'src/utils/downloadAsPivotExcel'; import { useStreamingExport } from 'src/components/StreamingExportModal'; import ViewQueryModal from '../controls/ViewQueryModal'; import EmbedCodeContent from '../EmbedCodeContent'; +import EmbeddedChartModal from 'src/dashboard/components/EmbeddedChartModal'; import { useDashboardsMenuItems } from './DashboardsSubMenu'; export const SEARCH_THRESHOLD = 10; @@ -72,6 +73,7 @@ const MENU_KEYS = { SHARE_SUBMENU: 'share_submenu', COPY_PERMALINK: 'copy_permalink', EMBED_CODE: 'embed_code', + EMBED_CHART: 'embed_chart', SHARE_BY_EMAIL: 'share_by_email', REPORT_SUBMENU: 'report_submenu', SET_UP_REPORT: 'set_up_report', @@ -136,6 +138,7 @@ export const useExploreAdditionalActionsMenu = ( const dispatch = useDispatch(); const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [dashboardSearchTerm, setDashboardSearchTerm] = useState(''); + const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false); const debouncedDashboardSearchTerm = useDebounceValue( dashboardSearchTerm, 300, @@ -800,27 +803,38 @@ export const useExploreAdditionalActionsMenu = ( }, ]; - if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) { + // Embed code is always available (simple iframe URL) + shareChildren.push({ + key: MENU_KEYS.EMBED_CODE, + label: ( + {t('Embed code')} + } + modalTitle={t('Embed code')} + modalBody={ + + } + maxWidth={`${theme.sizeUnit * 100}px`} + destroyOnHidden + responsive + /> + ), + onClick: () => setIsDropdownVisible(false), + }); + + // Add persistent embed chart option (only for saved charts with EMBEDDABLE_CHARTS enabled) + if (isFeatureEnabled(FeatureFlag.EmbeddableCharts) && slice?.slice_id) { shareChildren.push({ - key: MENU_KEYS.EMBED_CODE, - label: ( - {t('Embed code')} - } - modalTitle={t('Embed code')} - modalBody={ - - } - maxWidth={`${theme.sizeUnit * 100}px`} - destroyOnHidden - responsive - /> - ), - onClick: () => setIsDropdownVisible(false), + key: MENU_KEYS.EMBED_CHART, + label: t('Embed chart'), + onClick: () => { + setIsEmbedModalOpen(true); + setIsDropdownVisible(false); + }, }); } @@ -907,5 +921,21 @@ export const useExploreAdditionalActionsMenu = ( onDownload: handleDownloadComplete, }; - return [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState]; + // Embed chart modal component + const embedChartModal = slice?.slice_id ? ( + setIsEmbedModalOpen(false)} + /> + ) : null; + + return [ + menu, + isDropdownVisible, + setIsDropdownVisible, + streamingExportState, + embedChartModal, + ]; }; diff --git a/superset-frontend/src/pages/EmbeddedChartsList/index.tsx b/superset-frontend/src/pages/EmbeddedChartsList/index.tsx new file mode 100644 index 000000000000..ec7cadc5edb8 --- /dev/null +++ b/superset-frontend/src/pages/EmbeddedChartsList/index.tsx @@ -0,0 +1,249 @@ +/** + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { SupersetClient } from '@superset-ui/core'; +import { styled, css, t } from '@apache-superset/core/ui'; +import { DeleteModal, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import { ListView } from 'src/components'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; + +const PAGE_SIZE = 25; + +interface EmbeddedChart { + uuid: string; + chart_id: number; + chart_name: string | null; + allowed_domains: string[]; + changed_on: string | null; +} + +interface EmbeddedChartsListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +const ActionsWrapper = styled.div` + display: flex; + gap: 8px; +`; + +const ActionButton = styled.button` + ${({ theme }) => css` + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: ${theme.colorTextSecondary}; + &:hover { + color: ${theme.colorPrimary}; + } + `} +`; + +function EmbeddedChartsList({ + addDangerToast, + addSuccessToast, +}: EmbeddedChartsListProps) { + const [embeddedCharts, setEmbeddedCharts] = useState([]); + const [loading, setLoading] = useState(true); + const [chartToDelete, setChartToDelete] = useState( + null, + ); + + const fetchEmbeddedCharts = useCallback(async () => { + setLoading(true); + try { + const response = await SupersetClient.get({ + endpoint: '/api/v1/chart/embedded', + }); + setEmbeddedCharts(response.json.result || []); + } catch (error) { + addDangerToast(t('Error loading embedded charts')); + } finally { + setLoading(false); + } + }, [addDangerToast]); + + useEffect(() => { + fetchEmbeddedCharts(); + }, [fetchEmbeddedCharts]); + + const handleDelete = useCallback( + async (chart: EmbeddedChart) => { + try { + await SupersetClient.delete({ + endpoint: `/api/v1/chart/${chart.chart_id}/embedded`, + }); + addSuccessToast(t('Embedding disabled for %s', chart.chart_name)); + fetchEmbeddedCharts(); + } catch (error) { + addDangerToast(t('Error disabling embedding for %s', chart.chart_name)); + } finally { + setChartToDelete(null); + } + }, + [addDangerToast, addSuccessToast, fetchEmbeddedCharts], + ); + + const copyToClipboard = useCallback( + (text: string) => { + navigator.clipboard.writeText(text).then( + () => addSuccessToast(t('UUID copied to clipboard')), + () => addDangerToast(t('Failed to copy to clipboard')), + ); + }, + [addDangerToast, addSuccessToast], + ); + + const columns = useMemo( + () => [ + { + accessor: 'chart_name', + Header: t('Chart'), + Cell: ({ + row: { + original: { chart_id, chart_name }, + }, + }: { + row: { original: EmbeddedChart }; + }) => ( + + {chart_name || t('Untitled')} + + ), + }, + { + accessor: 'uuid', + Header: t('UUID'), + Cell: ({ + row: { + original: { uuid }, + }, + }: { + row: { original: EmbeddedChart }; + }) => ( + + copyToClipboard(uuid)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && copyToClipboard(uuid)} + > + {uuid.substring(0, 8)}... + + + ), + }, + { + accessor: 'allowed_domains', + Header: t('Allowed Domains'), + Cell: ({ + row: { + original: { allowed_domains }, + }, + }: { + row: { original: EmbeddedChart }; + }) => + allowed_domains.length > 0 ? allowed_domains.join(', ') : t('Any'), + }, + { + accessor: 'changed_on', + Header: t('Last Modified'), + Cell: ({ + row: { + original: { changed_on }, + }, + }: { + row: { original: EmbeddedChart }; + }) => + changed_on ? new Date(changed_on).toLocaleDateString() : t('N/A'), + }, + { + accessor: 'actions', + Header: t('Actions'), + disableSortBy: true, + Cell: ({ row: { original } }: { row: { original: EmbeddedChart } }) => ( + + + copyToClipboard(original.uuid)}> + + + + + + + + + + + + setChartToDelete(original)}> + + + + + ), + }, + ], + [copyToClipboard], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + + return ( + <> + + + className="embedded-charts-list-view" + columns={columns} + data={embeddedCharts} + count={embeddedCharts.length} + pageSize={PAGE_SIZE} + loading={loading} + initialSort={[{ id: 'chart_name', desc: false }]} + fetchData={() => {}} + refreshData={fetchEmbeddedCharts} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + bulkSelectEnabled={false} + disableBulkSelect={() => {}} + bulkActions={[]} + /> + {chartToDelete && ( + handleDelete(chartToDelete)} + onHide={() => setChartToDelete(null)} + open + title={t('Disable Embedding?')} + /> + )} + + ); +} + +export default withToasts(EmbeddedChartsList); diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index db4fff7214c0..89b80d981130 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -99,6 +99,13 @@ const ExecutionLogList = lazy( ), ); +const EmbeddedChartsList = lazy( + () => + import( + /* webpackChunkName: "EmbeddedChartsList" */ 'src/pages/EmbeddedChartsList' + ), +); + const Chart = lazy( () => import(/* webpackChunkName: "Chart" */ 'src/pages/Chart'), ); @@ -289,6 +296,10 @@ export const routes: Routes = [ path: '/rowlevelsecurity/list', Component: RowLevelSecurityList, }, + { + path: '/embeddedcharts/list/', + Component: EmbeddedChartsList, + }, { path: '/sqllab/', Component: SqlLab, diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 03e48cc3c577..8501883c9d40 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -300,6 +300,7 @@ const config = { menu: addPreamble('src/views/menu.tsx'), spa: addPreamble('/src/views/index.tsx'), embedded: addPreamble('/src/embedded/index.tsx'), + embeddedChart: addPreamble('/src/embeddedChart/index.tsx'), }, cache: { type: 'filesystem', // Enable filesystem caching diff --git a/superset/charts/api.py b/superset/charts/api.py index 66c0781ffb31..26c442373a47 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -22,7 +22,7 @@ from zipfile import is_zipfile, ZipFile from flask import redirect, request, Response, send_file, url_for -from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.api import expose, permission_name, protect, rison, safe from flask_appbuilder.hooks import before_request from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -48,6 +48,8 @@ ChartGetResponseSchema, ChartPostSchema, ChartPutSchema, + EmbeddedChartConfigSchema, + EmbeddedChartResponseSchema, get_delete_ids_schema, get_export_ids_schema, get_fav_star_ids_schema, @@ -56,7 +58,10 @@ thumbnail_query_schema, ) from superset.commands.chart.create import CreateChartCommand -from superset.commands.chart.delete import DeleteChartCommand +from superset.commands.chart.delete import ( + DeleteChartCommand, + DeleteEmbeddedChartCommand, +) from superset.commands.chart.exceptions import ( ChartCreateFailedError, ChartDeleteFailedError, @@ -79,9 +84,10 @@ ) from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod -from superset.daos.chart import ChartDAO +from superset.daos.chart import ChartDAO, EmbeddedChartDAO from superset.exceptions import ScreenshotImageNotAvailableException -from superset.extensions import event_logger +from superset.extensions import db, event_logger +from superset.models.embedded_chart import EmbeddedChart from superset.models.slice import Slice from superset.tasks.thumbnails import cache_chart_thumbnail from superset.tasks.utils import get_current_user @@ -130,6 +136,10 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "screenshot", "cache_screenshot", "warm_up_cache", + "get_embedded", + "set_embedded", + "delete_embedded", + "list_embedded", } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP @@ -232,6 +242,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() chart_get_response_schema = ChartGetResponseSchema() + embedded_response_schema = EmbeddedChartResponseSchema() + embedded_config_schema = EmbeddedChartConfigSchema() openapi_spec_tag = "Charts" """ Override the name set for this collection of endpoints """ @@ -1183,3 +1195,253 @@ def import_(self) -> Response: ) command.run() return self.response(200, message="OK") + + @expose("//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, + ) + def get_embedded(self, id_or_uuid: str) -> Response: + """Get the chart's embedded configuration. + --- + get: + summary: Get the chart's embedded configuration + parameters: + - in: path + schema: + type: string + name: id_or_uuid + description: The chart id or uuid + responses: + 200: + description: Result contains the embedded chart config + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedChartResponseSchema' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + chart = ChartDAO.get_by_id_or_uuid(id_or_uuid) + except ChartNotFoundError: + return self.response_404() + + if not chart.embedded: + return self.response(404) + embedded: EmbeddedChart = chart.embedded[0] + result = self.embedded_response_schema.dump(embedded) + return self.response(200, result=result) + + @expose("//embedded", methods=["POST", "PUT"]) + @protect() + @safe + @permission_name("set_embedded") + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded", + log_to_statsd=False, + ) + def set_embedded(self, id_or_uuid: str) -> Response: + """Set a chart's embedded configuration. + --- + post: + summary: Set a chart's embedded configuration + parameters: + - in: path + schema: + type: string + name: id_or_uuid + description: The chart id or uuid + requestBody: + description: The embedded configuration to set + required: true + content: + application/json: + schema: EmbeddedChartConfigSchema + responses: + 200: + description: Successfully set the configuration + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedChartResponseSchema' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + put: + description: >- + Sets a chart's embedded configuration. + parameters: + - in: path + schema: + type: string + name: id_or_uuid + description: The chart id or uuid + requestBody: + description: The embedded configuration to set + required: true + content: + application/json: + schema: EmbeddedChartConfigSchema + responses: + 200: + description: Successfully set the configuration + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedChartResponseSchema' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + chart = ChartDAO.get_by_id_or_uuid(id_or_uuid) + except ChartNotFoundError: + return self.response_404() + + try: + body = self.embedded_config_schema.load(request.json) + + embedded = EmbeddedChartDAO.upsert( + chart, + body["allowed_domains"], + ) + db.session.commit() # pylint: disable=consider-using-transaction + + result = self.embedded_response_schema.dump(embedded) + return self.response(200, result=result) + except ValidationError as error: + db.session.rollback() # pylint: disable=consider-using-transaction + return self.response_400(message=error.messages) + + @expose("//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, + ) + def delete_embedded(self, id_or_uuid: str) -> Response: + """Delete a chart's embedded configuration. + --- + delete: + summary: Delete a chart's embedded configuration + parameters: + - in: path + schema: + type: string + name: id_or_uuid + description: The chart id or uuid + responses: + 200: + description: Successfully removed the configuration + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + chart = ChartDAO.get_by_id_or_uuid(id_or_uuid) + except ChartNotFoundError: + return self.response_404() + + DeleteEmbeddedChartCommand(chart).run() + return self.response(200, message="OK") + + @expose("/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__}.list_embedded", + log_to_statsd=False, + ) + def list_embedded(self) -> Response: + """List all embedded chart configurations. + --- + get: + summary: List all embedded chart configurations + responses: + 200: + description: List of embedded charts + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + type: object + properties: + uuid: + type: string + chart_id: + type: integer + chart_name: + type: string + allowed_domains: + type: array + items: + type: string + changed_on: + type: string + 401: + $ref: '#/components/responses/401' + 500: + $ref: '#/components/responses/500' + """ + embedded_charts = ( + db.session.query(EmbeddedChart) + .join(Slice, EmbeddedChart.chart_id == Slice.id) + .all() + ) + result = [ + { + "uuid": str(ec.uuid), + "chart_id": ec.chart_id, + "chart_name": ec.chart.slice_name if ec.chart else None, + "allowed_domains": ec.allowed_domains, + "changed_on": ec.changed_on.isoformat() if ec.changed_on else None, + } + for ec in embedded_charts + ] + return self.response(200, result=result) diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 2ed6446cee6e..dfb6f1a56cf4 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -345,6 +345,17 @@ class ChartGetCachedScreenshotResponseSchema(Schema): ) +class EmbeddedChartConfigSchema(Schema): + allowed_domains = fields.List(fields.String(), required=True) + + +class EmbeddedChartResponseSchema(Schema): + uuid = fields.String() + allowed_domains = fields.List(fields.String()) + chart_id = fields.Integer() + changed_on = fields.DateTime() + + class ChartDataColumnSchema(Schema): column_name = fields.String( metadata={"description": "The name of the target column", "example": "mycol"}, @@ -1713,5 +1724,7 @@ class ChartGetResponseSchema(Schema): ChartGetDatasourceResponseSchema, ChartGetResponseSchema, ChartCacheScreenshotResponseSchema, + EmbeddedChartConfigSchema, + EmbeddedChartResponseSchema, GetFavStarIdsSchema, ) diff --git a/superset/commands/chart/delete.py b/superset/commands/chart/delete.py index 00e6d201bcc9..0cb4795320f1 100644 --- a/superset/commands/chart/delete.py +++ b/superset/commands/chart/delete.py @@ -23,12 +23,13 @@ from superset import security_manager from superset.commands.base import BaseCommand from superset.commands.chart.exceptions import ( + ChartDeleteEmbeddedFailedError, ChartDeleteFailedError, ChartDeleteFailedReportsExistError, ChartForbiddenError, ChartNotFoundError, ) -from superset.daos.chart import ChartDAO +from superset.daos.chart import ChartDAO, EmbeddedChartDAO from superset.daos.report import ReportScheduleDAO from superset.exceptions import SupersetSecurityException from superset.models.slice import Slice @@ -68,3 +69,16 @@ def validate(self) -> None: security_manager.raise_for_ownership(model) except SupersetSecurityException as ex: raise ChartForbiddenError() from ex + + +class DeleteEmbeddedChartCommand(BaseCommand): + def __init__(self, chart: Slice): + self._chart = chart + + @transaction(on_error=partial(on_error, reraise=ChartDeleteEmbeddedFailedError)) + def run(self) -> None: + self.validate() + return EmbeddedChartDAO.delete(self._chart.embedded) + + def validate(self) -> None: + pass diff --git a/superset/commands/chart/exceptions.py b/superset/commands/chart/exceptions.py index 72ef71f466e8..f1ac31c41f2b 100644 --- a/superset/commands/chart/exceptions.py +++ b/superset/commands/chart/exceptions.py @@ -127,6 +127,10 @@ class ChartDeleteFailedReportsExistError(ChartDeleteFailedError): message = _("There are associated alerts or reports") +class ChartDeleteEmbeddedFailedError(DeleteFailedError): + message = _("Embedded chart could not be deleted.") + + class ChartAccessDeniedError(ForbiddenError): message = _("You don't have access to this chart.") diff --git a/superset/config.py b/superset/config.py index 7b188370e77c..a9a7102667dd 100644 --- a/superset/config.py +++ b/superset/config.py @@ -559,6 +559,8 @@ class D3TimeFormat(TypedDict, total=False): # This feature flag is stil in beta and is not recommended for production use. "GLOBAL_ASYNC_QUERIES": False, "EMBEDDED_SUPERSET": False, + # Enables the "Embed code" and "Embed chart" options in the Share menu + "EMBEDDABLE_CHARTS": True, # Enables Alerts and reports new implementation "ALERT_REPORTS": False, "ALERT_REPORT_TABS": False, @@ -591,8 +593,6 @@ class D3TimeFormat(TypedDict, total=False): "CACHE_IMPERSONATION": False, # Enable caching per user key for Superset cache (not database cache impersonation) "CACHE_QUERY_BY_USER": False, - # Enable sharing charts with embedding - "EMBEDDABLE_CHARTS": True, "DRILL_TO_DETAIL": True, # deprecated "DRILL_BY": True, "DATAPANEL_CLOSED_BY_DEFAULT": False, diff --git a/superset/daos/chart.py b/superset/daos/chart.py index 56488f1c1937..ccae4cf1650f 100644 --- a/superset/daos/chart.py +++ b/superset/daos/chart.py @@ -18,7 +18,7 @@ import logging from datetime import datetime -from typing import Dict, List, TYPE_CHECKING +from typing import Any, Dict, List, TYPE_CHECKING from flask_appbuilder.models.sqla.interface import SQLAInterface @@ -27,6 +27,7 @@ from superset.daos.base import BaseDAO from superset.extensions import db from superset.models.core import FavStar, FavStarClassName +from superset.models.embedded_chart import EmbeddedChart from superset.models.slice import id_or_uuid_filter, Slice from superset.utils.core import get_user_id @@ -102,3 +103,33 @@ def remove_favorite(chart: Slice) -> None: ) if fav: db.session.delete(fav) + + +class EmbeddedChartDAO(BaseDAO[EmbeddedChart]): + # There isn't really a regular scenario where we would rather get Embedded by id + id_column_name = "uuid" + + @staticmethod + def upsert(chart: Slice, allowed_domains: list[str]) -> EmbeddedChart: + """ + Sets up a chart to be embeddable. + Upsert is used to preserve the embedded_chart uuid across updates. + """ + embedded: EmbeddedChart = ( + chart.embedded[0] if chart.embedded else EmbeddedChart() + ) + embedded.allow_domain_list = ",".join(allowed_domains) + chart.embedded = [embedded] + return embedded + + @classmethod + def create( + cls, + item: EmbeddedChartDAO | None = None, + attributes: dict[str, Any] | None = None, + ) -> Any: + """ + Use EmbeddedChartDAO.upsert() instead. + At least, until we are ok with more than one embedded item per chart. + """ + raise NotImplementedError("Use EmbeddedChartDAO.upsert() instead.") diff --git a/superset/embedded_chart/__init__.py b/superset/embedded_chart/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/embedded_chart/__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_chart/api.py b/superset/embedded_chart/api.py new file mode 100644 index 000000000000..aaa50df0b3df --- /dev/null +++ b/superset/embedded_chart/api.py @@ -0,0 +1,304 @@ +# 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 datetime import datetime, timedelta, timezone +from typing import Any + +from flask import g, request, Response +from flask_appbuilder.api import expose, protect, safe + +from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand +from superset.daos.key_value import KeyValueDAO +from superset.embedded_chart.exceptions import ( + EmbeddedChartAccessDeniedError, + EmbeddedChartPermalinkNotFoundError, +) +from superset.explore.permalink.schemas import ExplorePermalinkSchema +from superset.extensions import event_logger, security_manager +from superset.key_value.shared_entries import get_permalink_salt +from superset.key_value.types import ( + KeyValueResource, + MarshmallowKeyValueCodec, + SharedKey, +) +from superset.key_value.utils import decode_permalink_id +from superset.security.guest_token import ( + GuestTokenResource, + GuestTokenResourceType, + GuestTokenRlsRule, + GuestTokenUser, + GuestUser, +) +from superset.views.base_api import BaseSupersetApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class EmbeddedChartRestApi(BaseSupersetApi): + """REST API for embedded chart data retrieval.""" + + resource_name = "embedded_chart" + allow_browser_login = True + openapi_spec_tag = "Embedded Chart" + + def _validate_guest_token_access(self, permalink_key: str) -> bool: + """ + Validate that the guest token grants access to this permalink. + + Guest tokens contain a list of resources the user can access. + For embedded charts, we check that the permalink_key is in that list. + """ + user = g.user + if not isinstance(user, GuestUser): + return False + + for resource in user.resources: + if ( + resource.get("type") == GuestTokenResourceType.CHART_PERMALINK.value + and str(resource.get("id")) == permalink_key + ): + return True + return False + + def _get_permalink_value(self, permalink_key: str) -> dict[str, Any] | None: + """ + Get permalink value without access checks. + + For embedded charts, access is controlled via guest token validation, + so we skip the normal dataset/chart access checks. + """ + # Use the same salt, resource, and codec as the explore permalink command + salt = get_permalink_salt(SharedKey.EXPLORE_PERMALINK_SALT) + codec = MarshmallowKeyValueCodec(ExplorePermalinkSchema()) + key = decode_permalink_id(permalink_key, salt=salt) + return KeyValueDAO.get_value( + KeyValueResource.EXPLORE_PERMALINK, + key, + codec, + ) + + @expose("/", methods=("GET",)) + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", + log_to_statsd=False, + ) + def get(self, permalink_key: str) -> Response: + """Get chart form_data from permalink key. + --- + get: + summary: Get embedded chart configuration + description: >- + Retrieves the form_data for rendering an embedded chart. + This endpoint is used by the embedded chart iframe to load + the chart configuration. + parameters: + - in: path + schema: + type: string + name: permalink_key + description: The chart permalink key + required: true + responses: + 200: + description: Chart permalink state + content: + application/json: + schema: + type: object + properties: + state: + type: object + properties: + formData: + type: object + description: The chart configuration formData + allowedDomains: + type: array + items: + type: string + description: Domains allowed to embed this chart + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + # Validate guest token grants access to this permalink + if not self._validate_guest_token_access(permalink_key): + raise EmbeddedChartAccessDeniedError() + + # Get permalink value without access checks (guest token already validated) + permalink_value = self._get_permalink_value(permalink_key) + if not permalink_value: + raise EmbeddedChartPermalinkNotFoundError() + + # Return state in the format expected by the frontend: + # { state: { formData: {...}, allowedDomains: [...] } } + state = permalink_value.get("state", {}) + + return self.response( + 200, + state=state, + ) + except EmbeddedChartAccessDeniedError: + return self.response_401() + except EmbeddedChartPermalinkNotFoundError: + return self.response_404() + except Exception: + logger.exception("Error fetching embedded chart") + return self.response_500() + + @expose("/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", + log_to_statsd=False, + ) + def post(self) -> Response: + """Create an embeddable chart with guest token. + --- + post: + summary: Create embeddable chart + description: >- + Creates an embeddable chart configuration with a guest token. + The returned iframe_url and guest_token can be used to embed + the chart in external applications. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - form_data + properties: + form_data: + type: object + description: Chart form_data configuration + allowed_domains: + type: array + items: + type: string + description: Domains allowed to embed this chart + ttl_minutes: + type: integer + default: 60 + description: Time-to-live for the embed in minutes + responses: + 200: + description: Embeddable chart created + content: + application/json: + schema: + type: object + properties: + iframe_url: + type: string + description: URL to use in iframe src + guest_token: + type: string + description: Guest token for authentication + permalink_key: + type: string + description: Permalink key for the chart + expires_at: + type: string + format: date-time + description: When the embed expires + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 500: + $ref: '#/components/responses/500' + """ + try: + body = request.json or {} + form_data = body.get("form_data", {}) + allowed_domains: list[str] = body.get("allowed_domains", []) + ttl_minutes: int = body.get("ttl_minutes", 60) + + if not form_data: + return self.response_400(message="form_data is required") + + # Validate required form_data structure + if not form_data.get("datasource"): + return self.response_400( + message="form_data must include 'datasource' field" + ) + + # Create permalink with the form_data + state = { + "formData": form_data, + "allowedDomains": allowed_domains, + } + permalink_key = CreateExplorePermalinkCommand(state).run() + + # Calculate expiration + expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes) + + # Generate guest token + username = g.user.username if hasattr(g, "user") and g.user else "anonymous" + guest_user: GuestTokenUser = { + "username": f"embed_{username}", + "first_name": "Embed", + "last_name": "User", + } + + resources: list[GuestTokenResource] = [ + { + "type": GuestTokenResourceType.CHART_PERMALINK, + "id": permalink_key, + } + ] + + rls_rules: list[GuestTokenRlsRule] = [] + + guest_token_result = security_manager.create_guest_access_token( + user=guest_user, + resources=resources, + rls=rls_rules, + ) + + # Handle both bytes (older PyJWT) and string (PyJWT 2.0+) + guest_token = ( + guest_token_result.decode("utf-8") + if isinstance(guest_token_result, bytes) + else guest_token_result + ) + + # Build iframe URL using request host + base_url = request.host_url.rstrip("/") + iframe_url = f"{base_url}/embedded/chart/?permalink_key={permalink_key}" + + return self.response( + 200, + iframe_url=iframe_url, + guest_token=guest_token, + permalink_key=permalink_key, + expires_at=expires_at.isoformat(), + ) + + except Exception: + logger.exception("Error creating embedded chart") + return self.response_500() diff --git a/superset/embedded_chart/exceptions.py b/superset/embedded_chart/exceptions.py new file mode 100644 index 000000000000..79e3ad500ee3 --- /dev/null +++ b/superset/embedded_chart/exceptions.py @@ -0,0 +1,35 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from superset.exceptions import SupersetException + + +class EmbeddedChartPermalinkNotFoundError(SupersetException): + """Raised when an embedded chart permalink is not found or has expired.""" + + message = "The embedded chart permalink could not be found or has expired." + + +class EmbeddedChartAccessDeniedError(SupersetException): + """Raised when access to an embedded chart is denied.""" + + message = "Access to this embedded chart is denied." + + +class EmbeddedChartFeatureDisabledError(SupersetException): + """Raised when the embeddable charts feature is disabled.""" + + message = "The embeddable charts feature is not enabled." diff --git a/superset/embedded_chart/view.py b/superset/embedded_chart/view.py new file mode 100644 index 000000000000..f281f4705f26 --- /dev/null +++ b/superset/embedded_chart/view.py @@ -0,0 +1,136 @@ +# 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 Callable +from urllib.parse import urlparse + +from flask import abort, current_app, request +from flask_appbuilder import expose +from flask_login import AnonymousUserMixin, login_user + +from superset import event_logger +from superset.daos.key_value import KeyValueDAO +from superset.explore.permalink.schemas import ExplorePermalinkSchema +from superset.key_value.shared_entries import get_permalink_salt +from superset.key_value.types import ( + KeyValueResource, + MarshmallowKeyValueCodec, + SharedKey, +) +from superset.key_value.utils import decode_permalink_id +from superset.superset_typing import FlaskResponse +from superset.utils import json +from superset.views.base import BaseSupersetView, common_bootstrap_payload + +logger = logging.getLogger(__name__) + + +def same_origin(url1: str | None, url2: str | None) -> bool: + """Check if two URLs have the same origin (scheme + netloc).""" + if not url1 or not url2: + return False + parsed1 = urlparse(url1) + parsed2 = urlparse(url2) + # For domain matching, we just check if the host matches + # url2 might just be a domain like "example.com" + if not parsed2.scheme: + # url2 is just a domain, check if it matches url1's netloc + return parsed1.netloc == url2 or parsed1.netloc.endswith(f".{url2}") + return (parsed1.scheme, parsed1.netloc) == (parsed2.scheme, parsed2.netloc) + + +class EmbeddedChartView(BaseSupersetView): + """Server-side rendering for embedded chart pages.""" + + route_base = "/embedded/chart" + + @expose("/") + @event_logger.log_this_with_extra_payload + def embedded_chart( + self, + add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, + ) -> FlaskResponse: + """ + Server side rendering for the embedded chart page. + Expects ?permalink_key=xxx query parameter. + """ + # Get permalink_key from query params + permalink_key = request.args.get("permalink_key") + if not permalink_key: + logger.warning("Missing permalink_key in embedded chart request") + abort(404) + + # Get permalink value to check allowed domains + try: + salt = get_permalink_salt(SharedKey.EXPLORE_PERMALINK_SALT) + codec = MarshmallowKeyValueCodec(ExplorePermalinkSchema()) + key = decode_permalink_id(permalink_key, salt=salt) + permalink_value = KeyValueDAO.get_value( + KeyValueResource.EXPLORE_PERMALINK, + key, + codec, + ) + except Exception: + logger.exception("Error fetching permalink for referrer validation") + permalink_value = None + + # Validate request referrer against allowed domains (if configured) + if permalink_value: + state = permalink_value.get("state", {}) + allowed_domains = state.get("allowedDomains", []) + + if allowed_domains: + is_referrer_allowed = False + for domain in allowed_domains: + if same_origin(request.referrer, domain): + is_referrer_allowed = True + break + + if not is_referrer_allowed: + logger.warning( + "Embedded chart referrer not in allowed domains: %s", + request.referrer, + ) + abort(403) + + # Log in as anonymous user for page rendering + # 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_user(AnonymousUserMixin(), force=True) + + add_extra_log_payload( + embedded_type="chart", + permalink_key=permalink_key, + ) + + bootstrap_data = { + "config": { + "GUEST_TOKEN_HEADER_NAME": current_app.config.get( + "GUEST_TOKEN_HEADER_NAME", "X-GuestToken" + ), + }, + "common": common_bootstrap_payload(), + "embedded_chart": True, + } + + return self.render_template( + "superset/spa.html", + entry="embeddedChart", + bootstrap_data=json.dumps( + bootstrap_data, default=json.pessimistic_json_iso_dttm_ser + ), + ) diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 1f18f7da0cdc..1d8211539549 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -168,6 +168,8 @@ def init_views(self) -> None: from superset.datasource.api import DatasourceRestApi from superset.embedded.api import EmbeddedDashboardRestApi from superset.embedded.view import EmbeddedView + from superset.embedded_chart.api import EmbeddedChartRestApi + from superset.embedded_chart.view import EmbeddedChartView from superset.explore.api import ExploreRestApi from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi @@ -201,6 +203,7 @@ def init_views(self) -> None: from superset.views.database.views import DatabaseView from superset.views.datasource.views import DatasetEditor, Datasource from superset.views.dynamic_plugins import DynamicPluginsView + from superset.views.embedded_charts import EmbeddedChartsView from superset.views.error_handling import set_app_error_handlers from superset.views.explore import ExplorePermalinkView, ExploreView from superset.views.groups import GroupsListView @@ -256,6 +259,7 @@ def init_views(self) -> None: appbuilder.add_api(DatasetMetricRestApi) appbuilder.add_api(DatasourceRestApi) appbuilder.add_api(EmbeddedDashboardRestApi) + appbuilder.add_api(EmbeddedChartRestApi) appbuilder.add_api(ExploreRestApi) appbuilder.add_api(ExploreFormDataRestApi) appbuilder.add_api(ExplorePermalinkRestApi) @@ -416,6 +420,7 @@ def init_views(self) -> None: appbuilder.add_view_no_menu(Datasource) appbuilder.add_view_no_menu(DatasetEditor) appbuilder.add_view_no_menu(EmbeddedView) + appbuilder.add_view_no_menu(EmbeddedChartView) appbuilder.add_view_no_menu(ExploreView) appbuilder.add_view_no_menu(ExplorePermalinkView) appbuilder.add_view_no_menu(SavedQueryView) @@ -518,6 +523,15 @@ def init_views(self) -> None: icon="fa-lock", ) + appbuilder.add_view( + EmbeddedChartsView, + "Embedded Charts", + label=_("Embedded Charts"), + category="Security", + category_label=_("Security"), + icon="fa-code", + ) + def init_core_dependencies(self) -> None: """Initialize core dependency injection for direct import patterns.""" from superset.core.api.core_api_injection import ( diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 64730a54caab..869bb9d6cfe6 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -316,6 +316,9 @@ def create_mcp_app( get_dataset_info, list_datasets, ) +from superset.mcp_service.embedded_chart.tool import ( # noqa: F401, E402 + get_embeddable_chart, +) from superset.mcp_service.explore.tool import ( # noqa: F401, E402 generate_explore_link, ) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index 633e3c6a9c69..acb4cda480ac 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -24,6 +24,7 @@ from typing import Any, Dict +from superset.mcp_service.auth import has_dataset_access from superset.mcp_service.chart.schemas import ( ChartCapabilities, ChartSemantics, @@ -34,6 +35,116 @@ from superset.mcp_service.utils.url_utils import get_superset_base_url from superset.utils import json +# ============================================================================= +# Visualization Type Constants +# ============================================================================= + +# Time series viz types that require a datetime column on the x-axis +TIMESERIES_VIZ_TYPES = { + "echarts_timeseries", + "echarts_timeseries_line", + "echarts_timeseries_bar", + "echarts_timeseries_area", + "echarts_timeseries_scatter", + "echarts_timeseries_smooth", + "echarts_timeseries_step", + "mixed_timeseries", + "line", + "area", +} + +# Categorical viz types for bar/pie charts (use these for non-time data) +# Note: dist_bar is deprecated, use 'bar' instead +CATEGORICAL_VIZ_TYPES = { + "bar", + "pie", + "big_number", + "big_number_total", + "table", + "pivot_table_v2", +} + + +# ============================================================================= +# Dataset Resolution +# ============================================================================= + + +def resolve_dataset( + dataset_id: int | str, check_access: bool = True +) -> tuple[Any | None, str | None]: + """ + Resolve a dataset by ID (numeric) or UUID with optional access checking. + + Args: + dataset_id: Numeric dataset ID or UUID string + check_access: Whether to check user has access to the dataset + + Returns: + Tuple of (dataset, error_message). If successful, error_message is None. + If failed, dataset is None and error_message contains the reason. + """ + from superset.daos.dataset import DatasetDAO + + dataset = None + + if isinstance(dataset_id, int) or ( + isinstance(dataset_id, str) and dataset_id.isdigit() + ): + numeric_id = int(dataset_id) if isinstance(dataset_id, str) else dataset_id + dataset = DatasetDAO.find_by_id(numeric_id) + else: + # Try UUID lookup + dataset = DatasetDAO.find_by_id(dataset_id, id_column="uuid") + + if not dataset: + return None, f"Dataset not found: {dataset_id}" + + if check_access and not has_dataset_access(dataset): + return None, "Access denied to dataset" + + return dataset, None + + +def validate_timeseries_config( + viz_type: str, form_data: Dict[str, Any], dataset: Any +) -> str | None: + """ + Validate that timeseries chart types have required datetime configuration. + + Args: + viz_type: The visualization type + form_data: Chart form_data configuration + dataset: The dataset object + + Returns: + Error message if validation fails, None if valid. + """ + if viz_type not in TIMESERIES_VIZ_TYPES: + return None + + has_x_axis = form_data.get("x_axis") + has_granularity = form_data.get("granularity_sqla") + has_time_column = form_data.get("time_column") + + if has_x_axis or has_granularity or has_time_column: + return None + + # Check if dataset has a main temporal column configured + main_dttm_col = getattr(dataset, "main_dttm_col", None) + if main_dttm_col: + return None + + # Suggest appropriate categorical chart type + categorical_alternative = "bar" if "bar" in viz_type else "pie" + + return ( + f"Time series chart type '{viz_type}' requires a datetime column on the " + f"x-axis. For categorical data like 'genre', use " + f"viz_type='{categorical_alternative}' instead. Alternatively, add 'x_axis' " + f"or 'granularity_sqla' to form_data with a datetime column name." + ) + def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> str: """Generate an explore link for the given dataset and form data.""" diff --git a/superset/mcp_service/embedded_chart/__init__.py b/superset/mcp_service/embedded_chart/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/mcp_service/embedded_chart/__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/mcp_service/embedded_chart/schemas.py b/superset/mcp_service/embedded_chart/schemas.py new file mode 100644 index 000000000000..3c9e4f0527b1 --- /dev/null +++ b/superset/mcp_service/embedded_chart/schemas.py @@ -0,0 +1,104 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class GetEmbeddableChartRequest(BaseModel): + """Request schema for get_embeddable_chart tool.""" + + datasource_id: int | str = Field( + ..., + description="Dataset ID (numeric) or UUID", + ) + viz_type: str = Field( + ..., + description=( + "Visualization type. For CATEGORICAL data (e.g., genre, country): use " + "'bar', 'pie', 'table'. For TIME SERIES data (requires datetime column): " + "use 'echarts_timeseries_line', 'echarts_timeseries_bar', " + "'echarts_timeseries_area'. Common mistake: using 'echarts_timeseries_bar' " + "for categorical data - use 'bar' instead." + ), + ) + form_data: dict[str, Any] = Field( + ..., + description="Chart configuration including metrics, dimensions, filters", + ) + form_data_overrides: dict[str, Any] | None = Field( + default=None, + description="Optional overrides to merge with form_data", + ) + ttl_minutes: int = Field( + default=60, + ge=1, + le=10080, # max 1 week + description="Permalink TTL in minutes (default: 60, max: 10080 = 1 week)", + ) + height: int = Field( + default=400, + ge=100, + le=2000, + description="Chart height in pixels for iframe (default: 400)", + ) + rls_rules: list[dict[str, Any]] = Field( + default_factory=list, + description="Row-level security rules to apply to the guest token", + ) + allowed_domains: list[str] = Field( + default_factory=list, + description=( + "List of domains allowed to embed this chart. " + "If empty, any domain can embed (less secure). " + "Example: ['https://example.com', 'https://app.example.com']" + ), + ) + + +class GetEmbeddableChartResponse(BaseModel): + """Response schema for get_embeddable_chart tool.""" + + success: bool = Field( + ..., + description="Whether the operation succeeded", + ) + iframe_url: str | None = Field( + default=None, + description="URL for embedding in iframe", + ) + guest_token: str | None = Field( + default=None, + description="Guest token for authentication", + ) + iframe_html: str | None = Field( + default=None, + description="Ready-to-use HTML iframe snippet", + ) + permalink_key: str | None = Field( + default=None, + description="The permalink key for the chart", + ) + expires_at: datetime | None = Field( + default=None, + description="When the permalink and token expire", + ) + error: str | None = Field( + default=None, + description="Error message if operation failed", + ) diff --git a/superset/mcp_service/embedded_chart/tool/__init__.py b/superset/mcp_service/embedded_chart/tool/__init__.py new file mode 100644 index 000000000000..98c6f1a7cee0 --- /dev/null +++ b/superset/mcp_service/embedded_chart/tool/__init__.py @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from .get_embeddable_chart import get_embeddable_chart + +__all__ = ["get_embeddable_chart"] diff --git a/superset/mcp_service/embedded_chart/tool/get_embeddable_chart.py b/superset/mcp_service/embedded_chart/tool/get_embeddable_chart.py new file mode 100644 index 000000000000..4f4e82fb7fea --- /dev/null +++ b/superset/mcp_service/embedded_chart/tool/get_embeddable_chart.py @@ -0,0 +1,231 @@ +# 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. +""" +MCP tool: get_embeddable_chart + +Creates an embeddable chart iframe with guest token authentication. +This enables AI agents to generate charts that can be displayed in +external applications via iframe. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from fastmcp import Context +from flask import g +from superset_core.mcp import tool + +from superset.mcp_service.chart.chart_utils import ( + resolve_dataset, + validate_timeseries_config, +) +from superset.mcp_service.embedded_chart.schemas import ( + GetEmbeddableChartRequest, + GetEmbeddableChartResponse, +) +from superset.mcp_service.utils.schema_utils import parse_request +from superset.mcp_service.utils.url_utils import get_superset_base_url +from superset.security.guest_token import ( + GuestTokenResource, + GuestTokenRlsRule, + GuestTokenUser, +) + +logger = logging.getLogger(__name__) + + +@tool(tags=["core"]) +@parse_request(GetEmbeddableChartRequest) +async def get_embeddable_chart( + request: GetEmbeddableChartRequest, + ctx: Context, +) -> GetEmbeddableChartResponse: + """Create an embeddable chart iframe URL with guest token authentication. + + This tool creates an ephemeral chart visualization that can be embedded + in external applications via iframe. The chart is configured via form_data + and stored as a permalink with TTL. + + IMPORTANT - Chart Type Selection: + - For CATEGORICAL data (genre, country, status): use 'bar', 'pie', 'table' + - For TIME SERIES data (dates/timestamps): use 'echarts_timeseries_*' + - Common mistake: using 'echarts_timeseries_bar' for categorical data + + Example 1 - Categorical bar chart (sales by genre): + ```json + { + "datasource_id": 22, + "viz_type": "bar", + "form_data": { + "metrics": [{"aggregate": "SUM", "column": {"column_name": "sales"}}], + "groupby": ["genre"] + } + } + ``` + + Example 2 - Time series line chart (requires datetime column): + ```json + { + "datasource_id": 123, + "viz_type": "echarts_timeseries_line", + "form_data": { + "x_axis": "created_at", + "metrics": ["count"], + "time_range": "Last 7 days" + } + } + ``` + + Returns iframe_url, guest_token, and ready-to-use iframe_html snippet. + """ + await ctx.info( + f"Creating embeddable chart: datasource_id={request.datasource_id}, " + f"viz_type={request.viz_type}" + ) + + try: + # Import here to avoid circular imports + from superset.commands.explore.permalink.create import ( + CreateExplorePermalinkCommand, + ) + from superset.extensions import security_manager + from superset.security.guest_token import GuestTokenResourceType + + # Resolve dataset using shared utility + dataset, error = resolve_dataset(request.datasource_id) + if error: + await ctx.error(error) + return GetEmbeddableChartResponse(success=False, error=error) + + # Validate timeseries viz types have required datetime configuration + validation_error = validate_timeseries_config( + request.viz_type, request.form_data, dataset + ) + if validation_error: + await ctx.error(validation_error) + return GetEmbeddableChartResponse(success=False, error=validation_error) + + # Build complete form_data + form_data = { + **request.form_data, + "viz_type": request.viz_type, + "datasource": f"{dataset.id}__table", + } + + # Apply overrides if provided + if request.form_data_overrides: + form_data.update(request.form_data_overrides) + + # Create permalink with allowed_domains for referrer validation + state = { + "formData": form_data, + "allowedDomains": request.allowed_domains, + } + permalink_key = CreateExplorePermalinkCommand(state).run() + + await ctx.debug(f"Created permalink: {permalink_key}") + + # Calculate expiration + expires_at = datetime.now(timezone.utc) + timedelta(minutes=request.ttl_minutes) + + # Generate guest token + username = g.user.username if hasattr(g, "user") and g.user else "anonymous" + guest_user: GuestTokenUser = { + "username": f"mcp_embed_{username}", + "first_name": "MCP", + "last_name": "Embed User", + } + + resources: list[GuestTokenResource] = [ + { + "type": GuestTokenResourceType.CHART_PERMALINK, + "id": permalink_key, + } + ] + + # Convert request rls_rules to GuestTokenRlsRule format + rls_rules: list[GuestTokenRlsRule] = [ + {"dataset": rule.get("dataset"), "clause": rule.get("clause", "")} + for rule in request.rls_rules + ] + + guest_token_result = security_manager.create_guest_access_token( + user=guest_user, + resources=resources, + rls=rls_rules, + ) + + # Handle both bytes (older PyJWT) and string (PyJWT 2.0+) + guest_token = ( + guest_token_result.decode("utf-8") + if isinstance(guest_token_result, bytes) + else guest_token_result + ) + + # Build URLs + base_url = get_superset_base_url() + iframe_url = f"{base_url}/embedded/chart/?permalink_key={permalink_key}" + + # Generate iframe HTML snippet + iframe_html = f""" +""" + + await ctx.info(f"Embeddable chart created successfully: {permalink_key}") + + return GetEmbeddableChartResponse( + success=True, + iframe_url=iframe_url, + guest_token=guest_token, + iframe_html=iframe_html, + permalink_key=permalink_key, + expires_at=expires_at, + ) + + except Exception as ex: + logger.exception("Failed to create embeddable chart: %s", ex) + await ctx.error(f"Failed to create embeddable chart: {ex}") + return GetEmbeddableChartResponse( + success=False, + error=str(ex), + ) diff --git a/superset/migrations/versions/2026-01-08_16-45_50aff65919a0_add_embedded_charts_table.py b/superset/migrations/versions/2026-01-08_16-45_50aff65919a0_add_embedded_charts_table.py new file mode 100644 index 000000000000..80bdc1bef83b --- /dev/null +++ b/superset/migrations/versions/2026-01-08_16-45_50aff65919a0_add_embedded_charts_table.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. +"""add_embedded_charts_table + +Revision ID: 50aff65919a0 +Revises: f5b5f88d8526 +Create Date: 2026-01-08 16:45:00.000000 + +""" + +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils import UUIDType + +from superset.migrations.shared.utils import create_table + +# revision identifiers, used by Alembic. +revision = "50aff65919a0" +down_revision = "f5b5f88d8526" + + +def upgrade(): + create_table( + "embedded_charts", + sa.Column("created_on", sa.DateTime(), nullable=True), + sa.Column("changed_on", sa.DateTime(), nullable=True), + sa.Column("allow_domain_list", sa.Text(), nullable=True), + sa.Column("uuid", UUIDType(binary=True), default=uuid4, primary_key=True), + sa.Column( + "chart_id", + sa.Integer(), + sa.ForeignKey("slices.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("changed_by_fk", sa.Integer(), nullable=True), + sa.Column("created_by_fk", sa.Integer(), nullable=True), + ) + + +def downgrade(): + op.drop_table("embedded_charts") diff --git a/superset/models/embedded_chart.py b/superset/models/embedded_chart.py new file mode 100644 index 000000000000..60eca10f0df0 --- /dev/null +++ b/superset/models/embedded_chart.py @@ -0,0 +1,58 @@ +# 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 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 EmbeddedChart(Model, AuditMixinNullable): + """ + A configuration of embedding for a chart. + + References the chart (slice), and contains a config for embedding that chart. + + This data model allows multiple configurations for a given chart, + but at this time the API only allows setting one. + """ + + __tablename__ = "embedded_charts" + + uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True) + allow_domain_list = Column(Text) # reference the `allowed_domains` property instead + chart_id = Column( + Integer, + ForeignKey("slices.id", ondelete="CASCADE"), + nullable=False, + ) + chart = relationship( + "Slice", + back_populates="embedded", + foreign_keys=[chart_id], + ) + + @property + def allowed_domains(self) -> list[str]: + """ + A list of domains which are allowed to embed the chart. + An empty list means any domain can embed. + """ + return self.allow_domain_list.split(",") if self.allow_domain_list else [] diff --git a/superset/models/slice.py b/superset/models/slice.py index db58c6c8e50f..f4c22f8a5ca4 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -120,6 +120,11 @@ class Slice( # pylint: disable=too-many-public-methods remote_side="SqlaTable.id", lazy="subquery", ) + embedded = relationship( + "EmbeddedChart", + back_populates="chart", + cascade="all, delete-orphan", + ) token = "" diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py index e0403e07e104..ee65db5be36d 100644 --- a/superset/security/guest_token.py +++ b/superset/security/guest_token.py @@ -30,6 +30,7 @@ class GuestTokenUser(TypedDict, total=False): class GuestTokenResourceType(StrEnum): DASHBOARD = "dashboard" + CHART_PERMALINK = "chart_permalink" class GuestTokenResource(TypedDict): diff --git a/superset/security/manager.py b/superset/security/manager.py index 1a3532f262d5..8f51ab616903 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -445,6 +445,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ("can_read", "AnnotationLayerRestApi"), # Chart permalinks (for shared chart links) ("can_read", "ExplorePermalinkRestApi"), + # User info for embedded views (needed by frontend during initialization) + ("can_read", "CurrentUserRestApi"), } # View menus that Public role should NOT have access to @@ -2564,6 +2566,8 @@ def raise_for_access( # noqa: C901 self.can_access_schema(datasource) or self.can_access("datasource_access", datasource.perm or "") or self.is_owner(datasource) + # Grant access for embedded charts via guest token with chart_permalink + or self.has_guest_chart_permalink_access(datasource) or ( # Grant access to the datasource only if dashboard RBAC is enabled # or the user is an embedded guest user with access to the dashboard @@ -2823,7 +2827,11 @@ def validate_guest_token_resources(resources: GuestTokenResources) -> None: from superset.commands.dashboard.embedded.exceptions import ( EmbeddedDashboardNotFoundError, ) + from superset.commands.explore.permalink.get import GetExplorePermalinkCommand from superset.daos.dashboard import EmbeddedDashboardDAO + from superset.embedded_chart.exceptions import ( + EmbeddedChartPermalinkNotFoundError, + ) from superset.models.dashboard import Dashboard for resource in resources: @@ -2834,6 +2842,17 @@ def validate_guest_token_resources(resources: GuestTokenResources) -> None: embedded = EmbeddedDashboardDAO.find_by_id(str(resource["id"])) if not embedded: raise EmbeddedDashboardNotFoundError() + elif resource["type"] == GuestTokenResourceType.CHART_PERMALINK.value: + # Validate that the chart permalink exists + permalink_key = str(resource["id"]) + try: + permalink_value = GetExplorePermalinkCommand(permalink_key).run() + if not permalink_value: + raise EmbeddedChartPermalinkNotFoundError() + except EmbeddedChartPermalinkNotFoundError: + raise + except Exception: + raise EmbeddedChartPermalinkNotFoundError() from None def create_guest_access_token( self, @@ -2952,6 +2971,37 @@ def has_guest_access(self, dashboard: "Dashboard") -> bool: return True return False + def has_guest_chart_permalink_access(self, datasource: Any) -> bool: + """ + Check if a guest user has access to a datasource via a chart permalink resource. + + For embedded charts, the guest token contains chart_permalink resources. + This method validates that the requested datasource matches one of the + chart permalinks in the user's token. + """ + user = self.get_current_guest_user_if_guest() + if not user or not isinstance(user, GuestUser): + return False + + # Get chart permalink resources from the guest token + permalink_resources = [ + r + for r in user.resources + if r.get("type") == GuestTokenResourceType.CHART_PERMALINK.value + ] + + if not permalink_resources: + return False + + # For embedded charts, we allow access to any datasource that is + # referenced by a chart permalink in the guest token. + # The permalink validation happens at the embedded_chart API level, + # ensuring the user only accesses charts they have been granted access to. + # Here we simply need to verify the user has at least one chart_permalink + # resource, as the permalink's form_data already specifies which datasource + # to use. + return True + def raise_for_ownership(self, resource: Model) -> None: """ Raise an exception if the user does not own the resource. diff --git a/superset/views/embedded_charts.py b/superset/views/embedded_charts.py new file mode 100644 index 000000000000..bde53640eebd --- /dev/null +++ b/superset/views/embedded_charts.py @@ -0,0 +1,34 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_appbuilder import expose, has_access + +from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP +from superset.superset_typing import FlaskResponse +from superset.views.base import BaseSupersetView + + +class EmbeddedChartsView(BaseSupersetView): + """View for managing embedded charts list.""" + + route_base = "/embeddedcharts" + class_permission_name = "Chart" + method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP + + @expose("/list/") + @has_access + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index b8b60355419a..57c31d831d09 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -306,6 +306,10 @@ def test_info_security_chart(self): "can_write", "can_export", "can_warm_up_cache", + "can_get_embedded", + "can_set_embedded", + "can_delete_embedded", + "can_list_embedded", } def test_delete_chart(self): diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index f58566ceb299..bc2b814d3814 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1648,6 +1648,8 @@ def test_views_are_secured(self): # TODO (embedded) remove Dashboard:embedded after uuids have been shipped ["Dashboard", "embedded"], ["EmbeddedView", "embedded"], + ["EmbeddedChartRestApi", "get"], + ["EmbeddedChartView", "embedded_chart"], ["R", "index"], ["Superset", "log"], ["Superset", "theme"],