Compare commits

...
Author SHA1 Message Date
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 649c062825 chore(deps): bump baseline-browser-mapping from 2.11.13 to 2.11.15 in /docs (#43444)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 09:52:03 -07:00
3f10d8b1cc fix(versioning): suppress automatic chart normalization changes (#43350)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:09:20 -07:00
Mehmet Salih Yavuz 15b98aa6da fix(plugin-chart-echarts): apply contribution before rename with time comparison (#43384) 2026-08-24 19:08:08 +03:00
7f1b41444c fix: add missing access checks to semantic-layer and theme endpoints (#43389)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:52:45 -07:00
8a32324184 fix: add missing ownership checks to tag, report-log, and dataset-schema endpoints (#43390)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:44:22 -07:00
Joe Li 4623d9219a fix: update in the wild link on community page (#43403) 2026-08-24 22:28:19 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dcac129b51 chore(deps): bump js-yaml from 5.2.3 to 5.3.0 in /docs (#43442)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:17:39 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7196ce8a0c chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#43445)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e7756aeef9 chore(deps): bump immer from 11.1.16 to 11.1.17 in /superset-frontend (#43448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:27 +07:00
DanielSwift1992 af859717a7 chore: remove two stale codecov ignore paths (#43438) 2026-08-24 22:15:57 +07:00
72 changed files with 4326 additions and 147 deletions
-2
View File
@@ -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:
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -62,11 +62,11 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"baseline-browser-mapping": "^2.11.15",
"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",
+1 -1
View File
@@ -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.',
+8 -8
View File
@@ -6522,10 +6522,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
version "2.11.13"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.15, baseline-browser-mapping@^2.9.19:
version "2.11.15"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz#9c0cac93d7d304f3d61bb41088a102cd62e68676"
integrity sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==
batch@0.6.1:
version "0.6.1"
@@ -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"
+4 -4
View File
@@ -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",
@@ -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",
+1 -1
View File
@@ -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",
@@ -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,
@@ -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;
@@ -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;
}
+6 -2
View File
@@ -427,7 +427,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
@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:
@@ -691,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
)
+24
View File
@@ -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):
+22 -3
View File
@@ -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(
+7 -1
View File
@@ -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):
+9 -10
View File
@@ -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()
+7 -6
View File
@@ -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}")
+2 -5
View File
@@ -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
+42 -7
View File
@@ -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)
+13
View File
@@ -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.")
+2 -5
View File
@@ -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
-21
View File
@@ -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
)
+12
View File
@@ -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(
+10
View File
@@ -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()
+25
View File
@@ -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,
+17 -1
View File
@@ -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
+18 -3
View File
@@ -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)
+29 -1
View File
@@ -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"
+8
View File
@@ -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",
+15
View File
@@ -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
+5
View File
@@ -1728,6 +1728,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"CssTemplate",
"Dataset",
"Datasource",
"Theme",
} | READ_ONLY_MODEL_VIEWS
GAMMA_EXCLUDED_PVMS = {
@@ -1752,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",
+37
View File
@@ -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))
+20
View File
@@ -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,
+58
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
)
]
+14
View File
@@ -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)):
@@ -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.
+43
View File
@@ -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)
+49 -1
View File
@@ -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 (
+59
View File
@@ -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
+45
View File
@@ -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
+66
View File
@@ -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"
+37
View File
@@ -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
)
+32 -1
View File
@@ -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
# =============================================================================
+164
View File
@@ -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
+51
View File
@@ -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"""
+17 -1
View File
@@ -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
)