mirror of
https://github.com/apache/superset.git
synced 2026-08-24 17:11:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fac82c39 | ||
|
|
3f10d8b1cc | ||
|
|
15b98aa6da | ||
|
|
7f1b41444c | ||
|
|
8a32324184 | ||
|
|
4623d9219a | ||
|
|
dcac129b51 | ||
|
|
7196ce8a0c | ||
|
|
e7756aeef9 | ||
|
|
af859717a7 | ||
|
|
8fa48d73f6 | ||
|
|
90a3d93002 | ||
|
|
5ffb72151e | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb |
@@ -3,9 +3,7 @@ codecov:
|
||||
after_n_builds: 4
|
||||
ignore:
|
||||
- "superset/migrations/versions/*.py"
|
||||
- "superset-frontend/packages/superset-ui-demo/**/*"
|
||||
- "**/*.stories.tsx"
|
||||
- "**/*.stories.jsx"
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Copy image to GHCR
|
||||
env:
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.2.3",
|
||||
"js-yaml": "^5.3.0",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -67,7 +67,7 @@ const communityLinks = [
|
||||
'Join our monthly virtual meetups and register for any upcoming events on Meetup',
|
||||
},
|
||||
{
|
||||
url: 'https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md',
|
||||
url: 'https://superset.apache.org/inTheWild/',
|
||||
title: 'Organizations',
|
||||
description:
|
||||
'A list of some of the organizations using Superset in production.',
|
||||
|
||||
+4
-4
@@ -10291,10 +10291,10 @@ js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, j
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^5.2.3:
|
||||
version "5.2.3"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.3.tgz#0942ae8f507e22eb0e54624871789cd477106e54"
|
||||
integrity sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==
|
||||
js-yaml@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.3.0.tgz#526430a6da31065127528ae695ce168cfc5f91f0"
|
||||
integrity sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
|
||||
Generated
+23
-26
@@ -86,7 +86,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"immer": "^11.1.17",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -220,7 +220,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -17190,9 +17190,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
|
||||
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
|
||||
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18541,9 +18541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"version": "1.11.22",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
|
||||
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -23907,9 +23907,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.17",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
|
||||
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -34873,12 +34873,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz",
|
||||
"integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
|
||||
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
@@ -42962,7 +42968,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -42974,7 +42980,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -43073,15 +43079,6 @@
|
||||
"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",
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -177,7 +177,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"immer": "^11.1.17",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -297,7 +297,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -79,7 +79,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
|
||||
@@ -40,10 +40,18 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { getAccessToken } from '../../helpers/api/embedded';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
async function authorizeApi(page: Page): Promise<void> {
|
||||
const accessToken = await getAccessToken(page);
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Visible row text must never expose synthetic identifiers (layout node
|
||||
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
|
||||
// these to human names or kind-only phrasing.
|
||||
@@ -79,19 +87,35 @@ async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
* this reads like its sibling specs, but fall back to whatever the instance
|
||||
* has rather than requiring a particular fixture to be loaded.
|
||||
*/
|
||||
async function anyDatasetId(page: Page): Promise<number> {
|
||||
async function anyDataset(page: Page): Promise<{
|
||||
id: number;
|
||||
columnName: string;
|
||||
}> {
|
||||
const named = await getDatasetByName(page, DATASET_NAME);
|
||||
if (named) {
|
||||
return named.id;
|
||||
let datasetId = named?.id;
|
||||
if (!datasetId) {
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
datasetId = first.id;
|
||||
}
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
return first.id;
|
||||
if (datasetId === undefined) {
|
||||
throw new Error('Unable to resolve a dataset id');
|
||||
}
|
||||
|
||||
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
|
||||
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
|
||||
const { columns } = (await detailRes.json()).result;
|
||||
const [firstColumn] = columns;
|
||||
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
|
||||
return {
|
||||
id: datasetId,
|
||||
columnName: firstColumn.column_name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the Explore "Additional actions → View version history" panel. */
|
||||
@@ -109,7 +133,8 @@ testWithAssets(
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const datasetId = await anyDatasetId(page);
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
|
||||
const baseName = `version_history_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
@@ -123,7 +148,7 @@ testWithAssets(
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [],
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
@@ -171,3 +196,79 @@ testWithAssets(
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'minor edit of a non-canonical chart omits hydration noise',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const baseName = `version_history_normalization_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: baseName,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
// Deliberately omit visualization defaults. Explore hydration supplies
|
||||
// them, reproducing params imported before they were canonical.
|
||||
params: JSON.stringify({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
extra_form_data: {},
|
||||
dashboards: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
});
|
||||
expect(chartResp.ok(), 'chart creation').toBeTruthy();
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const editorResp = await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
});
|
||||
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
|
||||
|
||||
await page.goto(`explore/?slice_id=${chartId}`);
|
||||
await page.getByRole('combobox', { name: 'Row limit' }).click();
|
||||
await page.getByRole('option', { name: '100', exact: true }).click();
|
||||
await page.locator('[data-test="query-save-button"]').click();
|
||||
await page.locator('[data-test="save-overwrite-radio"]').click();
|
||||
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().includes(`/api/v1/chart/${chartId}`),
|
||||
);
|
||||
await page.locator('[data-test="btn-modal-save"]').click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
|
||||
|
||||
const requestPayload = saveResponse.request().postDataJSON();
|
||||
const savedParams = JSON.parse(requestPayload.params);
|
||||
expect(
|
||||
savedParams.matrixify_enable,
|
||||
'overwrite contains a default absent from the stored params',
|
||||
).toBe(false);
|
||||
|
||||
await openVersionHistory(page);
|
||||
const panel = page.locator('[aria-label="Version history"]');
|
||||
const newestGroup = panel
|
||||
.locator('[data-test="version-history-save-group"]')
|
||||
.first();
|
||||
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
|
||||
await newestGroup.getByRole('button').first().click();
|
||||
|
||||
const rows = newestGroup.locator(
|
||||
'[data-test="version-history-action-row"]',
|
||||
);
|
||||
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(/row limit/i);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,15 +102,18 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||
2. Resample must come before rolling so that imputed values are
|
||||
included in the rolling window calculation.
|
||||
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
3. Contribution must come before rename because it relies on the
|
||||
`__<time offset>` suffix to compute each time shift separately,
|
||||
and rename strips that suffix.
|
||||
4. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
*/
|
||||
post_processing: [
|
||||
pivotOperatorInRuntime,
|
||||
resampleOperator(formData, baseQueryObject),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
timeCompareOperator(formData, baseQueryObject),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
contributionOperator(formData, baseQueryObject, time_offsets),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
sortOperator(formData, baseQueryObject),
|
||||
flattenOperator(formData, baseQueryObject),
|
||||
// todo: move prophet before flatten
|
||||
|
||||
@@ -64,6 +64,28 @@ describe('Timeseries buildQuery', () => {
|
||||
expect(query.metrics).toEqual(['bar', 'baz']);
|
||||
});
|
||||
|
||||
test('should apply contribution before rename with time comparison', () => {
|
||||
// rename strips the `__<offset>` suffix that contribution relies on to
|
||||
// compute each time shift separately
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
metrics: ['bar'],
|
||||
x_axis: 'ds',
|
||||
groupby: ['col1'],
|
||||
contributionMode: 'row',
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 week ago'],
|
||||
});
|
||||
const [query] = queryContext.queries;
|
||||
const operations = (query.post_processing || []).map(
|
||||
operator => operator?.operation,
|
||||
);
|
||||
expect(operations).toContain('contribution');
|
||||
expect(operations.indexOf('contribution')).toBeLessThan(
|
||||
operations.indexOf('rename'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not order by timeseries limit if orderby provided', () => {
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
|
||||
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
|
||||
test('returns false when editors is omitted', () => {
|
||||
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
|
||||
});
|
||||
|
||||
test('returns true when the user is granted editorship only through extra_editors', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
|
||||
});
|
||||
|
||||
test('unions editors and extra_editors rather than preferring one', () => {
|
||||
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
|
||||
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when extra_editors names other subjects', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
|
||||
);
|
||||
};
|
||||
|
||||
const isUserInEditors = (editors: Subject[] = []): boolean =>
|
||||
isUserInSubjects(editors);
|
||||
|
||||
export const isUserAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
) =>
|
||||
@@ -66,10 +63,12 @@ export const isUserAdmin = (
|
||||
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
|
||||
);
|
||||
|
||||
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
|
||||
export const isUserEditorOrAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
editors: Subject[] = [],
|
||||
): boolean => isUserInEditors(editors) || isUserAdmin(user);
|
||||
extraEditors?: SubjectRef[] | null,
|
||||
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
|
||||
|
||||
/**
|
||||
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
|
||||
|
||||
@@ -17,10 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, VizType } from '@superset-ui/core';
|
||||
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
beforeEach(() => mockedIsFeatureEnabled.mockReturnValue(false));
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
@@ -343,3 +353,67 @@ test('extracts currency formats from metrics in dataset', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('seeds only guarded matching-input hydration transitions', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: { conf: { DEFAULT_TIME_FILTER: 'Last year' } },
|
||||
explore: {},
|
||||
}));
|
||||
const persisted = {
|
||||
...exploreInitialData.form_data,
|
||||
};
|
||||
delete persisted.time_range;
|
||||
const initialData = {
|
||||
...exploreInitialData,
|
||||
form_data: { ...persisted },
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
form_data: { ...persisted },
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(initialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking: expect.objectContaining({
|
||||
chartId: 371,
|
||||
transitions: expect.objectContaining({
|
||||
time_range: {
|
||||
control: 'time_range',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: 'Last year',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not seed normalization metadata for dashboard overrides', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
window.history.pushState({}, '', '/explore/?dashboard_id=12');
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(exploreInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: HYDRATE_CHART_NORMALIZATION }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,10 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { nanoid } from 'nanoid';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { hydrateChartNormalization } from 'src/features/versionHistory/reducer';
|
||||
import { automaticNormalizationTransitions } from 'src/features/versionHistory/normalization';
|
||||
|
||||
enum ColorSchemeType {
|
||||
CATEGORICAL = 'CATEGORICAL',
|
||||
@@ -78,6 +82,8 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const persistedFormData = cloneDeep(initialSlice?.form_data ?? {});
|
||||
const preHydrationFormData = cloneDeep(initialFormData ?? {});
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
@@ -213,6 +219,10 @@ export const hydrateExplore =
|
||||
exploreState,
|
||||
);
|
||||
});
|
||||
const hydratedFormData = {
|
||||
...initialFormData,
|
||||
...getFormDataFromControls(exploreState.controls),
|
||||
};
|
||||
const sliceFormData = initialSlice
|
||||
? getFormDataFromControls(initialControls)
|
||||
: null;
|
||||
@@ -233,7 +243,7 @@ export const hydrateExplore =
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
return dispatch({
|
||||
const result = dispatch({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: {
|
||||
charts: {
|
||||
@@ -253,6 +263,28 @@ export const hydrateExplore =
|
||||
dataMask,
|
||||
},
|
||||
});
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
initialSlice?.slice_id &&
|
||||
!isCachedFormData &&
|
||||
!dashboardId &&
|
||||
getUrlParam(URL_PARAMS.vizType) === null
|
||||
) {
|
||||
dispatch(
|
||||
hydrateChartNormalization({
|
||||
chartId: initialSlice.slice_id,
|
||||
hydrationSessionId: nanoid(),
|
||||
transitions: automaticNormalizationTransitions(
|
||||
persistedFormData,
|
||||
preHydrationFormData,
|
||||
hydratedFormData,
|
||||
),
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type HydrateExplore = {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Dispatch } from 'redux';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
DatasourceType,
|
||||
isFeatureEnabled,
|
||||
QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
VizType,
|
||||
@@ -37,6 +38,13 @@ import {
|
||||
} from './saveModalActions';
|
||||
import { Operators } from '../constants';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Define test constants and mock data using imported types
|
||||
const sliceId = 10;
|
||||
const sliceName = 'New chart';
|
||||
@@ -92,17 +100,159 @@ const sliceResponsePayload: Partial<PayloadSlice> = {
|
||||
};
|
||||
|
||||
const sampleError = new Error('sampleError');
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
|
||||
jest.mock('../exploreUtils', () => ({
|
||||
buildV1ChartDataPayload: jest.fn(() => queryContext),
|
||||
}));
|
||||
|
||||
beforeEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('existing-chart overwrite sends only still-matching normalization metadata', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
object_control: { a: 1, b: 2 },
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
object_control: {
|
||||
control: 'object_control',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
{
|
||||
control: 'object_control',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
]);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'BEGIN_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'COMPLETE_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('matches normalization metadata against finalized payload filters', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const extraTemporalFilter = {
|
||||
expressionType: 'SIMPLE',
|
||||
clause: 'WHERE',
|
||||
subject: 'ds',
|
||||
operator: Operators.TemporalRange,
|
||||
comparator: '',
|
||||
isExtra: true,
|
||||
} as SimpleAdhocFilter;
|
||||
const savedTemporalFilter = {
|
||||
...extraTemporalFilter,
|
||||
comparator: 'No filter',
|
||||
isExtra: false,
|
||||
};
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
adhoc_filters: [extraTemporalFilter],
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
adhoc_filters: {
|
||||
control: 'adhoc_filters',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: [savedTemporalFilter],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
expect.objectContaining({
|
||||
control: 'adhoc_filters',
|
||||
to_value: [savedTemporalFilter],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests updateSlice action
|
||||
*/
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
test('updateSlice handles success', async () => {
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
@@ -734,3 +884,97 @@ describe('getSlicePayload', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('existing-chart overwrite covers stash-removed keys as drop transitions', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
// The stash removed order_desc from active form data...
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// ...and holds it with the value it had when hidden.
|
||||
hiddenFormData: { order_desc: true },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
// Persisted params carry the key the stash removed, same value.
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stashed value the user changed before hiding is not covered', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// Stash holds a USER-edited value; persisted differs, so the removal
|
||||
// stays recorded.
|
||||
hiddenFormData: { order_desc: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop-2',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Dispatch } from 'redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
DatasourceType,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
type QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
SupersetClient,
|
||||
@@ -30,11 +32,25 @@ import { isEmpty } from 'lodash-es';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { Operators } from '../constants';
|
||||
import { buildV1ChartDataPayload } from '../exploreUtils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
beginChartNormalizationSave,
|
||||
completeChartNormalizationSave,
|
||||
} from 'src/features/versionHistory/reducer';
|
||||
import type {
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
} from 'src/features/versionHistory/types';
|
||||
import {
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from 'src/features/versionHistory/normalization';
|
||||
|
||||
export interface PayloadSlice extends Slice {
|
||||
params: string;
|
||||
dashboards: number[];
|
||||
query_context: string;
|
||||
normalization_changes?: AutomaticNormalizationTransitions[string][];
|
||||
}
|
||||
const ADHOC_FILTER_REGEX = /^adhoc_filters/;
|
||||
|
||||
@@ -233,21 +249,84 @@ export const updateSlice =
|
||||
new?: boolean;
|
||||
},
|
||||
) =>
|
||||
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
|
||||
async (
|
||||
dispatch: Dispatch,
|
||||
getState: () => Partial<QueryFormData> & {
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
explore?: {
|
||||
form_data?: QueryFormData;
|
||||
hiddenFormData?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
|
||||
const formData = getState().explore?.form_data;
|
||||
const initialState = getState();
|
||||
const formData = JSON.parse(
|
||||
JSON.stringify(initialState.explore?.form_data ?? {}),
|
||||
) as QueryFormData;
|
||||
const tracking = initialState.versionHistory?.chartNormalization;
|
||||
const saveAttemptId = nanoid();
|
||||
const shouldAttachNormalization =
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
tracking?.chartId === sliceId;
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
beginChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const payload = await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
);
|
||||
const savedFormData = JSON.parse(payload.params ?? '{}') as QueryFormData;
|
||||
// Hydration-time transitions that still hold, plus save-time drops of
|
||||
// keys the stash removed (mutually exclusive per control: a surviving
|
||||
// hydration transition implies the key is present in the payload, a
|
||||
// stash drop implies it is absent).
|
||||
const matchingTransitions = shouldAttachNormalization
|
||||
? {
|
||||
...matchingAutomaticNormalizationTransitions(
|
||||
tracking,
|
||||
savedFormData,
|
||||
),
|
||||
...stashDropNormalizationTransitions(
|
||||
(formDataFromSlice ?? {}) as Record<string, unknown>,
|
||||
initialState.explore?.hiddenFormData,
|
||||
savedFormData,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
if (
|
||||
shouldAttachNormalization &&
|
||||
Object.keys(matchingTransitions).length
|
||||
) {
|
||||
payload.normalization_changes = Object.values(matchingTransitions);
|
||||
}
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/chart/${sliceId}`,
|
||||
jsonPayload: await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
),
|
||||
jsonPayload: payload,
|
||||
});
|
||||
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
completeChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(saveSliceSuccess(response.json));
|
||||
addToasts(false, sliceName, addedToDashboard).map(dispatch);
|
||||
return response.json;
|
||||
|
||||
@@ -96,7 +96,11 @@ export default function ChartCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
chart.editors,
|
||||
chart.extra_editors,
|
||||
);
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (canEdit) {
|
||||
|
||||
@@ -83,7 +83,11 @@ function DashboardCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
dashboard.editors,
|
||||
dashboard.extra_editors,
|
||||
);
|
||||
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
||||
const thumbnailUrl =
|
||||
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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 {
|
||||
automaticNormalizationTransitions,
|
||||
isJsonValue,
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from './normalization';
|
||||
|
||||
test('recognizes only values that JSON can represent faithfully', () => {
|
||||
expect(isJsonValue({ nested: [null, true, 3, 'value'] })).toBe(true);
|
||||
expect(isJsonValue(Number.NaN)).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isJsonValue(new Date())).toBe(false);
|
||||
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
|
||||
test('records hydration changes only when input matches persisted data', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: null },
|
||||
{ row_limit: 10000, show_legend: true },
|
||||
),
|
||||
).toEqual({
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: 500 },
|
||||
{ row_limit: 10000 },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not interpret a missing hydrated control as normalization', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ obsolete_control: true },
|
||||
{ obsolete_control: true },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('keeps only valid, unchanged transitions for a save', () => {
|
||||
const rowLimit = {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
};
|
||||
const tracking = {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: rowLimit,
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchingAutomaticNormalizationTransitions(tracking, {
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
}),
|
||||
).toEqual({ row_limit: rowLimit });
|
||||
});
|
||||
|
||||
test('covers a stash-removed key still equal to its persisted value', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true, row_limit: 5000 },
|
||||
{ order_desc: true },
|
||||
{ row_limit: 5000 },
|
||||
),
|
||||
).toEqual({
|
||||
order_desc: {
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not cover a stashed value the user changed before it was hidden', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ server_page_length: 10 },
|
||||
{ server_page_length: 25 },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover stashed keys that were never persisted', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({}, { totals_aggregate: 'SUM' }, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover keys the outgoing payload still carries', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('drop coverage requires a stash', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({ order_desc: true }, undefined, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 isEqual from 'lodash-es/isEqual';
|
||||
import type {
|
||||
AutomaticNormalizationTransition,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
|
||||
const isJsonValueInternal = (
|
||||
value: unknown,
|
||||
ancestors: WeakSet<object>,
|
||||
): value is JsonValue => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== 'object' || ancestors.has(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
let isJsonCompatible: boolean;
|
||||
if (Array.isArray(value)) {
|
||||
isJsonCompatible = value.every(item =>
|
||||
isJsonValueInternal(item, ancestors),
|
||||
);
|
||||
} else {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
isJsonCompatible =
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(value).every(item => isJsonValueInternal(item, ancestors));
|
||||
}
|
||||
ancestors.delete(value);
|
||||
return isJsonCompatible;
|
||||
};
|
||||
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
isJsonValueInternal(value, new WeakSet());
|
||||
|
||||
/** Structural equality for JSON values, independent of object key order. */
|
||||
export const jsonValuesEqual = (left: unknown, right: unknown) =>
|
||||
isEqual(left, right);
|
||||
|
||||
interface NormalizationSnapshots {
|
||||
control: string;
|
||||
persisted: Record<string, unknown>;
|
||||
input: Record<string, unknown>;
|
||||
hydrated: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const automaticNormalizationTransition = ({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
}: NormalizationSnapshots): AutomaticNormalizationTransition | undefined => {
|
||||
const fromPresent = Object.hasOwn(persisted, control);
|
||||
const inputPresent = Object.hasOwn(input, control);
|
||||
const toPresent = Object.hasOwn(hydrated, control);
|
||||
const fromValue = persisted[control];
|
||||
const inputValue = input[control];
|
||||
const toValue = hydrated[control];
|
||||
|
||||
const inputMatchesPersisted =
|
||||
fromPresent === inputPresent && jsonValuesEqual(fromValue, inputValue);
|
||||
const hydrationChangedValue =
|
||||
fromPresent !== toPresent || !jsonValuesEqual(fromValue, toValue);
|
||||
|
||||
// Disappearing keys (!toPresent) are deliberately not covered here:
|
||||
// hydration itself never removes keys from the merged snapshot. Machine
|
||||
// removals happen later, when StashFormDataContainer stashes invisible
|
||||
// controls out of form_data — those are covered at save time by
|
||||
// stashDropNormalizationTransitions, which uses the stash itself
|
||||
// (explore.hiddenFormData) as the proof the removal was not user-made.
|
||||
if (!inputMatchesPersisted || !toPresent || !hydrationChangedValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonValue(toValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fromPresent) {
|
||||
return {
|
||||
control,
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
}
|
||||
if (!isJsonValue(fromValue)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const automaticNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
hydrated: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
const controls = new Set([...Object.keys(input), ...Object.keys(hydrated)]);
|
||||
controls.forEach(control => {
|
||||
const transition = automaticNormalizationTransition({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
});
|
||||
if (transition) {
|
||||
transitions[control] = transition;
|
||||
}
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advisory transitions for keys the stash removed from form_data.
|
||||
*
|
||||
* StashFormDataContainer moves an invisible control's value out of
|
||||
* ``form_data`` into ``explore.hiddenFormData``. That removal is
|
||||
* machine-made by construction, but it happens in render effects after
|
||||
* hydration, so hydration-time tracking cannot see it. This computes the
|
||||
* matching drop transitions at save time: a key counts only when the stash
|
||||
* holds it, the stashed value still equals the persisted value (a user edit
|
||||
* before hiding breaks the equality and stays recorded), and the outgoing
|
||||
* payload no longer carries the key. Keys absent from the stash — e.g.
|
||||
* removed by a viz-type switch — are never covered.
|
||||
*/
|
||||
export const stashDropNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
hiddenFormData: Record<string, unknown> | undefined,
|
||||
outgoingFormData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
if (!hiddenFormData) {
|
||||
return transitions;
|
||||
}
|
||||
Object.keys(hiddenFormData).forEach(control => {
|
||||
if (!Object.hasOwn(persisted, control)) return;
|
||||
if (Object.hasOwn(outgoingFormData, control)) return;
|
||||
const fromValue = persisted[control];
|
||||
if (!isJsonValue(fromValue)) return;
|
||||
if (!jsonValuesEqual(hiddenFormData[control], fromValue)) return;
|
||||
transitions[control] = {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: false,
|
||||
};
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
export const matchingAutomaticNormalizationTransitions = (
|
||||
tracking: ChartNormalizationTrackingState | null | undefined,
|
||||
formData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions =>
|
||||
Object.fromEntries(
|
||||
Object.entries(tracking?.transitions ?? {}).filter(
|
||||
([control, transition]) =>
|
||||
!tracking?.invalidatedControls[control] &&
|
||||
Object.hasOwn(formData, control) === transition.to_present &&
|
||||
(!transition.to_present ||
|
||||
jsonValuesEqual(formData[control], transition.to_value)),
|
||||
),
|
||||
);
|
||||
@@ -18,10 +18,14 @@
|
||||
*/
|
||||
import versionHistoryReducer, {
|
||||
appendVersionSessionLog,
|
||||
beginChartNormalizationSave,
|
||||
clearVersionPreview,
|
||||
clearVersionSessionLog,
|
||||
completeChartNormalizationSave,
|
||||
closeVersionHistoryPanel,
|
||||
openVersionHistoryPanel,
|
||||
hydrateChartNormalization,
|
||||
invalidateChartNormalizationControls,
|
||||
selectIsChartVersionPreviewActive,
|
||||
selectIsDashboardVersionPreviewActive,
|
||||
selectVersionHistory,
|
||||
@@ -145,3 +149,55 @@ test('per-entity preview selectors only match their own entity type', () => {
|
||||
expect(selectIsChartVersionPreviewActive(state)).toBe(true);
|
||||
expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('normalization tracking invalidates controls without re-adding transitions', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-a',
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
invalidateChartNormalizationControls(['row_limit']),
|
||||
);
|
||||
expect(state.chartNormalization?.invalidatedControls).toEqual({
|
||||
row_limit: true,
|
||||
});
|
||||
expect(state.chartNormalization?.transitions.row_limit).toBeDefined();
|
||||
});
|
||||
|
||||
test('late save completion cannot rebase another hydration session', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-b',
|
||||
transitions: {},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
beginChartNormalizationSave(7, 'session-b', 'attempt-b'),
|
||||
);
|
||||
const unchanged = versionHistoryReducer(
|
||||
state,
|
||||
completeChartNormalizationSave(7, 'session-a', 'attempt-a', {}),
|
||||
);
|
||||
expect(unchanged).toBe(state);
|
||||
expect(unchanged.chartNormalization?.saveAttemptId).toBe('attempt-b');
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
import type {
|
||||
ActivityInclude,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
SessionLogEntry,
|
||||
VersionedEntityType,
|
||||
VersionHistoryState,
|
||||
@@ -33,6 +35,12 @@ export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
|
||||
export const VERSION_RESTORED = 'VERSION_RESTORED';
|
||||
export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
|
||||
export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
|
||||
export const HYDRATE_CHART_NORMALIZATION = 'HYDRATE_CHART_NORMALIZATION';
|
||||
export const INVALIDATE_CHART_NORMALIZATION_CONTROLS =
|
||||
'INVALIDATE_CHART_NORMALIZATION_CONTROLS';
|
||||
export const BEGIN_CHART_NORMALIZATION_SAVE = 'BEGIN_CHART_NORMALIZATION_SAVE';
|
||||
export const COMPLETE_CHART_NORMALIZATION_SAVE =
|
||||
'COMPLETE_CHART_NORMALIZATION_SAVE';
|
||||
|
||||
/** Upper bound on retained unsaved-edit entries; older ones drop off. */
|
||||
export const MAX_SESSION_LOG_ENTRIES = 50;
|
||||
@@ -86,6 +94,31 @@ interface ClearSessionLogAction {
|
||||
type: typeof CLEAR_VERSION_SESSION_LOG;
|
||||
}
|
||||
|
||||
interface HydrateChartNormalizationAction {
|
||||
type: typeof HYDRATE_CHART_NORMALIZATION;
|
||||
tracking: ChartNormalizationTrackingState;
|
||||
}
|
||||
|
||||
interface InvalidateChartNormalizationControlsAction {
|
||||
type: typeof INVALIDATE_CHART_NORMALIZATION_CONTROLS;
|
||||
controls: string[];
|
||||
}
|
||||
|
||||
interface BeginChartNormalizationSaveAction {
|
||||
type: typeof BEGIN_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
}
|
||||
|
||||
interface CompleteChartNormalizationSaveAction {
|
||||
type: typeof COMPLETE_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
}
|
||||
|
||||
export type VersionHistoryAction =
|
||||
| OpenPanelAction
|
||||
| ClosePanelAction
|
||||
@@ -95,7 +128,11 @@ export type VersionHistoryAction =
|
||||
| PreviewAppliedAction
|
||||
| VersionRestoredAction
|
||||
| AppendSessionLogAction
|
||||
| ClearSessionLogAction;
|
||||
| ClearSessionLogAction
|
||||
| HydrateChartNormalizationAction
|
||||
| InvalidateChartNormalizationControlsAction
|
||||
| BeginChartNormalizationSaveAction
|
||||
| CompleteChartNormalizationSaveAction;
|
||||
|
||||
export const openVersionHistoryPanel = (
|
||||
entityType: VersionedEntityType,
|
||||
@@ -161,6 +198,44 @@ export const clearVersionSessionLog = (): ClearSessionLogAction => ({
|
||||
type: CLEAR_VERSION_SESSION_LOG,
|
||||
});
|
||||
|
||||
export const hydrateChartNormalization = (
|
||||
tracking: ChartNormalizationTrackingState,
|
||||
): HydrateChartNormalizationAction => ({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking,
|
||||
});
|
||||
|
||||
export const invalidateChartNormalizationControls = (
|
||||
controls: string[],
|
||||
): InvalidateChartNormalizationControlsAction => ({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls,
|
||||
});
|
||||
|
||||
export const beginChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
): BeginChartNormalizationSaveAction => ({
|
||||
type: BEGIN_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
});
|
||||
|
||||
export const completeChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
transitions: AutomaticNormalizationTransitions,
|
||||
): CompleteChartNormalizationSaveAction => ({
|
||||
type: COMPLETE_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
transitions,
|
||||
});
|
||||
|
||||
const initialState: VersionHistoryState = {
|
||||
isPanelOpen: false,
|
||||
entityType: null,
|
||||
@@ -170,6 +245,7 @@ const initialState: VersionHistoryState = {
|
||||
sessionLog: [],
|
||||
restoreCount: 0,
|
||||
lastRestoredEntityUuid: null,
|
||||
chartNormalization: null,
|
||||
};
|
||||
|
||||
export default function versionHistoryReducer(
|
||||
@@ -240,6 +316,58 @@ export default function versionHistoryReducer(
|
||||
}
|
||||
case CLEAR_VERSION_SESSION_LOG:
|
||||
return { ...state, sessionLog: [] };
|
||||
case HYDRATE_CHART_NORMALIZATION:
|
||||
return { ...state, chartNormalization: action.tracking };
|
||||
case INVALIDATE_CHART_NORMALIZATION_CONTROLS: {
|
||||
if (!state.chartNormalization || action.controls.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const invalidatedControls = {
|
||||
...state.chartNormalization.invalidatedControls,
|
||||
};
|
||||
action.controls.forEach(control => {
|
||||
invalidatedControls[control] = true;
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
invalidatedControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
case BEGIN_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
saveAttemptId: action.saveAttemptId,
|
||||
},
|
||||
};
|
||||
case COMPLETE_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId ||
|
||||
state.chartNormalization.saveAttemptId !== action.saveAttemptId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
transitions: action.transitions,
|
||||
saveAttemptId: null,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -292,3 +420,6 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
|
||||
|
||||
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).sessionLog;
|
||||
|
||||
export const selectChartNormalization = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).chartNormalization;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
|
||||
import {
|
||||
APPEND_VERSION_SESSION_LOG,
|
||||
CLEAR_VERSION_SESSION_LOG,
|
||||
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
} from './reducer';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -77,7 +78,7 @@ test('falls back to a humanized control name when no label exists', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('skips programmatic control writes so untouched charts stay clean', async () => {
|
||||
test('programmatic writes invalidate normalization without logging an edit', async () => {
|
||||
// Effects rewrite controls with no user gesture (transferred-control
|
||||
// cleanup after load, derived margins); logging them would report unsaved
|
||||
// edits the user never made. Built with the REAL action creator so the
|
||||
@@ -89,15 +90,54 @@ test('skips programmatic control writes so untouched charts stay clean', async (
|
||||
explore: { controls: { metrics: { label: 'Metrics' } } },
|
||||
});
|
||||
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls: ['metrics'],
|
||||
});
|
||||
|
||||
// The same creator without the mark still logs.
|
||||
store.dispatch.mockClear();
|
||||
run(store, setControlValue('metrics', []));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a write matching the hydrated default preserves normalization', async () => {
|
||||
const { setControlValue } =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({
|
||||
explore: {
|
||||
controls: { show_totals: { label: 'Show totals' } },
|
||||
form_data: { show_totals: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
show_totals: {
|
||||
control: 'show_totals',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
run(
|
||||
store,
|
||||
setControlValue('show_totals', false, undefined, { programmatic: true }),
|
||||
);
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clears the session log when the explore page hydrates', () => {
|
||||
const store = buildStore();
|
||||
run(store, { type: 'HYDRATE_EXPLORE', data: {} });
|
||||
@@ -188,7 +228,7 @@ test('a history step cannot collapse into an adjacent control entry', async () =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({ explore: { controls: {} } });
|
||||
run(store, setExploreControls({} as never));
|
||||
const { entry } = store.dispatch.mock.calls[0][0];
|
||||
const [[{ entry }]] = store.dispatch.mock.calls;
|
||||
expect(entry.controlName).not.toMatch(/^[a-z]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
import type { Middleware } from 'redux';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
|
||||
import {
|
||||
appendVersionSessionLog,
|
||||
clearVersionSessionLog,
|
||||
invalidateChartNormalizationControls,
|
||||
} from './reducer';
|
||||
import { jsonValuesEqual } from './normalization';
|
||||
import type { ChartNormalizationTrackingState } from './types';
|
||||
|
||||
// Action types are inlined (rather than imported from the explore
|
||||
// module) so this middleware does not pull explore code into every
|
||||
@@ -47,9 +53,54 @@ interface SessionLogState {
|
||||
user?: { firstName?: string; lastName?: string };
|
||||
explore?: {
|
||||
controls?: Record<string, { label?: unknown } | undefined>;
|
||||
form_data?: Record<string, unknown>;
|
||||
};
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Untrusted Explore action shape; fields narrow only at this boundary. */
|
||||
interface ExploreBoundaryAction {
|
||||
type: unknown;
|
||||
controlName?: unknown;
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const changedFormDataKeys = (
|
||||
before: Record<string, unknown> = {},
|
||||
after: Record<string, unknown> = {},
|
||||
) =>
|
||||
[...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
||||
key => before[key] !== after[key],
|
||||
);
|
||||
|
||||
/**
|
||||
* Anti-corruption adapter from Explore's action vocabulary to the stable
|
||||
* versioning concept of controls whose user-intent evidence is no longer valid.
|
||||
*/
|
||||
export const normalizationControlsChangedByExplore = (
|
||||
action: ExploreBoundaryAction,
|
||||
before: Record<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
) => {
|
||||
if (action.type === HYDRATE_EXPLORE) {
|
||||
return [];
|
||||
}
|
||||
const controls = changedFormDataKeys(before, after);
|
||||
if (
|
||||
action.type === SET_FIELD_VALUE &&
|
||||
typeof action.controlName === 'string'
|
||||
) {
|
||||
controls.push(action.controlName);
|
||||
} else if (action.type === SET_EXPLORE_CONTROLS && action.formData) {
|
||||
controls.push(...Object.keys(action.formData));
|
||||
} else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
|
||||
controls.push(DATASOURCE_CONTROL_NAME);
|
||||
}
|
||||
return [...new Set(controls)];
|
||||
};
|
||||
|
||||
function controlLabel(state: SessionLogState, controlName: string): string {
|
||||
const label = state.explore?.controls?.[controlName]?.label;
|
||||
return typeof label === 'string' && label
|
||||
@@ -64,6 +115,25 @@ function userName(state: SessionLogState): string | null {
|
||||
return name || null;
|
||||
}
|
||||
|
||||
const normalizationControlsNoLongerMatching = (
|
||||
controls: string[],
|
||||
state: SessionLogState,
|
||||
) => {
|
||||
const formData = state.explore?.form_data ?? {};
|
||||
const transitions = state.versionHistory?.chartNormalization?.transitions;
|
||||
return controls.filter(control => {
|
||||
const transition = transitions?.[control];
|
||||
if (!transition) {
|
||||
return true;
|
||||
}
|
||||
const present = Object.hasOwn(formData, control);
|
||||
return (
|
||||
present !== transition.to_present ||
|
||||
(present && !jsonValuesEqual(formData[control], transition.to_value))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records unsaved explore control changes in the version history
|
||||
* session log ("Current version" section) and resets the log whenever
|
||||
@@ -71,6 +141,7 @@ function userName(state: SessionLogState): string | null {
|
||||
*/
|
||||
export const versionSessionLogMiddleware: Middleware =
|
||||
store => next => action => {
|
||||
const before = (store.getState() as SessionLogState).explore?.form_data;
|
||||
const result = next(action);
|
||||
if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
|
||||
return result;
|
||||
@@ -151,5 +222,17 @@ export const versionSessionLogMiddleware: Middleware =
|
||||
}),
|
||||
);
|
||||
}
|
||||
const state = store.getState() as SessionLogState;
|
||||
const changedControls = normalizationControlsNoLongerMatching(
|
||||
normalizationControlsChangedByExplore(
|
||||
action,
|
||||
before,
|
||||
state.explore?.form_data,
|
||||
),
|
||||
state,
|
||||
);
|
||||
if (changedControls.length) {
|
||||
store.dispatch(invalidateChartNormalizationControls(changedControls));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -209,6 +209,45 @@ export interface SessionLogEntry {
|
||||
user: string | null;
|
||||
}
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
type PresentNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: true; from_value: JsonValue }
|
||||
: { to_present: true; to_value: JsonValue };
|
||||
|
||||
type MissingNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: false; from_value?: never }
|
||||
: { to_present: false; to_value?: never };
|
||||
|
||||
/** One guarded hydration transition sent with an existing-chart overwrite. */
|
||||
export type AutomaticNormalizationTransition = { control: string } & (
|
||||
| PresentNormalizationValue<'from'>
|
||||
| MissingNormalizationValue<'from'>
|
||||
) &
|
||||
(PresentNormalizationValue<'to'> | MissingNormalizationValue<'to'>);
|
||||
|
||||
export type AutomaticNormalizationTransitions = Record<
|
||||
string,
|
||||
AutomaticNormalizationTransition
|
||||
>;
|
||||
|
||||
/** Identity-bound state for one chart hydration and its in-flight save. */
|
||||
export interface ChartNormalizationTrackingState {
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
invalidatedControls: Record<string, true>;
|
||||
saveAttemptId: string | null;
|
||||
}
|
||||
|
||||
export interface VersionHistoryState {
|
||||
isPanelOpen: boolean;
|
||||
entityType: VersionedEntityType | null;
|
||||
@@ -228,4 +267,6 @@ export interface VersionHistoryState {
|
||||
* the one their page shows.
|
||||
*/
|
||||
lastRestoredEntityUuid: string | null;
|
||||
/** Advisory transitions for the active Explore chart hydration. */
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
}
|
||||
|
||||
@@ -650,7 +650,11 @@ function ChartList(props: ChartListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Chart>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const openEditModal = () => openChartEditModal(original);
|
||||
const handleExport = () => handleBulkChartExport([original]);
|
||||
if (!canEdit && !canDelete && !canExport) {
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface Dashboard {
|
||||
description?: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags: TagType[];
|
||||
created_by: object;
|
||||
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const handleDelete = () =>
|
||||
handleDashboardDelete(
|
||||
original,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Chart {
|
||||
cache_timeout: number | null;
|
||||
thumbnail_url?: string;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags?: TagType[];
|
||||
last_saved_at?: string;
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface Dashboard {
|
||||
url: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
+19
-3
@@ -92,7 +92,10 @@ from superset.exceptions import (
|
||||
)
|
||||
from superset.extensions import event_logger, security_manager
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -410,12 +413,21 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
@expose("/<pk>/deck_layers/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: (f"{self.__class__.__name__}.deck_layers"),
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.deck_layers",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def deck_layers(self, pk: int) -> Response:
|
||||
@@ -679,13 +691,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
normalization_changes: object = item.pop("normalization_changes", None)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off, so this stays inert under the
|
||||
# kill-switch).
|
||||
old_info = current_entity_version_info(Slice, pk)
|
||||
|
||||
try:
|
||||
changed_model = UpdateChartCommand(pk, item).run()
|
||||
changed_model = UpdateChartCommand(
|
||||
pk, item, normalization_changes=normalization_changes
|
||||
).run()
|
||||
new_info = current_entity_version_info(
|
||||
Slice, changed_model.id, changed_model.uuid
|
||||
)
|
||||
|
||||
@@ -368,6 +368,30 @@ class ChartPutSchema(Schema):
|
||||
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
|
||||
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
normalization_changes: fields.Raw = fields.Raw(
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"Optional advisory Explore hydration transitions used only to "
|
||||
"remove exact automatic normalization changes from human-readable "
|
||||
"version history. Invalid metadata is ignored."
|
||||
),
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["control", "from_present", "to_present"],
|
||||
"properties": {
|
||||
"control": {"type": "string", "maxLength": 256},
|
||||
"from_present": {"type": "boolean"},
|
||||
"from_value": {},
|
||||
"to_present": {"type": "boolean"},
|
||||
"to_value": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ChartGetDatasourceObjectDataResponseSchema(Schema):
|
||||
|
||||
@@ -44,11 +44,15 @@ from superset.commands.utils import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,10 +64,16 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(self, model_id: int, data: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
def __init__(
|
||||
self,
|
||||
model_id: int,
|
||||
data: dict[str, Any],
|
||||
normalization_changes: object = None,
|
||||
) -> None:
|
||||
self._model_id: int = model_id
|
||||
self._properties: dict[str, Any] = data.copy()
|
||||
self._model: Optional[Slice] = None
|
||||
self._normalization_changes: object = normalization_changes
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=ChartUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
@@ -78,6 +88,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = g.user
|
||||
|
||||
if self._normalization_changes is not None and "params" in self._properties:
|
||||
register_matching_normalization_context(
|
||||
db.session,
|
||||
self._model.id,
|
||||
self._normalization_changes,
|
||||
self._model.params,
|
||||
self._properties["params"],
|
||||
)
|
||||
|
||||
return ChartDAO.update(self._model, self._properties)
|
||||
|
||||
def _validate_new_dashboard_access(
|
||||
|
||||
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
|
||||
|
||||
def validate(self) -> None:
|
||||
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
|
||||
if not layer:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(layer):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
|
||||
@@ -21,17 +21,17 @@ from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, pk: int):
|
||||
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
|
||||
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
@@ -23,9 +23,9 @@ from typing import Any
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
@@ -33,8 +33,8 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -66,10 +66,8 @@ class UpdateSemanticViewCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name = self._properties.get("name", self._model.name)
|
||||
layer_uuid = str(self._model.semantic_layer_uuid)
|
||||
@@ -116,6 +114,9 @@ class UpdateSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
|
||||
raise SemanticLayerInvalidError(f"Name already exists: {name}")
|
||||
|
||||
@@ -21,11 +21,8 @@ from typing import Any
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
|
||||
@@ -18,19 +18,22 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import TagNotFoundValidationError
|
||||
from superset.commands.tag.exceptions import (
|
||||
TagDeleteFailedError,
|
||||
TagDeleteForbiddenValidationError,
|
||||
TaggedObjectDeleteFailedError,
|
||||
TaggedObjectNotFoundError,
|
||||
TagInvalidError,
|
||||
TagNotFoundError,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.views.base import DeleteMixin
|
||||
|
||||
@@ -134,10 +137,42 @@ class DeleteTagsCommand(DeleteMixin, BaseCommand):
|
||||
TagDAO.delete_tags(self._tags)
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions = []
|
||||
# Validate tag exists
|
||||
for tag in self._tags:
|
||||
if not TagDAO.find_by_name(tag):
|
||||
exceptions.append(TagNotFoundError(tag))
|
||||
# Every item appended here must be a ValidationError (or subclass),
|
||||
# since TagInvalidError.normalized_messages() calls
|
||||
# .normalized_messages() on each one to build the aggregated 422
|
||||
# response.
|
||||
exceptions: list[ValidationError] = []
|
||||
for tag_name in self._tags:
|
||||
tag_name = tag_name.strip()
|
||||
tag = TagDAO.find_by_name(tag_name)
|
||||
# Validate tag exists
|
||||
if not tag:
|
||||
exceptions.append(
|
||||
TagNotFoundValidationError(f"Tag with name {tag_name} not found.")
|
||||
)
|
||||
continue
|
||||
# System-generated tags (type:*, editor:*, favorited_by:*) are
|
||||
# maintained by Superset itself and must not be deletable through
|
||||
# the bulk route.
|
||||
if tag.type is not None and tag.type != TagType.custom:
|
||||
exceptions.append(
|
||||
TagDeleteForbiddenValidationError(
|
||||
f"Tag {tag_name} is a system tag and cannot be deleted"
|
||||
)
|
||||
)
|
||||
continue
|
||||
# Deleting a tag cascades removal of all of its associations
|
||||
# org-wide, so existence is not enough: require the user to be an
|
||||
# admin or the tag's creator (the single-association route
|
||||
# enforces per-object access in DeleteTaggedObjectCommand).
|
||||
if not (
|
||||
security_manager.is_admin()
|
||||
or (tag.created_by and tag.created_by == security_manager.current_user)
|
||||
):
|
||||
exceptions.append(
|
||||
TagDeleteForbiddenValidationError(
|
||||
f"Access denied to tag {tag_name}"
|
||||
)
|
||||
)
|
||||
if exceptions:
|
||||
raise TagInvalidError(exceptions=exceptions)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
@@ -44,6 +45,18 @@ class TagDeleteFailedError(DeleteFailedError):
|
||||
message = _("Tag could not be deleted.")
|
||||
|
||||
|
||||
class TagDeleteForbiddenValidationError(ValidationError):
|
||||
"""A tag exists but may not be deleted (a system-generated tag, or the
|
||||
caller lacks ownership/admin rights). Unlike ``TagDeleteFailedError``,
|
||||
this is a ``ValidationError`` so it can be composited into a
|
||||
``TagInvalidError`` alongside other validation failures and still
|
||||
support ``CommandInvalidError.normalized_messages()``.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, field_name="tags")
|
||||
|
||||
|
||||
class TaggedObjectDeleteFailedError(DeleteFailedError):
|
||||
message = _("Tagged Object could not be deleted.")
|
||||
|
||||
|
||||
@@ -22,11 +22,8 @@ from flask_appbuilder.models.sqla import Model
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.tag.exceptions import TagInvalidError, TagNotFoundError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.tags.models import Tag
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from superset import security_manager
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.query import SavedQueryDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
@@ -52,22 +50,3 @@ def to_object_model(
|
||||
|
||||
return DatasetDAO.find_by_id(object_id, skip_base_filter=skip_base_filter)
|
||||
return None
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user may create/modify tag relationships on ``model``.
|
||||
|
||||
Mirrors the editorship check the bulk-create path already applies, or the
|
||||
object's creator, so the tag-update path enforces the same boundary.
|
||||
Look the model up with
|
||||
``skip_base_filter=True`` before calling this, so an object the user cannot
|
||||
access reaches the check instead of resolving to ``None`` and being written
|
||||
without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
@@ -44,6 +44,18 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | No
|
||||
if existing:
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
if existing.is_system:
|
||||
raise ThemeImportError("Cannot overwrite a system theme via import")
|
||||
# The active system-default/dark theme slot may be overwritten by
|
||||
# admins only; a non-admin overwriting it would change the theme
|
||||
# rendered for every user, including the login page and other
|
||||
# admins.
|
||||
if (
|
||||
existing.is_system_default or existing.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise ThemeImportError(
|
||||
"Cannot overwrite the active system-default/dark theme via import"
|
||||
)
|
||||
config["id"] = existing.id
|
||||
elif not can_write:
|
||||
raise ThemeImportError(
|
||||
|
||||
@@ -18,8 +18,10 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import UpdateMixin
|
||||
from superset.commands.theme.exceptions import (
|
||||
SystemThemeInUseError,
|
||||
SystemThemeProtectedError,
|
||||
ThemeNotFoundError,
|
||||
)
|
||||
@@ -52,3 +54,11 @@ class UpdateThemeCommand(UpdateMixin):
|
||||
# Check if it's a system theme
|
||||
if self._model.is_system:
|
||||
raise SystemThemeProtectedError()
|
||||
|
||||
# The active system-default/dark theme slot may be edited by admins
|
||||
# only; a non-admin editing it would change the theme rendered for
|
||||
# every user, including the login page and other admins.
|
||||
if (
|
||||
self._model.is_system_default or self._model.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise SystemThemeInUseError()
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.daos.exceptions import DatasourceNotFound
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.subjects.exceptions import SubjectsNotFoundValidationError
|
||||
from superset.subjects.models import Subject
|
||||
from superset.subjects.utils import (
|
||||
@@ -52,6 +53,30 @@ def _has_extra_editors_resolver() -> bool:
|
||||
return bool(has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER"))
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user is authorized to create/modify ``model``.
|
||||
|
||||
Delegates to ``security_manager.raise_for_editorship``, which grants
|
||||
access to admins and any subject in ``model.editors`` (when that
|
||||
relationship exists). For models that don't carry an ``editors``
|
||||
relationship, or when the current subject isn't one of them, this falls
|
||||
back to allowing the object's creator.
|
||||
|
||||
Callers that need to distinguish "not found" from "no access" should
|
||||
look the model up bypassing DAO base filters (e.g.
|
||||
``skip_base_filter=True``) before calling this, so an object the user
|
||||
cannot access reaches the check instead of resolving to ``None`` and
|
||||
being written without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
|
||||
def populate_subject_list(
|
||||
subject_ids: list[int] | None,
|
||||
default_to_user: bool,
|
||||
|
||||
@@ -213,7 +213,9 @@ def orderby_from_form_data(
|
||||
# The drag-and-drop "sort by" control persists a list; the frontend unwraps it
|
||||
# with ``ensureIsArray(...)[0]`` (``plugin-chart-table/src/buildQuery.ts:67``).
|
||||
# Read raw, a list would nest inside ``orderby`` and fail the query.
|
||||
raw_sort_metric = form_data.get("timeseries_limit_metric")
|
||||
raw_sort_metric = form_data.get("series_limit_metric") or form_data.get(
|
||||
"timeseries_limit_metric"
|
||||
)
|
||||
sort_metric = (
|
||||
next(iter(as_list(raw_sort_metric)), None) if raw_sort_metric else None
|
||||
) or (metrics[0] if form_data.get("sort_by_metric") else None)
|
||||
|
||||
+17
-1
@@ -21,7 +21,11 @@ from flask import g
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
from superset.commands.tag.exceptions import TagNotFoundError
|
||||
from superset.commands.tag.utils import to_object_type
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
@@ -372,6 +376,18 @@ class TagDAO(BaseDAO[Tag]):
|
||||
if not bulk_create:
|
||||
# delete relationships that aren't retained from single tag create
|
||||
for object_type, object_id in tagged_objects_to_delete:
|
||||
# Only remove associations from objects the current user may
|
||||
# modify, mirroring the per-object check applied to additions.
|
||||
# Look the object up bypassing the access base filter so an
|
||||
# inaccessible object reaches the check instead of resolving
|
||||
# to None and having its association deleted unchecked.
|
||||
model = to_object_model(
|
||||
object_type, # type: ignore
|
||||
object_id,
|
||||
skip_base_filter=True,
|
||||
)
|
||||
if model and not current_user_can_modify_object(model):
|
||||
continue
|
||||
# delete objects that were removed
|
||||
TagDAO.delete_tagged_object(
|
||||
object_type, # type: ignore
|
||||
|
||||
@@ -142,7 +142,10 @@ from superset.extensions import event_logger, security_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.security.guest_token import GuestUser
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -433,6 +436,15 @@ class DashboardRestApi(
|
||||
"""
|
||||
return super().get_list(**kwargs)
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Dashboard, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"]
|
||||
order_columns = [
|
||||
"changed_by.first_name",
|
||||
|
||||
@@ -34,7 +34,6 @@ from superset import security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.exceptions import SupersetMarshmallowValidationError
|
||||
from superset.models.sql_types import parse_currency_string
|
||||
from superset.subjects.schemas import SubjectResponseSchema
|
||||
from superset.utils import json
|
||||
|
||||
get_delete_ids_schema = {
|
||||
@@ -480,16 +479,32 @@ class DatasetColumnDrillInfoSchema(Schema):
|
||||
|
||||
|
||||
class UserSchema(Schema):
|
||||
# Deliberately excludes ``email``: drill_info is reachable by any user
|
||||
# with read access to the dataset (and, via the dashboard fallback, by
|
||||
# embedded guests), so exposing maintainer emails here would leak user
|
||||
# PII across an access boundary. Mirrors the dashboard/RLS user schemas,
|
||||
# which expose names only.
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
email = fields.String()
|
||||
|
||||
|
||||
class DrillInfoEditorSchema(Schema):
|
||||
# Deliberately excludes ``secondary_label``: for a user-backed Subject,
|
||||
# user-subject synchronization (superset.subjects.sync.sync_user_subject)
|
||||
# stores that user's email in this field, so including it here would
|
||||
# leak the same maintainer PII that ``UserSchema`` above excludes
|
||||
# ``email`` to avoid, just through a different field name.
|
||||
id = fields.Int()
|
||||
label = fields.String()
|
||||
img = fields.String()
|
||||
type = fields.Integer()
|
||||
|
||||
|
||||
class DatasetDrillInfoSchema(Schema):
|
||||
id = fields.Integer()
|
||||
columns = fields.List(fields.Nested(DatasetColumnDrillInfoSchema))
|
||||
table_name = fields.String()
|
||||
editors = fields.List(fields.Nested(SubjectResponseSchema))
|
||||
editors = fields.List(fields.Nested(DrillInfoEditorSchema))
|
||||
created_by = fields.Nested(UserSchema)
|
||||
created_on_humanized = fields.String()
|
||||
changed_by = fields.Nested(UserSchema)
|
||||
|
||||
@@ -290,7 +290,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "girl")],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric=metric,
|
||||
series_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -321,7 +321,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "boy")],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric=metric,
|
||||
series_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -498,7 +498,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
viz_type="echarts_timeseries_line",
|
||||
granularity_sqla="ds",
|
||||
groupby=["name"],
|
||||
timeseries_limit_metric={
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
@@ -522,7 +522,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
metrics=metrics,
|
||||
groupby=["name"],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric={
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Boys
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Girls
|
||||
|
||||
@@ -30,6 +30,7 @@ from superset.commands.temporary_cache.exceptions import (
|
||||
TemporaryCacheResourceNotFoundError,
|
||||
)
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
|
||||
from superset.extensions import event_logger
|
||||
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
|
||||
@@ -110,6 +111,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("PUT",))
|
||||
@protect()
|
||||
@@ -183,6 +186,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("GET",))
|
||||
@protect()
|
||||
@@ -234,6 +239,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("DELETE",))
|
||||
@protect()
|
||||
@@ -286,3 +293,5 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# under the License.
|
||||
from typing import Optional
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartAccessDeniedError,
|
||||
@@ -33,6 +35,7 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.query import QueryDAO
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.utils.core import DatasourceType
|
||||
|
||||
|
||||
@@ -53,7 +56,13 @@ def check_query_access(query_id: int) -> Optional[bool]:
|
||||
# Access checks below, no need to validate them twice as they can be expensive.
|
||||
query = QueryDAO.find_by_id(query_id, skip_base_filter=True)
|
||||
if query:
|
||||
security_manager.raise_for_access(query=query)
|
||||
try:
|
||||
security_manager.raise_for_access(query=query)
|
||||
except TemplateError as ex:
|
||||
# raise_for_access() Jinja-renders the query's SQL to resolve
|
||||
# the tables it touches; a malformed template surfaces here as
|
||||
# a raw jinja2 exception rather than a Superset one.
|
||||
raise SupersetTemplateException(str(ex)) from ex
|
||||
return True
|
||||
raise QueryNotFoundValidationError()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.daos.base import _escape_like
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.reports.models import ReportExecutionLog, ReportSchedule
|
||||
from superset.subjects.filters import subject_relation_exists_for_current_user
|
||||
from superset.views.base import BaseFilter
|
||||
|
||||
@@ -43,6 +43,34 @@ class ReportScheduleFilter(BaseFilter): # pylint: disable=too-few-public-method
|
||||
return query.filter(ReportSchedule.id.in_(editor_ids_query))
|
||||
|
||||
|
||||
class ReportExecutionLogFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Scope execution logs to report schedules the user can edit, mirroring
|
||||
``ReportScheduleFilter`` on the schedule API. Logs carry evaluated alert
|
||||
values and database error messages, so they must not be readable across
|
||||
ownership boundaries via an attacker-chosen schedule id.
|
||||
|
||||
The unrestricted bypass is gated by ``can_access_all_queries`` rather
|
||||
than ``can_access_all_datasources``: the latter is also granted to
|
||||
stock Alpha, which would let a non-editor Alpha user read every other
|
||||
schedule's evaluated values and database errors. ``can_access_all_queries``
|
||||
is the admin-only permission this codebase already uses to gate the
|
||||
equivalent per-execution data on SQL Lab query history
|
||||
(see ``superset.queries.filters.QueryFilter``).
|
||||
"""
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
if security_manager.can_access_all_queries():
|
||||
return query
|
||||
|
||||
from superset.subjects.models import report_schedule_editors
|
||||
|
||||
editor_ids_query = db.session.query(
|
||||
report_schedule_editors.c.report_schedule_id
|
||||
).filter(subject_relation_exists_for_current_user(report_schedule_editors))
|
||||
return query.filter(ReportExecutionLog.report_schedule_id.in_(editor_ids_query))
|
||||
|
||||
|
||||
class ReportScheduleAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
name = _("All Text")
|
||||
arg_name = "report_all_text"
|
||||
|
||||
@@ -31,6 +31,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.reports.filters import ReportExecutionLogFilter
|
||||
from superset.reports.logs.schemas import openapi_spec_methods_override
|
||||
from superset.reports.models import ReportExecutionLog
|
||||
from superset.views.base_api import BaseSupersetModelRestApi
|
||||
@@ -53,6 +54,13 @@ class ReportExecutionLogRestApi(BaseSupersetModelRestApi):
|
||||
class_permission_name = "ReportSchedule"
|
||||
resource_name = "report"
|
||||
allow_browser_login = True
|
||||
# Ownership scoping. Without a base filter the only scoping on these
|
||||
# routes is the caller-controlled ``pk`` path parameter (folded into the
|
||||
# rison filters below), so any role with ReportSchedule read could
|
||||
# iterate every schedule's logs deployment-wide. Mirrors the
|
||||
# ReportScheduleFilter applied by the schedule API
|
||||
# (superset/reports/api.py).
|
||||
base_filters = [["id", ReportExecutionLogFilter, lambda: []]]
|
||||
|
||||
show_columns = [
|
||||
"id",
|
||||
|
||||
@@ -40,6 +40,7 @@ from superset.commands.dashboard.embedded.exceptions import (
|
||||
EmbeddedDashboardNotFoundError,
|
||||
)
|
||||
from superset.commands.exceptions import ForbiddenError
|
||||
from superset.constants import RouteMethod
|
||||
from superset.exceptions import SupersetGenericErrorException
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.security.guest_token import (
|
||||
@@ -423,6 +424,20 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
|
||||
resource_name = "security/user_registrations"
|
||||
datamodel = SQLAInterface(RegisterUser)
|
||||
allow_browser_login = True
|
||||
# POST/PUT are intentionally excluded: restricting the exposed routes
|
||||
# keeps the FAB default create/update handlers from ever being
|
||||
# registered, so a mis-granted role cannot silently alter a pending
|
||||
# registration. DELETE is kept: the User Registrations admin page
|
||||
# deletes pending registrations through this route, and the class is
|
||||
# gated Admin-only via ADMIN_ONLY_VIEW_MENUS (keyed on the view-menu
|
||||
# name, i.e. every permission on this class, not just specific ones),
|
||||
# so exposing it does not grant non-Admin roles anything.
|
||||
include_route_methods = {
|
||||
RouteMethod.GET,
|
||||
RouteMethod.GET_LIST,
|
||||
RouteMethod.INFO,
|
||||
RouteMethod.DELETE,
|
||||
}
|
||||
# NOTE: registration_hash is intentionally excluded from both list_columns
|
||||
# and search_columns. It is a bearer token for the
|
||||
# /register/activation/<hash> flow; exposing it in API responses (and thus
|
||||
|
||||
@@ -170,6 +170,36 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
|
||||
return subject_ids
|
||||
|
||||
|
||||
def get_extra_editors_by_pk(
|
||||
model_cls: type[Model], primary_keys: list[Any]
|
||||
) -> dict[Any, list[int]]:
|
||||
"""
|
||||
Resolve extra editor subject IDs for a batch of resources, keyed by
|
||||
primary key. List responses only have serialized rows, not model
|
||||
instances, so this re-queries the page's rows in one batched query.
|
||||
"""
|
||||
if not primary_keys or not (
|
||||
has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER")
|
||||
):
|
||||
return {}
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import db
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
|
||||
pk_col = inspect(model_cls).primary_key[0]
|
||||
resources = (
|
||||
db.session.query(model_cls)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
|
||||
.filter(pk_col.in_(primary_keys))
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
getattr(resource, pk_col.name): get_extra_editor_subject_ids(resource)
|
||||
for resource in resources
|
||||
}
|
||||
|
||||
|
||||
def _render_permission_instructions_link(
|
||||
*,
|
||||
datasource_id: str = "",
|
||||
@@ -1698,6 +1728,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"CssTemplate",
|
||||
"Dataset",
|
||||
"Datasource",
|
||||
"Theme",
|
||||
} | READ_ONLY_MODEL_VIEWS
|
||||
|
||||
GAMMA_EXCLUDED_PVMS = {
|
||||
@@ -1722,6 +1753,10 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"Security",
|
||||
"SQL Lab",
|
||||
"User Registrations",
|
||||
# REST counterpart of the FAB "User Registrations" views. FAB derives
|
||||
# the view-menu name from the class name; without this entry
|
||||
# _is_gamma_pvm grants its permissions to stock Gamma and Alpha.
|
||||
"UserRegistrationsRestAPI",
|
||||
"User's Statistics",
|
||||
# Guarding all AB_ADD_SECURITY_API = True REST APIs
|
||||
"RoleRestAPI",
|
||||
|
||||
@@ -41,6 +41,7 @@ from superset.commands.semantic_layer.delete import (
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
@@ -210,6 +211,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
description: Semantic view structure
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -338,6 +341,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
errors.append(
|
||||
{"name": view_data.get("name"), "error": "Semantic layer not found"}
|
||||
)
|
||||
except SemanticViewForbiddenError as ex:
|
||||
errors.append({"name": view_data.get("name"), "error": str(ex)})
|
||||
except SemanticViewCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating semantic view: %s",
|
||||
@@ -447,6 +452,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
description: Semantic view deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -665,6 +672,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Runtime JSON Schema
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
@@ -672,6 +681,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
runtime_data = body.get("runtime_data")
|
||||
|
||||
@@ -716,6 +730,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Available views
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
@@ -726,6 +742,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
runtime_data = body.get("runtime_data", {})
|
||||
|
||||
@@ -855,6 +876,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -870,6 +893,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
return self.response(200, result={"uuid": str(changed_model.uuid)})
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerForbiddenError as ex:
|
||||
return self.response(403, message=str(ex))
|
||||
except SemanticLayerInvalidError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except SemanticLayerUpdateFailedError as ex:
|
||||
@@ -899,6 +924,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Semantic layer deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -909,6 +936,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
return self.response(200, message="OK")
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerForbiddenError as ex:
|
||||
return self.response(403, message=str(ex))
|
||||
except SemanticLayerDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error deleting semantic layer: %s",
|
||||
@@ -1152,10 +1181,18 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: A semantic layer
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
layer = SemanticLayerDAO.find_by_uuid(uuid)
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
return self.response(200, result=_serialize_layer(layer))
|
||||
|
||||
@@ -148,6 +148,26 @@ class SemanticLayer(AuditMixinNullable, Model):
|
||||
"""Compute the permission string for this semantic layer."""
|
||||
return f"[{self.name}](id:{self.uuid.hex})"
|
||||
|
||||
def raise_for_access(self) -> None:
|
||||
"""Check that the user has access to this semantic layer."""
|
||||
from superset import security_manager
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
if security_manager.can_access_all_datasources():
|
||||
return
|
||||
|
||||
if self.perm and security_manager.can_access("datasource_access", self.perm):
|
||||
return
|
||||
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message=str(_("You don't have access to this semantic layer.")),
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def after_insert(
|
||||
mapper: Mapper,
|
||||
|
||||
@@ -486,6 +486,64 @@ class TagRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<pk>", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def delete(self, pk: int) -> Response:
|
||||
"""Deletes a Tag
|
||||
---
|
||||
delete:
|
||||
description: >-
|
||||
Delete a Tag by id. This will remove all tagged objects with
|
||||
this tag.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
responses:
|
||||
200:
|
||||
description: Tag deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
# Overrides the FAB-generated single-object delete route (which would
|
||||
# otherwise call self.datamodel.delete directly, bypassing
|
||||
# DeleteTagsCommand's ownership and system-tag checks) so both the
|
||||
# single-object and bulk-delete routes share the same validation.
|
||||
tag = TagDAO.find_by_id(pk)
|
||||
if not tag:
|
||||
return self.response_404()
|
||||
try:
|
||||
DeleteTagsCommand([tag.name]).run()
|
||||
return self.response(200, message="OK")
|
||||
except TagNotFoundError:
|
||||
return self.response_404()
|
||||
except TagInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except TagDeleteFailedError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -350,6 +350,8 @@ class ThemeRestApi(BaseSupersetModelRestApi):
|
||||
return self.response_404()
|
||||
except SystemThemeProtectedError:
|
||||
return self.response_403()
|
||||
except SystemThemeInUseError:
|
||||
return self.response_403()
|
||||
except Exception as ex:
|
||||
logger.exception("Unexpected error in PUT /theme/%s", pk)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
+13
-1
@@ -626,9 +626,21 @@ def sanitize_svg_content(svg_content: str) -> str:
|
||||
return ""
|
||||
|
||||
# Minimal protection: remove obvious malicious content, preserve all SVG features
|
||||
# The closing tag pattern tolerates attributes/whitespace after "script"
|
||||
# (e.g. "</script foo>"), which browsers still parse as a valid closer.
|
||||
content = re.sub(
|
||||
r"<script[^>]*>.*?</script>", "", svg_content, flags=re.IGNORECASE | re.DOTALL
|
||||
r"<script\b[^>]*>.*?</script\b[^>]*>",
|
||||
"",
|
||||
svg_content,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Second pass: an unterminated <script ...> opener has no matching
|
||||
# closer, so browsers treat everything after it as script content
|
||||
# through end-of-file. Drop the opener and the remainder of the
|
||||
# content with it, rather than leaving the payload text behind.
|
||||
content = re.sub(r"<script\b[^>]*>.*", "", content, flags=re.IGNORECASE | re.DOTALL)
|
||||
# Drop any orphaned closing </script ...> fragment too.
|
||||
content = re.sub(r"</script\b[^>]*>?", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"javascript:", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"data:[^;]*;[^,]*,.*javascript", "", content, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from sqlalchemy import event
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session, SessionTransaction
|
||||
|
||||
from superset.versioning.changes.normalization import NORMALIZATION_CONTEXT_KEY
|
||||
from superset.versioning.changes.shadow_queries import (
|
||||
_dashboard_child_records_for_tx_from_shadows,
|
||||
_dataset_child_records_for_tx_from_shadows,
|
||||
@@ -217,6 +218,7 @@ def _reset_transaction_state(session: Session) -> None:
|
||||
session.info.pop(ACTION_META_KEY, None)
|
||||
session.info.pop(_INITIAL_STATES_KEY, None)
|
||||
session.info.pop(_FINALIZING_KEY, None)
|
||||
session.info.pop(NORMALIZATION_CONTEXT_KEY, None)
|
||||
|
||||
|
||||
def _reset_after_outer_transaction(
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# 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.
|
||||
"""Types and bounds for chart normalization change summaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, NotRequired, TypeAlias, TypedDict, TypeGuard
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.utils import json
|
||||
from superset.versioning.diff import ChangeRecord
|
||||
|
||||
JsonScalar: TypeAlias = None | bool | int | float | str
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
MAX_NORMALIZATION_TRANSITIONS: Final[int] = 256
|
||||
MAX_CONTROL_NAME_BYTES: Final[int] = 256
|
||||
MAX_NORMALIZATION_METADATA_BYTES: Final[int] = 256 * 1024
|
||||
MAX_NORMALIZATION_VALUE_DEPTH: Final[int] = 20
|
||||
|
||||
NORMALIZATION_CONTEXT_KEY: Final[str] = "_versioning_chart_normalization_context"
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NormalizationTransitionPayload(TypedDict):
|
||||
"""Presence-aware transition received as advisory request metadata."""
|
||||
|
||||
control: str
|
||||
from_present: bool
|
||||
from_value: NotRequired[JsonValue]
|
||||
to_present: bool
|
||||
to_value: NotRequired[JsonValue]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizationTransition:
|
||||
"""Validated top-level chart params transition."""
|
||||
|
||||
control: str
|
||||
from_present: bool
|
||||
from_value: JsonValue
|
||||
to_present: bool
|
||||
to_value: JsonValue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizationContext:
|
||||
"""Consume-once evidence scoped to one chart update operation."""
|
||||
|
||||
chart_id: int
|
||||
operation_token: str
|
||||
transitions: tuple[NormalizationTransition, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizationContextRegistry:
|
||||
"""Operation-token registry retained for the active transaction."""
|
||||
|
||||
contexts: dict[tuple[int, str], NormalizationContext]
|
||||
active_tokens: dict[int, str | None]
|
||||
|
||||
|
||||
class _InvalidNormalizationEnvelopeError(ValueError):
|
||||
"""Advisory metadata whose ambiguity requires rejecting all transitions."""
|
||||
|
||||
|
||||
def _json_depth(value: JsonValue) -> int:
|
||||
if isinstance(value, list):
|
||||
return 1 + max((_json_depth(item) for item in value), default=0)
|
||||
if isinstance(value, dict):
|
||||
return 1 + max((_json_depth(item) for item in value.values()), default=0)
|
||||
return 0
|
||||
|
||||
|
||||
def _is_json_value(value: object) -> TypeGuard[JsonValue]:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return all(_is_json_value(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(key, str) and _is_json_value(item) for key, item in value.items()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _json_equal(left: JsonValue, right: JsonValue) -> bool:
|
||||
"""Compare JSON values without Python's ``True == 1`` coercion."""
|
||||
if type(left) is not type(right):
|
||||
return False
|
||||
if isinstance(left, list) and isinstance(right, list):
|
||||
return len(left) == len(right) and all(
|
||||
_json_equal(a, b) for a, b in zip(left, right, strict=False)
|
||||
)
|
||||
if isinstance(left, dict) and isinstance(right, dict):
|
||||
return left.keys() == right.keys() and all(
|
||||
_json_equal(left[key], right[key]) for key in left
|
||||
)
|
||||
return left == right
|
||||
|
||||
|
||||
def _parse_normalization_transition(
|
||||
item: object,
|
||||
) -> NormalizationTransition | None:
|
||||
"""Parse one transition, skipping malformed entries without ambiguity."""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
control: object = item.get("control")
|
||||
from_present: object = item.get("from_present")
|
||||
to_present: object = item.get("to_present")
|
||||
if (
|
||||
not isinstance(control, str)
|
||||
or not control
|
||||
or len(control.encode()) > MAX_CONTROL_NAME_BYTES
|
||||
or not isinstance(from_present, bool)
|
||||
or not isinstance(to_present, bool)
|
||||
):
|
||||
return None
|
||||
if (from_present != ("from_value" in item)) or (to_present != ("to_value" in item)):
|
||||
return None
|
||||
from_value: object = item.get("from_value")
|
||||
to_value: object = item.get("to_value")
|
||||
if not _is_json_value(from_value) or not _is_json_value(to_value):
|
||||
return None
|
||||
if (
|
||||
_json_depth(from_value) > MAX_NORMALIZATION_VALUE_DEPTH
|
||||
or _json_depth(to_value) > MAX_NORMALIZATION_VALUE_DEPTH
|
||||
):
|
||||
raise _InvalidNormalizationEnvelopeError
|
||||
return NormalizationTransition(
|
||||
control=control,
|
||||
from_present=from_present,
|
||||
from_value=from_value,
|
||||
to_present=to_present,
|
||||
to_value=to_value,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_normalization_changes(
|
||||
raw: object,
|
||||
) -> tuple[NormalizationTransition, ...]:
|
||||
"""Return bounded valid entries, or no exclusions for an invalid envelope."""
|
||||
try:
|
||||
encoded: bytes = json.dumps(
|
||||
raw, ensure_ascii=False, separators=(",", ":")
|
||||
).encode()
|
||||
if (
|
||||
not isinstance(raw, list)
|
||||
or len(raw) > MAX_NORMALIZATION_TRANSITIONS
|
||||
or len(encoded) > MAX_NORMALIZATION_METADATA_BYTES
|
||||
):
|
||||
return ()
|
||||
transitions: list[NormalizationTransition] = []
|
||||
controls: set[str] = set()
|
||||
for item in raw:
|
||||
transition: NormalizationTransition | None = (
|
||||
_parse_normalization_transition(item)
|
||||
)
|
||||
if transition is None:
|
||||
continue
|
||||
if transition.control in controls:
|
||||
return ()
|
||||
controls.add(transition.control)
|
||||
transitions.append(transition)
|
||||
return tuple(transitions)
|
||||
except (
|
||||
_InvalidNormalizationEnvelopeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeError,
|
||||
RecursionError,
|
||||
):
|
||||
return ()
|
||||
|
||||
|
||||
def matching_normalization_context(
|
||||
chart_id: int,
|
||||
raw: object,
|
||||
before_params: dict[str, JsonValue],
|
||||
after_params: dict[str, JsonValue],
|
||||
) -> NormalizationContext | None:
|
||||
"""Match sanitized advisory transitions against exact params states."""
|
||||
matching: list[NormalizationTransition] = []
|
||||
for transition in sanitize_normalization_changes(raw):
|
||||
before_present: bool = transition.control in before_params
|
||||
after_present: bool = transition.control in after_params
|
||||
if (
|
||||
before_present != transition.from_present
|
||||
or after_present != transition.to_present
|
||||
):
|
||||
continue
|
||||
if before_present and not _json_equal(
|
||||
before_params[transition.control], transition.from_value
|
||||
):
|
||||
continue
|
||||
if after_present and not _json_equal(
|
||||
after_params[transition.control], transition.to_value
|
||||
):
|
||||
continue
|
||||
matching.append(transition)
|
||||
if not matching:
|
||||
return None
|
||||
return NormalizationContext(chart_id, str(uuid4()), tuple(matching))
|
||||
|
||||
|
||||
def register_matching_normalization_context(
|
||||
session: Session,
|
||||
chart_id: int,
|
||||
raw: object,
|
||||
before_params_json: str | bytes | bytearray | None,
|
||||
after_params_json: str | bytes | bytearray | None,
|
||||
) -> None:
|
||||
"""Validate and register advisory evidence for one chart update."""
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
before_params: object = json.loads(before_params_json or "{}")
|
||||
after_params: object = json.loads(after_params_json or "{}")
|
||||
if not isinstance(before_params, dict) or not isinstance(after_params, dict):
|
||||
return
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
chart_id, raw, before_params, after_params
|
||||
)
|
||||
if context is not None:
|
||||
store_normalization_context(session, context)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Ignoring chart normalization metadata for chart id=%s", chart_id
|
||||
)
|
||||
|
||||
|
||||
def store_normalization_context(
|
||||
session: Session, context: NormalizationContext
|
||||
) -> None:
|
||||
"""Store one operation's evidence, invalidating ambiguous same-chart evidence."""
|
||||
registry: NormalizationContextRegistry = session.info.setdefault(
|
||||
NORMALIZATION_CONTEXT_KEY,
|
||||
NormalizationContextRegistry(contexts={}, active_tokens={}),
|
||||
)
|
||||
existing_token: str | None = registry.active_tokens.get(context.chart_id)
|
||||
if existing_token is not None:
|
||||
registry.contexts.pop((context.chart_id, existing_token), None)
|
||||
registry.active_tokens[context.chart_id] = None
|
||||
return
|
||||
if context.chart_id in registry.active_tokens:
|
||||
return
|
||||
registry.contexts[(context.chart_id, context.operation_token)] = context
|
||||
registry.active_tokens[context.chart_id] = context.operation_token
|
||||
|
||||
|
||||
def consume_normalization_context(
|
||||
session: Session, chart_id: int
|
||||
) -> NormalizationContext | None:
|
||||
"""Consume chart-scoped evidence at most once."""
|
||||
registry: NormalizationContextRegistry | None = session.info.get(
|
||||
NORMALIZATION_CONTEXT_KEY
|
||||
)
|
||||
if registry is None:
|
||||
return None
|
||||
operation_token: str | None = registry.active_tokens.pop(chart_id, None)
|
||||
if operation_token is None:
|
||||
return None
|
||||
return registry.contexts.pop((chart_id, operation_token), None)
|
||||
|
||||
|
||||
def filter_normalization_records(
|
||||
records: list[ChangeRecord], context: NormalizationContext | None
|
||||
) -> list[ChangeRecord]:
|
||||
"""Return a fresh readable diff with exact normalization controls omitted."""
|
||||
if context is None:
|
||||
return list(records)
|
||||
controls: set[str] = {transition.control for transition in context.transitions}
|
||||
return [
|
||||
record
|
||||
for record in records
|
||||
if not (
|
||||
len(record.path) >= 2
|
||||
and record.path[0] == "params"
|
||||
and record.path[1] in controls
|
||||
)
|
||||
]
|
||||
@@ -46,6 +46,10 @@ import sqlalchemy as sa
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.versioning.changes.normalization import (
|
||||
consume_normalization_context,
|
||||
filter_normalization_records,
|
||||
)
|
||||
from superset.versioning.changes.table import version_changes_table
|
||||
from superset.versioning.diff import (
|
||||
cap_records,
|
||||
@@ -213,6 +217,16 @@ def bulk_insert_records(
|
||||
return
|
||||
rows = []
|
||||
for (entity_kind, entity_id), records in buffered.items():
|
||||
if entity_kind == "chart":
|
||||
try:
|
||||
records = filter_normalization_records(
|
||||
records, consume_normalization_context(session, entity_id)
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"version_changes: normalization filtering failed for chart id=%s",
|
||||
entity_id,
|
||||
)
|
||||
# Bound a single save's output: collapse field-level record explosions
|
||||
# and truncate over-large values before they hit version_changes.
|
||||
for seq, r in enumerate(cap_records(records)):
|
||||
|
||||
@@ -922,6 +922,55 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"no_extra_editors_list_dashboard",
|
||||
"no-extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_dashboards_list_includes_extra_editors_when_resolver_configured(
|
||||
self,
|
||||
):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"extra_editors_list_dashboard",
|
||||
"extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_admin_sees_existing_charts(self):
|
||||
"""Regression for #25890: GET /api/v1/chart/ as an Admin user should
|
||||
return existing charts, not an empty list."""
|
||||
@@ -944,6 +993,41 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"no_extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_charts_list_includes_extra_editors_when_resolver_configured(self):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_filter(self):
|
||||
"""
|
||||
Dashboard API: Test get dashboards filter
|
||||
|
||||
@@ -3421,6 +3421,73 @@ class TestDatasetApi(SupersetTestCase):
|
||||
|
||||
self.items_to_delete = [dataset]
|
||||
|
||||
def test_get_drill_info_does_not_expose_user_emails(self):
|
||||
"""
|
||||
Dataset API: drill_info must not leak creator/modifier email addresses.
|
||||
|
||||
The nested user schema exposes first/last name only; email is PII and
|
||||
is not part of the endpoint's select_columns contract.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
dataset = self.insert_dataset(
|
||||
table_name="test_drill_dataset_no_email",
|
||||
editor_user_ids=[],
|
||||
columns=[
|
||||
TableColumn(
|
||||
column_name="category",
|
||||
type="VARCHAR(255)",
|
||||
groupby=True,
|
||||
),
|
||||
],
|
||||
fetch_metadata=False,
|
||||
)
|
||||
|
||||
uri = f"api/v1/dataset/{dataset.id}/drill_info/"
|
||||
rv = self.get_assert_metric(uri, "get_drill_info")
|
||||
assert rv.status_code == 200
|
||||
|
||||
result = json.loads(rv.data.decode("utf-8"))["result"]
|
||||
for user_field in ("created_by", "changed_by"):
|
||||
assert "email" not in (result.get(user_field) or {})
|
||||
|
||||
self.items_to_delete = [dataset]
|
||||
|
||||
def test_get_drill_info_does_not_expose_editor_emails(self):
|
||||
"""
|
||||
Dataset API: drill_info must not leak an editor's email address
|
||||
through the ``editors`` list.
|
||||
|
||||
User-subject synchronization stores a user's email in the Subject's
|
||||
``secondary_label`` field, and ``editors`` nests Subjects directly,
|
||||
so email must be excluded the same way it is for created_by/changed_by.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
gamma_user = self.get_user(GAMMA_USERNAME)
|
||||
dataset = self.insert_dataset(
|
||||
table_name="test_drill_dataset_no_editor_email",
|
||||
editor_user_ids=[gamma_user.id],
|
||||
columns=[
|
||||
TableColumn(
|
||||
column_name="category",
|
||||
type="VARCHAR(255)",
|
||||
groupby=True,
|
||||
),
|
||||
],
|
||||
fetch_metadata=False,
|
||||
)
|
||||
|
||||
uri = f"api/v1/dataset/{dataset.id}/drill_info/"
|
||||
rv = self.get_assert_metric(uri, "get_drill_info")
|
||||
assert rv.status_code == 200
|
||||
|
||||
result = json.loads(rv.data.decode("utf-8"))["result"]
|
||||
editors = result.get("editors") or []
|
||||
assert len(editors) == 1
|
||||
assert "secondary_label" not in editors[0]
|
||||
assert gamma_user.email not in json.dumps(editors)
|
||||
|
||||
self.items_to_delete = [dataset]
|
||||
|
||||
def test_get_drill_info_admin_user_dataset_not_found(self):
|
||||
"""
|
||||
Dataset API: Test drill_info endpoint returns 404 for non-existent dataset.
|
||||
|
||||
@@ -570,6 +570,49 @@ class TestTagApi(InsertChartMixin, SupersetTestCase):
|
||||
tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names))
|
||||
assert tags.count() == 0
|
||||
|
||||
@pytest.mark.usefixtures("create_tags")
|
||||
def test_delete_tag_by_pk(self):
|
||||
"""
|
||||
Tag API: the single-object ``DELETE /api/v1/tag/<pk>`` route must
|
||||
share the same ownership/system-tag validation as bulk_delete
|
||||
(DeleteTagsCommand), not the FAB-generated model delete.
|
||||
"""
|
||||
tag = db.session.query(Tag).filter(Tag.name == "example_tag_1").one()
|
||||
system_tag = Tag(name="system:pk_delete_example", type=TagType.type)
|
||||
db.session.add(system_tag)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# a non-admin, non-creator user may not delete via the pk route
|
||||
self.login(GAMMA_USERNAME)
|
||||
rv = self.client.delete(f"api/v1/tag/{tag.id}", follow_redirects=True)
|
||||
assert rv.status_code == 422
|
||||
assert db.session.query(Tag).filter(Tag.id == tag.id).count() == 1
|
||||
|
||||
# system-generated tags are refused outright, even for an admin
|
||||
self.logout()
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.delete(
|
||||
f"api/v1/tag/{system_tag.id}", follow_redirects=True
|
||||
)
|
||||
assert rv.status_code == 422
|
||||
assert db.session.query(Tag).filter(Tag.id == system_tag.id).count() == 1
|
||||
|
||||
# an admin may delete a custom tag via the pk route
|
||||
rv = self.client.delete(f"api/v1/tag/{tag.id}", follow_redirects=True)
|
||||
assert rv.status_code == 200
|
||||
assert db.session.query(Tag).filter(Tag.id == tag.id).count() == 0
|
||||
finally:
|
||||
db.session.query(Tag).filter(Tag.id == system_tag.id).delete()
|
||||
db.session.commit()
|
||||
|
||||
def test_delete_tag_by_pk_not_found(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
existing_ids = [tag_id for (tag_id,) in db.session.query(Tag.id).all()]
|
||||
non_existent_id = max(existing_ids, default=0) + 1
|
||||
rv = self.client.delete(f"api/v1/tag/{non_existent_id}", follow_redirects=True)
|
||||
assert rv.status_code == 404
|
||||
|
||||
@pytest.mark.usefixtures("create_tags")
|
||||
def test_delete_favorite_tag(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
@@ -33,13 +33,14 @@ from superset.commands.exceptions import CommandInvalidError # noqa: F401
|
||||
from superset.commands.importers.exceptions import IncorrectVersionError # noqa: F401
|
||||
from superset.commands.tag.create import CreateCustomTagCommand
|
||||
from superset.commands.tag.delete import DeleteTaggedObjectCommand, DeleteTagsCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.connectors.sqla.models import SqlaTable # noqa: F401
|
||||
from superset.models.core import Database # noqa: F401
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice # noqa: F401
|
||||
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
from tests.integration_tests.fixtures.importexport import (
|
||||
chart_config, # noqa: F401
|
||||
dashboard_config, # noqa: F401
|
||||
@@ -127,6 +128,53 @@ class TestDeleteTagsCommand(SupersetTestCase):
|
||||
tags = db.session.query(Tag).filter(Tag.name.in_(example_tags))
|
||||
assert tags.count() == 0
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@pytest.mark.usefixtures("with_tagging_system_feature")
|
||||
def test_delete_tags_command_requires_authorization(self):
|
||||
"""
|
||||
Regression test: DeleteTagsCommand used to check only that each named
|
||||
tag existed, letting any Gamma user bulk-delete tags (and every
|
||||
association they carry) they neither created nor own. It must now
|
||||
require admin-or-creator, and must refuse system-generated tags
|
||||
outright for everyone.
|
||||
"""
|
||||
example_dashboard = (
|
||||
db.session.query(Dashboard)
|
||||
.filter_by(dashboard_title="World Bank's Data")
|
||||
.one()
|
||||
)
|
||||
self.login(ADMIN_USERNAME)
|
||||
example_tags = {"delete tag authz example"}
|
||||
CreateCustomTagCommand(
|
||||
ObjectType.dashboard.value, example_dashboard.id, example_tags
|
||||
).run()
|
||||
|
||||
system_tag = Tag(name="type:delete_tag_authz", type=TagType.type)
|
||||
db.session.add(system_tag)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# a non-admin who is not the tag's creator may not delete it
|
||||
self.logout()
|
||||
self.login(GAMMA_USERNAME)
|
||||
with pytest.raises(TagInvalidError):
|
||||
DeleteTagsCommand(example_tags).run()
|
||||
assert db.session.query(Tag).filter(Tag.name.in_(example_tags)).count() == 1
|
||||
|
||||
# system-generated tags are refused outright, even for an admin
|
||||
self.logout()
|
||||
self.login(ADMIN_USERNAME)
|
||||
with pytest.raises(TagInvalidError):
|
||||
DeleteTagsCommand([system_tag.name]).run()
|
||||
assert db.session.query(Tag).filter_by(name=system_tag.name).count() == 1
|
||||
finally:
|
||||
# cleanup
|
||||
self.logout()
|
||||
self.login(ADMIN_USERNAME)
|
||||
DeleteTagsCommand(example_tags).run()
|
||||
db.session.query(Tag).filter_by(name=system_tag.name).delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# test delete tagged objects command
|
||||
class TestDeleteTaggedObjectCommand(SupersetTestCase):
|
||||
|
||||
@@ -23,7 +23,11 @@ from superset.models.core import Theme
|
||||
from superset.utils import json
|
||||
from tests.conftest import with_config
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
from tests.integration_tests.constants import (
|
||||
ADMIN_USERNAME,
|
||||
ALPHA_USERNAME,
|
||||
GAMMA_USERNAME,
|
||||
)
|
||||
|
||||
|
||||
class TestThemeAPIPermissions(SupersetTestCase):
|
||||
@@ -92,9 +96,11 @@ class TestThemeAPIPermissions(SupersetTestCase):
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
|
||||
def test_non_admin_cannot_set_system_default(self):
|
||||
"""Test that non-admin users cannot set system themes"""
|
||||
# Login as gamma user
|
||||
self.login(GAMMA_USERNAME)
|
||||
"""Test that a non-admin user with theme write access (Alpha) still
|
||||
cannot set system themes, since that is an admin-only action."""
|
||||
# Login as alpha user, who has generic write access to themes but
|
||||
# is not an admin
|
||||
self.login(ALPHA_USERNAME)
|
||||
|
||||
# Try to set theme as system default
|
||||
response = self.client.put(
|
||||
@@ -110,6 +116,26 @@ class TestThemeAPIPermissions(SupersetTestCase):
|
||||
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
|
||||
assert theme.is_system_default is False
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
|
||||
def test_gamma_cannot_write_themes(self):
|
||||
"""Test that gamma users, who only have read access to themes, are
|
||||
rejected before reaching the admin-only check."""
|
||||
# Login as gamma user
|
||||
self.login(GAMMA_USERNAME)
|
||||
|
||||
# Try to set theme as system default
|
||||
response = self.client.put(
|
||||
f"/api/v1/theme/{self.regular_theme.id}/set_system_default"
|
||||
)
|
||||
|
||||
# Should be forbidden at the permission layer, since gamma has no
|
||||
# write access to themes at all
|
||||
assert response.status_code == 403
|
||||
|
||||
# Verify theme is not system default
|
||||
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
|
||||
assert theme.is_system_default is False
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": False})
|
||||
def test_system_theme_requires_config_enabled(self):
|
||||
"""Test that system theme APIs require configuration to be enabled"""
|
||||
|
||||
@@ -52,6 +52,7 @@ from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils import json as _json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
|
||||
load_birth_names_dashboard_with_slices,
|
||||
load_birth_names_data,
|
||||
@@ -163,6 +164,142 @@ class TestChartChangeRecords(SupersetTestCase):
|
||||
assert path == ["slice_name"]
|
||||
assert rows[0]["sequence"] == 0
|
||||
|
||||
def test_matching_hydration_metadata_omits_only_normalization_noise(
|
||||
self,
|
||||
) -> None:
|
||||
"""Readable history omits exact null/default and missing/default changes."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
before_params: dict[str, Any] = {
|
||||
"viz_type": "table",
|
||||
"granularity_sqla": "ds",
|
||||
"row_limit": None,
|
||||
}
|
||||
after_params: dict[str, Any] = {
|
||||
"viz_type": "table",
|
||||
"granularity_sqla": None,
|
||||
"row_limit": 10000,
|
||||
"show_legend": True,
|
||||
}
|
||||
chart.params = _json.dumps(before_params)
|
||||
db.session.commit()
|
||||
|
||||
metadata: list[dict[str, Any]] = [
|
||||
{
|
||||
"control": "granularity_sqla",
|
||||
"from_present": True,
|
||||
"from_value": "ds",
|
||||
"to_present": True,
|
||||
"to_value": None,
|
||||
},
|
||||
{
|
||||
"control": "row_limit",
|
||||
"from_present": True,
|
||||
"from_value": None,
|
||||
"to_present": True,
|
||||
"to_value": 10000,
|
||||
},
|
||||
{
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
},
|
||||
]
|
||||
updated_name: str = f"{chart.slice_name[:64]}_intentional"
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"params": _json.dumps(after_params),
|
||||
"slice_name": updated_name,
|
||||
"normalization_changes": metadata,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
response_body: dict[str, Any] = _json.loads(response.data)
|
||||
assert "normalization_changes" not in response_body["result"]
|
||||
db.session.refresh(chart)
|
||||
|
||||
ver_cls: Any = version_class(Slice)
|
||||
update_tx_id: int = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart.id)
|
||||
.filter(ver_cls.operation_type == 1)
|
||||
.order_by(ver_cls.transaction_id.desc())
|
||||
.first()
|
||||
.transaction_id
|
||||
)
|
||||
rows: list[dict[str, Any]] = _change_rows_for(
|
||||
update_tx_id, entity_kind="chart", entity_id=chart.id
|
||||
)
|
||||
paths: list[list[str]] = [
|
||||
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
|
||||
for row in rows
|
||||
]
|
||||
assert paths == [["slice_name"]]
|
||||
assert _json.loads(chart.params) == after_params
|
||||
|
||||
def test_null_normalization_metadata_is_ignored_by_chart_put(self) -> None:
|
||||
"""Explicit null advisory metadata cannot reject an otherwise valid save."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"slice_name": f"{chart.slice_name[:64]}_null_metadata",
|
||||
"normalization_changes": None,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
|
||||
def test_stale_normalization_metadata_fails_open_through_chart_put(self) -> None:
|
||||
"""Mismatched advisory evidence preserves the real params change."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
before_params: dict[str, Any] = {"viz_type": "table", "row_limit": 100}
|
||||
after_params: dict[str, Any] = {"viz_type": "table", "row_limit": 200}
|
||||
chart.params = _json.dumps(before_params)
|
||||
db.session.commit()
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"params": _json.dumps(after_params),
|
||||
"normalization_changes": [
|
||||
{
|
||||
"control": "row_limit",
|
||||
"from_present": True,
|
||||
"from_value": 999,
|
||||
"to_present": True,
|
||||
"to_value": 200,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
ver_cls: Any = version_class(Slice)
|
||||
transaction_id: int = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart.id)
|
||||
.filter(ver_cls.operation_type == 1)
|
||||
.order_by(ver_cls.transaction_id.desc())
|
||||
.first()
|
||||
.transaction_id
|
||||
)
|
||||
rows: list[dict[str, Any]] = _change_rows_for(
|
||||
transaction_id, entity_kind="chart", entity_id=chart.id
|
||||
)
|
||||
paths: list[list[str]] = [
|
||||
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
|
||||
for row in rows
|
||||
]
|
||||
assert ["params", "row_limit"] in paths
|
||||
|
||||
def test_last_saved_at_is_excluded_as_audit_noise(self) -> None:
|
||||
"""``last_saved_at`` / ``last_saved_by_fk`` are save-side-effect
|
||||
fields stamped by ``UpdateChartCommand`` and must not produce
|
||||
|
||||
@@ -25,6 +25,7 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
@@ -56,6 +57,36 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
|
||||
|
||||
|
||||
def test_create_semantic_layer_configuration_already_serialized(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When ``configuration`` is already a JSON string, it is passed through
|
||||
to the DAO unchanged instead of being re-serialized."""
|
||||
new_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = new_model
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": mock_cls},
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": '{"account": "test"}',
|
||||
}
|
||||
result = CreateSemanticLayerCommand(data).run()
|
||||
|
||||
assert result == new_model
|
||||
dao.create.assert_called_once_with(attributes=data)
|
||||
|
||||
|
||||
def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for unknown type."""
|
||||
mocker.patch(
|
||||
@@ -166,6 +197,11 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
mock_model.name = "orders"
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
@@ -182,6 +218,42 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_configuration_already_serialized(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When ``configuration`` is already a JSON string, it is passed through
|
||||
to the DAO unchanged instead of being re-serialized."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
data = {
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"db": "prod"}',
|
||||
}
|
||||
result = CreateSemanticViewCommand(data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.create.assert_called_once_with(attributes=data)
|
||||
|
||||
|
||||
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when layer not found."""
|
||||
dao_layer = mocker.patch(
|
||||
@@ -215,6 +287,11 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewCreateFailedError,
|
||||
@@ -228,3 +305,110 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
|
||||
def test_create_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when the caller may not modify
|
||||
the parent layer."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
dao_view.create.assert_not_called()
|
||||
|
||||
|
||||
def test_create_semantic_view_creator_of_layer_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the parent layer, but holds no explicit
|
||||
editorship on it, can still add a semantic view to it."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_layer.created_by = sm.current_user
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.create.assert_called_once()
|
||||
|
||||
|
||||
def test_create_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the parent layer nor is an editor of
|
||||
it is rejected."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
sm = mocker.patch(
|
||||
"superset.commands.utils.security_manager",
|
||||
)
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_layer.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
dao_view.create.assert_not_called()
|
||||
|
||||
@@ -21,7 +21,11 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
@@ -33,6 +37,11 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.find_by_uuid.assert_called_once_with("some-uuid")
|
||||
@@ -50,6 +59,71 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
DeleteSemanticLayerCommand("missing-uuid").run()
|
||||
|
||||
|
||||
def test_delete_semantic_layer_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerForbiddenError is raised for non-editors."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the layer, but holds no explicit editorship
|
||||
on it, can still delete it."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_layer_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the layer nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful deletion of a semantic view."""
|
||||
mock_model = MagicMock()
|
||||
@@ -59,10 +133,11 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
# Admin is owner of everything — no exception raised
|
||||
# Admin (or an editor) can modify anything — no exception raised.
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_editorship.return_value = None
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
@@ -76,12 +151,13 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = MagicMock()
|
||||
model = MagicMock()
|
||||
model.created_by = None
|
||||
dao.find_by_id.return_value = model
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
@@ -92,6 +168,56 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the view, but holds no explicit editorship on
|
||||
it, can still delete it."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the view nor is an editor of it is
|
||||
rejected."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when view is missing."""
|
||||
dao = mocker.patch(
|
||||
@@ -118,8 +244,9 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_editorship.return_value = None
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
@@ -133,7 +260,6 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
@@ -141,14 +267,67 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created every view in the batch, but holds no
|
||||
explicit editorship on them, can still bulk-delete them."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
for model in mock_models:
|
||||
model.created_by = sm.current_user
|
||||
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.delete.assert_called_once_with(mock_models)
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who is neither the creator of, nor an editor for, one of
|
||||
the views in the batch is rejected."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
# The first view belongs to the current user, the second doesn't.
|
||||
mock_models[0].created_by = sm.current_user
|
||||
mock_models[1].created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
|
||||
dao = mocker.patch(
|
||||
|
||||
@@ -21,6 +21,7 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewForbiddenError,
|
||||
@@ -46,7 +47,7 @@ def test_update_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"description": "Updated", "cache_timeout": 300}
|
||||
@@ -77,18 +78,65 @@ def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
)
|
||||
# Use a regular MagicMock for raise_for_editorship to avoid AsyncMock issues
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
UpdateSemanticViewCommand(1, {"description": "test"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the view, but holds no explicit editorship on
|
||||
it, can still update it."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = 1
|
||||
mock_model.configuration = "{}"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
data = {"description": "Updated"}
|
||||
result = UpdateSemanticViewCommand(1, data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao.update.assert_called_once_with(mock_model, attributes=data)
|
||||
|
||||
|
||||
def test_update_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the view nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
UpdateSemanticViewCommand(1, {"description": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
|
||||
"""Test that the command copies input data and does not mutate it."""
|
||||
mock_model = MagicMock()
|
||||
@@ -101,7 +149,7 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
original_data = {"description": "Original"}
|
||||
@@ -127,6 +175,10 @@ def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"name": "Updated", "description": "New desc"}
|
||||
result = UpdateSemanticLayerCommand("some-uuid", data).run()
|
||||
|
||||
@@ -146,6 +198,77 @@ def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_layer_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerForbiddenError is raised on ownership failure."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the layer, but holds no explicit editorship
|
||||
on it, can still update it."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
data = {"description": "Updated"}
|
||||
result = UpdateSemanticLayerCommand("some-uuid", data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao.update.assert_called_once_with(mock_model, attributes=data)
|
||||
|
||||
|
||||
def test_update_semantic_layer_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the layer nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
|
||||
mock_model = MagicMock()
|
||||
@@ -157,6 +280,10 @@ def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
|
||||
|
||||
@@ -174,6 +301,10 @@ def test_update_semantic_layer_validates_configuration(
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.update.registry",
|
||||
@@ -199,6 +330,10 @@ def test_update_semantic_layer_skips_name_check_when_no_name(
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
|
||||
|
||||
dao.validate_update_uniqueness.assert_not_called()
|
||||
@@ -215,6 +350,10 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
original_data = {"description": "Original"}
|
||||
UpdateSemanticLayerCommand("some-uuid", original_data).run()
|
||||
|
||||
@@ -249,7 +388,7 @@ def test_update_uniqueness_different_config_same_name(
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
# Update to a config that differs from an existing view
|
||||
@@ -279,7 +418,7 @@ def test_update_uniqueness_same_config_different_name(
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"name": "renamed_view", "configuration": {"schema": "prod"}}
|
||||
@@ -307,7 +446,7 @@ def test_update_uniqueness_same_config_same_name_fails(
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
|
||||
@@ -22,11 +22,13 @@ import pytest
|
||||
|
||||
from superset.commands.exceptions import TagForbiddenError, TagNotFoundValidationError
|
||||
from superset.commands.utils import (
|
||||
current_user_can_modify_object,
|
||||
Tag,
|
||||
TagType,
|
||||
update_tags,
|
||||
validate_tags,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
OBJECT_TYPES = {ObjectType.chart, ObjectType.chart}
|
||||
@@ -343,3 +345,60 @@ def test_update_tags_no_tags(mock_tag_dao, object_type):
|
||||
mock_tag_dao.create_custom_tagged_objects.assert_called_once_with(
|
||||
object_type, 1, new_tag_names
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_editor(mock_sm):
|
||||
"""
|
||||
An editor of the resource (or an admin, since ``raise_for_editorship``
|
||||
treats admins as editors of everything) is allowed to modify it.
|
||||
"""
|
||||
mock_sm.raise_for_editorship.return_value = None
|
||||
model = MagicMock()
|
||||
|
||||
assert current_user_can_modify_object(model) is True
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_creator_fallback(mock_sm):
|
||||
"""
|
||||
A resource without an ``editors`` relationship (or a user who isn't in
|
||||
it) still allows the object's creator through.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
model = MagicMock()
|
||||
model.created_by = mock_sm.current_user
|
||||
|
||||
assert current_user_can_modify_object(model) is True
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_denies_non_creator(mock_sm):
|
||||
"""
|
||||
A user who is neither an editor nor the creator is denied.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
mock_sm.current_user = MagicMock(name="current_user")
|
||||
model = MagicMock()
|
||||
model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
assert current_user_can_modify_object(model) is False
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_no_creator(mock_sm):
|
||||
"""
|
||||
A resource with no ``created_by`` set (e.g. created programmatically)
|
||||
is denied to non-editors.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
model = MagicMock()
|
||||
model.created_by = None
|
||||
|
||||
assert current_user_can_modify_object(model) is False
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.theme.exceptions import ThemeImportError
|
||||
from superset.commands.theme.import_themes import import_theme
|
||||
from superset.models.core import Theme
|
||||
|
||||
|
||||
def _mock_existing(
|
||||
is_system: bool = False,
|
||||
is_system_default: bool = False,
|
||||
is_system_dark: bool = False,
|
||||
) -> MagicMock:
|
||||
theme = MagicMock(spec=Theme)
|
||||
theme.id = 1
|
||||
theme.is_system = is_system
|
||||
theme.is_system_default = is_system_default
|
||||
theme.is_system_dark = is_system_dark
|
||||
return theme
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_theme_overwrite(mock_db, mock_security_manager):
|
||||
"""overwrite=True must not be able to replace a seeded system theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
existing = _mock_existing(is_system=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_default_overwrite(mock_db, mock_security_manager):
|
||||
"""A non-admin overwrite=True must not replace the active default theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_dark_overwrite(mock_db, mock_security_manager):
|
||||
"""A non-admin overwrite=True must not replace the active dark theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
existing = _mock_existing(is_system_dark=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_admin_allows_system_default_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""An admin overwrite=True may still replace the active default theme,
|
||||
mirroring UpdateThemeCommand's admin carve-out."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_admin_allows_system_dark_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""An admin overwrite=True may still replace the active dark theme,
|
||||
mirroring UpdateThemeCommand's admin carve-out."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing(is_system_dark=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_allows_regular_theme_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""A regular (non-system) theme can still be overwritten as before."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_no_overwrite_returns_existing(mock_db, mock_security_manager):
|
||||
"""Without overwrite=True, the existing theme is returned untouched."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
result = import_theme(config, overwrite=False)
|
||||
|
||||
assert result is existing
|
||||
@@ -218,6 +218,31 @@ def test_orderby_uses_timeseries_limit_metric_and_order_desc() -> None:
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_uses_series_limit_metric_and_order_desc() -> None:
|
||||
# series_limit_metric is the current field name; timeseries_limit_metric is
|
||||
# the deprecated alias kept above for back-compat with old saved charts.
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_prefers_series_limit_metric_over_deprecated_alias() -> None:
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"timeseries_limit_metric": "profit",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_pie_sort_by_metric() -> None:
|
||||
form_data = {"metric": "count", "groupby": ["c"], "sort_by_metric": True}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="pie")[
|
||||
|
||||
@@ -48,6 +48,51 @@ def test_validate_python_date_format_raises(payload) -> None:
|
||||
validate_python_date_format(payload)
|
||||
|
||||
|
||||
def test_drill_info_user_schema_does_not_expose_email() -> None:
|
||||
"""
|
||||
Regression test: the drill_info-local ``UserSchema`` used to declare an
|
||||
``email`` field. ``DatasetDrillInfoSchema`` nests it (unfiltered by
|
||||
``select_columns``) for both ``created_by`` and ``changed_by``, so any
|
||||
user with dataset-read access -- or, via the dashboard fallback,
|
||||
embedded guests -- received maintainer email addresses. Dataset-read
|
||||
access does not imply entitlement to other users' PII.
|
||||
"""
|
||||
from superset.datasets.schemas import UserSchema
|
||||
|
||||
class _FakeUser:
|
||||
first_name = "Jane"
|
||||
last_name = "Doe"
|
||||
email = "jane.doe@example.com"
|
||||
|
||||
dumped = UserSchema().dump(_FakeUser())
|
||||
assert "email" not in dumped
|
||||
assert dumped == {"first_name": "Jane", "last_name": "Doe"}
|
||||
|
||||
|
||||
def test_drill_info_editor_schema_does_not_expose_secondary_label() -> None:
|
||||
"""
|
||||
Regression test: ``DatasetDrillInfoSchema.editors`` used to nest the
|
||||
shared ``SubjectResponseSchema``, which includes ``secondary_label``.
|
||||
For a user-backed Subject, user-subject synchronization
|
||||
(``superset.subjects.sync.sync_user_subject``) stores that user's email
|
||||
in ``secondary_label``, so nesting it unfiltered leaked the same
|
||||
maintainer PII that dropping ``email`` from ``UserSchema`` was meant to
|
||||
close, just under a different field name.
|
||||
"""
|
||||
from superset.datasets.schemas import DrillInfoEditorSchema
|
||||
|
||||
class _FakeEditorSubject:
|
||||
id = 1
|
||||
label = "Jane Doe"
|
||||
secondary_label = "jane.doe@example.com"
|
||||
img = "avatar.png"
|
||||
type = 1
|
||||
|
||||
dumped = DrillInfoEditorSchema().dump(_FakeEditorSubject())
|
||||
assert "secondary_label" not in dumped
|
||||
assert dumped == {"id": 1, "label": "Jane Doe", "img": "avatar.png", "type": 1}
|
||||
|
||||
|
||||
def test_dataset_post_schema_has_all_put_scalar_fields() -> None:
|
||||
"""
|
||||
Every scalar model field accepted by DatasetPutSchema should also be accepted
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest import raises # noqa: PT013
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -30,7 +31,7 @@ from superset.commands.exceptions import (
|
||||
DatasourceNotFoundValidationError,
|
||||
QueryNotFoundValidationError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
|
||||
from superset.utils.core import DatasourceType, override_user
|
||||
|
||||
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
|
||||
@@ -340,6 +341,28 @@ def test_query_has_access(mocker: MockerFixture) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_query_malformed_jinja_template(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
``raise_for_access(query=...)`` Jinja-renders the query's SQL to resolve
|
||||
the tables it touches. A malformed template must surface as a
|
||||
``SupersetTemplateException``, not the raw ``jinja2`` exception.
|
||||
"""
|
||||
from superset.explore.utils import check_datasource_access
|
||||
from superset.models.sql_lab import Query
|
||||
|
||||
mocker.patch(query_find_by_id, return_value=Query())
|
||||
mocker.patch(
|
||||
raise_for_access,
|
||||
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
|
||||
)
|
||||
|
||||
with raises(SupersetTemplateException): # noqa: PT012
|
||||
check_datasource_access(
|
||||
datasource_id=1,
|
||||
datasource_type=DatasourceType.QUERY,
|
||||
)
|
||||
|
||||
|
||||
def test_query_no_access(mocker: MockerFixture, client) -> None:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.explore.utils import check_datasource_access
|
||||
|
||||
@@ -106,3 +106,69 @@ def test_report_schedule_all_text_filter_coerces_non_string(
|
||||
mock_report_schedule.sql,
|
||||
):
|
||||
column.ilike.assert_called_once_with(expected, escape="\\")
|
||||
|
||||
|
||||
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
|
||||
def test_report_execution_log_filter_admin_sees_all(mock_sm: MagicMock) -> None:
|
||||
"""
|
||||
Regression test: ``ReportExecutionLogRestApi`` had no base filter at all,
|
||||
so any role with generic ReportSchedule read could iterate every
|
||||
schedule's logs by pk. An admin (can_access_all_queries) must still
|
||||
see everything unfiltered.
|
||||
"""
|
||||
from superset.reports.filters import ReportExecutionLogFilter
|
||||
|
||||
mock_sm.can_access_all_queries.return_value = True
|
||||
query = MagicMock()
|
||||
f = ReportExecutionLogFilter("id", MagicMock())
|
||||
result = f.apply(query, None)
|
||||
assert result is query
|
||||
query.filter.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
|
||||
def test_report_execution_log_filter_stock_alpha_is_scoped(
|
||||
mock_sm: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
Regression test: the unrestricted bypass used to key off
|
||||
``can_access_all_datasources``, which is also granted to stock Alpha
|
||||
(see ``SupersetSecurityManager.ALPHA_ONLY_PERMISSIONS``), letting a
|
||||
non-editor Alpha user read every other schedule's evaluated alert
|
||||
values and database errors. The bypass must require
|
||||
``can_access_all_queries`` (admin-only) instead, matching the
|
||||
equivalent per-execution SQL Lab query history filter
|
||||
(``superset.queries.filters.QueryFilter``).
|
||||
"""
|
||||
from superset.reports.filters import ReportExecutionLogFilter
|
||||
|
||||
mock_sm.can_access_all_datasources.return_value = True
|
||||
mock_sm.can_access_all_queries.return_value = False
|
||||
query = MagicMock()
|
||||
f = ReportExecutionLogFilter("id", MagicMock())
|
||||
f.apply(query, None)
|
||||
query.filter.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.reports.filters.db")
|
||||
def test_report_execution_log_filter_non_admin_scoped_to_log_fk(
|
||||
mock_db: MagicMock, mock_sm: MagicMock
|
||||
) -> None:
|
||||
"""
|
||||
A non-admin must be scoped by ``ReportExecutionLog.report_schedule_id``
|
||||
directly (not by an unjoined filter on ``ReportSchedule.id``, which would
|
||||
pass for any log row as long as the caller edits at least one schedule).
|
||||
"""
|
||||
from superset.reports.filters import ReportExecutionLogFilter
|
||||
from superset.reports.models import ReportExecutionLog
|
||||
|
||||
mock_sm.can_access_all_queries.return_value = False
|
||||
query = MagicMock()
|
||||
f = ReportExecutionLogFilter("id", MagicMock())
|
||||
f.apply(query, None)
|
||||
|
||||
query.filter.assert_called_once()
|
||||
(filter_expr,) = query.filter.call_args[0]
|
||||
assert filter_expr.left.table is ReportExecutionLog.__table__
|
||||
assert filter_expr.left.name == "report_schedule_id"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from superset.reports.filters import ReportExecutionLogFilter
|
||||
from superset.reports.logs.api import ReportExecutionLogRestApi
|
||||
|
||||
|
||||
def test_execution_log_api_has_ownership_base_filter() -> None:
|
||||
"""
|
||||
Regression test: the schedule id in the ``/log/`` and ``/log/<id>``
|
||||
routes is caller-controlled (folded into the rison filters), so an
|
||||
editor-scoped base filter must be applied to both the list and item
|
||||
routes -- otherwise any role with generic ReportSchedule read can iterate
|
||||
every schedule's logs.
|
||||
"""
|
||||
assert ReportExecutionLogRestApi.base_filters, (
|
||||
"ReportExecutionLogRestApi must apply an ownership base filter; "
|
||||
"without one, logs are readable across schedules regardless of "
|
||||
"ownership"
|
||||
)
|
||||
assert any(
|
||||
filter_class is ReportExecutionLogFilter
|
||||
for _, filter_class, _ in ReportExecutionLogRestApi.base_filters
|
||||
)
|
||||
@@ -19,8 +19,10 @@ from typing import Any
|
||||
import pytest
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.constants import RouteMethod
|
||||
from superset.extensions import csrf
|
||||
from superset.security.api import RlsRuleSchema
|
||||
from superset.security.api import RlsRuleSchema, UserRegistrationsRestAPI
|
||||
from superset.security.manager import SupersetSecurityManager
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -172,3 +174,32 @@ def test_rls_rule_schema_rejects_falsy_dataset(dataset: Any) -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
RlsRuleSchema().load({"dataset": dataset, "clause": "tenant_id = 1"})
|
||||
assert "dataset" in exc_info.value.messages
|
||||
|
||||
|
||||
def test_user_registrations_rest_api_is_admin_only() -> None:
|
||||
"""
|
||||
The API is documented Admin-only, but the admin gate is membership in
|
||||
ADMIN_ONLY_VIEW_MENUS keyed by the FAB-derived view-menu name (the class
|
||||
name). If the entry is missing, ``superset init`` grants the API's
|
||||
permissions to stock Gamma and Alpha via ``_is_gamma_pvm``, exposing
|
||||
pending registrants' PII and registration deletion.
|
||||
"""
|
||||
assert "UserRegistrationsRestAPI" in SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS
|
||||
|
||||
|
||||
def test_user_registrations_rest_api_excludes_create_and_update() -> None:
|
||||
"""
|
||||
The FAB default POST/PUT handlers must not exist on this API, so a
|
||||
mis-granted role cannot create or silently alter a pending registration.
|
||||
DELETE stays registered: the User Registrations admin page deletes
|
||||
pending registrations through it, and the route is still fully gated
|
||||
Admin-only (see test_user_registrations_rest_api_is_admin_only).
|
||||
"""
|
||||
assert UserRegistrationsRestAPI.include_route_methods == {
|
||||
RouteMethod.GET,
|
||||
RouteMethod.GET_LIST,
|
||||
RouteMethod.INFO,
|
||||
RouteMethod.DELETE,
|
||||
}
|
||||
assert "post" not in UserRegistrationsRestAPI.include_route_methods
|
||||
assert "put" not in UserRegistrationsRestAPI.include_route_methods
|
||||
|
||||
@@ -36,6 +36,7 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.api import SemanticLayerRestApi, SemanticViewRestApi
|
||||
|
||||
@@ -529,6 +530,34 @@ def test_runtime_schema_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_runtime_schema_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/schema/runtime returns 403 when access is denied."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_layer.raise_for_access.assert_called_once()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_runtime_schema_unknown_type(
|
||||
client: Any,
|
||||
@@ -729,6 +758,30 @@ def test_put_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_put_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test PUT /<uuid> returns 403 when the caller is not an editor."""
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
)
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.UpdateSemanticLayerCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
|
||||
json={"name": "New"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_put_semantic_layer_invalid(
|
||||
client: Any,
|
||||
@@ -821,6 +874,27 @@ def test_delete_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE /<uuid> returns 403 when the caller is not an editor."""
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
)
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.DeleteSemanticLayerCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
|
||||
|
||||
response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_layer_failed(
|
||||
client: Any,
|
||||
@@ -939,6 +1013,33 @@ def test_get_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test GET /<uuid> returns 403 when user lacks access to the layer."""
|
||||
test_uuid = uuid_lib.uuid4()
|
||||
layer = MagicMock()
|
||||
layer.uuid = test_uuid
|
||||
layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = layer
|
||||
|
||||
response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
|
||||
|
||||
assert response.status_code == 403
|
||||
layer.raise_for_access.assert_called_once()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_serialize_layer_string_config(
|
||||
client: Any,
|
||||
@@ -1645,6 +1746,35 @@ def test_post_semantic_view_layer_not_found(
|
||||
assert result["errors"][0]["error"] == "Semantic layer not found"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / collects forbidden errors instead of aborting the batch."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewForbiddenError()
|
||||
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "View 1",
|
||||
"semantic_layer_uuid": str(uuid_lib.uuid4()),
|
||||
"configuration": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
result = response.json["result"]
|
||||
assert len(result["errors"]) == 1
|
||||
assert not result["created"]
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_create_failed(
|
||||
client: Any,
|
||||
@@ -1956,6 +2086,37 @@ def test_get_views(
|
||||
assert result[1]["name"] == "View B"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views returns 403 when access is denied."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {"database": "mydb"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_layer.raise_for_access.assert_called_once()
|
||||
mock_layer.implementation.get_semantic_views.assert_not_called()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_with_existing(
|
||||
client: Any,
|
||||
|
||||
@@ -1100,6 +1100,90 @@ def test_semantic_layer_get_perm_special_characters() -> None:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticLayer.raise_for_access tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_all_datasources(app: Any) -> None:
|
||||
"""Test raise_for_access passes when user has all_datasource_access."""
|
||||
from superset import security_manager
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=True
|
||||
):
|
||||
layer.raise_for_access()
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_perm(app: Any) -> None:
|
||||
"""Test raise_for_access passes when user has datasource_access to the
|
||||
layer's perm."""
|
||||
from superset import security_manager
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
security_manager, "can_access", return_value=True
|
||||
) as mock_can_access,
|
||||
):
|
||||
layer.raise_for_access()
|
||||
mock_can_access.assert_called_once_with("datasource_access", layer.perm)
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_denied(app: Any) -> None:
|
||||
"""Test raise_for_access raises SupersetSecurityException when denied."""
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(security_manager, "can_access", return_value=False),
|
||||
):
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
layer.raise_for_access()
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_no_perm_denied(app: Any) -> None:
|
||||
"""Test raise_for_access raises SupersetSecurityException when the layer
|
||||
has no perm set, without even attempting a datasource_access check."""
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = None
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(security_manager, "can_access") as mock_can_access,
|
||||
):
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
layer.raise_for_access()
|
||||
mock_can_access.assert_not_called()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticView.raise_for_access tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_routes_through_delete_tags_command(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Regression test: ``DELETE /api/v1/tag/<pk>`` used to fall through to the
|
||||
FAB-generated single-object delete route, which deletes the row directly
|
||||
via the datamodel and never runs ``DeleteTagsCommand.validate`` (the
|
||||
admin-or-creator check, and the system-tag refusal). The pk route must
|
||||
be overridden to share that same validation instead of duplicating it.
|
||||
"""
|
||||
mock_tag = MagicMock(id=1)
|
||||
mock_tag.name = "example_tag"
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
mock_command.return_value.run.return_value = None
|
||||
|
||||
response = client.delete("/api/v1/tag/1")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_command.assert_called_once_with(["example_tag"])
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_not_found(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""DELETE /api/v1/tag/<pk> returns 404 when the tag does not exist."""
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=None)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
|
||||
response = client.delete("/api/v1/tag/999")
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_command.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_denied_surfaces_as_422(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A non-admin, non-creator caller (or an attempt to delete a system tag)
|
||||
is rejected by DeleteTagsCommand.validate with TagInvalidError; the pk
|
||||
route must surface that as 422, not silently succeed.
|
||||
"""
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
|
||||
mock_tag = MagicMock(id=1)
|
||||
mock_tag.name = "someone_elses_tag"
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
mock_command.return_value.run.side_effect = TagInvalidError()
|
||||
|
||||
response = client.delete("/api/v1/tag/1")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_denied_with_populated_exceptions_surfaces_as_422(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Regression test for an AttributeError that only surfaced once
|
||||
DeleteTagsCommand.validate's TagInvalidError actually carried the
|
||||
exceptions it composites at runtime (a plain TagDeleteFailedError,
|
||||
not a ValidationError). The pk route calls
|
||||
``ex.normalized_messages()`` on the TagInvalidError it catches, which
|
||||
previously crashed with a 500 instead of returning 422 because
|
||||
TagDeleteFailedError has no ``normalized_messages()`` method. A
|
||||
TagInvalidError() with no exceptions (as in the test above) does not
|
||||
exercise that aggregation loop, so this test populates it the way the
|
||||
real command does.
|
||||
"""
|
||||
from superset.commands.tag.exceptions import (
|
||||
TagDeleteForbiddenValidationError,
|
||||
TagInvalidError,
|
||||
)
|
||||
|
||||
mock_tag = MagicMock(id=1)
|
||||
mock_tag.name = "system:some_type"
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
mock_command.return_value.run.side_effect = TagInvalidError(
|
||||
exceptions=[
|
||||
TagDeleteForbiddenValidationError(
|
||||
"Tag system:some_type is a system tag and cannot be deleted"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
response = client.delete("/api/v1/tag/1")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "tags" in response.json["message"]
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_race_with_bulk_delete_surfaces_as_404(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
If the tag is deleted concurrently between the ``find_by_id`` lookup and
|
||||
``DeleteTagsCommand.validate``'s own lookup, the command raises
|
||||
TagNotFoundError; the pk route must surface that as 404.
|
||||
"""
|
||||
from superset.commands.tag.exceptions import TagNotFoundError
|
||||
|
||||
mock_tag = MagicMock(id=1)
|
||||
mock_tag.name = "example_tag"
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
mock_command.return_value.run.side_effect = TagNotFoundError("example_tag")
|
||||
|
||||
response = client.delete("/api/v1/tag/1")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_tag_by_pk_delete_failed_surfaces_as_422(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""DeleteTagsCommand raising TagDeleteFailedError surfaces as 422."""
|
||||
from superset.commands.tag.exceptions import TagDeleteFailedError
|
||||
|
||||
mock_tag = MagicMock(id=1)
|
||||
mock_tag.name = "example_tag"
|
||||
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
|
||||
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
|
||||
mock_command.return_value.run.side_effect = TagDeleteFailedError()
|
||||
|
||||
response = client.delete("/api/v1/tag/1")
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,198 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from unittest.mock import PropertyMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from superset import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_with_tags(session: Session):
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.tags.models import Tag, TagType
|
||||
|
||||
engine = session.get_bind()
|
||||
Tag.metadata.create_all(engine) # pylint: disable=no-member
|
||||
User.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
owner = User(
|
||||
first_name="owner", last_name="owner", username="owner", email="owner@x.com"
|
||||
)
|
||||
other = User(
|
||||
first_name="other", last_name="other", username="other", email="other@x.com"
|
||||
)
|
||||
session.add(owner)
|
||||
session.add(other)
|
||||
session.flush()
|
||||
|
||||
owned_tag = Tag(
|
||||
name="owned_tag",
|
||||
type=TagType.custom,
|
||||
created_by_fk=owner.id,
|
||||
created_by=owner,
|
||||
)
|
||||
system_tag = Tag(name="type:some_type", type=TagType.type)
|
||||
|
||||
session.add(owned_tag)
|
||||
session.add(system_tag)
|
||||
session.commit()
|
||||
return session
|
||||
|
||||
|
||||
def test_delete_tags_command_admin_can_delete_any_custom_tag(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.tags.models import Tag
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
|
||||
DeleteTagsCommand(["owned_tag"]).run()
|
||||
|
||||
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is None
|
||||
|
||||
|
||||
def test_delete_tags_command_creator_can_delete_own_tag(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.tags.models import Tag
|
||||
|
||||
owner = db.session.query(User).filter_by(username="owner").one()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=False
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.current_user",
|
||||
new_callable=PropertyMock,
|
||||
return_value=owner,
|
||||
)
|
||||
|
||||
DeleteTagsCommand(["owned_tag"]).run()
|
||||
|
||||
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is None
|
||||
|
||||
|
||||
def test_delete_tags_command_non_creator_non_admin_denied(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
"""Regression test: DeleteTagsCommand.validate previously checked only
|
||||
that each named tag existed, letting any user with can_delete on Tag
|
||||
(default Gamma) bulk-delete tags -- and every association they carry --
|
||||
they neither created nor own.
|
||||
"""
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.tags.models import Tag
|
||||
|
||||
other = db.session.query(User).filter_by(username="other").one()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=False
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.current_user",
|
||||
new_callable=PropertyMock,
|
||||
return_value=other,
|
||||
)
|
||||
|
||||
with pytest.raises(TagInvalidError):
|
||||
DeleteTagsCommand(["owned_tag"]).run()
|
||||
|
||||
# the tag must survive the denied deletion
|
||||
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is not None
|
||||
|
||||
|
||||
def test_delete_tags_command_refuses_system_tag_even_for_admin(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
"""System-generated tags (type:*, editor:*, favorited_by:*) are
|
||||
maintained by Superset itself and must not be deletable through the
|
||||
bulk route, regardless of the caller's role.
|
||||
"""
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.tags.models import Tag
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
|
||||
with pytest.raises(TagInvalidError):
|
||||
DeleteTagsCommand(["type:some_type"]).run()
|
||||
|
||||
assert (
|
||||
db.session.query(Tag).filter_by(name="type:some_type").one_or_none() is not None
|
||||
)
|
||||
|
||||
|
||||
def test_delete_tags_command_refused_tag_reports_normalized_messages(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
"""Regression test: DeleteTagsCommand.validate previously appended a
|
||||
plain CommandException (TagDeleteFailedError) into the TagInvalidError
|
||||
it raises, which crashed with AttributeError as soon as anything called
|
||||
.normalized_messages() on that TagInvalidError (as the single-object
|
||||
DELETE /api/v1/tag/<pk> route does). Every exception composited into
|
||||
TagInvalidError must be a ValidationError so normalized_messages() can
|
||||
aggregate it.
|
||||
"""
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
|
||||
with pytest.raises(TagInvalidError) as excinfo:
|
||||
DeleteTagsCommand(["type:some_type"]).run()
|
||||
|
||||
messages = excinfo.value.normalized_messages()
|
||||
assert "tags" in messages
|
||||
assert "system tag" in messages["tags"][0]
|
||||
|
||||
|
||||
def test_delete_tags_command_not_found_reports_normalized_messages(
|
||||
session_with_tags: Session, mocker: MockerFixture
|
||||
):
|
||||
"""A nonexistent tag name is also composited into TagInvalidError; it
|
||||
must likewise support normalized_messages() without raising.
|
||||
"""
|
||||
from superset.commands.tag.delete import DeleteTagsCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
|
||||
with pytest.raises(TagInvalidError) as excinfo:
|
||||
DeleteTagsCommand(["does_not_exist"]).run()
|
||||
|
||||
messages = excinfo.value.normalized_messages()
|
||||
assert "tags" in messages
|
||||
assert "not found" in messages["tags"][0]
|
||||
@@ -264,3 +264,155 @@ def test_update_command_remove_all_tagged_objects(
|
||||
assert (
|
||||
len(db.session.query(TaggedObject).filter_by(tag_id=updated_tag.id).all()) == 0
|
||||
)
|
||||
|
||||
|
||||
def test_update_command_skips_removal_of_inaccessible_objects(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""Associations on objects the user cannot modify must survive an update.
|
||||
|
||||
Regression test: the deletion branch of ``create_tag_relationship``
|
||||
removed every association absent from the submitted set with no
|
||||
per-object check, so a low-privilege user could strip a tag from
|
||||
objects they are not allowed to modify (or from every object, by
|
||||
sending an empty ``objects_to_tag``).
|
||||
"""
|
||||
from superset.commands.tag.create import CreateCustomTagWithRelationshipsCommand
|
||||
from superset.commands.tag.update import UpdateTagCommand
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType, TaggedObject
|
||||
|
||||
dashboard = db.session.query(Dashboard).first()
|
||||
chart = db.session.query(Slice).first()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
mocker.patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart)
|
||||
mocker.patch(
|
||||
"superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
|
||||
)
|
||||
|
||||
# An admin tags both a dashboard and a chart
|
||||
CreateCustomTagWithRelationshipsCommand(
|
||||
data={
|
||||
"name": "test_tag",
|
||||
"objects_to_tag": [
|
||||
(ObjectType.dashboard, dashboard.id),
|
||||
(ObjectType.chart, chart.id),
|
||||
],
|
||||
}
|
||||
).run()
|
||||
|
||||
tag = TagDAO.find_by_name("test_tag")
|
||||
assert len(tag.objects) == 2
|
||||
|
||||
# A non-admin who may modify the chart but not the dashboard submits
|
||||
# only the chart: the dashboard association must not be deleted.
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=False
|
||||
)
|
||||
|
||||
def can_modify(model):
|
||||
return isinstance(model, Slice)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.tag.update.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
UpdateTagCommand(
|
||||
tag.id,
|
||||
{
|
||||
"name": "test_tag",
|
||||
"description": "test_description",
|
||||
"objects_to_tag": [(ObjectType.chart, chart.id)],
|
||||
},
|
||||
).run()
|
||||
|
||||
remaining = {
|
||||
(obj.object_type, obj.object_id)
|
||||
for obj in db.session.query(TaggedObject).filter_by(tag_id=tag.id).all()
|
||||
}
|
||||
assert (ObjectType.dashboard, dashboard.id) in remaining
|
||||
assert (ObjectType.chart, chart.id) in remaining
|
||||
|
||||
|
||||
def test_update_command_empty_objects_to_tag_only_removes_accessible(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""An empty/omitted objects_to_tag must not mass-delete every association.
|
||||
|
||||
A PUT with no objects_to_tag used to be treated as "delete every current
|
||||
association" with zero per-object check. Now each deletion is
|
||||
access-checked, so objects the caller cannot modify keep their
|
||||
association.
|
||||
"""
|
||||
from superset.commands.tag.create import CreateCustomTagWithRelationshipsCommand
|
||||
from superset.commands.tag.update import UpdateTagCommand
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType, TaggedObject
|
||||
|
||||
dashboard = db.session.query(Dashboard).first()
|
||||
chart = db.session.query(Slice).first()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=True
|
||||
)
|
||||
mocker.patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart)
|
||||
mocker.patch(
|
||||
"superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
|
||||
)
|
||||
|
||||
CreateCustomTagWithRelationshipsCommand(
|
||||
data={
|
||||
"name": "test_tag",
|
||||
"objects_to_tag": [
|
||||
(ObjectType.dashboard, dashboard.id),
|
||||
(ObjectType.chart, chart.id),
|
||||
],
|
||||
}
|
||||
).run()
|
||||
|
||||
tag = TagDAO.find_by_name("test_tag")
|
||||
assert len(tag.objects) == 2
|
||||
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.is_admin", return_value=False
|
||||
)
|
||||
|
||||
def can_modify(model):
|
||||
return isinstance(model, Slice)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.tag.update.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
UpdateTagCommand(
|
||||
tag.id,
|
||||
{
|
||||
"name": "test_tag",
|
||||
"description": "test_description",
|
||||
"objects_to_tag": [],
|
||||
},
|
||||
).run()
|
||||
|
||||
remaining = {
|
||||
(obj.object_type, obj.object_id)
|
||||
for obj in db.session.query(TaggedObject).filter_by(tag_id=tag.id).all()
|
||||
}
|
||||
assert (ObjectType.dashboard, dashboard.id) in remaining
|
||||
assert (ObjectType.chart, chart.id) not in remaining
|
||||
|
||||
@@ -20,6 +20,7 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
from superset.commands.theme.exceptions import (
|
||||
SystemThemeInUseError,
|
||||
SystemThemeProtectedError,
|
||||
ThemeNotFoundError,
|
||||
)
|
||||
@@ -62,6 +63,8 @@ class TestUpdateThemeCommand:
|
||||
# Arrange
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = False
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
command = UpdateThemeCommand(123, {"theme_name": "test"})
|
||||
|
||||
@@ -77,6 +80,8 @@ class TestUpdateThemeCommand:
|
||||
# Arrange
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = False
|
||||
mock_theme.is_system_dark = False
|
||||
mock_updated_theme = Mock(spec=Theme)
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
mock_theme_dao.update.return_value = mock_updated_theme
|
||||
@@ -92,6 +97,52 @@ class TestUpdateThemeCommand:
|
||||
mock_theme, {"theme_name": "updated_name"}
|
||||
)
|
||||
|
||||
@patch("superset.commands.theme.update.security_manager")
|
||||
@patch("superset.commands.theme.update.ThemeDAO")
|
||||
def test_validate_system_default_theme_blocks_non_admin(
|
||||
self, mock_theme_dao, mock_security_manager
|
||||
):
|
||||
"""Non-admins cannot update the active system-default/dark theme slot."""
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = True
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
|
||||
command = UpdateThemeCommand(123, {"json_data": "{}"})
|
||||
|
||||
with pytest.raises(SystemThemeInUseError):
|
||||
command.validate()
|
||||
|
||||
@patch("superset.commands.theme.update.security_manager")
|
||||
@patch("superset.commands.theme.update.ThemeDAO")
|
||||
def test_validate_system_default_theme_allows_admin(
|
||||
self, mock_theme_dao, mock_security_manager
|
||||
):
|
||||
"""Admins can still update the active system-default/dark theme slot."""
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = True
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
|
||||
command = UpdateThemeCommand(123, {"json_data": "{}"})
|
||||
|
||||
command.validate() # Should not raise any exception
|
||||
|
||||
assert command._model == mock_theme
|
||||
|
||||
|
||||
def test_theme_is_gamma_read_only():
|
||||
"""Theme writes must require at least Alpha; Gamma only gets read access."""
|
||||
from superset import security_manager
|
||||
|
||||
assert "Theme" in security_manager.GAMMA_READ_ONLY_MODEL_VIEWS
|
||||
|
||||
|
||||
class TestSeedSystemThemesCommand:
|
||||
"""Unit tests for SeedSystemThemesCommand"""
|
||||
|
||||
@@ -1942,13 +1942,29 @@ def test_sanitize_svg_content_safe():
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_scripts():
|
||||
"""Test that nh3 removes dangerous script content."""
|
||||
"""Test that dangerous script content is removed."""
|
||||
malicious_svg = '<svg><script>alert("xss")</script><rect/></svg>'
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "alert" not in result
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_script_with_attributes_on_closer():
|
||||
"""A closing </script foo> tag is still a valid closer to browsers."""
|
||||
malicious_svg = "<svg><script>fetch('/api/v1/me/')</script foo></svg>"
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "fetch" not in result
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_unterminated_script():
|
||||
"""An unterminated <script> opener with no closing tag is still stripped."""
|
||||
malicious_svg = "<svg><script>alert('xss')"
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "alert" not in result
|
||||
|
||||
|
||||
def test_sanitize_url_relative():
|
||||
"""Test that relative URLs are allowed."""
|
||||
assert sanitize_url("/static/spinner.gif") == "/static/spinner.gif"
|
||||
|
||||
@@ -188,6 +188,7 @@ def test_terminal_event_clears_transaction_state(
|
||||
listener.ACTION_META_KEY: {"headline": "restored"},
|
||||
listener._INITIAL_STATES_KEY: {("chart", 7): object()},
|
||||
listener._FINALIZING_KEY: True,
|
||||
listener.NORMALIZATION_CONTEXT_KEY: {"pending": True},
|
||||
"unrelated": "preserved",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.versioning.changes.normalization import (
|
||||
consume_normalization_context,
|
||||
filter_normalization_records,
|
||||
matching_normalization_context,
|
||||
MAX_NORMALIZATION_TRANSITIONS,
|
||||
NormalizationContext,
|
||||
NormalizationTransition,
|
||||
sanitize_normalization_changes,
|
||||
store_normalization_context,
|
||||
)
|
||||
from superset.versioning.diff import ChangeRecord
|
||||
|
||||
|
||||
def _transition(
|
||||
control: str = "row_limit", from_value: object = None, to_value: object = 10000
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"control": control,
|
||||
"from_present": True,
|
||||
"from_value": from_value,
|
||||
"to_present": True,
|
||||
"to_value": to_value,
|
||||
}
|
||||
|
||||
|
||||
def test_sanitizer_preserves_missing_and_null_as_distinct_states() -> None:
|
||||
missing: dict[str, object] = {
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
}
|
||||
null: dict[str, object] = _transition("row_limit")
|
||||
|
||||
transitions: tuple[NormalizationTransition, ...] = sanitize_normalization_changes(
|
||||
[missing, null]
|
||||
)
|
||||
|
||||
assert len(transitions) == 2
|
||||
assert not transitions[0].from_present
|
||||
assert transitions[1].from_present
|
||||
assert transitions[1].from_value is None
|
||||
|
||||
|
||||
def test_sanitizer_ignores_bad_entries_but_rejects_duplicate_envelope() -> None:
|
||||
assert len(sanitize_normalization_changes([{"bad": True}, _transition()])) == 1
|
||||
assert sanitize_normalization_changes([_transition(), _transition()]) == ()
|
||||
assert sanitize_normalization_changes({"not": "a list"}) == ()
|
||||
|
||||
|
||||
def test_sanitizer_rejects_bounded_envelope_failures() -> None:
|
||||
too_many: list[dict[str, object]] = [
|
||||
_transition(control=f"control_{index}")
|
||||
for index in range(MAX_NORMALIZATION_TRANSITIONS + 1)
|
||||
]
|
||||
deep_value: object = None
|
||||
for _index in range(22):
|
||||
deep_value = [deep_value]
|
||||
|
||||
assert sanitize_normalization_changes(too_many) == ()
|
||||
assert sanitize_normalization_changes([_transition(control="x" * 257)]) == ()
|
||||
assert sanitize_normalization_changes([_transition(from_value=deep_value)]) == ()
|
||||
assert sanitize_normalization_changes([object()]) == ()
|
||||
|
||||
|
||||
def test_matching_requires_exact_presence_and_json_value_types() -> None:
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
},
|
||||
_transition(),
|
||||
]
|
||||
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, raw, {"row_limit": None}, {"show_legend": True, "row_limit": 10000}
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert {item.control for item in context.transitions} == {
|
||||
"show_legend",
|
||||
"row_limit",
|
||||
}
|
||||
assert (
|
||||
matching_normalization_context(
|
||||
7,
|
||||
[_transition(from_value=True, to_value=2)],
|
||||
{"row_limit": 1},
|
||||
{"row_limit": 2},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_filter_returns_fresh_records_without_matching_params_control() -> None:
|
||||
records: list[ChangeRecord] = [
|
||||
ChangeRecord("field", "edit", ["params", "row_limit"], None, 10000),
|
||||
ChangeRecord("field", "edit", ["slice_name"], "Old", "New"),
|
||||
]
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
|
||||
)
|
||||
|
||||
filtered: list[ChangeRecord] = filter_normalization_records(records, context)
|
||||
|
||||
assert filtered == [records[1]]
|
||||
assert filtered is not records
|
||||
|
||||
|
||||
def test_context_is_consumed_once_and_same_chart_ambiguity_fails_open() -> None:
|
||||
session: Session = Session()
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
|
||||
)
|
||||
assert context is not None
|
||||
store_normalization_context(session, context)
|
||||
assert consume_normalization_context(session, 7) == context
|
||||
assert consume_normalization_context(session, 7) is None
|
||||
|
||||
store_normalization_context(session, context)
|
||||
store_normalization_context(session, context)
|
||||
assert consume_normalization_context(session, 7) is None
|
||||
|
||||
|
||||
def test_drop_transition_matches_and_filters_a_remove_record() -> None:
|
||||
"""A stash-time drop (present -> absent) suppresses its remove record."""
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "order_desc",
|
||||
"from_present": True,
|
||||
"from_value": True,
|
||||
"to_present": False,
|
||||
},
|
||||
]
|
||||
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, raw, {"order_desc": True, "row_limit": 100}, {"row_limit": 100}
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert [item.control for item in context.transitions] == ["order_desc"]
|
||||
|
||||
records: list[ChangeRecord] = [
|
||||
ChangeRecord("field", "remove", ["params", "order_desc"], True, None),
|
||||
ChangeRecord("field", "edit", ["params", "row_limit"], 100, 50),
|
||||
]
|
||||
assert filter_normalization_records(records, context) == [records[1]]
|
||||
|
||||
|
||||
def test_drop_transition_requires_the_key_to_be_absent_after() -> None:
|
||||
"""A drop advisory does not match when the key survived the save."""
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "order_desc",
|
||||
"from_present": True,
|
||||
"from_value": True,
|
||||
"to_present": False,
|
||||
},
|
||||
]
|
||||
assert (
|
||||
matching_normalization_context(
|
||||
7, raw, {"order_desc": True}, {"order_desc": False}
|
||||
)
|
||||
is None
|
||||
)
|
||||
Reference in New Issue
Block a user