From ebab31adc2073b62d88eae3f2974a07d45a09f01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:15:04 -0700 Subject: [PATCH 01/11] chore(deps): bump antd from 6.5.4 to 6.6.0 in /superset-frontend (#43294) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: rusackas Co-authored-by: Claude Opus 4.8 --- superset-frontend/package-lock.json | 131 +++++++++++------- superset-frontend/package.json | 2 +- .../components/Select/AsyncSelect.test.tsx | 10 +- .../src/components/Select/Select.test.tsx | 6 +- .../spec/helpers/testing-library.tsx | 2 +- .../FiltersConfigForm/DatasetSelect.test.tsx | 2 +- .../ColumnSelectPopover.test.tsx | 16 ++- .../AdhocMetricEditPopover.test.tsx | 16 ++- .../src/features/roles/RoleFormItems.test.tsx | 6 +- 9 files changed, 114 insertions(+), 77 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 04056e0216e2..9eb17e023c1d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -81,7 +81,7 @@ "@visx/xychart": "^4.0.0", "ag-grid-community": "36.1.0", "ag-grid-react": "36.1.0", - "antd": "^6.5.4", + "antd": "^6.6.0", "chrono-node": "^2.10.1", "classnames": "^2.2.5", "content-disposition": "^2.0.1", @@ -9479,13 +9479,13 @@ } }, "node_modules/@rc-component/cascader": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.17.0.tgz", - "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", "license": "MIT", "dependencies": { - "@rc-component/select": "~1.8.0", - "@rc-component/tree": "~1.3.2", + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, @@ -9627,14 +9627,14 @@ } }, "node_modules/@rc-component/image": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", - "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", "license": "MIT", "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", - "@rc-component/util": "^1.10.1", + "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { @@ -9672,16 +9672,34 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.1", + "@rc-component/virtual-list": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@rc-component/mentions": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.10.0.tgz", - "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.11.0.tgz", + "integrity": "sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==", "license": "MIT", "dependencies": { - "@rc-component/input": "~1.3.0", + "@rc-component/input": "~1.3.1", "@rc-component/menu": "~1.4.0", "@rc-component/trigger": "^3.0.0", - "@rc-component/util": "^1.3.0", + "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { @@ -9928,9 +9946,9 @@ } }, "node_modules/@rc-component/select": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.8.2.tgz", - "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz", + "integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==", "license": "MIT", "dependencies": { "@rc-component/overflow": "^1.0.0", @@ -9996,9 +10014,9 @@ } }, "node_modules/@rc-component/table": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.4.tgz", - "integrity": "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", "license": "MIT", "dependencies": { "@rc-component/context": "^2.0.1", @@ -10016,9 +10034,9 @@ } }, "node_modules/@rc-component/tabs": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.11.0.tgz", - "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.12.0.tgz", + "integrity": "sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==", "license": "MIT", "dependencies": { "@rc-component/dropdown": "~1.0.0", @@ -10037,13 +10055,13 @@ } }, "node_modules/@rc-component/tooltip": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", - "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.5.0.tgz", + "integrity": "sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==", "license": "MIT", "dependencies": { - "@rc-component/trigger": "^3.7.1", - "@rc-component/util": "^1.3.0", + "@rc-component/trigger": "^3.10.0", + "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { @@ -10071,9 +10089,9 @@ } }, "node_modules/@rc-component/tree": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", - "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", "license": "MIT", "dependencies": { "@rc-component/motion": "^1.0.0", @@ -10090,13 +10108,13 @@ } }, "node_modules/@rc-component/tree-select": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.11.0.tgz", - "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", "license": "MIT", "dependencies": { - "@rc-component/select": "~1.8.0", - "@rc-component/tree": "~1.3.2", + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, @@ -10160,12 +10178,12 @@ "license": "MIT" }, "node_modules/@rc-component/virtual-list": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", - "integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.20.0", + "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" @@ -10178,6 +10196,12 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -14886,9 +14910,9 @@ } }, "node_modules/antd": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/antd/-/antd-6.5.4.tgz", - "integrity": "sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz", + "integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==", "license": "MIT", "dependencies": { "@ant-design/colors": "^8.0.1", @@ -14898,7 +14922,7 @@ "@ant-design/icons": "^6.3.2", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", - "@rc-component/cascader": "~1.17.0", + "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", @@ -14906,10 +14930,11 @@ "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", - "@rc-component/image": "~1.9.0", + "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", - "@rc-component/mentions": "~1.10.0", + "@rc-component/listy": "~1.2.3", + "@rc-component/mentions": "~1.11.0", "@rc-component/menu": "~1.4.1", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", @@ -14921,16 +14946,16 @@ "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", - "@rc-component/select": "~1.8.2", + "@rc-component/select": "~1.10.0", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", - "@rc-component/table": "~1.10.4", - "@rc-component/tabs": "~1.11.0", - "@rc-component/tooltip": "~1.4.0", + "@rc-component/table": "~1.11.0", + "@rc-component/tabs": "~1.12.0", + "@rc-component/tooltip": "~1.5.0", "@rc-component/tour": "~2.4.0", - "@rc-component/tree": "~1.3.2", - "@rc-component/tree-select": "~1.11.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/tree-select": "~1.16.0", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.12.0", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 85782229f5b1..d3a5c04f4c56 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -158,7 +158,7 @@ "@visx/xychart": "^4.0.0", "ag-grid-community": "36.1.0", "ag-grid-react": "36.1.0", - "antd": "^6.5.4", + "antd": "^6.6.0", "chrono-node": "^2.10.1", "classnames": "^2.2.5", "content-disposition": "^2.0.1", diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx index 380fefecb2bd..b7e61498e676 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx @@ -107,12 +107,14 @@ const getAllSelectOptions = () => const findSelectOption = (text: string) => waitFor(() => - within(getElementByClassName('.rc-virtual-list')).getByText(text), + within(getElementByClassName('.ant-select-dropdown-list')).getByText(text), ); const querySelectOption = (text: string) => waitFor(() => - within(getElementByClassName('.rc-virtual-list')).queryByText(text), + within(getElementByClassName('.ant-select-dropdown-list')).queryByText( + text, + ), ); const findAllSelectOptions = () => @@ -644,7 +646,7 @@ test('does not add a new option if the option already exists', async () => { await type(option); await waitFor(() => { const array = within( - getElementByClassName('.rc-virtual-list'), + getElementByClassName('.ant-select-dropdown-list'), ).getAllByText(option); expect(array.length).toBe(1); }); @@ -1398,7 +1400,7 @@ test('appends page>1 results during an active search and discards them when sear // scrollTop via e.currentTarget in its onFallbackScroll handler, which // then forwards to onPopupScroll (handlePagination here). const holder = document.querySelector( - '.rc-virtual-list-holder', + '.ant-select-dropdown-list-holder', ) as HTMLElement | null; if (!holder) throw new Error('virtual-list holder not rendered'); Object.defineProperty(holder, 'scrollHeight', { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx index 5bd73e8d002f..8e71afb4b794 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx @@ -93,12 +93,14 @@ const deselectAllButtonText = (length: number) => const findSelectOption = (text: string) => waitFor(() => - within(getElementByClassName('.rc-virtual-list')).getByText(text), + within(getElementByClassName('.ant-select-dropdown-list')).getByText(text), ); const querySelectOption = (text: string) => waitFor(() => - within(getElementByClassName('.rc-virtual-list')).queryByText(text), + within(getElementByClassName('.ant-select-dropdown-list')).queryByText( + text, + ), ); const getAllSelectOptions = () => diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx index 1843036e29ea..1fbbc80f8a70 100644 --- a/superset-frontend/spec/helpers/testing-library.tsx +++ b/superset-frontend/spec/helpers/testing-library.tsx @@ -164,7 +164,7 @@ export async function selectOption(option: string, selectName?: string) { const item = await waitFor(() => within( // eslint-disable-next-line testing-library/no-node-access - document.querySelector('.rc-virtual-list')!, + document.querySelector('.ant-select-dropdown-list')!, ).getByText(option), ); await userEvent.click(item); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx index 4229e615ce43..b1458392414f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx @@ -76,7 +76,7 @@ const typeIntoSelect = async (text: string) => { const findOption = (text: string) => waitFor(() => { // eslint-disable-next-line testing-library/no-node-access - const virtualList = document.querySelector('.rc-virtual-list'); + const virtualList = document.querySelector('.ant-select-dropdown-list'); if (!virtualList) { throw new Error('Virtual list not found'); } diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx index 4c3d0fa13aac..f67b3f3f4063 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.test.tsx @@ -214,7 +214,9 @@ test('Should filter simple columns by column_name and verbose_name', async () => await userEvent.type(combobox, 'revenue'); - let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + let dropdown = document.querySelector( + '.ant-select-dropdown-list', + ) as HTMLElement; expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument(); expect( within(dropdown).queryByText('User Identifier'), @@ -226,7 +228,7 @@ test('Should filter simple columns by column_name and verbose_name', async () => await userEvent.clear(combobox); await userEvent.type(combobox, 'Identifier'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('User Identifier')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument(); expect(within(dropdown).queryByText('Creation Date')).not.toBeInTheDocument(); @@ -234,7 +236,7 @@ test('Should filter simple columns by column_name and verbose_name', async () => await userEvent.clear(combobox); await userEvent.type(combobox, '_at'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Creation Date')).toBeInTheDocument(); expect(within(dropdown).getByText('Last Update')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument(); @@ -288,7 +290,9 @@ test('Should filter saved expressions by column_name and verbose_name', async () await userEvent.type(combobox, 'revenue'); - let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + let dropdown = document.querySelector( + '.ant-select-dropdown-list', + ) as HTMLElement; expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument(); expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument(); expect(within(dropdown).queryByText('Net Profit')).not.toBeInTheDocument(); @@ -298,7 +302,7 @@ test('Should filter saved expressions by column_name and verbose_name', async () await userEvent.clear(combobox); await userEvent.type(combobox, 'Rate'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Discount Rate')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument(); expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument(); @@ -306,7 +310,7 @@ test('Should filter saved expressions by column_name and verbose_name', async () await userEvent.clear(combobox); await userEvent.type(combobox, 'profit'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Net Profit')).toBeInTheDocument(); expect(within(dropdown).getByText('Profit Margin')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument(); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx index cf28e55e6199..3dcd9def31c8 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx @@ -340,7 +340,9 @@ test('Should filter saved metrics by metric_name and verbose_name', async () => await userEvent.type(combobox, 'revenue'); - let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + let dropdown = document.querySelector( + '.ant-select-dropdown-list', + ) as HTMLElement; expect(within(dropdown).getByText('Gross Revenue')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument(); expect(within(dropdown).queryByText('Average Price')).not.toBeInTheDocument(); @@ -352,7 +354,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () => await userEvent.clear(combobox); await userEvent.type(combobox, 'Unique'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Unique Users')).toBeInTheDocument(); expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument(); expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument(); @@ -360,7 +362,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () => await userEvent.clear(combobox); await userEvent.type(combobox, 'total'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Total Count')).toBeInTheDocument(); expect(within(dropdown).getByText('Total Quantity')).toBeInTheDocument(); expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument(); @@ -421,7 +423,9 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn await userEvent.type(columnCombobox, 'product'); - let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + let dropdown = document.querySelector( + '.ant-select-dropdown-list', + ) as HTMLElement; expect(within(dropdown).getByText('Product Title')).toBeInTheDocument(); expect( within(dropdown).queryByText('User Identifier'), @@ -435,7 +439,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn await userEvent.clear(columnCombobox); await userEvent.type(columnCombobox, 'Modified'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument(); expect( within(dropdown).queryByText('User Identifier'), @@ -445,7 +449,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn await userEvent.clear(columnCombobox); await userEvent.type(columnCombobox, '_at'); - dropdown = document.querySelector('.rc-virtual-list') as HTMLElement; + dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement; expect(within(dropdown).getByText('Creation Timestamp')).toBeInTheDocument(); expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument(); expect( diff --git a/superset-frontend/src/features/roles/RoleFormItems.test.tsx b/superset-frontend/src/features/roles/RoleFormItems.test.tsx index 18c28e837a17..0fe0d031c951 100644 --- a/superset-frontend/src/features/roles/RoleFormItems.test.tsx +++ b/superset-frontend/src/features/roles/RoleFormItems.test.tsx @@ -88,9 +88,9 @@ test('PermissionsField shows a permission matched by its raw name even though th ), ); expect( - await within(document.querySelector('.rc-virtual-list')!).findByText( - 'stg silver', - ), + await within( + document.querySelector('.ant-select-dropdown-list')!, + ).findByText('stg silver'), ).toBeInTheDocument(); }); From 3c90bdc6f04c533167bd6f70b8bd2499416477e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:15:08 -0700 Subject: [PATCH 02/11] chore(deps-dev): bump oxfmt from 0.62.0 to 0.63.0 in /superset-frontend (#43293) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 169 +++++++++++++--------------- superset-frontend/package.json | 2 +- 2 files changed, 81 insertions(+), 90 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9eb17e023c1d..757baac4e998 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -254,7 +254,7 @@ "mini-css-extract-plugin": "^2.10.2", "minimizer-webpack-plugin": "^5.6.1", "open-cli": "^9.0.0", - "oxfmt": "^0.62.0", + "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "po2json": "^0.4.5", "postcss-styled-syntax": "^0.7.2", @@ -8441,9 +8441,9 @@ ] }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz", - "integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz", + "integrity": "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==", "cpu": [ "arm" ], @@ -8458,9 +8458,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz", - "integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz", + "integrity": "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==", "cpu": [ "arm64" ], @@ -8475,9 +8475,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz", - "integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz", + "integrity": "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==", "cpu": [ "arm64" ], @@ -8492,9 +8492,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz", - "integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz", + "integrity": "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==", "cpu": [ "x64" ], @@ -8509,9 +8509,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz", - "integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz", + "integrity": "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==", "cpu": [ "x64" ], @@ -8526,9 +8526,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz", - "integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz", + "integrity": "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==", "cpu": [ "arm" ], @@ -8543,9 +8543,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz", - "integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz", + "integrity": "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==", "cpu": [ "arm" ], @@ -8560,9 +8560,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz", - "integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz", + "integrity": "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==", "cpu": [ "arm64" ], @@ -8580,9 +8580,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz", - "integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz", + "integrity": "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==", "cpu": [ "arm64" ], @@ -8600,9 +8600,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz", - "integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz", + "integrity": "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==", "cpu": [ "ppc64" ], @@ -8620,9 +8620,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz", - "integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz", + "integrity": "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==", "cpu": [ "riscv64" ], @@ -8640,9 +8640,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz", - "integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz", + "integrity": "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==", "cpu": [ "riscv64" ], @@ -8660,9 +8660,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz", - "integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz", + "integrity": "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==", "cpu": [ "s390x" ], @@ -8680,9 +8680,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz", - "integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz", + "integrity": "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==", "cpu": [ "x64" ], @@ -8700,9 +8700,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz", - "integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz", + "integrity": "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==", "cpu": [ "x64" ], @@ -8720,9 +8720,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz", - "integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz", + "integrity": "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==", "cpu": [ "arm64" ], @@ -8737,9 +8737,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz", - "integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz", + "integrity": "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==", "cpu": [ "arm64" ], @@ -8754,9 +8754,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz", - "integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz", + "integrity": "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==", "cpu": [ "ia32" ], @@ -8771,9 +8771,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz", - "integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz", + "integrity": "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==", "cpu": [ "x64" ], @@ -31870,9 +31870,9 @@ } }, "node_modules/oxfmt": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz", - "integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==", + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.63.0.tgz", + "integrity": "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==", "dev": true, "license": "MIT", "dependencies": { @@ -31888,25 +31888,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.62.0", - "@oxfmt/binding-android-arm64": "0.62.0", - "@oxfmt/binding-darwin-arm64": "0.62.0", - "@oxfmt/binding-darwin-x64": "0.62.0", - "@oxfmt/binding-freebsd-x64": "0.62.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", - "@oxfmt/binding-linux-arm64-gnu": "0.62.0", - "@oxfmt/binding-linux-arm64-musl": "0.62.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", - "@oxfmt/binding-linux-riscv64-musl": "0.62.0", - "@oxfmt/binding-linux-s390x-gnu": "0.62.0", - "@oxfmt/binding-linux-x64-gnu": "0.62.0", - "@oxfmt/binding-linux-x64-musl": "0.62.0", - "@oxfmt/binding-openharmony-arm64": "0.62.0", - "@oxfmt/binding-win32-arm64-msvc": "0.62.0", - "@oxfmt/binding-win32-ia32-msvc": "0.62.0", - "@oxfmt/binding-win32-x64-msvc": "0.62.0" + "@oxfmt/binding-android-arm-eabi": "0.63.0", + "@oxfmt/binding-android-arm64": "0.63.0", + "@oxfmt/binding-darwin-arm64": "0.63.0", + "@oxfmt/binding-darwin-x64": "0.63.0", + "@oxfmt/binding-freebsd-x64": "0.63.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", + "@oxfmt/binding-linux-arm64-gnu": "0.63.0", + "@oxfmt/binding-linux-arm64-musl": "0.63.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", + "@oxfmt/binding-linux-riscv64-musl": "0.63.0", + "@oxfmt/binding-linux-s390x-gnu": "0.63.0", + "@oxfmt/binding-linux-x64-gnu": "0.63.0", + "@oxfmt/binding-linux-x64-musl": "0.63.0", + "@oxfmt/binding-openharmony-arm64": "0.63.0", + "@oxfmt/binding-win32-arm64-msvc": "0.63.0", + "@oxfmt/binding-win32-ia32-msvc": "0.63.0", + "@oxfmt/binding-win32-x64-msvc": "0.63.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -43073,15 +43073,6 @@ "node": ">=12" } }, - "packages/superset-ui-core/node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "packages/superset-ui-core/node_modules/react-ace": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index d3a5c04f4c56..737bad5a7022 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -331,7 +331,7 @@ "mini-css-extract-plugin": "^2.10.2", "minimizer-webpack-plugin": "^5.6.1", "open-cli": "^9.0.0", - "oxfmt": "^0.62.0", + "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "po2json": "^0.4.5", "postcss-styled-syntax": "^0.7.2", From 2d1daac11aa853094ca384e94d33f1de09d43e1c Mon Sep 17 00:00:00 2001 From: Joe Li Date: Tue, 18 Aug 2026 09:28:04 -0700 Subject: [PATCH 03/11] fix(explore): samples endpoint now honors requested row limit (#43148) Co-authored-by: Claude Opus 4.8 --- UPDATING.md | 2 + superset/views/datasource/utils.py | 10 ++- .../unit_tests/views/datasource/utils_test.py | 61 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 450f6881ca73..02f4bfaa66fc 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,8 @@ assists people when migrating to a new version. ## Next +- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests. + ### OAuth2 database callback metrics include their outcome The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with diff --git a/superset/views/datasource/utils.py b/superset/views/datasource/utils.py index d610397e4747..4f86e0d6d1c6 100644 --- a/superset/views/datasource/utils.py +++ b/superset/views/datasource/utils.py @@ -28,7 +28,11 @@ from superset.common.utils.query_cache_manager import QueryCacheManager from superset.constants import CacheRegion from superset.daos.datasource import DatasourceDAO -from superset.utils.core import extract_dataframe_dtypes, QueryStatus +from superset.utils.core import ( + apply_max_row_limit, + extract_dataframe_dtypes, + QueryStatus, +) from superset.views.datasource.schemas import SamplesPayloadSchema if TYPE_CHECKING: @@ -45,9 +49,11 @@ def get_limit_clause(page: Optional[int], per_page: Optional[int]) -> dict[str, if isinstance(page, int) and isinstance(per_page, int): limit = int(per_page) - if limit < 0 or limit > samples_row_limit: + if limit < 0: # reset limit value if input is invalid limit = samples_row_limit + elif limit: + limit = apply_max_row_limit(limit) offset = max((int(page) - 1) * limit, 0) diff --git a/tests/unit_tests/views/datasource/utils_test.py b/tests/unit_tests/views/datasource/utils_test.py index 4788fea92faf..bedc4835d01a 100644 --- a/tests/unit_tests/views/datasource/utils_test.py +++ b/tests/unit_tests/views/datasource/utils_test.py @@ -19,9 +19,11 @@ from unittest.mock import MagicMock, patch import pytest +from flask import current_app from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import SupersetSecurityException +from superset.views.datasource.utils import get_limit_clause @patch("superset.views.datasource.utils.get_limit_clause") @@ -215,3 +217,62 @@ def test_get_samples_count_star_access_denied(mock_get_limit_clause: MagicMock): mock_samples_context.raise_for_access.assert_called_once() # Verify count context was also checked mock_count_context.raise_for_access.assert_called_once() + + +@pytest.mark.parametrize("per_page", [5000, 10000]) +def test_get_limit_clause_honors_per_page_above_samples_row_limit( + per_page: int, +) -> None: + """Regression guard: the Explore Data panel "Samples" tab silently caps at + ``SAMPLES_ROW_LIMIT`` (config default 1000). + + The samples row-limit dropdown offers 5k/10k options and the samples + endpoint's ``SamplesRequestSchema`` accepts ``per_page`` up to 10000, yet + ``get_limit_clause`` resets any ``per_page`` above ``SAMPLES_ROW_LIMIT`` + back down to it. A user who selects 5k/10k therefore silently receives at + most 1000 rows, with no signal that the requested limit was overridden. + + The rows a user is allowed to request and the rows actually returned must + stay consistent: a ``per_page`` the endpoint accepts must not be silently + reduced below the request. + """ + assert get_limit_clause(page=1, per_page=per_page) == { + "row_offset": 0, + "row_limit": per_page, + } + + +@pytest.mark.parametrize( + "per_page,expected_row_limit", + [ + (0, 0), + (-1, 1000), + ], +) +def test_get_limit_clause_preserves_zero_and_negative_per_page( + per_page: int, + expected_row_limit: int, +) -> None: + assert get_limit_clause(page=1, per_page=per_page) == { + "row_offset": 0, + "row_limit": expected_row_limit, + } + + +def test_get_limit_clause_caps_per_page_at_sql_max_row( + app_context: None, +) -> None: + """When an operator configures ``SQL_MAX_ROW`` below the schema's + ``per_page`` maximum, ``apply_max_row_limit`` still reduces the + requested limit, and the offset for subsequent pages must be computed + from that reduced (effective) limit, not the raw request. + """ + with patch.dict(current_app.config, {"SQL_MAX_ROW": 2000}): + assert get_limit_clause(page=1, per_page=10000) == { + "row_offset": 0, + "row_limit": 2000, + } + assert get_limit_clause(page=2, per_page=10000) == { + "row_offset": 2000, + "row_limit": 2000, + } From 60e1802c52338544b385162d0b15f0468e142330 Mon Sep 17 00:00:00 2001 From: Joe Li Date: Tue, 18 Aug 2026 09:37:43 -0700 Subject: [PATCH 04/11] fix(dashboard): mute the Group By display control loading spinner (#42879) Co-authored-by: Claude Fable 5 Co-authored-by: Evan Rusackas --- .../FilterControls/GroupByFilterCard.test.tsx | 38 ++++++++++++++++++- .../FilterControls/GroupByFilterCard.tsx | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx index ba128d56dc06..3a155f7fece5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.test.tsx @@ -16,8 +16,20 @@ * specific language governing permissions and limitations * under the License. */ +import { + ChartCustomizationType, + type ChartCustomization, +} from '@superset-ui/core'; import { LabeledValue } from '@superset-ui/core/components'; -import { createLabelSortComparator } from './GroupByFilterCard'; +import { render, screen } from 'spec/helpers/testing-library'; +import GroupByFilterCard, { + createLabelSortComparator, +} from './GroupByFilterCard'; + +jest.mock('src/utils/cachedSupersetGet', () => ({ + // Never resolves, pinning the card in its column-loading state. + cachedSupersetGet: jest.fn(() => new Promise(() => {})), +})); const apple: LabeledValue = { value: 'a', label: 'Apple' }; const banana: LabeledValue = { value: 'b', label: 'Banana' }; @@ -39,3 +51,27 @@ test('preserves source order when sortAscending is unset', () => { expect(compare(apple, banana)).toBe(0); expect(compare(banana, apple)).toBe(0); }); + +const groupByCustomization: ChartCustomization = { + id: 'groupby-1', + name: 'Group By', + filterType: 'filter_groupby', + type: ChartCustomizationType.ChartCustomization, + targets: [{ datasetId: 1 }], + scope: { rootPath: [], excluded: [] }, + controlValues: {}, + defaultDataMask: {}, +}; + +test('renders the column-loading spinner small and muted', async () => { + render(, { + useRedux: true, + initialState: { + dataMask: {}, + nativeFilters: { filters: {} }, + }, + }); + const spinner = await screen.findByTestId('loading-indicator'); + expect(spinner).toHaveClass('inline'); + expect(spinner).toHaveStyle({ opacity: 0.25, width: '40px' }); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx index d214be3d7861..7363dfcab68d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx @@ -645,7 +645,7 @@ const GroupByFilterCard: FC = ({ {loading && (
- +
)} From fd063d17bfcf245c236de9ad4be69330342fe3b4 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Tue, 18 Aug 2026 09:40:08 -0700 Subject: [PATCH 05/11] fix(security): harden account password-change and session-invalidation handling (#42934) Co-authored-by: Claude Sonnet 5 Co-authored-by: Joe Li --- superset/security/manager.py | 117 ++++++-- superset/security/session_invalidation.py | 30 +- superset/views/users/api.py | 38 ++- superset/views/users/schemas.py | 28 +- ...st_password_change_session_invalidation.py | 276 ++++++++++++++++++ .../test_superset_user_api_subject_sync.py | 229 +++++++++++++++ .../unit_tests/views/test_current_user_api.py | 160 ++++++++++ 7 files changed, 852 insertions(+), 26 deletions(-) create mode 100644 tests/unit_tests/security/test_password_change_session_invalidation.py create mode 100644 tests/unit_tests/security/test_superset_user_api_subject_sync.py create mode 100644 tests/unit_tests/views/test_current_user_api.py diff --git a/superset/security/manager.py b/superset/security/manager.py index 9f87380f20e8..e048995f19a7 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -36,7 +36,7 @@ from flask import current_app, Flask, g, has_app_context, Request, Response from flask_appbuilder import Model -from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.api import expose, permission_name, protect, safe from flask_appbuilder.models.filters import BaseFilter from flask_appbuilder.security.manager import AUTH_REMOTE_USER from flask_appbuilder.security.sqla.apis import GroupApi, RoleApi, UserApi @@ -394,8 +394,11 @@ class SupersetUserApi(UserApi): """ Overriding the UserApi to sync Subject rows, filter excluded users, handle deletion constraints, and add audit logging. - UserApi has custom post/put that bypass hooks, so we override them - and sync after the parent method succeeds. + + The Subject sync happens in ``pre_add``/``pre_update``, which FAB calls + *before* the commit that ``self.datamodel.add``/``edit`` issues -- so the + sync rides that same commit rather than needing one of its own after the + fact. """ base_filters = [["username", ExcludeUsersFilter, lambda: []]] @@ -415,6 +418,45 @@ class SupersetUserApi(UserApi): "changed_on", ] + def pre_add(self, item: Model) -> None: + """Hash the password (FAB's own ``pre_add``), then sync the user's + ``Subject`` row before FAB's own commit. + + ``UserApi.post`` calls ``pre_add`` *before* ``self.datamodel.add``, + which is what actually issues the commit -- so flushing the new user + here (to obtain its id) and syncing its ``Subject`` row alongside it + means both writes ride the same transaction and commit together, + instead of the subject sync needing a second, separate commit after + the fact. + """ + super().pre_add(item) + from superset.daos.user import UserDAO + + self.datamodel.session.add(item) + self.datamodel.session.flush() + UserDAO._sync_subject(item) + + def pre_update(self, item: Model, data: dict[str, Any]) -> None: + """Same reasoning as ``pre_add``: ``UserApi.put`` calls ``pre_update`` + before ``self.datamodel.edit`` commits, so the subject sync lands in + that same transaction. + """ + super().pre_update(item, data) + from superset.daos.user import UserDAO + + UserDAO._sync_subject(item) + + if data.get("password"): + # An admin-initiated password change via this endpoint must + # invalidate the target account's other outstanding sessions, + # the same as the self-service ``/me/`` path and the two + # password-reset views. + from superset.security.session_invalidation import ( + invalidate_sessions_for_user, + ) + + invalidate_sessions_for_user(item.id) + @expose("/", methods=["POST"]) @protect() @safe @@ -430,17 +472,7 @@ def post(self) -> Response: 500: description: Server error """ - response = super().post() - if response.status_code == 201: - from superset.daos.user import UserDAO - - user_id = response.json.get("id") - if user_id: - user = self.datamodel.session.get(self.datamodel.obj, user_id) - if user: - UserDAO._sync_subject(user) - self.datamodel.session.commit() # pylint: disable=consider-using-transaction - return response + return super().post() @expose("/", methods=["PUT"]) @protect() @@ -464,15 +496,42 @@ def put(self, pk: int) -> Response: # type: ignore[override] 500: description: Server error """ - response = super().put(pk) - if response.status_code == 200: - from superset.daos.user import UserDAO + return super().put(pk) + + @expose("//sessions", methods=["DELETE"]) + @protect() + @permission_name("put") + @safe + def terminate_sessions(self, pk: int) -> Response: + """Terminate a user's outstanding sessions without disabling their account. + --- + delete: + parameters: + - in: path + name: pk + schema: + type: integer + responses: + 200: + description: Sessions terminated + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + from superset.security.session_invalidation import invalidate_sessions_for_user - user = self.datamodel.get(pk, self._base_filters) - if user: - UserDAO._sync_subject(user) - self.datamodel.session.commit() # pylint: disable=consider-using-transaction - return response + user = self.datamodel.get(pk, self._base_filters) + if not user: + return self.response_404() + + invalidate_sessions_for_user(user.id) + self.datamodel.session.commit() # pylint: disable=consider-using-transaction + _log_audit_event( + "UserSessionsTerminated", + {"target_username": user.username, "target_user_id": user.id}, + ) + return self.response(200, message="User sessions terminated.") def pre_delete(self, item: Model) -> None: from superset.daos.user import UserDAO @@ -1565,9 +1624,23 @@ def reset_password(self, userid: Union[int, str], password: str) -> None: bypassed. We distinguish the two by comparing the acting user (``g.user``) against the target ``userid``: they match for a self-service reset and differ for an admin reset. + + Also stamps the session-invalidation epoch for the target user, so + any session for the account that predates this reset stops working -- + regardless of which of the two paths triggered it. """ super().reset_password(userid, password) + # pylint: disable=import-outside-toplevel + from superset import db + from superset.security.session_invalidation import invalidate_sessions_for_user + + invalidate_sessions_for_user(int(userid)) + # ``super().reset_password`` (FAB's ``update_user``) already committed + # its own change in a separate transaction, so the epoch stamp above + # needs its own commit too, rather than riding an existing one. + db.session.commit() # pylint: disable=consider-using-transaction + acting_user = getattr(g, "user", None) acting_user_id = getattr(acting_user, "id", None) # ``userid`` arrives as a string (the ``pk`` request arg) on the admin diff --git a/superset/security/session_invalidation.py b/superset/security/session_invalidation.py index c76d16e386ea..a15a8b9b14d6 100644 --- a/superset/security/session_invalidation.py +++ b/superset/security/session_invalidation.py @@ -41,7 +41,7 @@ from flask import flash, session from flask_babel import gettext as __ from flask_login import current_user, logout_user -from sqlalchemy import event, inspect +from sqlalchemy import event, inspect, or_ from sqlalchemy.exc import IntegrityError from werkzeug.wrappers import Response @@ -163,9 +163,20 @@ def invalidate_user_sessions(connection: Any, user_id: int) -> None: ) def _stamp_existing() -> int: + # Guard against two concurrent writers regressing the epoch: a + # transaction that computed an earlier ``now`` can reach this UPDATE + # after one with a later ``now`` has already committed. Only apply + # the write when it would advance (or initialize) the stored value, + # so the epoch is monotonic regardless of commit order. return connection.execute( table.update() .where(table.c.user_id == user_id) + .where( + or_( + table.c.sessions_invalidated_at.is_(None), + table.c.sessions_invalidated_at < now, + ) + ) .values(sessions_invalidated_at=now, changed_on=now) ).rowcount @@ -187,6 +198,23 @@ def _stamp_existing() -> int: _stamp_existing() +def invalidate_sessions_for_user(user_id: int) -> None: + """Stamp the invalidation epoch for ``user_id`` from ordinary application code. + + Convenience wrapper around ``invalidate_user_sessions`` for callers that + don't have the raw ``Connection`` the ``after_update`` event listener + receives -- e.g. a password-change flow. The stamp is written through the + current session's own connection, so it participates in whatever + transaction the caller's other pending changes belong to; it is not + committed here, so the caller's own commit (or the next flush that + triggers one) is what makes it durable. + """ + # pylint: disable=import-outside-toplevel + from superset.extensions import db + + invalidate_user_sessions(db.session.connection(), user_id) + + def _stamp_epoch_on_disable(_mapper: Any, connection: Any, target: Any) -> None: history = inspect(target).attrs.active.history # Only act when ``active`` actually changed to False — ignore the diff --git a/superset/views/users/api.py b/superset/views/users/api.py index 5a1275f6b4d6..ce2d53892bed 100644 --- a/superset/views/users/api.py +++ b/superset/views/users/api.py @@ -23,11 +23,12 @@ from flask_appbuilder.security.sqla.models import User from marshmallow import ValidationError from sqlalchemy.orm.exc import NoResultFound -from werkzeug.security import generate_password_hash +from werkzeug.security import check_password_hash, generate_password_hash from superset import is_feature_enabled from superset.daos.user import UserDAO from superset.extensions import db, event_logger +from superset.security.session_invalidation import invalidate_sessions_for_user from superset.utils.slack import get_user_avatar, SlackClientError from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics from superset.views.users.schemas import CurrentUserPutSchema, UserResponseSchema @@ -49,12 +50,45 @@ class CurrentUserRestApi(BaseSupersetApi): def pre_update(self, item: User, data: Dict[str, Any]) -> None: item.changed_on = datetime.now() item.changed_by_fk = g.user.id + # Pop unconditionally: this key is only meaningful for verifying a + # password change below, and it isn't a real column on the user + # model -- it must never reach ``UserDAO.update``'s ``setattr`` loop. + current_password = data.pop("current_password", None) if "password" in data and data["password"]: + # An account with no password set yet (e.g. provisioned via an + # external auth backend) has nothing to prove knowledge of; for + # every other account, the caller must confirm the existing + # password before it can be replaced. + proof_ok = ( + item.password + and current_password + and check_password_hash(item.password, current_password) + ) + if item.password and not proof_ok: + raise ValidationError( + {"current_password": ["Incorrect current password."]} + ) + # Compute and assign the hash, then drop the plaintext from + # ``data`` -- it is passed to ``UserDAO.update`` as ``attributes`` + # right after this, and ``BaseDAO.update`` sets every key in it + # via ``setattr``. Leaving the plaintext in would overwrite the + # hash just assigned below with the raw value. + new_password = data.pop("password") item.password = generate_password_hash( - password=data["password"], + password=new_password, method=app.config.get("FAB_PASSWORD_HASH_METHOD", "scrypt"), salt_length=app.config.get("FAB_PASSWORD_HASH_SALT_LENGTH", 16), ) + # A changed password invalidates any other outstanding session + # for this account. + invalidate_sessions_for_user(item.id) + elif "password" in data: + # A falsy value (e.g. an empty string, which the complexity + # validator lets through when password complexity is disabled) + # skips the block above, but the key must still never reach + # ``UserDAO.update``'s ``setattr`` loop -- it would blank out + # the account's stored hash. + data.pop("password") @expose("/", methods=("GET",)) @protect() diff --git a/superset/views/users/schemas.py b/superset/views/users/schemas.py index 6493ba24177c..56f0fd2c563a 100644 --- a/superset/views/users/schemas.py +++ b/superset/views/users/schemas.py @@ -14,17 +14,22 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Any + from flask_appbuilder.security.sqla.apis.user.schema import User from flask_appbuilder.security.sqla.apis.user.validator import ( PasswordComplexityValidator, ) -from marshmallow import fields, Schema +from marshmallow import fields, Schema, validates_schema, ValidationError from marshmallow.fields import Boolean, Integer, String from marshmallow.validate import Length first_name_description = "The current user's first name" last_name_description = "The current user's last name" password_description = "The current user's password for authentication" # noqa: S105 +# Required, and verified against the account's existing password, whenever +# ``password`` is included in the payload. +current_password_description = "The current user's existing password" # noqa: S105 class UserGroupSchema(Schema): @@ -64,3 +69,24 @@ class CurrentUserPutSchema(Schema): validate=[PasswordComplexityValidator()], metadata={"description": password_description}, ) + current_password = fields.String( + required=False, + load_only=True, + metadata={"description": current_password_description}, + ) + + @validates_schema + def validate_current_password_required_with_password( + self, data: dict[str, Any], **kwargs: object + ) -> None: + """Require ``current_password`` whenever ``password`` is being set. + + This only checks that the field was supplied -- whether it actually + matches the account's existing password is verified against the + database in ``CurrentUserRestApi.pre_update``, which has access to + the user record this schema doesn't. + """ + if data.get("password") and not data.get("current_password"): + raise ValidationError( + {"current_password": ["This field is required to change the password."]} + ) diff --git a/tests/unit_tests/security/test_password_change_session_invalidation.py b/tests/unit_tests/security/test_password_change_session_invalidation.py new file mode 100644 index 000000000000..3adace910a76 --- /dev/null +++ b/tests/unit_tests/security/test_password_change_session_invalidation.py @@ -0,0 +1,276 @@ +# 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. +"""Password-change paths and the session-invalidation epoch. + +``UserAttribute.sessions_invalidated_at`` (see +``superset.security.session_invalidation``) is the mechanism that forces +outstanding sessions to log out. Originally it was stamped exclusively by the +``after_update`` listener that fires when an account's ``active`` flag flips +to ``False``; these tests now cover the additional password-change paths -- +self-service reset, admin-initiated reset, and the ``PUT /api/v1/me/`` +self-service update -- which also stamp that epoch, so a session authenticated +before a password change stops working after it. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import User + +from superset import db, security_manager +from superset.daos.user import UserDAO +from superset.models.user_attributes import UserAttribute +from superset.security.manager import SupersetUserApi +from superset.views.users.api import CurrentUserRestApi +from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401 + + +def _invalidated_at(user_id: int): + attr = db.session.query(UserAttribute).filter_by(user_id=user_id).one_or_none() + return attr.sessions_invalidated_at if attr else None + + +@pytest.fixture +def two_admins() -> Iterator[tuple[User, User]]: + """Two admin-role users for the reset_password tests below. + + ``SupersetSecurityManager.reset_password`` -> FAB's ``update_user`` + hard-commits the session (``commit=True`` by default), so the rollback + the shared ``after_each``/``admin_user`` fixtures rely on can't undo it. + This fixture creates its own users and deletes them again on teardown so + a committed reset doesn't leak rows into later tests. + """ + role = db.session.query(security_manager.role_model).filter_by(name="Admin").one() + target = User( + first_name="Target", + last_name="User", + email="session_invalidation_target@example.org", + username="session_invalidation_target", + roles=[role], + ) + actor = User( + first_name="Acting", + last_name="Admin", + email="session_invalidation_actor@example.org", + username="session_invalidation_actor", + roles=[role], + ) + db.session.add_all([target, actor]) + db.session.commit() + + yield target, actor + + db.session.query(UserAttribute).filter( + UserAttribute.user_id.in_([target.id, actor.id]) + ).delete(synchronize_session=False) + db.session.query(User).filter(User.id.in_([target.id, actor.id])).delete( + synchronize_session=False + ) + db.session.commit() + + +def test_self_service_password_reset_invalidates_other_sessions( + two_admins: tuple[User, User], +) -> None: + """``SupersetSecurityManager.reset_password`` used for a self-service + reset (acting user resets their own password) stamps the session epoch, + so any other outstanding session for the account stops working after the + password changes. + """ + target, _actor = two_admins + + with patch("superset.security.manager.g") as mock_g: + mock_g.user = target + security_manager.reset_password(target.id, "BrandNewPassw0rd!") + + assert _invalidated_at(target.id) is not None + + +def test_admin_password_reset_invalidates_target_sessions( + two_admins: tuple[User, User], +) -> None: + """An admin-initiated reset of *another* user's password also stamps the + epoch, so the target's outstanding sessions stop working -- this is the + closest existing action to an explicit "terminate that user's sessions", + short of disabling the account. + """ + target, actor = two_admins + + with patch("superset.security.manager.g") as mock_g: + mock_g.user = actor # differs from target: an admin-initiated reset + security_manager.reset_password(target.id, "TemporaryPassw0rd!") + + assert _invalidated_at(target.id) is not None + + +def test_update_me_password_change_invalidates_other_sessions( + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + """The ``PUT /api/v1/me/`` self-service password change (``pre_update`` + + ``UserDAO.update`` in ``CurrentUserRestApi.update_me``) also stamps the + session-invalidation epoch. ``admin_user`` starts with no password set, so + no ``current_password`` proof is required for this change to go through. + """ + api = CurrentUserRestApi() + data = {"password": "BrandNewPassw0rd!"} + + with patch("superset.views.users.api.g") as mock_g: + mock_g.user = admin_user + api.pre_update(admin_user, data) + UserDAO.update(item=admin_user, attributes=data) + db.session.flush() + + assert _invalidated_at(admin_user.id) is not None + + +def test_admin_edit_user_password_via_put_invalidates_target_sessions( + after_each: None, # noqa: F811 +) -> None: + """An admin editing another user's password via ``PUT + /api/v1/security/users/`` (``SupersetUserApi.pre_update``, which FAB's + ``UserApi.put`` calls before its own commit) must also stamp the target's + session-invalidation epoch, the same as the self-service ``/me/`` path and + the two password-reset views -- otherwise this admin path is the one way + to change a user's password that leaves their other sessions alive. + """ + role = db.session.query(security_manager.role_model).filter_by(name="Admin").one() + user = User( + first_name="Target", + last_name="User", + email="admin_edit_password_target@example.org", + username="admin_edit_password_target", + roles=[role], + ) + db.session.add(user) + db.session.commit() + + api = SupersetUserApi() + api.datamodel = SQLAInterface(User, db.session) + api.appbuilder = SimpleNamespace( + sm=SimpleNamespace(current_user=SimpleNamespace(id=1)) + ) + + api.pre_update(user, {"password": "AdminSetPassw0rd!"}) + + assert _invalidated_at(user.id) is not None + + db.session.query(UserAttribute).filter_by(user_id=user.id).delete( + synchronize_session=False + ) + db.session.query(User).filter_by(id=user.id).delete(synchronize_session=False) + db.session.commit() + + +def test_admin_edit_user_without_password_change_does_not_invalidate_sessions( + after_each: None, # noqa: F811 +) -> None: + """Editing a user through the same endpoint *without* touching the + password (e.g. renaming them) must not stamp the epoch -- only an actual + password change should force other sessions to log out. + """ + role = db.session.query(security_manager.role_model).filter_by(name="Admin").one() + user = User( + first_name="Target", + last_name="User", + email="admin_edit_no_password_target@example.org", + username="admin_edit_no_password_target", + roles=[role], + ) + db.session.add(user) + db.session.commit() + + api = SupersetUserApi() + api.datamodel = SQLAInterface(User, db.session) + api.appbuilder = SimpleNamespace( + sm=SimpleNamespace(current_user=SimpleNamespace(id=1)) + ) + + api.pre_update(user, {"first_name": "Renamed"}) + + assert _invalidated_at(user.id) is None + + db.session.query(User).filter_by(id=user.id).delete(synchronize_session=False) + db.session.commit() + + +def _make_api_for_target(user: User) -> SupersetUserApi: + """A ``SupersetUserApi`` instance wired to a fake ``datamodel`` that + resolves any pk lookup to ``user`` -- enough to exercise + ``terminate_sessions`` without going through HTTP/auth plumbing, mirroring + the pattern used in ``test_superset_user_api_subject_sync.py``. + """ + api = SupersetUserApi() + api.datamodel = SimpleNamespace( + session=db.session, + obj=User, + get=lambda pk, base_filters=None: user, + ) + api._base_filters = None + return api + + +def test_terminate_sessions_action_stamps_target_epoch_without_disabling_account( + after_each: None, # noqa: F811 +) -> None: + """``SupersetUserApi.terminate_sessions`` -- the direct, explicit + "terminate this user's sessions" admin action -- stamps the epoch for the + target user without flipping ``active`` or otherwise touching the account, + unlike the only other action that has this effect (disabling the user). + """ + role = db.session.query(security_manager.role_model).filter_by(name="Admin").one() + user = User( + first_name="Target", + last_name="User", + email="terminate_sessions_target@example.org", + username="terminate_sessions_target", + roles=[role], + ) + db.session.add(user) + db.session.flush() + + with patch.object(security_manager, "has_access", return_value=True): + response = _make_api_for_target(user).terminate_sessions(user.id) + + assert response.status_code == 200 + assert _invalidated_at(user.id) is not None + assert user.active + + +def test_terminate_sessions_action_404s_for_unknown_user( + after_each: None, # noqa: F811 +) -> None: + """A pk that doesn't resolve to a user (or is filtered out by + ``base_filters``) 404s rather than stamping anything. + """ + api = SupersetUserApi() + api.datamodel = SimpleNamespace( + session=db.session, + obj=User, + get=lambda pk, base_filters=None: None, + ) + api._base_filters = None + + with patch.object(security_manager, "has_access", return_value=True): + response = api.terminate_sessions(999999) + + assert response.status_code == 404 diff --git a/tests/unit_tests/security/test_superset_user_api_subject_sync.py b/tests/unit_tests/security/test_superset_user_api_subject_sync.py new file mode 100644 index 000000000000..e7af3ace2b3a --- /dev/null +++ b/tests/unit_tests/security/test_superset_user_api_subject_sync.py @@ -0,0 +1,229 @@ +# 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. +"""``SupersetUserApi`` syncs the ``Subject`` row that mirrors each ``User``. + +``UserApi.post``/``put`` call ``self.pre_add``/``self.pre_update`` *before* +the write that actually commits (``self.datamodel.add``/``edit``). Overriding +those hooks -- instead of syncing after the fact, in a second commit issued +once ``post``/``put`` have already returned -- means the subject sync rides +the same transaction as the user write: one commit persists both, and a +failure of that commit rolls both back together instead of leaving an +orphaned user with no matching ``Subject`` row. + +These tests exercise ``pre_add``/``pre_update`` directly, plus the exact call +pairs FAB's ``UserApi.post``/``put`` make (``pre_add``/``pre_update`` followed +by the real ``SQLAInterface.add``/``edit``), to confirm that pairing shares a +single commit and a single rollback. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import User + +from superset import db, security_manager +from superset.security.manager import SupersetUserApi +from superset.subjects.models import Subject +from superset.subjects.types import SubjectType +from tests.unit_tests.fixtures.common import after_each # noqa: F401 + + +def _admin_role() -> object: + return db.session.query(security_manager.role_model).filter_by(name="Admin").one() + + +def _make_pending_user(username: str) -> User: + """A transient ``User``, not yet added to the session. + + Mirrors what FAB's ``UserApi.post()`` builds (``model = User()``, with + attributes set from the request payload) just before it calls + ``self.pre_add(model)`` and then ``self.datamodel.add(model)``. + """ + return User( + first_name="New", + last_name="Guy", + email=f"{username}@example.org", + username=username, + roles=[_admin_role()], + password="irrelevant-pre-hash-value", # noqa: S106 + ) + + +def _make_persisted_user(username: str) -> User: + """A ``User`` already committed, standing in for one an earlier request + created -- the starting point for a ``put``/``pre_update`` flow. + """ + user = User( + first_name="New", + last_name="Guy", + email=f"{username}@example.org", + username=username, + roles=[_admin_role()], + ) + db.session.add(user) + db.session.commit() + return user + + +def _api_for(session=db.session) -> SupersetUserApi: # noqa: ANN001 + api = SupersetUserApi() + api.datamodel = SQLAInterface(User, session) + # FAB's own ``pre_update`` (which ``SupersetUserApi.pre_update`` calls via + # ``super()``) reads ``self.appbuilder.sm.current_user.id`` to stamp + # ``changed_by_fk`` -- stand in for the acting admin so that lookup + # succeeds outside of a real request/login context. + api.appbuilder = SimpleNamespace( + sm=SimpleNamespace(current_user=SimpleNamespace(id=1)) + ) + return api + + +def _subject_for(user_id: int) -> Subject | None: + return ( + db.session.query(Subject) + .filter_by(user_id=user_id, type=SubjectType.USER) + .one_or_none() + ) + + +def test_pre_add_syncs_subject_without_committing( + after_each: None, # noqa: F811 +) -> None: + """``pre_add`` flushes the new user (to obtain its id) and syncs its + ``Subject`` row, but does not commit -- that's still FAB's job, in + ``self.datamodel.add``, which runs right after. + """ + user = _make_pending_user("new_guy_pre_add") + api = _api_for() + + real_commit = db.session.commit + commit_calls = MagicMock(wraps=real_commit) + with patch.object(db.session, "commit", commit_calls): + api.pre_add(user) + + assert commit_calls.call_count == 0 + assert user.id is not None + assert _subject_for(user.id) is not None + + +def test_pre_add_and_datamodel_add_share_a_single_commit( + after_each: None, # noqa: F811 +) -> None: + """The exact pair of calls FAB's ``UserApi.post()`` makes -- + ``self.pre_add(model)`` then ``self.datamodel.add(model)`` -- persist the + user and its ``Subject`` row together, via exactly one commit. + """ + user = _make_pending_user("new_guy_shared_commit") + api = _api_for() + + real_commit = db.session.commit + commit_calls = MagicMock(wraps=real_commit) + with patch.object(db.session, "commit", commit_calls): + api.pre_add(user) + api.datamodel.add(user) + + assert commit_calls.call_count == 1 + subject = _subject_for(user.id) + assert subject is not None + + +def test_pre_add_failure_rolls_back_user_and_subject_together( + after_each: None, # noqa: F811 +) -> None: + """If the commit that follows ``pre_add`` fails (standing in: FAB's own + ``self.datamodel.add`` raising), the new user and the ``Subject`` row + flushed alongside it roll back together -- there is no window where the + user persists without a matching ``Subject``. + """ + user = _make_pending_user("new_guy_pre_add_fail") + api = _api_for() + + api.pre_add(user) + user_id = user.id + assert user_id is not None + assert _subject_for(user_id) is not None # flushed, visible pre-rollback + + db.session.rollback() # stands in for the follow-up commit failing + + assert db.session.query(User).filter_by(id=user_id).one_or_none() is None + assert _subject_for(user_id) is None + + +def test_pre_update_syncs_subject_without_committing( + after_each: None, # noqa: F811 +) -> None: + """Same reasoning as ``pre_add``, for an edit: ``pre_update`` syncs the + ``Subject`` row without committing, ahead of FAB's own + ``self.datamodel.edit``. + """ + user = _make_persisted_user("new_guy_pre_update") + api = _api_for() + + real_commit = db.session.commit + commit_calls = MagicMock(wraps=real_commit) + with patch.object(db.session, "commit", commit_calls): + api.pre_update(user, {}) + + assert commit_calls.call_count == 0 + assert _subject_for(user.id) is not None + + +def test_pre_update_and_datamodel_edit_share_a_single_commit( + after_each: None, # noqa: F811 +) -> None: + """The exact pair of calls FAB's ``UserApi.put()`` makes -- + ``self.pre_update(model, item)`` then ``self.datamodel.edit(model)`` -- + persist the edit and the ``Subject`` sync together, via exactly one + commit. + """ + user = _make_persisted_user("new_guy_shared_commit_put") + api = _api_for() + + real_commit = db.session.commit + commit_calls = MagicMock(wraps=real_commit) + with patch.object(db.session, "commit", commit_calls): + api.pre_update(user, {}) + api.datamodel.edit(user) + + assert commit_calls.call_count == 1 + assert _subject_for(user.id) is not None + + +def test_pre_update_failure_rolls_back_subject_sync_without_orphaning( + after_each: None, # noqa: F811 +) -> None: + """If the commit that follows ``pre_update`` fails, the ``Subject`` row it + flushed rolls back too -- the previously-persisted user row (created by + an earlier, already-successful request) is left exactly as it was, with + no half-applied sync attached to it. + """ + user = _make_persisted_user("new_guy_pre_update_fail") + user_id = user.id + api = _api_for() + + api.pre_update(user, {}) + assert _subject_for(user_id) is not None # flushed, visible pre-rollback + + db.session.rollback() # stands in for the follow-up commit failing + + # The user itself predates this (failed) request and survives. + assert db.session.query(User).filter_by(id=user_id).one_or_none() is not None + # But the subject sync this request attempted never landed. + assert _subject_for(user_id) is None diff --git a/tests/unit_tests/views/test_current_user_api.py b/tests/unit_tests/views/test_current_user_api.py new file mode 100644 index 000000000000..04d7ecc7b219 --- /dev/null +++ b/tests/unit_tests/views/test_current_user_api.py @@ -0,0 +1,160 @@ +# 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. +"""Unit tests for the ``CurrentUserRestApi`` self-service update flow. + +Covers the ``password`` handling in ``PUT /api/v1/me/``: whether the caller +must prove knowledge of the existing password, and whether the value that +ends up persisted is the hash computed in ``pre_update`` or the raw value +from the request payload. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest +from flask_appbuilder.security.sqla.models import User +from marshmallow import ValidationError +from werkzeug.security import check_password_hash, generate_password_hash + +from superset import db +from superset.daos.user import UserDAO +from superset.views.users.api import CurrentUserRestApi +from superset.views.users.schemas import CurrentUserPutSchema +from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401 + + +def _run_update_me(user: User, data: dict[str, Any]) -> None: + """Reproduce the body of ``CurrentUserRestApi.update_me`` for ``data``. + + Exercises the same two calls the endpoint makes -- ``pre_update`` followed + by ``UserDAO.update`` with the schema-loaded payload as ``attributes`` -- + without going through HTTP/auth plumbing, since neither call depends on it. + """ + api = CurrentUserRestApi() + with patch("superset.views.users.api.g") as mock_g: + mock_g.user = user + api.pre_update(user, data) + UserDAO.update(item=user, attributes=data) + + +def test_current_user_put_schema_has_current_password_field() -> None: + """The payload schema for ``PUT /api/v1/me/`` carries a field for proving + knowledge of the existing password, required whenever ``password`` is + supplied. + """ + schema = CurrentUserPutSchema() + + assert "password" in schema.fields + assert "current_password" in schema.fields + + with pytest.raises(ValidationError): + schema.load({"password": "BrandNewPassw0rd!"}) + + # Present alongside "password", it loads fine (the schema only checks + # that it was *supplied*; whether it's actually correct is verified + # against the database in ``CurrentUserRestApi.pre_update``). + loaded = schema.load( + {"password": "BrandNewPassw0rd!", "current_password": "OldPassw0rd!"} + ) + assert loaded["current_password"] == "OldPassw0rd!" # noqa: S105 + + +def test_update_me_rejects_password_change_without_correct_current_password( + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + """A caller can no longer change the password by supplying only the new + value: an account with an existing password must prove knowledge of it + via ``current_password`` -- omitting the field, or getting it wrong, both + reject the change and leave the stored password untouched. Supplying the + correct current password lets the change through. + """ + original_hash = generate_password_hash("OldPassw0rd!") + admin_user.password = original_hash + + with pytest.raises(ValidationError): + _run_update_me(admin_user, {"password": "BrandNewPassw0rd!"}) + assert admin_user.password == original_hash + + with pytest.raises(ValidationError): + _run_update_me( + admin_user, + { + "password": "BrandNewPassw0rd!", + "current_password": "WrongPassw0rd!", + }, + ) + assert admin_user.password == original_hash + + _run_update_me( + admin_user, + {"password": "BrandNewPassw0rd!", "current_password": "OldPassw0rd!"}, + ) + db.session.flush() + + assert admin_user.password != original_hash + assert check_password_hash(admin_user.password, "BrandNewPassw0rd!") + + +def test_update_me_password_change_persists_a_hash_not_plaintext( + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + """``pre_update`` computes ``generate_password_hash(data["password"])`` and + assigns it to the user, and ``UserDAO.update`` is then called with the + *same* ``data`` dict as ``attributes``. ``BaseDAO.update`` blindly + ``setattr``s every key in ``attributes``, so ``pre_update`` must remove + the plaintext ``password`` key (and any ``current_password``) from that + dict once it's done with them, or the plaintext would overwrite the hash + that was just computed and reach the database instead of it. + + ``admin_user`` starts with no password set, so this exercises the + first-password-set path, which requires no proof of a prior password. + """ + new_password = "BrandNewPassw0rd!" # noqa: S105 + + _run_update_me(admin_user, {"password": new_password}) + db.session.flush() + + stored_password = admin_user.password + + # The stored value should be a password hash that verifies against the + # new password -- not the plaintext value itself. + assert stored_password != new_password + assert check_password_hash(stored_password, new_password) + + +def test_update_me_falsy_password_does_not_blank_stored_hash( + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + """A falsy ``password`` (e.g. an empty string, which the schema's + complexity validator lets through when password complexity validation is + disabled) skips the hashing branch entirely -- ``pre_update`` must still + drop the key from ``data`` so it never reaches ``UserDAO.update``'s + ``setattr`` loop and blanks the account's stored hash. + """ + original_hash = generate_password_hash("OldPassw0rd!") + admin_user.password = original_hash + + _run_update_me(admin_user, {"password": "", "first_name": "Foo"}) + db.session.flush() + + assert admin_user.password == original_hash + assert admin_user.first_name == "Foo" From 086b4af65d473ec3b5e14589491b461fa75acca9 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 18 Aug 2026 11:11:27 -0600 Subject: [PATCH 06/11] feat(mcp): per-resource token scopes with user-permission intersection (#42297) Co-authored-by: Claude Fable 5 --- docs/admin_docs/security/security.mdx | 14 +- .../features/apiKeys/ApiKeyCreateModal.tsx | 34 ++- .../src/features/apiKeys/ApiKeyList.tsx | 13 + .../src/features/apiKeys/apiKeyScopes.test.ts | 50 ++++ .../src/features/apiKeys/apiKeyScopes.ts | 55 ++++ superset/mcp_service/auth.py | 100 +++++-- .../mcp_service/composite_token_verifier.py | 54 ++-- superset/mcp_service/mcp_config.py | 7 +- .../mcp_service/system/tool/get_schema.py | 9 +- superset/security/api_key_scopes.py | 77 ++++++ superset/security/manager.py | 118 ++++++++ .../queries/saved_queries/api_tests.py | 6 +- .../system/tool/test_get_schema.py | 39 ++- .../unit_tests/mcp_service/test_auth_rbac.py | 159 ++++++++++- .../test_composite_token_verifier.py | 48 +++- .../security/test_api_key_scopes.py | 251 ++++++++++++++++++ 16 files changed, 974 insertions(+), 60 deletions(-) create mode 100644 superset-frontend/src/features/apiKeys/apiKeyScopes.test.ts create mode 100644 superset-frontend/src/features/apiKeys/apiKeyScopes.ts create mode 100644 superset/security/api_key_scopes.py create mode 100644 tests/unit_tests/security/test_api_key_scopes.py diff --git a/docs/admin_docs/security/security.mdx b/docs/admin_docs/security/security.mdx index c659d252dfa0..ae5ad72be3ff 100644 --- a/docs/admin_docs/security/security.mdx +++ b/docs/admin_docs/security/security.mdx @@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page: 1. Open the user menu (top-right) and click **Info** to navigate to the User Info page 2. Expand the **API Keys** section 3. Click **+ API Key** -4. Enter a name and (optionally) an expiration date +4. Enter a name and optionally select resource scopes 5. Copy the generated token — it is shown only once Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys. @@ -415,6 +415,18 @@ Authorization: Bearer This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key. +#### API Key Scopes + +The creation dialog can restrict an API key to MCP resource actions such as +`superset:dashboard:read` or `superset:chart:write`. A scope is an additional +restriction: it never grants a permission that the creating user does not +already have through Superset RBAC. Write scopes also cover update and delete +operations for that resource; `superset:sqllab:write` covers SQL execution. + +Keys created without scopes retain legacy RBAC-only behavior. The scoped-key +restrictions described here are enforced by the MCP server; regular REST API +routes continue to apply their existing Superset RBAC checks. + #### Use Cases - **CI/CD pipelines** — automated chart/dashboard exports and imports diff --git a/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx index cc2cf7d4ac8e..bce1ba696eba 100644 --- a/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx +++ b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx @@ -27,8 +27,15 @@ import { Input, Button, Modal, + Select, } from '@superset-ui/core/components'; import { useToasts } from 'src/components/MessageToasts/withToasts'; +import copyTextToClipboard from 'src/utils/copy'; +import { + API_KEY_SCOPE_OPTIONS, + getApiKeyScopesHelpText, + serializeApiKeyScopes, +} from './apiKeyScopes'; interface ApiKeyCreateModalProps { show: boolean; @@ -38,6 +45,7 @@ interface ApiKeyCreateModalProps { interface FormValues { name: string; + scopes?: string[]; } export function ApiKeyCreateModal({ @@ -62,9 +70,13 @@ export function ApiKeyCreateModal({ const handleFormSubmit = async (values: FormValues) => { try { + const scopes = serializeApiKeyScopes(values.scopes); const response = await SupersetClient.post({ endpoint: '/api/v1/security/api_keys/', - jsonPayload: values, + jsonPayload: { + name: values.name, + ...(scopes && { scopes }), + }, }); const key = response.json?.result?.key; if (!key) { @@ -83,7 +95,7 @@ export function ApiKeyCreateModal({ return; } try { - await navigator.clipboard.writeText(createdKey); + await copyTextToClipboard(() => Promise.resolve(createdKey)); setCopied(true); if (copyTimerRef.current) { clearTimeout(copyTimerRef.current); @@ -170,6 +182,24 @@ export function ApiKeyCreateModal({ placeholder={t('e.g., CI/CD Pipeline, Analytics Script')} /> + +