Compare commits

...
Author SHA1 Message Date
amaannawab923 a658e8be09 fix(embedded): chain the migration onto the current master head
Master gained migrations while this branch was open, so down_revision pointed
at a revision that is no longer the tip and the chain had two heads.
2026-09-03 12:16:36 +05:30
amaannawab923 6a23050f04 fix(embedded): register the embedded chart response schema
The embedded endpoints reference EmbeddedChartResponseSchema by $ref, but it
was never added to openapi_spec_component_schemas, so the generated spec
pointed at a component that did not exist and OpenAPI validation failed. The
dashboard API registers its equivalent the same way. The config schema needs no
entry, since it is inlined rather than referenced.
2026-09-03 12:15:42 +05:30
amaannawab923 ab0a0b60bf fix(embedded): resolve the guest user before the chart lookup
Two unit test failures, one real and one a fixture gap.

has_embedded_chart_access queried the slice before establishing that a guest
user exists, so it issued a database lookup on every datasource check that
reached it, including paths where the grant could never hold. Resolving the
guest user first skips the query entirely in that case.

The embedded guest chart test builds its security manager as a mock specced
against the real class, so has_guest_access_to_chart returned a truthy mock and
the denial assertion no longer held. It is closed by default alongside the other
paths the helper already closes, and the chart-direct grant now has a test of
its own rather than only being exercised implicitly.
2026-09-03 12:15:42 +05:30
amaannawab923 1f772b3059 fix(embedded): document the embedded PUT and expect the new chart permission
set_embedded is exposed for POST and PUT but only documented post, so the
generated spec carried a put operation with no responses and failed OpenAPI
validation. The chart info permission set also needed can_set_embedded, which
the dashboard equivalent already lists.
2026-09-03 12:15:42 +05:30
amaannawab923 ccc6315890 fix(embedded): declare embedded_charts in the chart purge policy
The purge registry requires every inbound foreign key to a supported model to
be declared, so adding embedded_charts.slice_id left Slice with an incomplete
policy. Declared as OWNED with its three outbound keys, mirroring how
embedded_dashboards is declared against Dashboard, which matches the cascade
already on the relationship and the migration.
2026-09-03 12:15:42 +05:30
amaannawab923 cc9b1d8c28 fix(embedded): type the chart's datasource as Datasource
tsc rejected passing the loosely typed dataset to setDatasources, which stores
it as a Datasource. The explore endpoint returns the full datasource, so the
field is declared as one rather than cast at the call site. Also applies oxfmt
formatting the frontend hook expects.
2026-09-03 12:14:15 +05:30
amaannawab923 5bc5f5f19c chore(embedded): apply lint and formatting fixes
Import ordering and formatting from ruff, and dropping an unused alembic op
import from the migration, which builds its table through the shared helpers.
2026-09-03 12:14:15 +05:30
amaannawab923 66b884f199 fix(embedded): pad the embedded chart and size it to its container
The chart title and the header menu rendered flush against the iframe edge,
because on a dashboard that breathing room comes from the grid gutter and an
embed has no grid. The holder supplies it instead.

Sizing moved from the viewport to the holder's content box, so the chart lays
out inside that padding rather than overflowing it, and the holder also carries
dashboard-component-chart-holder, which is the class the fullscreen styles
select on.
2026-09-03 12:14:15 +05:30
amaannawab923 63d6c3b151 test(embedded): cover the fabricated hydrate payload
Runs the real dashboard reducers against the real payload, so a slice that a
HYDRATE_DASHBOARD handler dereferences without optional chaining cannot go
missing again. That failure only surfaces at runtime and only in the embedded
path, which is why it went unnoticed. Also fills in dashboardLayout and
nativeFilters on HydrateEmbeddedAction, which had gone stale.
2026-09-03 12:14:15 +05:30
amaannawab923 34f38ead76 fix(embedded): don't serve the mobile experience inside an iframe
useIsMobile matches a media query against the current viewport, which inside an
iframe is the size the host chose for the embed rather than the size of the
device. A narrow embed on a desktop was therefore treated as a phone and lost
its chart controls entirely, since SliceHeader hides them when isMobile. Mobile
consumption mode is a whole-app experience (route guarding, drawer navigation)
that an embed does not have to begin with.
2026-09-03 12:14:15 +05:30
amaannawab923 35ebc7d322 fix(embedded): restore fullscreen and image exports for embedded charts
The dashboard gives every chart a holder element that owns two things the
header controls reach for: the node handed to requestFullscreen, and the
dashboard-chart-id-<id> class the jpeg and PDF exports select on. Rendering
Chart directly meant neither existed, so fullscreen always reported
'not supported in this browser' and the image exports silently produced
nothing. The embed now supplies its own holder rather than adopting
ChartHolder, which would drag in drag-and-drop, resize and edit-mode logic an
iframe has no use for.

