mirror of
https://github.com/apache/superset.git
synced 2026-09-09 16:54:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d59eb79cf9 | ||
|
|
f559837f1c | ||
|
|
4c8fb70e5c | ||
|
|
4c9e4f3d17 | ||
|
|
e36912aa41 | ||
|
|
a733b1fd84 | ||
|
|
e5187b7b91 | ||
|
|
6b30778d81 | ||
|
|
3337218ea7 | ||
|
|
7ae1f859c0 | ||
|
|
7bf63bdd8c | ||
|
|
2a3cca40d8 | ||
|
|
976707dc8e | ||
|
|
29cc378010 | ||
|
|
31ad989238 | ||
|
|
3b68d0897e | ||
|
|
f573fa0c14 | ||
|
|
dad9b3e113 | ||
|
|
b4d2b470f5 | ||
|
|
116815a77f | ||
|
|
4b223c1851 | ||
|
|
223cf0e432 | ||
|
|
e5a85d89a0 | ||
|
|
287698785f | ||
|
|
e36787ea80 | ||
|
|
5e5e37e521 | ||
|
|
76a87ee241 | ||
|
|
6cfef05967 | ||
|
|
1d6768523a | ||
|
|
b62e0e5d2d | ||
|
|
0775f0dacb | ||
|
|
7a230c0090 |
@@ -61,6 +61,27 @@ embedDashboard({
|
||||
|
||||
---
|
||||
|
||||
## Guest role permissions
|
||||
|
||||
Embedded viewers authenticate with a guest token rather than a session, and Superset resolves them onto the role named by `GUEST_ROLE_NAME` (default: `Public`). What that role grants therefore decides whether an embed loads.
|
||||
|
||||
Set `PUBLIC_ROLE_LIKE = "Public"` and run `superset init` to sync the built-in defaults, which already cover both embeddable resource types. This is the supported path, and an embedded chart needs nothing added to it.
|
||||
|
||||
If you maintain the guest role by hand, an embedded chart needs at least:
|
||||
|
||||
| Grant | Needed for |
|
||||
| ------------------------------ | ---------------------------------------------------------------- |
|
||||
| `can read on Chart` | The chart definition, its dataset, and `/api/v1/chart/data` |
|
||||
| `can read on CurrentUserRestApi` | `/api/v1/me/roles/`, which the embedded page calls before it renders anything |
|
||||
|
||||
Omitting the second one is easy to miss: the embed fails during authentication rather than while loading the chart, so the browser console reports an authentication failure with no mention of a missing grant.
|
||||
|
||||
An embedded chart needs **no** grant on Explore. It reads its definition and dataset through the same `Chart` permission it already needs to fetch data, so a guest role that can open an embedded dashboard can open an embedded chart.
|
||||
|
||||
The guest token itself, not the role, decides *which* dashboards and charts are in scope — a role grant alone never widens a guest past the resources its token names.
|
||||
|
||||
---
|
||||
|
||||
## Callbacks
|
||||
|
||||
### `resolvePermalinkUrl`
|
||||
|
||||
Vendored
+350
-1
@@ -7019,6 +7019,44 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"EmbeddedChartConfig": {
|
||||
"properties": {
|
||||
"allowed_domains": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allowed_domains"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"EmbeddedChartResponseSchema": {
|
||||
"properties": {
|
||||
"allowed_domains": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"changed_by": {
|
||||
"$ref": "#/components/schemas/EmbeddedResponseUser"
|
||||
},
|
||||
"changed_on": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"chart_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"EmbeddedDashboardConfig": {
|
||||
"properties": {
|
||||
"allowed_domains": {
|
||||
@@ -7093,6 +7131,23 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"EmbeddedResponseUser": {
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"EngineInformation": {
|
||||
"properties": {
|
||||
"disable_ssh_tunneling": {
|
||||
@@ -10542,7 +10597,8 @@
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"dashboard"
|
||||
"dashboard",
|
||||
"chart"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -16682,6 +16738,299 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/chart/{pk}/embedded": {
|
||||
"delete": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The chart id",
|
||||
"in": "path",
|
||||
"name": "pk",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successfully removed the configuration"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a chart's embedded configuration",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The chart id",
|
||||
"in": "path",
|
||||
"name": "pk",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Result contains the embedded chart config"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get the chart's embedded configuration",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The chart id",
|
||||
"in": "path",
|
||||
"name": "pk",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EmbeddedChartConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The embedded configuration to set",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successfully set the configuration"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Set a chart's embedded configuration",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The chart id",
|
||||
"in": "path",
|
||||
"name": "pk",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EmbeddedChartConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The embedded configuration to set",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successfully set the configuration"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Update a chart's embedded configuration",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/chart/{pk}/embedded_context": {
|
||||
"get": {
|
||||
"description": "The chart analogue of a dashboard's ``/charts`` and ``/datasets`` sub-resources, collapsed into one call because a chart has exactly one of each. Sits under the ``Chart`` read permission, so a standalone embedded chart loads with the same grant its guest token already needs to fetch that chart's data.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The chart id",
|
||||
"in": "path",
|
||||
"name": "pk",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"properties": {
|
||||
"dataset": {
|
||||
"type": "object"
|
||||
},
|
||||
"slice": {
|
||||
"$ref": "#/components/schemas/ChartEntityResponseSchema"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The chart and its dataset"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get a chart and its dataset in one payload",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/chart/{pk}/favorites/": {
|
||||
"delete": {
|
||||
"parameters": [
|
||||
|
||||
@@ -168,6 +168,35 @@ test('enables Save Changes button when allowed domains are modified', async () =
|
||||
expect(saveChangesBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('refetches the configuration when resourceType changes but dashboardId does not', async () => {
|
||||
const chartResponse = {
|
||||
result: { uuid: 'chart-uuid', dashboard_id: '1', allowed_domains: [] },
|
||||
};
|
||||
(makeApi as any)
|
||||
.mockReturnValueOnce(jest.fn().mockResolvedValue(defaultResponse))
|
||||
.mockReturnValueOnce(jest.fn().mockResolvedValue(chartResponse));
|
||||
|
||||
const { rerender } = render(
|
||||
<DashboardEmbedModal {...defaultProps} resourceType="dashboard" />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
const dashboardDomainsInput = (await screen.findByRole('textbox', {
|
||||
name: /Allowed Domains/i,
|
||||
})) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
expect(dashboardDomainsInput.value).toBe('example.com');
|
||||
});
|
||||
|
||||
rerender(<DashboardEmbedModal {...defaultProps} resourceType="chart" />);
|
||||
|
||||
const chartDomainsInput = (await screen.findByRole('textbox', {
|
||||
name: /Allowed Domains/i,
|
||||
})) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
expect(chartDomainsInput.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders extension component when registered', async () => {
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
@@ -149,7 +156,10 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
setEmbedded(result);
|
||||
setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
|
||||
});
|
||||
}, [dashboardId]);
|
||||
// `endpoint` already incorporates both `dashboardId` and `resourceType`;
|
||||
// depending on it (rather than `dashboardId` alone) keeps this in sync if
|
||||
// a resource type ever changes without a `dashboardId` change.
|
||||
}, [endpoint]);
|
||||
|
||||
if (!ready) {
|
||||
return <Loading />;
|
||||
@@ -172,18 +182,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(
|
||||
@@ -272,6 +281,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);
|
||||
@@ -522,6 +534,14 @@ const SliceHeaderControls = (
|
||||
key: MenuKeys.Fullscreen,
|
||||
label: fullscreenLabel,
|
||||
},
|
||||
...(canEmbed
|
||||
? [
|
||||
{
|
||||
key: MenuKeys.ManageEmbedded,
|
||||
label: t('Embed chart'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
@@ -815,6 +835,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,111 @@
|
||||
/**
|
||||
* 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({});
|
||||
});
|
||||
|
||||
test('renames the misspelled API field to description_markdown for the chart stack', () => {
|
||||
const withDescription = {
|
||||
...chartData,
|
||||
slice: { ...chartData.slice, description_markeddown: '<p>hello</p>' },
|
||||
} as unknown as EmbeddedChartData;
|
||||
const { data } = hydrateEmbedded(withDescription, common);
|
||||
expect(data.sliceEntities.slices[SLICE_ID].description_markdown).toEqual(
|
||||
'<p>hello</p>',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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,
|
||||
// The API field is spelled `description_markeddown`; the chart
|
||||
// stack (see Chart.tsx) reads the correctly spelled
|
||||
// `description_markdown` off the store slice, matching the same
|
||||
// rename `hydrate.ts` and `sliceEntities.ts` already perform.
|
||||
description_markdown: 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 useEmbeddedChartData from './useEmbeddedChartData';
|
||||
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 } = useEmbeddedChartData(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,98 @@
|
||||
/**
|
||||
* 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: the slice and its dataset together, which is exactly the pair the
|
||||
* fabricated dashboard state needs.
|
||||
*
|
||||
* It reads them from the chart's own embedded-context endpoint rather than from
|
||||
* `/api/v1/explore/`. Explore sits under its own `Explore` permission, which an
|
||||
* embedded guest role does not hold, so a guest token would get a 403 and the
|
||||
* page would dead-end in its error state. This endpoint sits under the `Chart`
|
||||
* permission the guest already needs for `/api/v1/chart/data`, and it resolves
|
||||
* one fixed chart instead of assembling a payload from request-supplied form
|
||||
* data, which keeps the guest's reachable surface to what the embed needs.
|
||||
*/
|
||||
export default function useEmbeddedChartData(chartId: string | number): State {
|
||||
const [state, setState] = useState<State>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/chart/${chartId}/embedded_context`,
|
||||
})
|
||||
.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: {
|
||||
// `form_data` on the payload already carries the datasource and
|
||||
// viz_type the chart stack keys off.
|
||||
slice: {
|
||||
...result.slice,
|
||||
// The payload identifies the chart as `id`; the rest of the
|
||||
// embedded chart stack keys off `slice_id`.
|
||||
slice_id: Number(chartId),
|
||||
},
|
||||
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;
|
||||
}
|
||||
@@ -108,6 +108,12 @@ const LazyDashboardPage = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
// Keeps the dashboard chart stack out of the initial embedded bundle for
|
||||
// consumers who only ever embed dashboards, which never render this path.
|
||||
const LazyEmbeddedChart = lazy(
|
||||
() => import(/* webpackChunkName: "EmbeddedChart" */ './embeddedChart'),
|
||||
);
|
||||
|
||||
const EmbeddedLazyDashboardPage = () => {
|
||||
const uiConfig = useUiConfig();
|
||||
const emitDataMasks = uiConfig?.emitDataMasks;
|
||||
@@ -136,6 +142,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' ? (
|
||||
<LazyEmbeddedChart chartId={bootstrapData.embedded.chart_id!} />
|
||||
) : (
|
||||
<EmbeddedLazyDashboardPage />
|
||||
);
|
||||
|
||||
const EmbeddedRoute = () => (
|
||||
<EmbeddedContextProviders>
|
||||
<Global
|
||||
@@ -150,7 +166,7 @@ const EmbeddedRoute = () => (
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<EmbeddedLazyDashboardPage />
|
||||
<EmbeddedResource />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</Suspense>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -186,8 +186,12 @@ export interface BootstrapData {
|
||||
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
|
||||
};
|
||||
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[];
|
||||
};
|
||||
|
||||
+296
-3
@@ -67,9 +67,12 @@ from superset.charts.schemas import (
|
||||
chart_get_list_schema,
|
||||
CHART_SCHEMAS,
|
||||
ChartCacheWarmUpRequestSchema,
|
||||
ChartEntityResponseSchema,
|
||||
ChartGetResponseSchema,
|
||||
ChartPostSchema,
|
||||
ChartPutSchema,
|
||||
EmbeddedChartConfigSchema,
|
||||
EmbeddedChartResponseSchema,
|
||||
get_delete_ids_schema,
|
||||
get_export_ids_schema,
|
||||
get_fav_star_ids_schema,
|
||||
@@ -105,11 +108,14 @@ 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.dashboards.schemas import DashboardDatasetSchema
|
||||
from superset.exceptions import (
|
||||
ScreenshotImageNotAvailableException,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
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,
|
||||
@@ -234,6 +240,10 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
"get_version",
|
||||
"activity",
|
||||
"restore_version",
|
||||
"get_embedded",
|
||||
"get_embedded_context",
|
||||
"set_embedded",
|
||||
"delete_embedded",
|
||||
}
|
||||
class_permission_name = "Chart"
|
||||
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
|
||||
@@ -253,6 +263,10 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
# single chart's metadata can resolve a Multiple Layers container's
|
||||
# declared layers too.
|
||||
"deck_layers": "read",
|
||||
"get_embedded": "read",
|
||||
"get_embedded_context": "read",
|
||||
"set_embedded": "set_embedded",
|
||||
"delete_embedded": "set_embedded",
|
||||
}
|
||||
|
||||
list_columns = [
|
||||
@@ -366,9 +380,19 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
edit_model_schema = ChartPutSchema()
|
||||
chart_get_response_schema = ChartGetResponseSchema()
|
||||
|
||||
embedded_response_schema = EmbeddedChartResponseSchema()
|
||||
chart_entity_response_schema = ChartEntityResponseSchema()
|
||||
dashboard_dataset_schema = DashboardDatasetSchema()
|
||||
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 = {
|
||||
"chart_get_list_schema": chart_get_list_schema,
|
||||
@@ -2016,3 +2040,272 @@ 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_context", 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_context"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def get_embedded_context(self, pk: int) -> Response:
|
||||
"""Get a chart together with the dataset needed to render it.
|
||||
---
|
||||
get:
|
||||
summary: Get a chart and its dataset in one payload
|
||||
description: >-
|
||||
The chart analogue of a dashboard's ``/charts`` and ``/datasets``
|
||||
sub-resources, collapsed into one call because a chart has exactly
|
||||
one of each. Sits under the ``Chart`` read permission, so a
|
||||
standalone embedded chart loads with the same grant its guest token
|
||||
already needs to fetch that chart's data.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The chart id
|
||||
responses:
|
||||
200:
|
||||
description: The chart and its dataset
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: object
|
||||
properties:
|
||||
slice:
|
||||
$ref: '#/components/schemas/ChartEntityResponseSchema'
|
||||
dataset:
|
||||
type: object
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.dashboards.api import DASHBOARD_DATASET_INACCESSIBLE_FIELDS
|
||||
|
||||
# Resolved through the base filters so ChartFilter's scoping -- including
|
||||
# its embedded-guest branch -- decides what is visible here.
|
||||
chart = self.datamodel.get(pk, self._base_filters)
|
||||
if not chart:
|
||||
return self.response_404()
|
||||
try:
|
||||
security_manager.raise_for_access(chart=chart)
|
||||
except SupersetSecurityException:
|
||||
return self.response_403()
|
||||
datasource = chart.datasource
|
||||
if datasource is None:
|
||||
return self.response_404()
|
||||
|
||||
dataset = self.dashboard_dataset_schema.dump(datasource.data)
|
||||
# A dashboard narrows member datasets the caller cannot access on their
|
||||
# own, because it returns many datasets of uneven sensitivity. Here there
|
||||
# is exactly one and it belongs to the chart the caller was just
|
||||
# authorized on, so a guest holding a token for that chart keeps the
|
||||
# rendering metadata -- columns, metrics, verbose map -- it needs to draw
|
||||
# the chart. ``params`` is withheld even then: it is operator-authored
|
||||
# free-form configuration rather than anything the renderer reads.
|
||||
entitled_guest = security_manager.has_guest_access_to_chart(chart)
|
||||
if not (security_manager.can_access_datasource(datasource) or entitled_guest):
|
||||
for key in DASHBOARD_DATASET_INACCESSIBLE_FIELDS:
|
||||
dataset.pop(key, None)
|
||||
elif entitled_guest:
|
||||
dataset.pop("params", None)
|
||||
|
||||
return self.response(
|
||||
200,
|
||||
result={
|
||||
"slice": self.chart_entity_response_schema.dump(chart),
|
||||
"dataset": dataset,
|
||||
},
|
||||
)
|
||||
|
||||
@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")
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm.query import Query
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.connectors.sqla import models
|
||||
@@ -43,6 +44,35 @@ from superset.views.base_api import BaseFavoriteFilter
|
||||
from superset.views.filters import BaseDeletedRecencyFilter, BaseDeletedStateFilter
|
||||
|
||||
|
||||
def guest_embedded_chart_filter() -> Optional[ColumnElement[bool]]:
|
||||
"""SQLAlchemy condition matching the charts embedded in their own right that
|
||||
the current guest token grants, or None when it grants none.
|
||||
|
||||
The chart counterpart of ``guest_embedded_dashboard_filter``, which only ever
|
||||
resolves the dashboard resources of a token. A chart is addressed solely by
|
||||
its embed uuid, so there is no legacy raw-id form to route around here.
|
||||
|
||||
Returning None means "adds nothing to the guest's scope", never "not a
|
||||
guest": the caller decides what an empty scope means, so this can be OR-ed
|
||||
into the dashboard scope without widening a token that grants no charts.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.models.embedded_chart import EmbeddedChart
|
||||
from superset.security.guest_token import GuestTokenResourceType
|
||||
|
||||
guest = security_manager.get_current_guest_user_if_guest()
|
||||
if guest is None:
|
||||
return None
|
||||
uuids = [
|
||||
str(resource["id"])
|
||||
for resource in guest.resources
|
||||
if resource["type"] == GuestTokenResourceType.CHART.value
|
||||
]
|
||||
if not uuids:
|
||||
return None
|
||||
return Slice.embedded.any(EmbeddedChart.uuid.in_(uuids))
|
||||
|
||||
|
||||
class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
name = _("All Text")
|
||||
arg_name = "chart_all_text"
|
||||
@@ -111,11 +141,16 @@ class ChartCertifiedFilter(BaseFilter): # pylint: disable=too-few-public-method
|
||||
|
||||
class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
# Embedded guests are scoped to their token's dashboards first. A guest
|
||||
# is never entitled to all charts, regardless of what its role grants,
|
||||
# and an empty token scope denies all charts (a deny-all clause).
|
||||
# Embedded guests are scoped to what their token grants first: charts on
|
||||
# one of the token's embedded dashboards, plus charts embedded in their
|
||||
# own right. A guest is never entitled to all charts, regardless of what
|
||||
# its role grants, and a token granting neither denies all charts (the
|
||||
# dashboard branch contributes a deny-all clause).
|
||||
if (guest_dashboards := guest_embedded_dashboard_filter()) is not None:
|
||||
return query.filter(self.model.dashboards.any(guest_dashboards))
|
||||
guest_scope: list[Any] = [self.model.dashboards.any(guest_dashboards)]
|
||||
if (guest_charts := guest_embedded_chart_filter()) is not None:
|
||||
guest_scope.append(guest_charts)
|
||||
return query.filter(or_(*guest_scope))
|
||||
|
||||
if security_manager.can_access_all_datasources():
|
||||
return query
|
||||
|
||||
@@ -2112,3 +2112,31 @@ CHART_SCHEMAS = (
|
||||
ChartCacheScreenshotResponseSchema,
|
||||
GetFavStarIdsSchema,
|
||||
)
|
||||
|
||||
|
||||
class EmbeddedChartConfigSchema(Schema):
|
||||
allowed_domains = fields.List(fields.String(), required=True)
|
||||
|
||||
|
||||
class EmbeddedResponseUserSchema(Schema):
|
||||
"""
|
||||
``changed_by`` shape for ``EmbeddedChartResponseSchema``.
|
||||
|
||||
Deliberately separate from the module-level ``UserSchema`` (which other
|
||||
chart schemas dump ``email`` through): this mirrors
|
||||
``superset.dashboards.schemas.UserSchema``, the embedded-dashboard twin,
|
||||
so the two embedded-resource response shapes stay identical.
|
||||
"""
|
||||
|
||||
id = fields.Int()
|
||||
username = fields.String()
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
|
||||
|
||||
class EmbeddedChartResponseSchema(Schema):
|
||||
uuid = fields.String()
|
||||
allowed_domains = fields.List(fields.String())
|
||||
chart_id = fields.String(attribute="slice_id")
|
||||
changed_on = fields.DateTime()
|
||||
changed_by = fields.Nested(EmbeddedResponseUserSchema)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
@@ -106,7 +125,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.
|
||||
@@ -117,8 +140,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
|
||||
),
|
||||
|
||||
+53
@@ -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: 7e2c9a4f1b83
|
||||
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 = "7e2c9a4f1b83"
|
||||
|
||||
|
||||
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")
|
||||
@@ -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 []
|
||||
@@ -156,6 +156,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",
|
||||
|
||||
@@ -140,6 +140,7 @@ class GuestTokenUser(TypedDict, total=False):
|
||||
|
||||
class GuestTokenResourceType(StrEnum):
|
||||
DASHBOARD = "dashboard"
|
||||
CHART = "chart"
|
||||
|
||||
|
||||
class GuestTokenResource(TypedDict):
|
||||
|
||||
@@ -4792,6 +4792,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 "")
|
||||
@@ -4901,6 +4928,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)
|
||||
@@ -4999,8 +5028,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
|
||||
)
|
||||
)
|
||||
# Deliberately table-pinned: the guest token ``datasets``
|
||||
# allowlist is dataset-id space, so resolving other
|
||||
@@ -5338,6 +5371,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
|
||||
|
||||
@@ -5353,6 +5387,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,
|
||||
@@ -5446,9 +5485,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
version claim and are treated as
|
||||
:data:`DEFAULT_GUEST_TOKEN_REVOCATION_VERSION` (0), so they only become
|
||||
revoked once an admin has explicitly bumped the expected version above 0.
|
||||
- **Per-embedded-dashboard cutoff** (``guest_token_revoked_before``): a
|
||||
- **Per-embedded-resource cutoff** (``guest_token_revoked_before``): a
|
||||
token is revoked if its ``iat`` predates the revocation cutoff of any of
|
||||
its embedded-dashboard resources.
|
||||
its embedded resources, dashboard or chart.
|
||||
"""
|
||||
return cls._is_guest_token_revoked_by_version(
|
||||
token
|
||||
@@ -5472,31 +5511,41 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
@staticmethod
|
||||
def _is_guest_token_revoked_by_embedded(token: dict[str, Any]) -> bool:
|
||||
"""Return True if the token predates a revocation on any of its
|
||||
embedded-dashboard resources (``guest_token_revoked_before``).
|
||||
embedded resources (``guest_token_revoked_before``).
|
||||
|
||||
A token missing ``iat`` cannot prove it was issued after a revocation
|
||||
cutoff, so it is treated as revoked whenever any of its dashboard
|
||||
cutoff, so it is treated as revoked whenever any of its embedded
|
||||
resources has an active cutoff; otherwise it is not revoked.
|
||||
"""
|
||||
issued_at = token.get("iat")
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.daos.chart import EmbeddedChartDAO
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
for resource in token.get("resources") or []:
|
||||
if resource.get("type") != GuestTokenResourceType.DASHBOARD.value:
|
||||
continue
|
||||
resource_type = resource.get("type")
|
||||
resource_id = str(resource.get("id"))
|
||||
# A dashboard resource id may be an embedded UUID or, during the
|
||||
# UUID migration, a legacy dashboard id. Resolve the embedded
|
||||
# config(s) for either form (mirrors validate_guest_token_resources).
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(resource_id)
|
||||
if embedded:
|
||||
embedded_configs = [embedded]
|
||||
embedded_configs: list[Any]
|
||||
if resource_type == GuestTokenResourceType.DASHBOARD.value:
|
||||
# A dashboard resource id may be an embedded UUID or, during the
|
||||
# UUID migration, a legacy dashboard id. Resolve the embedded
|
||||
# config(s) for either form (mirrors
|
||||
# validate_guest_token_resources).
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(resource_id)
|
||||
if embedded:
|
||||
embedded_configs = [embedded]
|
||||
else:
|
||||
dashboard = Dashboard.get(resource_id)
|
||||
embedded_configs = list(dashboard.embedded) if dashboard else []
|
||||
elif resource_type == GuestTokenResourceType.CHART.value:
|
||||
# Charts are only ever addressed by the embedded uuid; there is
|
||||
# no legacy raw-id path to support.
|
||||
embedded_chart = EmbeddedChartDAO.find_by_id(resource_id)
|
||||
embedded_configs = [embedded_chart] if embedded_chart else []
|
||||
else:
|
||||
dashboard = Dashboard.get(resource_id)
|
||||
embedded_configs = dashboard.embedded if dashboard else []
|
||||
continue
|
||||
for embedded_config in embedded_configs:
|
||||
revoked_before = getattr(
|
||||
embedded_config, "guest_token_revoked_before", None
|
||||
@@ -5513,13 +5562,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
def revoke_guest_token_access(
|
||||
self, embedded_uuid: str, before: Optional[int] = None
|
||||
) -> None:
|
||||
"""Revoke all guest tokens issued for an embedded dashboard before
|
||||
``before`` (epoch seconds, default: now). Subsequent tokens are
|
||||
"""Revoke all guest tokens issued for an embedded dashboard or chart
|
||||
before ``before`` (epoch seconds, default: now). Subsequent tokens are
|
||||
unaffected."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.daos.chart import EmbeddedChartDAO
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
|
||||
embedded: Any = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
|
||||
if embedded is None:
|
||||
# The two embed uuid spaces are distinct, so falling through to
|
||||
# charts on a dashboard miss is unambiguous (mirrors EmbeddedView).
|
||||
embedded = EmbeddedChartDAO.find_by_id(str(embedded_uuid))
|
||||
if embedded is None:
|
||||
return
|
||||
# Round the cutoff up to the next whole second so that tokens whose
|
||||
@@ -5707,6 +5761,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:
|
||||
|
||||
@@ -321,6 +321,7 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
"can_write",
|
||||
"can_export",
|
||||
"can_warm_up_cache",
|
||||
"can_set_embedded",
|
||||
}
|
||||
|
||||
def test_delete_chart(self):
|
||||
|
||||
@@ -179,3 +179,144 @@ def test_chart_filter_guest_no_resources_denied(mocker: MockerFixture) -> None:
|
||||
assert filt.apply(query, None) is query
|
||||
query.filter.assert_called_once() # scoped (to nothing), not role-based
|
||||
viewers.assert_not_called()
|
||||
|
||||
|
||||
def _guest_with_resources(mocker: MockerFixture, resources: list[dict[str, Any]]):
|
||||
"""Point the security manager at a guest user carrying ``resources``."""
|
||||
from superset.extensions import security_manager
|
||||
|
||||
guest: MagicMock = MagicMock()
|
||||
guest.resources = resources
|
||||
return mocker.patch.object(
|
||||
security_manager, "get_current_guest_user_if_guest", return_value=guest
|
||||
)
|
||||
|
||||
|
||||
def test_guest_embedded_chart_filter_matches_token_charts(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A token's chart resources become an EXISTS over the chart's embed rows."""
|
||||
from superset.charts.filters import guest_embedded_chart_filter
|
||||
|
||||
_guest_with_resources(
|
||||
mocker,
|
||||
[
|
||||
{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"},
|
||||
{"type": "dashboard", "id": "22222222-2222-2222-2222-222222222222"},
|
||||
],
|
||||
)
|
||||
|
||||
clause = guest_embedded_chart_filter()
|
||||
assert clause is not None
|
||||
compiled: str = str(clause.compile(create_engine("sqlite://")))
|
||||
assert "EXISTS" in compiled
|
||||
# Scoped through the chart's own embed config, not through dashboards.
|
||||
assert "embedded_charts" in compiled
|
||||
|
||||
|
||||
def test_guest_embedded_chart_filter_none_without_chart_resources(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A dashboard-only token contributes nothing to the chart scope, so the
|
||||
dashboard branch alone decides what the guest sees."""
|
||||
from superset.charts.filters import guest_embedded_chart_filter
|
||||
|
||||
_guest_with_resources(mocker, [{"type": "dashboard", "id": "abc"}])
|
||||
|
||||
assert guest_embedded_chart_filter() is None
|
||||
|
||||
|
||||
def test_guest_embedded_chart_filter_none_for_non_guest(mocker: MockerFixture) -> None:
|
||||
from superset.charts.filters import guest_embedded_chart_filter
|
||||
from superset.extensions import security_manager
|
||||
|
||||
mocker.patch.object(
|
||||
security_manager, "get_current_guest_user_if_guest", return_value=None
|
||||
)
|
||||
|
||||
assert guest_embedded_chart_filter() is None
|
||||
|
||||
|
||||
def test_chart_filter_scopes_guest_to_token_charts(mocker: MockerFixture) -> None:
|
||||
"""A token issued for a standalone embedded chart resolves that chart.
|
||||
|
||||
Without the chart branch the dashboard scope is a deny-all clause, so the
|
||||
chart the token was minted for is filtered out of the Chart API entirely.
|
||||
"""
|
||||
from sqlalchemy import false
|
||||
|
||||
from superset.charts.filters import ChartFilter
|
||||
from superset.extensions import security_manager
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.charts.filters.guest_embedded_dashboard_filter",
|
||||
return_value=false(),
|
||||
)
|
||||
_guest_with_resources(
|
||||
mocker, [{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"}]
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
query: MagicMock = MagicMock()
|
||||
|
||||
def _capture_filter(clause: object) -> MagicMock:
|
||||
captured["clause"] = clause
|
||||
return query
|
||||
|
||||
query.filter.side_effect = _capture_filter
|
||||
|
||||
filt: ChartFilter = ChartFilter.__new__(ChartFilter)
|
||||
filt.model = Slice
|
||||
assert filt.apply(query, None) is query
|
||||
query.filter.assert_called_once()
|
||||
query.join.assert_not_called()
|
||||
|
||||
compiled: str = str(captured["clause"].compile(create_engine("sqlite://")))
|
||||
assert "embedded_charts" in compiled
|
||||
|
||||
|
||||
def test_chart_filter_guest_scope_unions_dashboards_and_charts(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A token holding both resource kinds resolves both, and nothing else."""
|
||||
from superset.charts.filters import ChartFilter
|
||||
from superset.extensions import security_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.charts.filters.guest_embedded_dashboard_filter",
|
||||
return_value=Dashboard.id.in_([1, 2]),
|
||||
)
|
||||
_guest_with_resources(
|
||||
mocker,
|
||||
[
|
||||
{"type": "dashboard", "id": "1"},
|
||||
{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"},
|
||||
],
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
query: MagicMock = MagicMock()
|
||||
|
||||
def _capture_filter(clause: object) -> MagicMock:
|
||||
captured["clause"] = clause
|
||||
return query
|
||||
|
||||
query.filter.side_effect = _capture_filter
|
||||
|
||||
filt: ChartFilter = ChartFilter.__new__(ChartFilter)
|
||||
filt.model = Slice
|
||||
filt.apply(query, None)
|
||||
|
||||
compiled: str = str(captured["clause"].compile(create_engine("sqlite://")))
|
||||
assert "dashboard_slices" in compiled
|
||||
assert "embedded_charts" in compiled
|
||||
assert " OR " in compiled
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from flask import current_app
|
||||
@@ -37,6 +40,7 @@ from superset.charts.schemas import (
|
||||
ChartPostSchema,
|
||||
ChartPutSchema,
|
||||
DEFAULT_MAX_PROPHET_PERIODS,
|
||||
EmbeddedChartResponseSchema,
|
||||
get_max_prophet_periods,
|
||||
get_prophet_time_grain_choices,
|
||||
get_time_grain_choices,
|
||||
@@ -683,3 +687,61 @@ def test_prophet_accepts_every_mapped_grain(app_context: None, grain: str) -> No
|
||||
{"time_grain": grain, "periods": 7, "confidence_interval": 0.8}
|
||||
)
|
||||
assert result["time_grain"] == grain
|
||||
|
||||
|
||||
def test_embedded_chart_response_schema_dumps_chart_id(app_context: None) -> None:
|
||||
"""
|
||||
The model backing an embedded chart response exposes ``slice_id``, not
|
||||
``chart_id``. The schema field must be bound to the model's actual
|
||||
attribute so the identifier is present in the response instead of
|
||||
silently dropping out.
|
||||
"""
|
||||
fake_user = SimpleNamespace(
|
||||
id=7,
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
last_name="Doe",
|
||||
email="alice@example.com",
|
||||
)
|
||||
fake_embedded = SimpleNamespace(
|
||||
uuid="11111111-1111-1111-1111-111111111111",
|
||||
allowed_domains=["https://example.org"],
|
||||
slice_id=42,
|
||||
changed_on=datetime(2024, 1, 1),
|
||||
changed_by=fake_user,
|
||||
)
|
||||
|
||||
result = EmbeddedChartResponseSchema().dump(fake_embedded)
|
||||
|
||||
assert result["chart_id"] == "42"
|
||||
|
||||
|
||||
def test_embedded_chart_response_schema_changed_by_mirrors_dashboard_twin(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""
|
||||
``EmbeddedDashboardResponseSchema.changed_by`` (the twin schema) dumps
|
||||
``username``, not ``email``. Keeping the two embedded-resource response
|
||||
shapes identical means the chart side must do the same, through a nested
|
||||
schema dedicated to this response rather than the shared, email-dumping
|
||||
``UserSchema`` other chart endpoints rely on.
|
||||
"""
|
||||
fake_user = SimpleNamespace(
|
||||
id=7,
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
last_name="Doe",
|
||||
email="alice@example.com",
|
||||
)
|
||||
fake_embedded = SimpleNamespace(
|
||||
uuid="11111111-1111-1111-1111-111111111111",
|
||||
allowed_domains=["https://example.org"],
|
||||
slice_id=42,
|
||||
changed_on=datetime(2024, 1, 1),
|
||||
changed_by=fake_user,
|
||||
)
|
||||
|
||||
result = EmbeddedChartResponseSchema().dump(fake_embedded)
|
||||
|
||||
assert result["changed_by"]["username"] == "alice"
|
||||
assert "email" not in result["changed_by"]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -21,12 +21,17 @@ from unittest.mock import MagicMock, patch
|
||||
from superset.security.manager import SupersetSecurityManager
|
||||
|
||||
_DASHBOARD_RESOURCE = {"type": "dashboard", "id": "abc-uuid"}
|
||||
_CHART_RESOURCE = {"type": "chart", "id": "chart-uuid"}
|
||||
|
||||
|
||||
def _token(iat: int) -> dict[str, Any]:
|
||||
return {"type": "guest", "iat": iat, "resources": [_DASHBOARD_RESOURCE]}
|
||||
|
||||
|
||||
def _chart_token(iat: int) -> dict[str, Any]:
|
||||
return {"type": "guest", "iat": iat, "resources": [_CHART_RESOURCE]}
|
||||
|
||||
|
||||
def _embedded(revoked_before) -> MagicMock:
|
||||
embedded = MagicMock()
|
||||
embedded.guest_token_revoked_before = revoked_before
|
||||
@@ -115,6 +120,82 @@ def test_guest_token_not_revoked_when_resource_unresolvable() -> None:
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is False
|
||||
|
||||
|
||||
def test_chart_guest_token_revoked_when_issued_before_revocation() -> None:
|
||||
# A chart resource carries the same per-embed cutoff semantics as a
|
||||
# dashboard one: issued at 1000, revoked from 2000 -> rejected.
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
assert (
|
||||
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is True
|
||||
)
|
||||
|
||||
|
||||
def test_chart_guest_token_valid_when_issued_after_revocation() -> None:
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
assert (
|
||||
SupersetSecurityManager._is_guest_token_revoked(_chart_token(3000)) is False
|
||||
)
|
||||
|
||||
|
||||
def test_chart_guest_token_not_revoked_when_no_revocation_set() -> None:
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(None),
|
||||
):
|
||||
assert (
|
||||
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is False
|
||||
)
|
||||
|
||||
|
||||
def test_chart_guest_token_without_iat_is_revoked_when_cutoff_set() -> None:
|
||||
# Same fail-closed rule as the dashboard path: without ``iat`` the token
|
||||
# cannot be shown to postdate the cutoff.
|
||||
token = {"type": "guest", "resources": [_CHART_RESOURCE]}
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(token) is True
|
||||
|
||||
|
||||
def test_chart_guest_token_not_revoked_when_resource_unresolvable() -> None:
|
||||
# A chart resource id is only ever an embedded uuid, so an unresolved id
|
||||
# leaves no cutoff to enforce.
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=None,
|
||||
):
|
||||
assert (
|
||||
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is False
|
||||
)
|
||||
|
||||
|
||||
def test_guest_token_revoked_by_any_resource_in_a_mixed_token() -> None:
|
||||
# A token scoped to both an embedded dashboard and an embedded chart is
|
||||
# revoked when either resource's cutoff postdates it.
|
||||
token = {
|
||||
"type": "guest",
|
||||
"iat": 1000,
|
||||
"resources": [_DASHBOARD_RESOURCE, _CHART_RESOURCE],
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(None),
|
||||
),
|
||||
patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(token) is True
|
||||
|
||||
|
||||
def _manager() -> SupersetSecurityManager:
|
||||
# Build an instance without running the (heavy) FAB __init__: we only
|
||||
# exercise revoke_guest_token_access, which depends on nothing but
|
||||
@@ -149,9 +230,75 @@ def test_revoke_guest_token_access_defaults_to_ceil_of_now() -> None:
|
||||
|
||||
|
||||
def test_revoke_guest_token_access_noop_when_embedded_missing() -> None:
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
# Should simply return without raising when the UUID does not resolve.
|
||||
_manager().revoke_guest_token_access("missing-uuid")
|
||||
|
||||
|
||||
def test_revoke_guest_token_access_resolves_an_embedded_chart() -> None:
|
||||
# A uuid that is not an embedded dashboard is retried against embedded
|
||||
# charts, so the cutoff lands on the chart's own config.
|
||||
embedded_chart = _embedded(None)
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=embedded_chart,
|
||||
),
|
||||
):
|
||||
_manager().revoke_guest_token_access("chart-uuid", before=1234)
|
||||
assert embedded_chart.guest_token_revoked_before == 1234
|
||||
|
||||
|
||||
def test_revoked_chart_guest_token_is_rejected_by_the_request_loader() -> None:
|
||||
"""A cutoff on the embedded chart makes the request loader reject the token.
|
||||
|
||||
``get_guest_user_from_request`` swallows every failure into ``None``, so the
|
||||
same request is also replayed with the cutoff cleared: only the cutoff can
|
||||
explain the difference between the two outcomes.
|
||||
"""
|
||||
manager = _manager()
|
||||
token = dict(_chart_token(1000), user={"username": "guest"}, rls_rules=[])
|
||||
request = MagicMock()
|
||||
request.headers = {"X-GuestToken": "raw-chart-guest-token"}
|
||||
guest_user = object()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
SupersetSecurityManager, "parse_jwt_guest_token", return_value=token
|
||||
),
|
||||
patch.object(
|
||||
SupersetSecurityManager,
|
||||
"get_guest_user_from_token",
|
||||
return_value=guest_user,
|
||||
),
|
||||
patch(
|
||||
"superset.security.manager.get_conf",
|
||||
return_value={
|
||||
"GUEST_TOKEN_HEADER_NAME": "X-GuestToken",
|
||||
"GUEST_TOKEN_REVOCATION_ENABLED": False,
|
||||
},
|
||||
),
|
||||
):
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
assert manager.get_guest_user_from_request(request) is None
|
||||
with patch(
|
||||
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
|
||||
return_value=_embedded(None),
|
||||
):
|
||||
assert manager.get_guest_user_from_request(request) is guest_user
|
||||
|
||||
Reference in New Issue
Block a user