Compare commits

..
Author SHA1 Message Date
Evan RusackasandClaude Opus 4.8 08238c8e38 fix(embedded): restore chart rendering for guest dashboards
Embedded dashboards stopped rendering their charts for guest (embedded)
users: the dashboard payload dropped member charts and withheld their
form_data, so the frontend had nothing to render and hung on the loading
spinner.

An embedded guest carries no standalone datasource grant, so the chart
branch of raise_for_access could not recognise a guest during dashboard
serialization (the dashboard/form_data context a chart-data request
provides is absent there). Grant a guest access to a member chart of a
dashboard their token covers, bounded by the token's dataset allowlist.
Non-guest access is unchanged.

Also keep the embedded Playwright helper's error-surfacing diagnostic and
the cold-CI render timeout, which is what pinned this down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:41:30 -07:00
27 changed files with 366 additions and 736 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "zulu"
java-version: "21"
-1
View File
@@ -26,7 +26,6 @@ assists people when migrating to a new version.
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
### Selenium support removed — Playwright is now required for screenshots
+7 -8
View File
@@ -3215,17 +3215,16 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/browserslist": {
@@ -11185,9 +11184,9 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"peer": true,
"requires": {
@@ -36,9 +36,6 @@
"cypress": {
"form-data": "^2.3.4"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.8"
},
"qs": "^6.14.2",
"uuid": "^11.1.1"
},
+28 -21
View File
@@ -100,7 +100,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.0",
"immer": "^11.1.16",
"immer": "^11.1.15",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -108,7 +108,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.27.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -272,7 +272,7 @@
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.9",
"tsx": "^4.23.7",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -18005,14 +18005,11 @@
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -24187,9 +24184,9 @@
"license": "MIT"
},
"node_modules/immer": {
"version": "11.1.16",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
"version": "11.1.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -28748,14 +28745,15 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.0.tgz",
"integrity": "sha512-WEbvl2ju0MUZ+R83HeCosmJBTyYdhmFcajeQ7kwLyJ0EHUw9YG/k2QLcMmAQ8sXZpkWq1BbmfjT5lh/oInOnCw==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.27.0.tgz",
"integrity": "sha512-K8W9LTTjFEJsg9qsnJbKk+zbXrmSqa+nU1EiFXez5gQ0T0RMtylZUelgg1/RE6vCUMvHX0gaYfWU9g2mTWuA0g==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
"plugins/mapbox-gl-pmtiles-provider",
"test/bundlers/*",
"test/build/vite",
"test/build/webpack",
"test/build/typings"
]
},
@@ -39966,9 +39964,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.9",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
"version": "4.23.7",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.7.tgz",
"integrity": "sha512-3f/u/+UDCNQ7iwUZW9FCMnNGIHzElGJYh0S/yy8IvWSsn5O7fEO/897FaG7FA2W8yryiRyuwXZ1PYLAKYaqSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43235,7 +43233,7 @@
"ag-grid-react": "36.0.2",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.50.0",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"d3-format": "^3.1.2",
"d3-interpolate": "^3.0.1",
@@ -43353,6 +43351,15 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
@@ -43936,7 +43943,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
+3 -6
View File
@@ -177,7 +177,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.0",
"immer": "^11.1.16",
"immer": "^11.1.15",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -185,7 +185,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.27.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -349,7 +349,7 @@
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.9",
"tsx": "^4.23.7",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -412,9 +412,6 @@
"lerna": {
"js-yaml": "^4.3.0"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.8"
},
"nwsapi": "^2.2.13",
"puppeteer": "^22.4.1",
"tar": "^7.5.16",
@@ -60,7 +60,7 @@
"ag-grid-react": "36.0.2",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.50.0",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"d3-format": "^3.1.2",
"d3-interpolate": "^3.0.1",
@@ -103,16 +103,40 @@ export class EmbeddedPage {
/**
* Wait for dashboard content to render inside the iframe.
* Looks for the grid-container which indicates charts are loading/loaded.
*
* Races the grid against the test app's `#error` box so an embed failure
* surfaces its message immediately, instead of blindly timing out on the
* grid selector and hiding the real reason.
*/
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
const frame = this.iframe;
await frame
const timeout = options?.timeout ?? EMBEDDED.DASHBOARD_RENDER;
const grid = this.iframe
.locator('.grid-container, [data-test="grid-container"]')
.first()
.waitFor({
state: 'visible',
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
});
.first();
const errorBox = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
const ready = grid
.waitFor({ state: 'visible', timeout })
.then(() => 'ready' as const)
.catch(() => 'gridTimeout' as const);
const failed = errorBox
.waitFor({ state: 'visible', timeout })
.then(() => 'error' as const)
.catch(() => 'errorTimeout' as const);
const outcome = await Promise.race([ready, failed]);
if (outcome === 'ready') return;
if (outcome === 'error') {
const message = (await errorBox.textContent())?.trim() || 'unknown error';
throw new Error(`Embedded dashboard failed to render: ${message}`);
}
const status = (
await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()
)?.trim();
throw new Error(
`Embedded dashboard did not render within ${timeout}ms ` +
`(status: ${status ?? 'unknown'})`,
);
}
/**
@@ -1,162 +0,0 @@
/**
* 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.
*/
/**
* Regression coverage for the Drill to Detail modal's results table.
*
* The drill pane measures its available height with a resize detector and
* hands it to a virtualized table, so the rows only render if the modal's
* internal height chain (resizable wrapper -> modal container -> modal body ->
* flex pane) actually resolves to a real height. That chain is wired together
* with CSS selectors targeting Ant Design's internal modal classes, which
* TypeScript cannot see and unit tests do not exercise: when the antd v6
* upgrade renamed `.ant-modal-content` to `.ant-modal-container`, the chain
* silently broke, the pane measured ~0, and the table briefly flashed its rows
* before collapsing to an empty body with only the header and pagination
* visible.
*
* Only a real browser sees layout, so this is pinned here rather than in the
* DOM-contract unit suite. Because the failure mode is
* render-then-collapse, a single "rows are visible" read could pass during
* the initial flash the assertion therefore lets the height settle once,
* then requires it to hold across further spaced reads with no more
* retrying, so a later or partial collapse cannot be masked by an early-exit
* retry that stopped at the first passing sample.
*
* CI green => the drill modal's table renders rows at a stable, non-collapsed
* height.
* CI red => the modal height chain broke again (or drill-to-detail failed to
* open/load at all).
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { createDashboardWithCharts } from './dashboard-test-helpers';
const MIN_STABLE_BODY_HEIGHT = 100;
// Max fraction the body height may drift from the settled baseline below; a
// partial collapse (e.g. 400px -> 150px) still clears MIN_STABLE_BODY_HEIGHT
// but fails this, so the check enforces stability, not just a floor.
const HEIGHT_DRIFT_TOLERANCE = 0.25;
// Extra spaced reads taken *after* the height has settled, and the gap
// between them. These are plain assertions, not wrapped in a retrying
// helper: once settled, a retry would return on the first passing sample
// and could mask a collapse that only shows up later in the window.
const HEIGHT_SAMPLE_COUNT = 2;
const HEIGHT_SAMPLE_INTERVAL_MS = 300;
testWithAssets(
'drill to detail modal renders result rows at a stable height',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const { dashboardId } = await createDashboardWithCharts(
page,
testAssets,
testWithAssets.info(),
{
datasetName: 'birth_names',
chartNamePrefix: 'drill_detail',
dashboardTitlePrefix: 'drill_detail_modal',
chartSpecs: [
{
viz_type: 'pie',
params: {
groupby: ['gender'],
metric: 'count',
},
},
],
},
);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.waitForChartsToLoad();
// Open the chart context menu. The ECharts canvas exposes no data-test
// hooks for its regions, so right-click the centre of the chart container;
// the exact-text match below then works whether the click landed on a
// slice (which adds "Drill to detail by" items) or on the chart background.
// The first right-click after load can be swallowed by a chart re-render
// closing the menu, so retry the click until the menu actually shows.
const chart = page.locator('[data-test="chart-container"]').first();
await chart.scrollIntoViewIfNeeded();
const contextMenu = page.locator('[data-test="chart-context-menu"]');
await expect(async () => {
const box = await chart.boundingBox();
if (!box) {
throw new Error('chart container has no bounding box');
}
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, {
button: 'right',
});
await expect(contextMenu).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
await page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
const modal = page.locator('.ant-modal:visible');
await expect(modal).toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
// Wait for the samples request to resolve into a rendered table: the row
// count pill and the virtualized body both come from the loaded page.
const tableBody = modal.locator('.virtual-grid');
await expect(tableBody).toBeAttached({ timeout: TIMEOUT.CHART_RENDER });
// The regression collapses the body *after* first paint, so first let the
// height settle above the floor (retrying is safe here: the collapse is
// persistent, so a broken build never finds a passing read and this
// still times out red), then, without any further retrying, take extra
// spaced reads and require each to hold within tolerance of that settled
// baseline — a delayed or partial collapse can no longer be masked by an
// early-exit retry that stopped at the first passing sample.
let baselineHeight = 0;
await expect
.poll(
async () => {
baselineHeight = (await tableBody.boundingBox())?.height ?? 0;
return baselineHeight;
},
{ timeout: TIMEOUT.CHART_RENDER },
)
.toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
for (let sample = 0; sample < HEIGHT_SAMPLE_COUNT; sample += 1) {
// eslint-disable-next-line no-await-in-loop -- reads must be sequential
// and spaced out to observe a delayed collapse; there is nothing to
// parallelize.
await page.waitForTimeout(HEIGHT_SAMPLE_INTERVAL_MS);
// eslint-disable-next-line no-await-in-loop -- see above
const box = await tableBody.boundingBox();
const height = box?.height ?? 0;
expect(height).toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
expect(Math.abs(height - baselineHeight)).toBeLessThanOrEqual(
baselineHeight * HEIGHT_DRIFT_TOLERANCE,
);
}
// And the rows are real data, not just an expanded empty scroller:
// birth_names sample rows always carry a gender value.
await expect(modal.getByText(/^(boy|girl)$/).first()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
},
);
@@ -101,7 +101,7 @@ export const EMBEDDED = {
/** Timeout for iframe to appear in the DOM */
IFRAME_LOAD: 15000, // 15s
/** Timeout for dashboard content to render inside the iframe */
DASHBOARD_RENDER: 30000, // 30s
DASHBOARD_RENDER: 60000, // 60s (embedded dashboards are slow to render on cold CI)
/** Timeout for individual chart cells to finish rendering */
CHART_RENDER: TIMEOUT.CHART_RENDER,
} as const;
@@ -827,14 +827,6 @@ export default function transformProps(
series.unshift(baselineSeries);
}
// Snapshot the observation-series count before annotation layers are
// appended below. Annotation series (formula/interval/event/timeseries)
// carry their own configured values, which the Y axis clamp further
// below must not rewrite, or an annotation could be moved to a location
// that doesn't match its configuration.
const observationSeriesCount = series.length;
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<string, number>, selectedValue: string) => {
const index = series.findIndex(({ name }) => name === selectedValue);
@@ -994,60 +986,6 @@ export default function transformProps(
}
}
// Whenever a Y axis bound is defined, whether explicitly configured or
// derived above from the data, clamp series values to those bounds
// instead of leaving raw out-of-range values in place. ECharts axis
// clipping can otherwise drop an out-of-bounds point (and the line
// segments around it) entirely rather than truncating it at the
// boundary (see https://github.com/apache/superset/issues/27449).
if (yAxisMin !== undefined || yAxisMax !== undefined) {
const valueIndex = isHorizontal ? 0 : 1;
type AxisValue = string | number | null | undefined;
type AxisPoint = AxisValue[];
const clampAxisValue = (value: AxisValue): AxisValue => {
if (typeof value !== 'number' || Number.isNaN(value)) return value;
let clamped = value;
if (yAxisMin !== undefined) clamped = Math.max(clamped, yAxisMin);
if (yAxisMax !== undefined) clamped = Math.min(clamped, yAxisMax);
return clamped;
};
const clampPoint = (point: AxisPoint): AxisPoint => {
const newPoint = [...point];
newPoint[valueIndex] = clampAxisValue(newPoint[valueIndex]);
return newPoint;
};
series.forEach((s, index) => {
// Skip annotation series appended above; only clamp the chart's own
// observation/legend/baseline series.
if (index >= observationSeriesCount) return;
if (!Array.isArray(s.data)) return;
const clampedData = (
s.data as (AxisPoint | Record<string, unknown>)[]
).map(point => {
if (Array.isArray(point)) {
return clampPoint(point);
}
// Some series paths (e.g. colorByPrimaryAxis, or negative bar
// label positioning) wrap the tuple in an object of the shape
// `{ value: [x, y], ... }` instead of passing the tuple
// directly; clamp the wrapped tuple in place so those points
// aren't skipped and left to be dropped by ECharts axis clipping.
if (
point &&
typeof point === 'object' &&
Array.isArray((point as { value?: unknown }).value)
) {
return {
...point,
value: clampPoint((point as { value: AxisPoint }).value),
};
}
return point;
});
s.data = clampedData as typeof s.data;
});
}
// A dashboard-level time grain override (e.g. via a filter or the temporal
// range control) is delivered in extraFormData and should take precedence
// over the chart's own time grain when formatting temporal axes/tooltips.
@@ -1555,198 +1555,6 @@ test('computes row-contribution axis padding per stack when time_compare splits
expect(xAxisRaw.max).toBeLessThan(1.5);
});
test('clamps series values to the yAxis max instead of dropping out-of-range points (#27449)', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[
{ 'Series A': 1 },
{ 'Series A': 2 },
{ 'Series A': 3 },
{ 'Series A': 4 },
{ 'Series A': 1000 },
{ 'Series A': 4 },
{ 'Series A': 2 },
],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as [number, number][];
// The point that was 1000 should be present (not dropped) and clamped to
// the configured yAxis max of 10, rather than disappearing entirely.
expect(data).toHaveLength(7);
expect(data[4][1]).toBe(10);
});
test('clamps series values to the yAxis min when a value falls below it', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[{ 'Series A': -1000 }, { 'Series A': 2 }, { 'Series A': 3 }],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as [number, number][];
expect(data).toHaveLength(3);
expect(data[0][1]).toBe(0);
});
test('clamps series values to the yAxis bounds when colorByPrimaryAxis wraps points in objects (#27449)', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[{ 'Series A': 1 }, { 'Series A': 1000 }, { 'Series A': 2 }],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
colorByPrimaryAxis: true,
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as { value: [number, number] }[];
// colorByPrimaryAxis wraps each point as `{ value: [x, y], itemStyle }`
// rather than a bare tuple; the wrapped value must still be clamped
// instead of being skipped and left for ECharts to drop.
expect(data).toHaveLength(3);
expect(data[1].value[1]).toBe(10);
});
test('does not clamp a timeseries annotation series to the Y axis bounds (#27449)', () => {
const timeseries: TimeseriesAnnotationLayer = {
annotationType: AnnotationType.Timeseries,
name: 'My Timeseries',
show: true,
showLabel: true,
sourceType: AnnotationSourceType.Line,
style: AnnotationStyle.Solid,
titleColumn: '',
value: 3,
};
const annotationData = {
'My Timeseries': {
records: [
{ x: 0, y: 11000 },
{ x: 300000000, y: 21000 },
],
},
};
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData([{ 'Series A': 1 }, { 'Series A': 2 }], {
intervalMs: 300000000,
}),
{ annotation_data: annotationData },
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
annotationLayers: [timeseries],
},
annotationData,
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const annotationSeries = series.find(s => s.id === 'My Timeseries');
expect(annotationSeries).toBeDefined();
const data = annotationSeries!.data as [number, number][];
// The annotation carries its own configured values (11000, 21000), which
// are unrelated to the chart's own out-of-range-data problem this PR
// fixes. They must be left untouched by the Y axis clamp rather than
// rewritten to the yAxisBounds max of 10.
expect(data[0][1]).toBe(11000);
expect(data[1][1]).toBe(21000);
});
test('clamps series values at the correct tuple index for horizontal bar charts (#27449)', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[{ 'Series A': 15000 }, { 'Series A': 20000 }, { 'Series A': 18000 }],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...baseFormDataHorizontalBar,
yAxisBounds: [0, 16000],
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as [number, number][];
// In horizontal orientation the value sits at tuple index 0 (the axes are
// swapped), so the clamp must target that index rather than index 1.
expect(data).toHaveLength(3);
expect(data[1][0]).toBe(16000);
});
test('legend is visible on tall charts when enabled by the user', () => {
const chartProps = createTestChartProps({
height: 400,
@@ -30,7 +30,7 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
+9 -15
View File
@@ -91,21 +91,6 @@ class ExportChartsCommand(ExportModelsCommand):
def enable_tag_export(cls) -> None:
cls._include_tags = True
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
yield from super().run()
# Tags are exported once for all requested charts (rather than per
# chart in `_export`) so a multi-chart export doesn't lose tags to
# the parent's per-file-name de-duplication of `tags.yaml`.
if (
self.export_related
and ExportChartsCommand._include_tags
and feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM")
):
yield from ExportTagsCommand(
chart_ids=[model.id for model in self._models]
).run()
@staticmethod
def _export(
model: Slice, export_related: bool = True
@@ -117,3 +102,12 @@ class ExportChartsCommand(ExportModelsCommand):
if model.table and export_related:
yield from ExportDatasetsCommand([model.table.id]).run()
# Check if the calling class is ExportDashboardCommands
if (
export_related
and ExportChartsCommand._include_tags
and feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM")
):
chart_id = model.id
yield from ExportTagsCommand().export(chart_ids=[chart_id])
+1 -2
View File
@@ -68,9 +68,8 @@ class ChartWarmUpCacheCommand(BaseCommand):
# Apply dashboard filters if dashboard_id is provided
if dashboard_filters := self._get_dashboard_filters(chart.id):
for query in query_context.queries:
query.filter = (
query.filter.extend(
cast(list[QueryObjectFilterClause], dashboard_filters)
+ query.filter
)
query_context.force = True
+2 -2
View File
@@ -398,9 +398,9 @@ class ExportDashboardsCommand(ExportModelsCommand):
yield from command.run()
command.enable_tag_export()
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
yield from ExportTagsCommand(
yield from ExportTagsCommand.export(
dashboard_ids=dashboard_ids, chart_ids=chart_ids
).run()
)
# Export related theme
if model.theme:
+4 -2
View File
@@ -70,10 +70,12 @@ class ExportAssetsCommand(BaseCommand):
elif command == ExportChartsCommand:
chart_ids = ids
yield from ExportTagsCommand(
# FIXME: It would probably be better to align the tags export
# command with the other export commands
yield from ExportTagsCommand.export(
dashboard_ids=dashboard_ids,
chart_ids=chart_ids,
).run()
)
def validate(self) -> None:
pass
+31 -55
View File
@@ -23,78 +23,33 @@ from collections.abc import Iterator
import yaml
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.tag import TagDAO
from superset.extensions import feature_flag_manager
from superset.tags.models import ObjectType, TagType
from superset.commands.export.models import ExportModelsCommand
from superset.tags.models import TagType
from superset.commands.tag.exceptions import TagNotFoundError
class ExportTagsCommand(ExportModelsCommand):
dao = TagDAO
# pylint: disable=too-few-public-methods
class ExportTagsCommand:
not_found = TagNotFoundError
def __init__(
self,
model_ids: Optional[list[int]] = None,
export_related: bool = True,
*,
dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None,
chart_ids: Optional[Union[int, List[Union[int, str]]]] = None,
):
super().__init__(model_ids=model_ids or [], export_related=export_related)
self.dashboard_ids = dashboard_ids
self.chart_ids = chart_ids
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return
self.validate()
dashboard_ids: list[Union[int, str]] = (
[self.dashboard_ids]
if isinstance(self.dashboard_ids, int)
else list(self.dashboard_ids or [])
)
chart_ids: list[Union[int, str]] = (
[self.chart_ids]
if isinstance(self.chart_ids, int)
else list(self.chart_ids or [])
)
if self.model_ids:
for tag in self._models:
for tagged_object in tag.objects:
if tagged_object.object_type == ObjectType.dashboard:
dashboard_ids.append(tagged_object.object_id)
elif tagged_object.object_type == ObjectType.chart:
chart_ids.append(tagged_object.object_id)
dashboard_ids = list(dict.fromkeys(dashboard_ids))
chart_ids = list(dict.fromkeys(chart_ids))
yield (
ExportTagsCommand._file_name(),
lambda: ExportTagsCommand._file_content(
dashboard_ids or None, chart_ids or None
),
)
@staticmethod
def _file_name(model: Any = None) -> str:
def _file_name() -> str:
# Use the model to determine the filename
return "tags.yaml"
@staticmethod
def _merge_tags(
dashboard_tags: List[dict[str, Any]], chart_tags: List[dict[str, Any]]
) -> List[dict[str, Any]]:
# Create a dictionary to prevent duplicates based on tag name
tags_dict = {tag["tag_name"]: tag for tag in dashboard_tags}
# Add chart tags, preserving unique tag names
for tag in chart_tags:
if tag["tag_name"] not in tags_dict:
tags_dict[tag["tag_name"]] = tag
# Return merged tags as a list
return list(tags_dict.values())
@staticmethod
@@ -107,9 +62,13 @@ class ExportTagsCommand(ExportModelsCommand):
dashboard_tags = []
chart_tags = []
# Fetch dashboard tags if provided
if dashboard_ids:
# Ensure dashboard_ids is a list
if isinstance(dashboard_ids, int):
dashboard_ids = [dashboard_ids]
dashboard_ids = [
dashboard_ids
] # Convert single int to list for consistency
dashboards = [
dashboard
@@ -129,9 +88,11 @@ class ExportTagsCommand(ExportModelsCommand):
]
dashboard_tags.extend(filtered_tags)
# Fetch chart tags if provided
if chart_ids:
# Ensure chart_ids is a list
if isinstance(chart_ids, int):
chart_ids = [chart_ids]
chart_ids = [chart_ids] # Convert single int to list for consistency
charts = [
chart
@@ -148,8 +109,23 @@ class ExportTagsCommand(ExportModelsCommand):
]
chart_tags.extend(filtered_tags)
# Merge the tags from both dashboards and charts
merged_tags = ExportTagsCommand._merge_tags(dashboard_tags, chart_tags)
payload["tags"].extend(merged_tags)
# Convert to YAML format
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
@staticmethod
def export(
dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None,
chart_ids: Optional[Union[int, List[Union[int, str]]]] = None,
) -> Iterator[tuple[str, Callable[[], str]]]:
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return
yield (
ExportTagsCommand._file_name(),
lambda: ExportTagsCommand._file_content(dashboard_ids, chart_ids),
)
+39
View File
@@ -4371,6 +4371,25 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
elif chart.datasource and self.can_access_datasource(chart.datasource):
return
# An embedded guest may access a member chart of a dashboard their
# guest token grants. Embedded dashboards render their member charts
# client-side, so the chart definitions must be served even though a
# guest holds no standalone datasource grant. The chart's dataset
# must still satisfy any allowlist the token carries, and data
# queries are re-checked through the datasource branch above (which
# receives the dashboard context in the chart-data form_data).
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._guest_token_allows_dataset(
chart.datasource.id if chart.datasource else None
)
):
return
raise SupersetSecurityException(self.get_chart_access_error_object(chart))
def get_user_by_username(self, username: str) -> Optional[User]:
@@ -4928,6 +4947,26 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return user
return None
def _guest_token_allows_dataset(self, datasource_id: Optional[int]) -> bool:
"""Return whether the current guest token permits this dataset.
A token without a ``datasets`` allowlist permits every dataset
(backward compatible). A token that carries one permits only the listed
integer IDs; a malformed allowlist permits nothing. Non-guest callers
are unaffected: they hold no guest token and always get ``True``.
"""
guest_user = self.get_current_guest_user_if_guest()
if not guest_user:
return True
allowed_datasets: Optional[list[int]] = guest_user.guest_token.get("datasets")
if allowed_datasets is None:
return True
return (
isinstance(allowed_datasets, list)
and all(isinstance(d, int) for d in allowed_datasets)
and datasource_id in allowed_datasets
)
def has_guest_access(self, dashboard: "Dashboard") -> bool:
user = self.get_current_guest_user_if_guest()
if not user:
+3 -23
View File
@@ -37,9 +37,6 @@ from flask_babel import _
from werkzeug.exceptions import BadRequest
from superset import appbuilder, dataframe, db, result_set
from superset.charts.data.dashboard_filter_context import (
get_dashboard_filter_context,
)
from superset.common.db_query_status import QueryStatus
from superset.exceptions import (
SerializationError,
@@ -505,20 +502,11 @@ def get_dashboard_extra_filters(
return []
with contextlib.suppress(json.JSONDecodeError):
# does this dashboard have default filters?
json_metadata = json.loads(dashboard.json_metadata)
native_filters = [
flt
for flt in get_dashboard_filter_context(
dashboard_id=dashboard_id,
chart_id=slice_id,
).extra_form_data.get("filters", [])
if isinstance(flt, dict)
]
# does this dashboard have legacy default filters?
default_filters = json.loads(json_metadata.get("default_filters", "null"))
if not default_filters:
return native_filters
return []
# are default filters applicable to the given slice?
filter_scopes = json_metadata.get("filter_scopes", {})
@@ -529,15 +517,7 @@ def get_dashboard_extra_filters(
and isinstance(filter_scopes, dict)
and isinstance(default_filters, dict)
):
return [
*build_extra_filters(
layout,
filter_scopes,
default_filters,
slice_id,
),
*native_filters,
]
return build_extra_filters(layout, filter_scopes, default_filters, slice_id)
return []
@@ -2266,79 +2266,6 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
{"chart_id": slc.id, "viz_error": None, "viz_status": "success"}
]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_warm_up_cache_native_defaults_hit_browser_query_cache(self) -> None:
self.login(ADMIN_USERNAME)
chart = self.get_slice("Pivot Table v2")
dashboard = self.get_dash_by_slug("births")
saved_query_context = json.loads(chart.query_context)
chart_filter = {"col": "name", "op": "!=", "val": "__missing_name__"}
for query in saved_query_context["queries"]:
query["filters"] = [*(query.get("filters") or []), chart_filter]
chart.query_context = json.dumps(saved_query_context)
metadata = json.loads(dashboard.json_metadata or "{}")
legacy_filter = {"col": "name", "op": "in", "val": ["Alice"]}
metadata["default_filters"] = json.dumps(
{"-1": {legacy_filter["col"]: legacy_filter["val"]}}
)
metadata["filter_scopes"] = {}
native_filter = {"col": "gender", "op": "IN", "val": ["girl"]}
metadata["native_filter_configuration"] = [
{
"id": "NATIVE_FILTER-gender",
"name": "Gender",
"type": "NATIVE_FILTER",
"filterType": "filter_select",
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
"targets": [
{
"datasetId": chart.datasource_id,
"column": {"name": "gender"},
}
],
"defaultDataMask": {
"extraFormData": {"filters": [native_filter]},
"filterState": {"value": ["girl"]},
},
"controlValues": {},
}
]
dashboard.json_metadata = json.dumps(metadata)
db.session.commit()
warm_up_response = self.client.put(
"/api/v1/chart/warm_up_cache",
json={"chart_id": chart.id, "dashboard_id": dashboard.id},
)
assert warm_up_response.status_code == 200
assert warm_up_response.json["result"] == [
{"chart_id": chart.id, "viz_error": None, "viz_status": "success"}
]
browser_query_context = json.loads(chart.query_context)
browser_query_context["force"] = False
for query in browser_query_context["queries"]:
query["filters"] = [
legacy_filter,
native_filter,
*(query.get("filters") or []),
]
assert browser_query_context["queries"][0]["filters"] == [
legacy_filter,
native_filter,
chart_filter,
]
chart_data_response = self.client.post(
"/api/v1/chart/data",
json=browser_query_context,
)
assert chart_data_response.status_code == 200
assert chart_data_response.json["result"][0]["is_cached"] is True
def test_warm_up_cache_chart_id_required(self):
self.login(ADMIN_USERNAME)
rv = self.client.put("/api/v1/chart/warm_up_cache", json={"dashboard_id": 1})
@@ -30,14 +30,14 @@ def mock_security_manager():
@patch("superset.commands.chart.warm_up_cache.get_dashboard_extra_filters")
@patch("superset.commands.chart.warm_up_cache.ChartDataCommand")
def test_prepends_dashboard_filters_to_non_legacy_chart(
def test_applies_dashboard_filters_to_non_legacy_chart(
mock_chart_data_command, mock_get_dashboard_filters
):
"""Verify dashboard filters precede saved chart filters."""
"""Verify dashboard filters are added to query.filter for non-legacy viz"""
# Setup: Mock dashboard filters response
legacy_filter = {"col": "region", "op": "in", "val": ["Americas"]}
native_filter = {"col": "country", "op": "IN", "val": ["USA", "France"]}
mock_get_dashboard_filters.return_value = [legacy_filter, native_filter]
mock_get_dashboard_filters.return_value = [
{"col": "country", "op": "in", "val": ["USA", "France"]}
]
# Create a chart with non-legacy viz type
chart = Slice(
@@ -48,10 +48,9 @@ def test_prepends_dashboard_filters_to_non_legacy_chart(
datasource_type="table",
)
# Create mock query with a saved chart filter
chart_filter = {"col": "state", "op": "==", "val": "CA"}
# Create mock query with empty filter list
mock_query = Mock()
mock_query.filter = [chart_filter]
mock_query.filter = []
mock_qc = Mock()
mock_qc.queries = [mock_query]
mock_qc.force = False
@@ -65,8 +64,13 @@ def test_prepends_dashboard_filters_to_non_legacy_chart(
# Execute with dashboard_id
result = ChartWarmUpCacheCommand(chart, 42, None).run()
# VALIDATE: Dashboard filters precede the chart's saved filters
assert mock_query.filter == [legacy_filter, native_filter, chart_filter]
# VALIDATE: Filters were added to query.filter
assert len(mock_query.filter) == 1, "Filter should be added to query"
assert mock_query.filter[0] == {
"col": "country",
"op": "in",
"val": ["USA", "France"],
}, "Filter content should match dashboard filter"
# VALIDATE: get_dashboard_extra_filters was called correctly
mock_get_dashboard_filters.assert_called_once_with(123, 42)
+2 -2
View File
@@ -102,10 +102,10 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
]
ExportTagsCommand = mocker.patch( # noqa: N806
"superset.commands.export.assets.ExportTagsCommand"
"superset.commands.export.assets.ExportTagsCommand.export"
)
ExportTagsCommand.return_value.run.return_value = [
ExportTagsCommand.return_value = [
("tags.yaml", lambda: "<TAGS CONTENTS>"),
]
@@ -0,0 +1,185 @@
# 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.
"""Tests for embedded-guest access to a dashboard's member charts.
An embedded guest holds no standalone datasource grant, so the chart branch of
``raise_for_access`` must recognise dashboard-level guest access when serving a
member chart's definition. Without it the dashboard payload drops the guest's
charts (and strips their ``form_data``) and the embedded dashboard cannot render.
The grant stays bounded by the token's optional dataset allowlist.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from superset.exceptions import SupersetSecurityException
from superset.security.guest_token import (
GuestToken,
GuestTokenResourceType,
GuestUser,
)
from superset.security.manager import SupersetSecurityManager
def _make_chart(dashboards: list[object], datasource_id: int = 1) -> MagicMock:
"""A member chart with no viewers and a datasource the guest cannot access
through a standalone grant."""
chart = MagicMock()
chart.viewers = []
chart.datasource = MagicMock()
chart.datasource.id = datasource_id
chart.dashboards = dashboards
return chart
def _sm_for_chart_access(is_guest: bool) -> MagicMock:
"""Security-manager mock where every non-guest path to the chart is closed,
so only the embedded-guest branch can grant access. The dataset allowlist
helper defaults to permissive; tests tighten it where relevant."""
sm = MagicMock(spec=SupersetSecurityManager)
sm.is_admin.return_value = False
sm.is_editor.return_value = False
sm.is_viewer.return_value = False
sm.can_access_datasource.return_value = False
sm.is_guest_user.return_value = is_guest
sm._guest_token_allows_dataset.return_value = True
return sm
# ---------------------------------------------------------------------------
# raise_for_access — chart branch
# ---------------------------------------------------------------------------
def test_guest_can_access_member_chart_of_granted_dashboard() -> None:
"""A guest whose token grants a chart's dashboard may access that chart."""
granted_dashboard = MagicMock()
chart = _make_chart([granted_dashboard])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.side_effect = lambda dash: dash is granted_dashboard
with patch("superset.is_feature_enabled", return_value=True):
SupersetSecurityManager.raise_for_access(sm, chart=chart) # no exception
def test_guest_cannot_access_chart_outside_granted_dashboards() -> None:
"""A guest whose token grants none of a chart's dashboards is denied."""
chart = _make_chart([MagicMock(), MagicMock()])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.return_value = False
with patch("superset.is_feature_enabled", return_value=True):
with pytest.raises(SupersetSecurityException):
SupersetSecurityManager.raise_for_access(sm, chart=chart)
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."""
granted_dashboard = MagicMock()
chart = _make_chart([granted_dashboard])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.return_value = True
sm._guest_token_allows_dataset.return_value = False
with patch("superset.is_feature_enabled", return_value=True):
with pytest.raises(SupersetSecurityException):
SupersetSecurityManager.raise_for_access(sm, chart=chart)
def test_guest_chart_access_requires_embedded_feature_flag() -> None:
"""The guest grant is gated on EMBEDDED_SUPERSET; disabled means denied."""
granted_dashboard = MagicMock()
chart = _make_chart([granted_dashboard])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.return_value = True
with patch("superset.is_feature_enabled", return_value=False):
with pytest.raises(SupersetSecurityException):
SupersetSecurityManager.raise_for_access(sm, chart=chart)
def test_non_guest_denied_without_consulting_dashboard_membership() -> None:
"""A non-guest with no chart access is still denied, and the guest branch
(dashboard membership) is never evaluated for them."""
chart = _make_chart([MagicMock()])
sm = _sm_for_chart_access(is_guest=False)
with patch("superset.is_feature_enabled", return_value=True):
with pytest.raises(SupersetSecurityException):
SupersetSecurityManager.raise_for_access(sm, chart=chart)
sm.has_guest_access.assert_not_called()
# ---------------------------------------------------------------------------
# _guest_token_allows_dataset — allowlist helper
# ---------------------------------------------------------------------------
def _guest_with_datasets(datasets: list[int] | None) -> GuestUser:
token: GuestToken = {
"user": {},
"resources": [{"type": GuestTokenResourceType.DASHBOARD, "id": "dash-uuid"}],
"rls_rules": [],
"iat": 0,
"exp": 9999999999,
}
if datasets is not None:
token["datasets"] = datasets
return GuestUser(token=token, roles=[])
def _sm_with_guest(guest_user: GuestUser | None) -> MagicMock:
sm = MagicMock(spec=SupersetSecurityManager)
sm.get_current_guest_user_if_guest.return_value = guest_user
return sm
def test_allows_dataset_non_guest_always_true() -> None:
sm = _sm_with_guest(None)
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 99) is True
def test_allows_dataset_no_allowlist_claim_is_true() -> None:
sm = _sm_with_guest(_guest_with_datasets(None))
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 99) is True
def test_allows_dataset_listed_id_is_true() -> None:
sm = _sm_with_guest(_guest_with_datasets([7, 8]))
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 7) is True
def test_allows_dataset_unlisted_id_is_false() -> None:
sm = _sm_with_guest(_guest_with_datasets([7, 8]))
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 99) is False
def test_allows_dataset_empty_allowlist_blocks_all() -> None:
sm = _sm_with_guest(_guest_with_datasets([]))
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 7) is False
def test_allows_dataset_malformed_allowlist_blocks() -> None:
guest_user = _guest_with_datasets(None)
guest_user.guest_token["datasets"] = ["7", "8"] # type: ignore[list-item]
sm = _sm_with_guest(guest_user)
assert SupersetSecurityManager._guest_token_allows_dataset(sm, 7) is False
+1 -84
View File
@@ -16,17 +16,9 @@
# under the License.
"""Tests for superset.views.utils module"""
from unittest.mock import patch
from flask import current_app
from sqlalchemy.orm.session import Session
from superset import db
from superset.connectors.sqla.models import Database, SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from superset.views.utils import get_dashboard_extra_filters, get_form_data
from superset.views.utils import get_form_data
def test_get_form_data_handles_non_json_body_with_json_content_type() -> None:
@@ -59,78 +51,3 @@ def test_get_form_data_handles_non_dict_json_body() -> None:
assert form_data == {}
assert slc is None
def test_get_dashboard_extra_filters_includes_native_filter_defaults(
session: Session,
) -> None:
"""Native filter defaults are included in dashboard cache warming."""
Dashboard.metadata.create_all(session.get_bind())
dataset = SqlaTable(
table_name="extra_filters_table",
database=Database(database_name="extra_filters_db", sqlalchemy_uri="sqlite://"),
)
db.session.add(dataset)
db.session.flush()
chart = Slice(
slice_name="chart_with_native_filter",
datasource_id=dataset.id,
datasource_type="table",
)
dashboard = Dashboard(
dashboard_title="native_filter_dash",
slices=[chart],
published=True,
json_metadata=json.dumps(
{
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-1",
"name": "Region filter",
"type": "NATIVE_FILTER",
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
"targets": [{"column": {"name": "region"}}],
"defaultDataMask": {
"extraFormData": {
"filters": [
{"col": "region", "op": "IN", "val": ["APAC"]}
]
},
"filterState": {"value": ["APAC"]},
},
"controlValues": {},
}
]
}
),
position_json="{}",
)
db.session.add_all([chart, dashboard])
db.session.flush()
with patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"):
extra_filters = get_dashboard_extra_filters(chart.id, dashboard.id)
assert extra_filters == [{"col": "region", "op": "IN", "val": ["APAC"]}]
legacy_filter = {"col": "country", "op": "in", "val": ["Brazil"]}
metadata = json.loads(dashboard.json_metadata)
metadata["default_filters"] = json.dumps({"legacy-filter": {"country": ["Brazil"]}})
metadata["filter_scopes"] = {}
dashboard.json_metadata = json.dumps(metadata)
with (
patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"),
patch(
"superset.views.utils.build_extra_filters",
return_value=[legacy_filter],
),
):
extra_filters = get_dashboard_extra_filters(chart.id, dashboard.id)
assert extra_filters == [
legacy_filter,
{"col": "region", "op": "IN", "val": ["APAC"]},
]