Popups are portaled into the fullscreen element for the same reason the
dashboard does it: only that subtree is painted, so the header menu would
otherwise be unreachable while fullscreen.
2026-09-03 12:14:15 +05:30
amaannawab923 73f03c1b3e fix(embedded): don't 500 on a malformed Referer
same_origin parses the referrer eagerly, so an attacker-controlled value whose
authority looks like it carries a non-numeric port (for example
http://localhost:3007.evil.com/) raised ValueError instead of returning False.
Access already failed closed, but any anonymous client could turn one header
into a stack trace. This is not specific to embedded charts: the same path
serves embedded dashboards today.
2026-09-03 12:14:15 +05:30
amaannawab923 39fd3ddd04 fix(embedded): say 'chart' in the embed modal when embedding a chart
The modal is shared with the dashboard flow and its copy was hardcoded to
'dashboard', so a chart embed read 'This dashboard is ready to embed'.
2026-09-03 12:14:15 +05:30
amaannawab923 fa28a97583 feat(embedded): complete the standalone chart render path
Two gaps surfaced once a chart was actually rendered on its own:

- The nativeFilters and dashboardLayout reducers dereference their slice
  unconditionally on HYDRATE_DASHBOARD, so the fabricated state has to carry
  both even though a lone chart has no layout tree and no native filters.

- raise_for_access gates guest datasource access entirely on a dashboardId in
  the form data, which a standalone chart never has. Added a chart leg that
  authorizes the datasource when the guest token was issued for that chart and
  the request targets the chart's own datasource.
2026-09-03 12:14:15 +05:30
amaannawab923 7909ddb775 feat(embedded): allow guest tokens scoped to a chart
Adds a CHART guest token resource type, validates it against the embedded
chart uuid, and lets a directly-embedded chart satisfy the guest branch of
raise_for_access without belonging to a dashboard.
2026-09-03 12:14:15 +05:30
amaannawab923 5f439b18f3 fix(embedded): correct import paths for the 6.x module layout
t moved to @apache-superset/core/translation, css/styled to
@apache-superset/core/theme, and ErrorBoundary is a named export.
2026-09-03 12:14:15 +05:30
amaannawab923 4c80658354 feat(embedded): expose Embed chart from the chart menu
Generalises the embed modal with an optional resourceType (defaulting to
dashboard, so existing call sites are unchanged) and adds an Embed chart
item to the chart header menu, gated on EMBEDDED_SUPERSET and the
can_set_embedded permission on Chart.
2026-09-03 12:14:15 +05:30
amaannawab923 8609fab3c6 feat(embedded): render an embedded chart via fabricated dashboard state
Builds the minimum slice of dashboard state a single chart needs and renders
the existing dashboard Chart component against it, so cross-filtering, drill
and the header controls work without reimplementation.

Reuses HYDRATE_DASHBOARD rather than adding a parallel action, so no
dashboard reducer changes are required; datasources is populated through its
own setDatasources action.
2026-09-03 12:14:15 +05:30
amaannawab923 a4be5ffa0c feat(embedded): resolve a uuid as either an embedded dashboard or chart
The embed view now falls back to EmbeddedChartDAO and passes resource_type
plus chart_id into the bootstrap payload.
2026-09-03 12:14:15 +05:30
amaannawab923 3620c4cde5 feat(embedded): add EmbeddedChartDAO, schemas and chart embedded endpoints
Adds GET/POST/PUT/DELETE on /api/v1/chart/<pk>/embedded, mirroring the
dashboard embedded endpoints, with a set_embedded permission for writes.
2026-09-03 12:14:15 +05:30
amaannawab923 d9f3ed34dd feat(embedded): add EmbeddedChart model and migration
Mirrors EmbeddedDashboard so both embeddable resource types share the same
guest-token and allowed-domain semantics, including
guest_token_revoked_before.
2026-09-03 12:14:15 +05:30
21 changed files with 1108 additions and 28 deletions
@@ -44,6 +44,9 @@ const extensionsRegistry = getExtensionsRegistry();
type Props = {
dashboardId: string;
// Which resource the id refers to. Defaults to 'dashboard' so existing
// call sites are unaffected; charts reuse the same controls.
resourceType?: 'dashboard' | 'chart';
show: boolean;
onHide: () => void;
};
@@ -59,7 +62,11 @@ const ButtonRow = styled.div`
justify-content: flex-end;
`;
export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
export const DashboardEmbedControls = ({
dashboardId,
resourceType = 'dashboard',
onHide,
}: Props) => {
const { addInfoToast, addDangerToast } = useToasts();
const [ready, setReady] = useState(true); // whether we have initialized yet
const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
@@ -67,7 +74,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
const [allowedDomains, setAllowedDomains] = useState<string>('');
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
const endpoint = `/api/v1/${resourceType}/${dashboardId}/embedded`;
// whether saveable changes have been made to the config
const isDirty =
!embedded ||
@@ -172,18 +179,26 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
<DocsConfigDetails embeddedId={embedded.uuid} />
) : (
<p>
{t(
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
)}
{resourceType === 'chart'
? t(
'This chart is ready to embed. In your application, pass the following id to the SDK:',
)
: t(
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
)}
<br />
<code>{embedded.uuid}</code>
</p>
)
) : (
<p>
{t(
'Configure this dashboard to embed it into an external web application.',
)}
{resourceType === 'chart'
? t(
'Configure this chart to embed it into an external web application.',
)
: t(
'Configure this dashboard to embed it into an external web application.',
)}
</p>
)}
<p>
@@ -64,6 +64,8 @@ import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
import DashboardEmbedModal from 'src/dashboard/components/EmbeddedModal';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { openInNewTab } from 'src/utils/navigationUtils';
import { usePermissions } from 'src/hooks/usePermissions';
@@ -173,6 +175,13 @@ const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [embedModalIsOpen, setEmbedModalIsOpen] = useState(false);
const user = useSelector((state: RootState) => state.user);
// Mirrors the dashboard's `userCanCurate`: embedding must be enabled and the
// user must hold the chart-level set_embedded permission.
const canEmbed =
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
findPermission('can_set_embedded', 'Chart', user?.roles);
// setting openKeys undefined falls back to uncontrolled behaviour
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
@@ -263,6 +272,9 @@ const SliceHeaderControls = (
refreshChart();
props.addSuccessToast(t('Data refreshed'));
break;
case MenuKeys.ManageEmbedded:
setEmbedModalIsOpen(true);
break;
case MenuKeys.ToggleChartDescription:
// eslint-disable-next-line no-unused-expressions
props.toggleExpandSlice?.(props.slice.slice_id);
@@ -513,6 +525,14 @@ const SliceHeaderControls = (
key: MenuKeys.Fullscreen,
label: fullscreenLabel,
},
...(canEmbed
? [
{
key: MenuKeys.ManageEmbedded,
label: t('Embed chart'),
},
]
: []),
{
type: 'divider',
},
@@ -806,6 +826,14 @@ const SliceHeaderControls = (
dataset={datasetWithVerboseMap}
/>
{canEditCrossFilters && scopingModal}
{canEmbed && (
<DashboardEmbedModal
show={embedModalIsOpen}
onHide={() => setEmbedModalIsOpen(false)}
dashboardId={String(slice.slice_id)}
resourceType="chart"
/>
)}
{isFullSize && <Global styles={fullscreenStyles(theme)} />}
</>
);
@@ -0,0 +1,100 @@
/**
* 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 { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import dashboardLayout from 'src/dashboard/reducers/dashboardLayout';
import nativeFilters from 'src/dashboard/reducers/nativeFilters';
import dashboardStateReducer from 'src/dashboard/reducers/dashboardState';
import sliceEntities from 'src/dashboard/reducers/sliceEntities';
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
import hydrateEmbedded, { EmbeddedChartData } from './hydrateEmbedded';
const SLICE_ID = 103;
const chartData = {
slice: {
slice_id: SLICE_ID,
slice_url: `/explore/?slice_id=${SLICE_ID}`,
slice_name: 'Preferred Employment Style',
form_data: {
viz_type: 'treemap_v2',
datasource: '4__table',
slice_id: SLICE_ID,
},
description: null,
changed_on: '2026-01-01T00:00:00',
},
dataset: { uid: '4__table', id: 4 },
} as unknown as EmbeddedChartData;
const common = { locale: 'en' } as unknown as CommonBootstrapData;
const build = () => hydrateEmbedded(chartData, common);
test('dispatches HYDRATE_DASHBOARD rather than a parallel action', () => {
expect(build().type).toEqual(HYDRATE_DASHBOARD);
});
test('keys the fabricated state by slice id', () => {
const { data } = build();
expect(Object.keys(data.charts)).toEqual([String(SLICE_ID)]);
expect(data.sliceEntities.slices[SLICE_ID].slice_name).toEqual(
'Preferred Employment Style',
);
expect(data.dataMask[SLICE_ID]).toBeDefined();
expect(data.dashboardState.sliceIds).toEqual([SLICE_ID]);
});
test('keeps the actions that would navigate out of the iframe switched off', () => {
const { dashboardInfo } = build().data;
expect(dashboardInfo.superset_can_explore).toBe(false);
expect(dashboardInfo.superset_can_share).toBe(false);
expect(dashboardInfo.crossFiltersEnabled).toBe(false);
// Chart.tsx reads this one, so downloads stay available.
expect(dashboardInfo.superset_can_download).toBe(true);
});
/**
* The regression this file exists for. `nativeFilters` and `dashboardLayout`
* read `action.data.<slice>` with no optional chaining, so omitting either one
* throws at runtime — and only in the embedded path, where a dashboard
* developer would never see it. Run the real reducers against the real payload
* rather than asserting on shape, so this keeps holding if they change.
*/
describe('every HYDRATE_DASHBOARD handler survives the fabricated payload', () => {
const cases: [string, (state: any, action: any) => unknown][] = [
['dashboardLayout', dashboardLayout],
['nativeFilters', nativeFilters],
['dashboardState', dashboardStateReducer],
['sliceEntities', sliceEntities],
];
test.each(cases)('%s', (_name, reducer) => {
expect(() => reducer(undefined, build())).not.toThrow();
});
});
test('carries a layout tree so the layout reducer has a root to hydrate', () => {
const layout = build().data.dashboardLayout.present;
expect(layout.ROOT_ID).toBeDefined();
expect(layout.GRID_ID).toBeDefined();
});
test('carries an empty native filter map', () => {
expect(build().data.nativeFilters.filters).toEqual({});
});
@@ -0,0 +1,163 @@
/**
* 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 { DataMaskWithId, JsonObject } from '@superset-ui/core';
import { chart } from 'src/components/Chart/chartReducer';
import { getInitialDataMask } from 'src/dataMask/reducer';
import { applyDefaultFormData } from 'src/explore/store';
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import { Datasource } from 'src/dashboard/types';
import {
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
} from 'src/dashboard/util/constants';
import {
DASHBOARD_ROOT_TYPE,
DASHBOARD_GRID_TYPE,
} from 'src/dashboard/util/componentTypes';
/**
* A chart embedded on its own still renders through the dashboard's chart
* stack, because that is where cross-filtering, drill, and the header controls
* live. Rather than reimplement any of that, this builds the minimum slice of
* dashboard state a single chart needs and lets the existing components run
* against it unchanged.
*
* It reuses HYDRATE_DASHBOARD rather than introducing a parallel action, so
* every dashboard reducer stays untouched: `charts`, `sliceEntities`,
* `dataMask`, `dashboardInfo` and `dashboardState` all already handle it.
* `dashboardLayout` and `nativeFilters` handle it too but dereference their
* slice unconditionally, so the payload carries an empty stand-in for each.
* `datasources` is the one slice with no hydrate handler at all, so the caller
* dispatches `setDatasources` for it separately.
*
* Every slice any HYDRATE_DASHBOARD handler reads has to appear here; the
* accompanying test asserts that, because a missing one only fails at runtime
* and only in the embedded path.
*/
export interface EmbeddedChartData {
slice: {
slice_id: number;
slice_url: string;
slice_name: string;
form_data: JsonObject & { viz_type: string; datasource: string };
description?: string | null;
description_markeddown?: string | null;
modified?: string | null;
changed_on?: string | number | null;
};
// The explore endpoint returns the full datasource, and `setDatasources`
// stores it as one, so it is typed as such rather than loosely.
dataset: Datasource;
}
export interface HydrateEmbeddedAction {
type: typeof HYDRATE_DASHBOARD;
data: {
charts: Record<number, JsonObject>;
sliceEntities: { slices: Record<number, JsonObject> };
dataMask: Record<number, DataMaskWithId>;
dashboardInfo: JsonObject;
dashboardState: JsonObject;
dashboardLayout: { present: JsonObject };
nativeFilters: { filters: JsonObject };
};
}
const hydrateEmbedded = (
{ slice }: EmbeddedChartData,
common: CommonBootstrapData,
): HydrateEmbeddedAction => {
const key = slice.slice_id;
return {
type: HYDRATE_DASHBOARD,
data: {
charts: {
[key]: {
...chart,
id: key,
form_data: applyDefaultFormData(slice.form_data),
},
},
sliceEntities: {
slices: {
[key]: {
slice_id: key,
slice_url: slice.slice_url,
slice_name: slice.slice_name,
form_data: slice.form_data,
viz_type: slice.form_data.viz_type,
datasource: slice.form_data.datasource,
description: slice.description,
description_markeddown: slice.description_markeddown,
modified: slice.modified,
changed_on: slice.changed_on
? new Date(slice.changed_on).getTime()
: undefined,
},
},
},
dataMask: {
[key]: getInitialDataMask(key) as DataMaskWithId,
},
dashboardInfo: {
common,
// A guest viewing an embedded chart has no Superset UI to navigate to,
// so the actions that would leave the iframe stay off.
metadata: {},
superset_can_explore: false,
superset_can_share: false,
// Chart.tsx reads `superset_can_download`; `superset_can_csv` is not
// a key the current dashboard chart stack looks at.
superset_can_download: true,
crossFiltersEnabled: false,
},
dashboardState: {
expandedSlices: { [key]: false },
sliceIds: [key],
},
// Both of these reducers dereference their slice unconditionally on
// HYDRATE_DASHBOARD, so they have to be present even though a lone chart
// has no layout tree and no native filters of its own.
dashboardLayout: {
present: {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
type: DASHBOARD_GRID_TYPE,
parents: [DASHBOARD_ROOT_ID],
children: [],
meta: {},
},
},
},
nativeFilters: {
filters: {},
},
},
};
};
export default hydrateEmbedded;
@@ -0,0 +1,166 @@
/**
* 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 { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { AntdThemeProvider, Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import getBootstrapData from 'src/utils/getBootstrapData';
import { setDatasources } from 'src/dashboard/actions/datasources';
import useExploreData from './useExploreData';
import hydrateEmbedded from './hydrateEmbedded';
/**
* Fills the iframe. The chart is measured by its container rather than the
* dashboard grid, so the wrapper owns the dimensions the dashboard would
* normally supply.
*/
const Fill = styled.div`
${() => css`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: hidden;
`}
`;
/**
* The dashboard gives each chart a holder element that owns two things the
* header controls reach for: the node passed to `requestFullscreen`, and the
* `dashboard-chart-id-<id>` class the screenshot exports select on. An embedded
* chart renders without `ChartHolder`, so it has to provide both itself.
*/
const Holder = styled.div`
${({ theme }) => css`
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
/* Without this the chart title and the header menu sit flush against the
iframe edge. On a dashboard the grid gutter supplies this breathing
room; an embed has no grid, so the holder supplies it. */
padding: ${theme.sizeUnit * 4}px;
`}
`;
/**
* Tracks the holder's content box, which excludes its padding, so the chart is
* laid out inside that padding rather than overflowing it. Falls back to the
* viewport for the first paint and where ResizeObserver is unavailable.
*/
const useContainerSize = (
ref: RefObject<HTMLElement>,
enabled: boolean,
): { width: number; height: number } => {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const element = ref.current;
if (!enabled || !element || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(entries => {
const box = entries[0]?.contentRect;
if (box?.width && box?.height) {
setSize({ width: box.width, height: box.height });
}
});
observer.observe(element);
return () => observer.disconnect();
}, [ref, enabled]);
return size;
};
export default function EmbeddedChart({ chartId }: { chartId: string }) {
const dispatch = useDispatch();
const { data, loading, error } = useExploreData(chartId);
const [hydrated, setHydrated] = useState(false);
const [isFullSize, setIsFullSize] = useState(false);
const holderRef = useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(holderRef, hydrated);
const handleToggleFullSize = useCallback(() => {
setIsFullSize(current => !current);
}, []);
useEffect(() => {
if (!data) return;
const bootstrapData = getBootstrapData();
// `datasources` has no HYDRATE_DASHBOARD handler, so it is populated
// through its own action rather than the hydrate payload.
dispatch(setDatasources([data.dataset]));
dispatch(hydrateEmbedded(data, bootstrapData.common));
setHydrated(true);
}, [data, dispatch]);
if (loading || (!hydrated && !error)) return <Loading />;
if (error || !data)
return <div>{error ?? t('The chart could not be loaded.')}</div>;
return (
<Fill>
<ErrorBoundary>
<Holder
ref={holderRef}
className={`dashboard-component-chart-holder dashboard-chart-id-${data.slice.slice_id}`}
>
<AntdThemeProvider
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body,
// otherwise the header menu is unreachable while fullscreen.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
>
<Chart
id={data.slice.slice_id}
componentId={`EMBEDDED_CHART-${data.slice.slice_id}`}
// There is no dashboard behind an embedded chart; the fabricated
// state is keyed by slice id and nothing reads this as a lookup.
dashboardId={0}
width={width}
height={height}
sliceName={data.slice.slice_name}
isComponentVisible
isInView
chartHolderRef={holderRef}
isFullSize={isFullSize}
handleToggleFullSize={handleToggleFullSize}
// Renaming is a dashboard-owner action with no meaning here.
updateSliceName={() => {}}
/>
</AntdThemeProvider>
</Holder>
</ErrorBoundary>
</Fill>
);
}
@@ -0,0 +1,89 @@
/**
* 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 { useEffect, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { EmbeddedChartData } from './hydrateEmbedded';
interface State {
data: EmbeddedChartData | null;
loading: boolean;
error: string | null;
}
/**
* Fetches the one chart this iframe renders, in the shape `hydrateEmbedded`
* expects. Uses the explore endpoint because it returns the slice and its
* dataset together, which is exactly the pair the fabricated dashboard state
* needs and avoids a second round trip for the datasource.
*/
export default function useExploreData(chartId: string | number): State {
const [state, setState] = useState<State>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
SupersetClient.get({
endpoint: `/api/v1/explore/?slice_id=${chartId}`,
})
.then(({ json }) => {
if (cancelled) return;
const result = json?.result;
if (!result?.slice || !result?.dataset) {
setState({
data: null,
loading: false,
error: t('The chart could not be loaded.'),
});
return;
}
setState({
data: {
slice: {
...result.slice,
// `form_data` on the explore payload already carries the
// datasource and viz_type the chart stack keys off.
form_data: result.form_data ?? result.slice.form_data,
},
dataset: result.dataset,
},
loading: false,
error: null,
});
})
.catch(() => {
if (cancelled) return;
setState({
data: null,
loading: false,
error: t('The chart could not be loaded.'),
});
});
return () => {
cancelled = true;
};
}, [chartId]);
return state;
}
+12 -1
View File
@@ -47,6 +47,7 @@ import {
getThemeController,
} from './EmbeddedContextProviders';
import { embeddedApi } from './api';
import EmbeddedChart from './embeddedChart';
import { getDataMaskChangeTrigger } from './utils';
import { validateMessageEvent } from './originValidation';
@@ -131,6 +132,16 @@ const EmbeddedLazyDashboardPage = () => {
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
};
// A uuid resolves to either a dashboard or a single chart. Payloads written
// before charts were embeddable omit `resource_type`, so anything other than
// an explicit 'chart' keeps the original dashboard behaviour.
const EmbeddedResource = () =>
bootstrapData.embedded?.resource_type === 'chart' ? (
<EmbeddedChart chartId={bootstrapData.embedded.chart_id!} />
) : (
<EmbeddedLazyDashboardPage />
);
const EmbeddedRoute = () => (
<EmbeddedContextProviders>
<Global
@@ -145,7 +156,7 @@ const EmbeddedRoute = () => (
/>
<Suspense fallback={<Loading />}>
<ErrorBoundary>
<EmbeddedLazyDashboardPage />
<EmbeddedResource />
</ErrorBoundary>
<ToastContainer position="top" />
</Suspense>
+7 -1
View File
@@ -19,6 +19,7 @@
import { useEffect, useState } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
// Matches antd's screenSMMax token; used only when no theme is in scope.
const FALLBACK_MOBILE_MAX_WIDTH = 767;
@@ -29,7 +30,12 @@ const FALLBACK_MOBILE_MAX_WIDTH = 767;
* interpolations; prefer `useIsMobile` in components.
*/
export function isMobileConsumptionEnabled(): boolean {
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
// Inside an iframe the viewport is the size the host chose for the embed,
// not the size of the device, so a narrow embed on a desktop would
// otherwise be served the phone experience and lose its chart controls.
// Mobile consumption mode is a whole-app experience (route guarding,
// drawer navigation) that an embed does not have in the first place.
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode) && !isEmbedded();
}
/**
@@ -183,8 +183,12 @@ export interface BootstrapData {
common: CommonBootstrapData;
config?: any;
embedded?: {
// Which resource this uuid resolves to. Older payloads predate charts
// being embeddable and omit it, so treat a missing value as a dashboard.
resource_type?: 'dashboard' | 'chart';
dashboard_id: string;
// Domains allowed to embed this dashboard. An empty/undefined list means
chart_id?: string;
// Domains allowed to embed this resource. An empty/undefined list means
// any domain is allowed (no restriction).
allowed_domains?: string[];
};
+205 -4
View File
@@ -22,7 +22,13 @@ from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
from flask import current_app, redirect, request, Response, url_for
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.api import (
expose,
permission_name,
protect,
rison as parse_rison,
safe,
)
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
@@ -51,6 +57,8 @@ from superset.charts.schemas import (
ChartGetResponseSchema,
ChartPostSchema,
ChartPutSchema,
EmbeddedChartConfigSchema,
EmbeddedChartResponseSchema,
get_delete_ids_schema,
get_export_ids_schema,
get_fav_star_ids_schema,
@@ -86,11 +94,12 @@ from superset.commands.importers.exceptions import (
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
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, security_manager
from superset.extensions import db, event_logger, security_manager
from superset.models.embedded_chart import EmbeddedChart
from superset.models.slice import Slice
from superset.security.manager import (
get_extra_editor_subject_ids,
@@ -175,6 +184,9 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
"get_version",
"activity",
"restore_version",
"get_embedded",
"set_embedded",
"delete_embedded",
}
class_permission_name = "Chart"
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -194,6 +206,9 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
# single chart's metadata can resolve a Multiple Layers container's
# declared layers too.
"deck_layers": "read",
"get_embedded": "read",
"set_embedded": "set_embedded",
"delete_embedded": "set_embedded",
}
list_columns = [
@@ -307,9 +322,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
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 """
openapi_spec_component_schemas = CHART_SCHEMAS + (VersionListItemSchema,)
openapi_spec_component_schemas = CHART_SCHEMAS + (
VersionListItemSchema,
# Referenced by $ref from the embedded endpoints, so it has to be
# registered as a component rather than only inlined.
EmbeddedChartResponseSchema,
)
apispec_parameter_schemas = {
"screenshot_query_schema": screenshot_query_schema,
@@ -1874,3 +1897,181 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
return restore_version_endpoint(
self, Slice, RestoreChartVersionCommand, uuid_str, version_uuid_str
)
@expose("/<pk>/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, pk: int) -> Response:
"""Get the chart's embedded configuration.
---
get:
summary: Get the chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
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'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
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("/<pk>/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, pk: int) -> Response:
"""Set a chart's embedded configuration.
---
post:
summary: Set a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
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:
summary: Update a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
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'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
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("/<pk>/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, pk: int) -> Response:
"""Delete a chart's embedded configuration.
---
delete:
summary: Delete a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
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'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
chart.embedded = []
db.session.commit() # pylint: disable=consider-using-transaction
return self.response(200, message="OK")
+12
View File
@@ -1998,3 +1998,15 @@ CHART_SCHEMAS = (
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
)
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.String()
changed_on = fields.DateTime()
changed_by = fields.Nested(UserSchema)
@@ -415,6 +415,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
# avoid circular import: model listener registration imports neutral event helpers
from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.embedded_chart import EmbeddedChart # noqa: F401
from superset.models.embedded_dashboard import EmbeddedDashboard # noqa: F401
from superset.models.slice import Slice
from superset.models.user_attributes import UserAttribute # noqa: F401
@@ -550,6 +551,13 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
fk("slices", "chart_editors", "id", "chart_id", "inbound"),
fk("slices", "chart_viewers", "id", "chart_id", "inbound"),
fk("slices", "dashboard_slices", "id", "slice_id", "inbound"),
fk(
"slices",
"embedded_charts",
"id",
"slice_id",
"inbound",
),
fk("slices", "report_schedule", "id", "chart_id", "inbound"),
version("slices", "slices_version"),
relationship("slices", "tables", "manytoone", "table"),
@@ -574,6 +582,27 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
"id",
"outbound",
),
fk(
"embedded_charts",
"ab_user",
"changed_by_fk",
"id",
"outbound",
),
fk(
"embedded_charts",
"ab_user",
"created_by_fk",
"id",
"outbound",
),
fk(
"embedded_charts",
"slices",
"slice_id",
"id",
"outbound",
),
),
(
DependencyClassification.PRESERVE,
@@ -582,6 +611,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.ASSOCIATION,
DependencyClassification.ASSOCIATION,
DependencyClassification.ASSOCIATION,
DependencyClassification.OWNED,
DependencyClassification.BLOCK,
DependencyClassification.VERSION_OWNED,
DependencyClassification.PRESERVE,
@@ -592,6 +622,9 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
),
(tag_cleanup, chart_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
+32 -1
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, List
from typing import Any, Dict, List
from flask_appbuilder.models.sqla.interface import SQLAInterface
from sqlalchemy import or_, select
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import ChartNotFoundError
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
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
@@ -166,3 +167,33 @@ class ChartDAO(BaseDAO[Slice]):
)
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: EmbeddedChart | 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.")
+37 -10
View File
@@ -22,6 +22,7 @@ from flask_login import AnonymousUserMixin, login_user
from flask_wtf.csrf import same_origin
from superset import event_logger, is_feature_enabled
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
@@ -45,28 +46,45 @@ class EmbeddedView(BaseSupersetView):
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
) -> FlaskResponse:
"""
Server side rendering for the embedded dashboard page
:param uuid: identifier for embedded dashboard
Server side rendering for the embedded dashboard or chart page
:param uuid: identifier for the embedded dashboard or chart
:param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
default value to appease pylint
"""
if not is_feature_enabled("EMBEDDED_SUPERSET"):
abort(404)
# A uuid identifies either an embedded dashboard or an embedded chart.
# Dashboards are looked up first since they are the older, more common
# resource; the two id spaces are distinct so ordering is not ambiguous.
resource_type = "dashboard"
embedded = EmbeddedDashboardDAO.find_by_id(uuid)
if not embedded:
embedded = EmbeddedChartDAO.find_by_id(uuid)
resource_type = "chart"
if not embedded:
abort(404)
assert embedded is not None
dashboard = embedded.dashboard
resource = (
embedded.dashboard if resource_type == "dashboard" else embedded.slice
)
# validate request referrer in allowed domains
is_referrer_allowed = not embedded.allowed_domains
for domain in embedded.allowed_domains:
if same_origin(request.referrer, domain):
is_referrer_allowed = True
break
try:
if same_origin(request.referrer, domain):
is_referrer_allowed = True
break
except ValueError:
# The referrer is attacker-controlled and same_origin parses it
# eagerly, so a malformed authority (e.g. a host that looks like
# it carries a non-numeric port) raises rather than returning
# False. Treat it as a non-match instead of a 500.
continue
if not is_referrer_allowed:
abort(403)
@@ -91,7 +109,8 @@ class EmbeddedView(BaseSupersetView):
login_user(AnonymousUserMixin(), force=True)
add_extra_log_payload(
embedded_dashboard_id=uuid,
embedded_id=uuid,
resource_type=resource_type,
dashboard_version="v2",
)
@@ -101,7 +120,11 @@ class EmbeddedView(BaseSupersetView):
},
"common": common_bootstrap_payload(),
"embedded": {
"dashboard_id": embedded.dashboard_id,
"resource_type": resource_type,
"dashboard_id": (
embedded.dashboard_id if resource_type == "dashboard" else None
),
"chart_id": embedded.slice_id if resource_type == "chart" else None,
# The list of domains allowed to embed this dashboard. An empty
# list means any domain is allowed (no restriction). The frontend
# uses this to validate the origin of incoming postMessage events.
@@ -112,8 +135,12 @@ class EmbeddedView(BaseSupersetView):
return self.render_template(
"superset/spa.html",
entry="embedded",
title=dashboard.dashboard_title,
dashboard_description=dashboard.description,
title=(
resource.dashboard_title
if resource_type == "dashboard"
else resource.slice_name
),
dashboard_description=resource.description,
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
@@ -0,0 +1,53 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""add embedded_charts table
Revision ID: a1c7e4b62f18
Revises: 39097d124752
Create Date: 2026-09-01 10:00:00.000000
"""
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import create_table, drop_table
# revision identifiers, used by Alembic.
revision = "a1c7e4b62f18"
down_revision = "8f31c5d726ab"
def upgrade() -> None:
create_table(
"embedded_charts",
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.Column("uuid", UUIDType(binary=True), primary_key=True),
sa.Column("allow_domain_list", sa.Text(), nullable=True),
sa.Column("guest_token_revoked_before", sa.Integer(), nullable=True),
sa.Column("slice_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["slice_id"], ["slices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
)
def downgrade() -> None:
drop_table("embedded_charts")
+64
View File
@@ -0,0 +1,64 @@
# 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, and contains a config for embedding that chart.
Mirrors ``EmbeddedDashboard`` so both embeddable resource types share the
same guest-token and allowed-domain semantics.
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
# Epoch seconds; guest tokens whose `iat` predates this are rejected. Set to
# "now" to revoke all currently-issued guest tokens for this embedded
# chart. NULL = no revocation.
guest_token_revoked_before = Column(Integer, nullable=True)
slice_id = Column(
Integer,
ForeignKey("slices.id", ondelete="CASCADE"),
nullable=False,
)
slice = relationship(
"Slice",
back_populates="embedded",
foreign_keys=[slice_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 []
+5
View File
@@ -155,6 +155,11 @@ class Slice( # pylint: disable=too-many-public-methods
secondary=chart_viewers,
passive_deletes=True,
)
embedded = relationship(
"EmbeddedChart",
back_populates="slice",
cascade="all, delete-orphan",
)
tags = relationship(
"Tag",
+1
View File
@@ -121,6 +121,7 @@ class GuestTokenUser(TypedDict, total=False):
class GuestTokenResourceType(StrEnum):
DASHBOARD = "dashboard"
CHART = "chart"
class GuestTokenResource(TypedDict):
+56 -2
View File
@@ -4642,6 +4642,33 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return self.is_viewer(viewer_slc) or self.is_editor(viewer_slc)
def has_embedded_chart_access() -> bool:
# A chart embedded on its own has no parent dashboard, so the
# dashboard leg below can never authorize it. Grant datasource
# access when the guest token was issued for this very chart and
# the request is for that chart's own datasource.
# Resolve the guest user before touching the database: without
# one the chart grant can never hold, so the lookup below would
# be a query issued on every datasource check for nothing.
if not (
is_feature_enabled("EMBEDDED_SUPERSET")
and self.get_current_guest_user_if_guest()
and form_data
and form_data.get("type") != "NATIVE_FILTER"
and (embedded_slice_id := form_data.get("slice_id"))
and (
embedded_slc := self.session.query(Slice)
.filter(Slice.id == embedded_slice_id)
.one_or_none()
)
):
return False
return (
embedded_slc.datasource == datasource
and self.has_guest_access_to_chart(embedded_slc)
)
if not (
self.can_access_schema(datasource)
or self.can_access("datasource_access", datasource.perm or "")
@@ -4748,6 +4775,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# access if the user is a viewer or editor of the chart
# and promiscuous mode is enabled.
or has_promiscuous_chart_access()
# Standalone embedded chart, authorized by its own guest token.
or has_embedded_chart_access()
):
raise SupersetSecurityException(
self.get_datasource_access_error_object(datasource)
@@ -4816,8 +4845,12 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
if (
is_feature_enabled("EMBEDDED_SUPERSET")
and self.is_guest_user()
and any(
self.has_guest_access(dashboard_) for dashboard_ in chart.dashboards
and (
self.has_guest_access_to_chart(chart)
or any(
self.has_guest_access(dashboard_)
for dashboard_ in chart.dashboards
)
)
and self._guest_token_allows_dataset(
chart.datasource.id if chart.datasource else None
@@ -5150,6 +5183,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
from superset.commands.dashboard.embedded.exceptions import (
EmbeddedDashboardNotFoundError,
)
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.models.dashboard import Dashboard
@@ -5165,6 +5199,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# A raw dashboard id must still reference an embedded dashboard;
# otherwise a guest token could be scoped to a non-embedded one.
raise EmbeddedDashboardNotFoundError()
elif resource["type"] == GuestTokenResourceType.CHART.value:
# Charts are only ever addressed by the embedded uuid; there is
# no legacy raw-id path to support.
if not EmbeddedChartDAO.find_by_id(str(resource["id"])):
raise EmbeddedDashboardNotFoundError()
def create_guest_access_token(
self,
@@ -5519,6 +5558,21 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
and datasource_id in allowed_datasets
)
def has_guest_access_to_chart(self, chart: "Slice") -> bool:
"""
Whether the current guest token grants this chart directly, i.e. the
chart is embedded on its own rather than through a dashboard.
"""
user = self.get_current_guest_user_if_guest()
if not user or not chart.embedded:
return False
embedded_uuid = str(chart.embedded[0].uuid)
return any(
r["type"] == GuestTokenResourceType.CHART and str(r["id"]) == embedded_uuid
for r in user.resources
)
def has_guest_access(self, dashboard: "Dashboard") -> bool:
user = self.get_current_guest_user_if_guest()
if not user:
@@ -320,6 +320,7 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
"can_write",
"can_export",
"can_warm_up_cache",
"can_set_embedded",
}
def test_delete_chart(self):
@@ -60,6 +60,10 @@ def _sm_for_chart_access(is_guest: bool) -> MagicMock:
sm.can_access_datasource.return_value = False
sm.is_guest_user.return_value = is_guest
sm._guest_token_allows_dataset.return_value = True
# A chart embedded on its own is granted through its own token rather than
# through a dashboard. Closed by default so each test opens exactly one
# guest path and the assertion reflects that path alone.
sm.has_guest_access_to_chart.return_value = False
return sm
@@ -90,6 +94,18 @@ def test_guest_cannot_access_chart_outside_granted_dashboards() -> None:
SupersetSecurityManager.raise_for_access(sm, chart=chart)
def test_guest_can_access_chart_granted_directly() -> None:
"""A guest whose token was issued for the chart itself may access it, even
when none of its dashboards are granted."""
chart = _make_chart([MagicMock()])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.return_value = False
sm.has_guest_access_to_chart.return_value = True
with patch("superset.is_feature_enabled", return_value=True):
SupersetSecurityManager.raise_for_access(sm, chart=chart) # no exception
def test_guest_denied_member_chart_outside_dataset_allowlist() -> None:
"""Even on a granted dashboard, a chart whose dataset the token's allowlist
excludes stays inaccessible."""