Compare commits

..
21 changed files with 835 additions and 453 deletions
-1
View File
@@ -25,7 +25,6 @@ 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.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
### MCP tool results preserve stored string values
@@ -26,8 +26,7 @@ page and its menu entry are hidden, and deletes are permanent as before.
## Finding archived objects
Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
**Dataset** (shown as **Datasource** when semantic layers are enabled) — from
the Type selector. The view shows one type at a time; each
**Dataset** — from the Type selector. The view shows one type at a time; each
type is read from its own list endpoint, so the same row-level access rules that
govern the normal lists apply here.
+1 -7
View File
@@ -142,13 +142,7 @@ bigquery = [
"google-cloud-bigquery>=3.42.3",
]
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
# The `cockroachdb` PyPI package (last released 2021) is abandoned and its
# SQLAlchemy dialect cannot even import under SQLAlchemy 2.0 (it references
# sqlalchemy.dialects.postgresql.psycopg2.PGCompiler_psycopg2, removed in
# 2.0). sqlalchemy-cockroachdb is the actively maintained replacement,
# already linked from CockroachDbEngineSpec.metadata's docs_url, and
# registers the same `cockroachdb` SQLAlchemy dialect entry point.
cockroachdb = ["sqlalchemy-cockroachdb>=2.0.0, <3"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
# explicitly excluding SQLAlchemy 2.0. See superset/db_engine_specs/d1.py's
+1 -1
View File
@@ -16,5 +16,5 @@
# specific language governing permissions and limitations
# under the License.
#
-e .[development,bigquery,cockroachdb,druid,duckdb,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e .[development,bigquery,druid,duckdb,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e ./superset-extensions-cli[test]
-3
View File
@@ -960,13 +960,10 @@ sqlalchemy==2.0.52
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-cockroachdb
# sqlalchemy-continuum
# sqlalchemy-utils
sqlalchemy-bigquery==1.17.2
# via apache-superset
sqlalchemy-cockroachdb==2.0.4
# via apache-superset
sqlalchemy-continuum==1.7.0
# via
# -c requirements/base-constraint.txt
+64 -82
View File
@@ -185,9 +185,9 @@
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -11550,15 +11550,15 @@
}
},
"node_modules/@swc/core": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.0.tgz",
"integrity": "sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.28"
"@swc/types": "^0.1.27"
},
"engines": {
"node": ">=10"
@@ -11568,18 +11568,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.16.0",
"@swc/core-darwin-x64": "1.16.0",
"@swc/core-linux-arm-gnueabihf": "1.16.0",
"@swc/core-linux-arm64-gnu": "1.16.0",
"@swc/core-linux-arm64-musl": "1.16.0",
"@swc/core-linux-ppc64-gnu": "1.16.0",
"@swc/core-linux-s390x-gnu": "1.16.0",
"@swc/core-linux-x64-gnu": "1.16.0",
"@swc/core-linux-x64-musl": "1.16.0",
"@swc/core-win32-arm64-msvc": "1.16.0",
"@swc/core-win32-ia32-msvc": "1.16.0",
"@swc/core-win32-x64-msvc": "1.16.0"
"@swc/core-darwin-arm64": "1.15.47",
"@swc/core-darwin-x64": "1.15.47",
"@swc/core-linux-arm-gnueabihf": "1.15.47",
"@swc/core-linux-arm64-gnu": "1.15.47",
"@swc/core-linux-arm64-musl": "1.15.47",
"@swc/core-linux-ppc64-gnu": "1.15.47",
"@swc/core-linux-s390x-gnu": "1.15.47",
"@swc/core-linux-x64-gnu": "1.15.47",
"@swc/core-linux-x64-musl": "1.15.47",
"@swc/core-win32-arm64-msvc": "1.15.47",
"@swc/core-win32-ia32-msvc": "1.15.47",
"@swc/core-win32-x64-msvc": "1.15.47"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -11591,9 +11591,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.0.tgz",
"integrity": "sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz",
"integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==",
"cpu": [
"arm64"
],
@@ -11607,9 +11607,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.0.tgz",
"integrity": "sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz",
"integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==",
"cpu": [
"x64"
],
@@ -11623,9 +11623,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.0.tgz",
"integrity": "sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz",
"integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==",
"cpu": [
"arm"
],
@@ -11639,15 +11639,12 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.0.tgz",
"integrity": "sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz",
"integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11658,15 +11655,12 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.0.tgz",
"integrity": "sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz",
"integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11677,15 +11671,12 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.0.tgz",
"integrity": "sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz",
"integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11696,15 +11687,12 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.0.tgz",
"integrity": "sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz",
"integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11715,15 +11703,12 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.0.tgz",
"integrity": "sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz",
"integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11734,15 +11719,12 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.0.tgz",
"integrity": "sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz",
"integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11753,9 +11735,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.0.tgz",
"integrity": "sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz",
"integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==",
"cpu": [
"arm64"
],
@@ -11769,9 +11751,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.0.tgz",
"integrity": "sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz",
"integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==",
"cpu": [
"ia32"
],
@@ -11785,9 +11767,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.0.tgz",
"integrity": "sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz",
"integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==",
"cpu": [
"x64"
],
@@ -11826,9 +11808,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-15.0.0.tgz",
"integrity": "sha512-B0L0KuItii5XatOskjeFW4kNPXYEDo5JYm+k5Lze3LEY46q4L7foVkXiUFbNn0GjbKJCOv+nU2nM57k4LYLbHw==",
"version": "14.19.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11836,9 +11818,9 @@
}
},
"node_modules/@swc/plugin-transform-imports": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-13.0.0.tgz",
"integrity": "sha512-G8Wp8zX92O5F2YQ8OSqoAbNqPiU7VTLKFBtmN4W0y29SaNUDi8rLwvos5P5J1qdrQP3BmnQnS1wdZioMZXlJmw==",
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-12.5.0.tgz",
"integrity": "sha512-b9ReG4NY9OwIIqXLlTuOb7k4N2yRBl501iNiBEKaiTazpxXxg6nR2XKOPojlyu1yb5YnK3s3EjTZX3DGSIDKNg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11846,9 +11828,9 @@
}
},
"node_modules/@swc/types": {
"version": "0.1.28",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
+3 -3
View File
@@ -262,9 +262,9 @@
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -18,15 +18,9 @@
*/
import { createMemoryHistory, type Update } from 'history';
import { Router } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
render,
screen,
fireEvent,
within,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import type Chart from 'src/types/Chart';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import ChartCard from './ChartCard';
jest.mock('@superset-ui/core', () => ({
@@ -43,18 +37,7 @@ const mockChart = {
thumbnail_url: '/thumbnail.png',
} as Chart;
// Admin qualifies as editor, so the card's delete entry is enabled.
const adminUser = {
userId: 1,
username: 'admin',
roles: { Admin: [] },
permissions: {},
} as unknown as UserWithPermissionsAndRoles;
const renderCard = (
history: ReturnType<typeof createMemoryHistory>,
props: Partial<React.ComponentProps<typeof ChartCard>> = {},
) =>
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
render(
<Router history={history}>
<ChartCard
@@ -69,7 +52,6 @@ const renderCard = (
favoriteStatus={false}
showThumbnails
handleBulkChartExport={jest.fn()}
{...props}
/>
</Router>,
);
@@ -124,44 +106,3 @@ test('clicking the card outside the thumbnail navigates to the chart', () => {
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
});
test('with soft delete on, the card delete flow shows the archive dialog', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
flag => flag === FeatureFlag.SoftDelete,
);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Archive'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Archive Sample Chart?')).toBeInTheDocument();
// The body comes from the shared soft-delete copy module; its exact
// wording evolves there (location hint, retention clause), so pin the
// stable prefix rather than a full sentence.
expect(
within(dialog).getByText(/This chart will be moved to Recently Archived/),
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: 'Archive' }),
).toBeInTheDocument();
// Recoverable deletes drop the type-DELETE friction.
expect(
within(dialog).queryByTestId('delete-modal-input'),
).not.toBeInTheDocument();
});
test('with soft delete off, the card delete dialog is the permanent-delete one', async () => {
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Delete'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
expect(
within(dialog).getByText(/Are you sure you want to delete/),
).toBeInTheDocument();
expect(within(dialog).getByTestId('delete-modal-input')).toBeInTheDocument();
});
@@ -38,10 +38,6 @@ import {
isNavigationHandledByLink,
} from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import {
archiveConfirmDescription,
deleteActionLabel,
} from 'src/utils/softDeleteCopy';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
@@ -163,29 +159,15 @@ export default function ChartCard({
}
if (canDelete) {
// With soft delete on, deleting archives the chart (recoverable), so the
// confirmation drops the type-DELETE friction and uses the shared archive
// copy -- matching the list view's dialog for the same action.
const softDelete = isFeatureEnabled(FeatureFlag.SoftDelete);
menuItems.push({
key: 'delete',
label: (
<ConfirmStatusChange
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', { name: chart.slice_name })
: t('Please confirm')
}
title={t('Please confirm')}
description={
softDelete ? (
<p>{archiveConfirmDescription(t('chart'))}</p>
) : (
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
?
</>
)
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
</>
}
onConfirm={() =>
handleChartDelete(
@@ -222,7 +204,7 @@ export default function ChartCard({
vertical-align: text-top;
`}
/>{' '}
{deleteActionLabel()}
{t('Delete')}
</button>
</Tooltip>
)}
@@ -25,10 +25,8 @@ import {
fireEvent,
userEvent,
waitFor,
within,
selectOption,
} from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -90,15 +88,6 @@ const mockCharts = [
// list so `_info` requests resolve to it rather than the broader list glob.
// withToasts injects the toast callbacks as props; the harness renders no
// toast container, so the spy is the only way to pin what the user is told.
// The type label for the dataset concept is flag-aware (SEMANTIC_LAYERS →
// "Datasource"); mock the flag reader so tests can exercise both states. The
// default (false for every flag) matches the real test environment, where no
// bootstrap flags are set.
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => false),
}));
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -155,13 +144,6 @@ beforeEach(() => {
mockAddDangerToast.mockClear();
});
afterEach(() => {
// The flag mock is shared module state; restore the environment default so a
// flag-flipping test that dies mid-body (e.g. by Jest timeout) cannot leak
// SEMANTIC_LAYERS into whichever test runs next.
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
});
test('renders archived rows with Name and Type columns', async () => {
mockRoutes();
renderArchivedList();
@@ -591,57 +573,3 @@ test('a viewer who can read none of the types gets an empty state, not three 403
// No list fetch was ever issued.
expect(fetchMock.callHistory.calls(/chart\/\?q/)).toHaveLength(0);
});
test('labels the dataset type "Datasource" when semantic layers is enabled', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.SemanticLayers,
);
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Datasource' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Dataset' }),
).not.toBeInTheDocument();
// Selecting the renamed option still drives the dataset resource —
// the underlying type value is flag-independent.
await selectOption('Datasource', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
// Pin the Type COLUMN cell, not just the Select's own rendered value.
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Datasource'),
).toBeInTheDocument();
});
test('labels the dataset type "Dataset" when semantic layers is disabled', async () => {
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Dataset' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Datasource' }),
).not.toBeInTheDocument();
await selectOption('Dataset', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Dataset'),
).toBeInTheDocument();
});
@@ -37,7 +37,6 @@ import {
type ListViewFilters,
} from 'src/components';
import SubMenu from 'src/features/home/SubMenu';
import { datasetLabel } from 'src/features/semanticLayers/label';
import withToasts from 'src/components/MessageToasts/withToasts';
import { recoveredToast } from 'src/utils/softDeleteCopy';
import { findPermission } from 'src/utils/findPermission';
@@ -83,12 +82,10 @@ const EmptyStateRow = styled.div`
`}
`;
// Getters, not strings: the dataset label follows the SEMANTIC_LAYERS flag
// ("Dataset" / "Datasource"), read at render time via the shared naming module.
const TYPE_LABELS: Record<ArchivedType, () => string> = {
chart: () => t('Chart'),
dashboard: () => t('Dashboard'),
dataset: datasetLabel,
const TYPE_LABELS: Record<ArchivedType, string> = {
chart: t('Chart'),
dashboard: t('Dashboard'),
dataset: t('Dataset'),
};
interface ToastProps {
@@ -169,7 +166,7 @@ function ArchivedListBody({
refreshData,
} = useListViewResource<ArchivedItem>(
config.resource,
TYPE_LABELS[type](),
TYPE_LABELS[type],
addDangerToast,
true,
[],
@@ -250,7 +247,7 @@ function ArchivedListBody({
name => {
const { text, options } = recoveredToast(
name,
TYPE_LABELS[type](),
TYPE_LABELS[type],
item.url ?? item.explore_url,
);
addSuccessToast(text, options);
@@ -309,7 +306,7 @@ function ArchivedListBody({
id: config.nameField,
},
{
Cell: () => TYPE_LABELS[type](),
Cell: () => TYPE_LABELS[type],
Header: t('Type'),
id: 'type',
disableSortBy: true,
@@ -542,7 +539,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
onChange={handleTypeChange}
options={availableTypes.map(option => ({
value: option,
label: TYPE_LABELS[option](),
label: TYPE_LABELS[option],
}))}
/>
</TypeSelectRow>
@@ -96,29 +96,19 @@ test('a malformed window does not leak into the copy', () => {
test('the confirm copy quotes the window when there is one', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 30 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 30 days.',
'This chart will be moved to Recently Archived. You can recover it there within 30 days.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 30 days.',
);
});
test('a one-day window is quoted in the singular', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 1 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 1 day.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 1 day.',
'These charts will be moved to Recently Archived. You can recover them there within 30 days.',
);
});
test('the confirm copy omits the clause when there is no window', () => {
withConf({});
expect(archiveConfirmDescription('dashboard')).toBe(
'This dashboard will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This dashboard will be moved to Recently Archived. You can recover it there.',
);
expect(archiveConfirmDescription('dashboards', true)).toBe(
'These dashboards will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These dashboards will be moved to Recently Archived. You can recover them there.',
);
});
+7 -14
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { escape } from 'lodash-es';
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
@@ -62,32 +62,25 @@ export function archiveConfirmDescription(
// Each case is a single, complete translation unit (rather than two joined
// fragments) so translators control the whole sentence; only the noun and the
// day count are interpolated, matching Superset's existing `%(...)s` usage.
// The timed variants pluralize on the day count (`tn`) because the retention
// window accepts 1: "within 1 days" is exactly the copy defect this module
// exists to prevent.
const days = getSoftDeleteRetentionDays();
if (days) {
return plural
? tn(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s day.',
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s days.',
days,
? t(
'These %(type)s will be moved to Recently Archived. You can recover them there within %(days)s days.',
{ type: typeLabel, days },
)
: tn(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s day.',
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s days.',
days,
: t(
'This %(type)s will be moved to Recently Archived. You can recover it there within %(days)s days.',
{ type: typeLabel, days },
);
}
return plural
? t(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These %(type)s will be moved to Recently Archived. You can recover them there.',
{ type: typeLabel },
)
: t(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This %(type)s will be moved to Recently Archived. You can recover it there.',
{ type: typeLabel },
);
}
+4
View File
@@ -1316,6 +1316,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
try:
TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK")
except OAuth2RedirectError:
# OAuth2 connections pass, so they can be saved. A user later
# can then store an OAuth2 token.
return self.response(200, message="OK")
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
+1 -1
View File
@@ -44,7 +44,7 @@ class CockroachDbEngineSpec(PostgresEngineSpec):
DatabaseCategory.TRADITIONAL_RDBMS,
DatabaseCategory.OPEN_SOURCE,
],
"pypi_packages": ["sqlalchemy-cockroachdb"],
"pypi_packages": ["cockroachdb"],
"connection_string": "cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable",
"default_port": 26257,
"docs_url": "https://github.com/cockroachdb/sqlalchemy-cockroachdb",
-12
View File
@@ -5618,18 +5618,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
editor_subject_ids = set(get_extra_editor_subject_ids(resource))
if hasattr(resource, "editors"):
editor_subject_ids.update(s.id for s in resource.editors)
# Fallback ONLY for Query and SavedQuery models that use 'user_id'
from superset.models.sql_lab import Query, SavedQuery
from superset.subjects.utils import get_user_subject
if (
isinstance(resource, (Query, SavedQuery))
and getattr(resource, "user_id", None) is not None
):
if subject := get_user_subject(resource.user_id):
editor_subject_ids.add(subject.id)
return bool(subject_ids & editor_subject_ids)
def is_viewer(self, resource: Model) -> bool:
+10 -18
View File
@@ -13831,18 +13831,14 @@ msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s day."
msgid_plural ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"These %(type)s will be moved to Recently Archived. You can recover them "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there."
"These %(type)s will be moved to Recently Archived. You can recover them "
"there."
msgstr ""
msgid "These are the datasets this filter will be applied to."
@@ -13850,18 +13846,14 @@ msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s day."
msgid_plural ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"This %(type)s will be moved to Recently Archived. You can recover it "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there."
"This %(type)s will be moved to Recently Archived. You can recover it "
"there."
msgstr ""
msgid ""
@@ -2403,6 +2403,54 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
@with_config({"PREVENT_UNSAFE_DB_CONNECTIONS": False})
def test_test_connection_oauth2(self):
"""
Database API: Test test connection flow with a connection authenticated via
OAuth2.
The test would always raise ``OAuth2RedirectError``, and we can't start the
OAuth2 dance before the connection is saved, so it should return a 200 status.
"""
self.login(ADMIN_USERNAME)
example_db = get_example_database()
masked_encrypted_extra = json.dumps(
{
"oauth2_client_info": {
"id": "client_id",
"secret": "client_secret",
"scope": "some-scope",
"authorization_request_uri": "https://example.org/authorize",
"token_request_uri": "https://example.org/token",
}
}
)
data = {
"database_name": "examples",
"masked_encrypted_extra": masked_encrypted_extra,
"impersonate_user": True,
"sqlalchemy_uri": example_db.safe_sqlalchemy_uri(),
"server_cert": None,
}
url = "api/v1/database/test_connection/"
with (
mock.patch(
"superset.commands.database.test_connection.ping",
side_effect=Exception("Unauthorized"),
),
mock.patch.object(
example_db.db_engine_spec,
"needs_oauth2",
return_value=True,
),
):
rv = self.post_assert_metric(url, data, "test_connection")
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
assert json.loads(rv.data.decode("utf-8")) == {"message": "OK"}
def test_test_connection_failed(self):
"""
Database API: Test test connection failed
@@ -605,6 +605,9 @@ class TestSavedQueryApi(SupersetTestCase):
db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0]
)
self.login(ADMIN_USERNAME)
# Freeze relative to the persisted timestamp so database-specific
# timestamp precision cannot make the humanized value age into the
# next bucket while the request is being handled.
with freeze_time(saved_query.changed_on):
uri = f"api/v1/saved_query/{saved_query.id}"
rv = self.get_assert_metric(uri, "get")
@@ -42,19 +42,3 @@ def test_convert_dttm(
)
assert_convert_dttm(spec, target_type, expected_result, dttm)
def test_dialect_loads_under_installed_sqlalchemy() -> None:
"""
``create_engine`` resolves and imports the ``cockroachdb`` SQLAlchemy
dialect entry point without connecting anywhere. This is a regression
test for the ``cockroachdb`` PyPI package (last released 2021, replaced
by ``sqlalchemy-cockroachdb`` -- see the ``cockroachdb`` extra in
pyproject.toml): its dialect referenced
``sqlalchemy.dialects.postgresql.psycopg2.PGCompiler_psycopg2``, which
SQLAlchemy 2.0 removed, so merely constructing an engine raised
``ImportError`` before any connection was attempted.
"""
from sqlalchemy import create_engine
create_engine("cockroachdb://root@localhost:26257/defaultdb?sslmode=disable")
+672 -111
View File
@@ -36,6 +36,7 @@ from superset.extensions import appbuilder
from superset.models.slice import Slice
from superset.security.manager import (
_collect_sortable_identifiers,
_sql_filters_modified,
freeze_value,
query_context_modified,
SupersetSecurityManager,
@@ -3793,121 +3794,681 @@ def test_validate_guest_token_resources_accepts_embedded_int_id(
)
def test_is_editor_query_owner(mocker: MockerFixture, app_context: None) -> None:
"""
Test that a Query owner is considered an editor via Subject resolution.
"""
from superset.models.sql_lab import Query
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
subject_user_100 = mocker.MagicMock(id=1000)
subject_user_200 = mocker.MagicMock(id=2000)
def mock_get_user_subject(uid: int):
if uid == 100:
return subject_user_100
if uid == 200:
return subject_user_200
return None
mocker.patch(
"superset.subjects.utils.get_user_subject",
side_effect=mock_get_user_subject,
)
query = Query(user_id=100)
assert sm.is_editor(query) is True
other_query = Query(user_id=200)
assert sm.is_editor(other_query) is False
# ---------------------------------------------------------------------------
# _sql_filters_modified block custom SQL injection by guest users
# ---------------------------------------------------------------------------
def test_is_editor_saved_query_owner(mocker: MockerFixture, app_context: None) -> None:
"""
Test that a SavedQuery owner is considered an editor via Subject resolution.
"""
from superset.models.sql_lab import SavedQuery
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
subject_user_100 = mocker.MagicMock(id=1000)
subject_user_200 = mocker.MagicMock(id=2000)
def mock_get_user_subject(uid: int):
if uid == 100:
return subject_user_100
if uid == 200:
return subject_user_200
return None
mocker.patch(
"superset.subjects.utils.get_user_subject",
side_effect=mock_get_user_subject,
)
saved_query = SavedQuery(user_id=100)
assert sm.is_editor(saved_query) is True
other_saved_query = SavedQuery(user_id=200)
assert sm.is_editor(other_saved_query) is False
def test_is_editor_other_model_with_user_id_not_editor(
mocker: MockerFixture, app_context: None
def test_sql_filters_extras_where_injected_blocked(
mocker: MockerFixture,
) -> None:
"""
Test that a model with user_id that is NOT Query or SavedQuery
does NOT receive the fallback and is not considered an editor.
"""
from superset.models.sql_lab import TabState
"""Injecting extras.where when the chart has no SQL filters is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"metrics": ["count"]}
query = QueryObject(extras={"where": "1=1"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_having_injected_blocked(
mocker: MockerFixture,
) -> None:
"""Injecting extras.having when the chart has no SQL filters is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"having": "COUNT(*) > 0"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_where_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the chart's own SQL WHERE filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
# freeform_where_having wraps each clause in parens
query = QueryObject(extras={"where": "(region = 'EMEA')"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_having_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the chart's own SQL HAVING filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "SUM(sales) > 100",
"clause": "HAVING",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject(extras={"having": "(SUM(sales) > 100)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_adhoc_sql_filter_injected_blocked(
mocker: MockerFixture,
) -> None:
"""Injecting a new SQL adhoc filter not on the stored chart is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject()
query_context.queries = [query]
injected_filter = {
"expressionType": "SQL",
"sqlExpression": "1=1",
"clause": "WHERE",
}
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [injected_filter]}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_adhoc_sql_filter_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the exact stored SQL adhoc filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [sql_filter]}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_empty_extras_always_allowed(
mocker: MockerFixture,
) -> None:
"""No SQL in extras is always allowed, even when the chart has SQL filters."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_from_stored_qc_allowed(
mocker: MockerFixture,
) -> None:
"""extras.where from stored query_context is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [{"extras": {"where": "(col > 5)"}}],
}
query = QueryObject(extras={"where": "(col > 5)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_multi_query_stored_predicate_allowed(
mocker: MockerFixture,
) -> None:
"""Multiple queries replaying predicates from the stored chart are allowed.
The allowed set is global across all stored queries per-query pinning is
intentionally not applied because there is no stable identity linking a
request query to a stored query, and all queries share the same
chart/datasource so predicates only restrict rows, never expand access.
"""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [
{"extras": {"where": "(region = 'EMEA')"}},
{"extras": {"where": "(status = 'active')"}},
],
}
# Both request queries use predicates from the stored chart.
query_context.queries = [
QueryObject(extras={"where": "(region = 'EMEA')"}),
QueryObject(extras={"where": "(status = 'active')"}),
]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_multi_query_novel_predicate_blocked(
mocker: MockerFixture,
) -> None:
"""A novel predicate on any query is blocked even when others are valid."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [{"extras": {"where": "(region = 'EMEA')"}}],
}
query_context.queries = [
QueryObject(extras={"where": "(region = 'EMEA')"}),
QueryObject(extras={"where": "(1=1)"}), # not stored
]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_different_sql_blocked(
mocker: MockerFixture,
) -> None:
"""Modified SQL (appending extra predicates) is blocked."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "col > 5",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
# Attacker appends extra predicate
query = QueryObject(
extras={"where": "(col > 5) AND (1=1)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_simple_filters_not_blocked(
mocker: MockerFixture,
) -> None:
"""SIMPLE structured filters (from dashboard native filters) are not blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(
filters=[{"col": "country", "op": "==", "val": "US"}],
)
query_context.queries = [query]
simple_adhoc_filter = {
"expressionType": "SIMPLE",
"subject": "country",
"operator": "==",
"comparator": "US",
"clause": "WHERE",
}
form_data: dict[str, Any] = {
"slice_id": 1,
"adhoc_filters": [simple_adhoc_filter],
}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_structured_filter_adhoc_col_blocked(
mocker: MockerFixture,
) -> None:
"""Structured filter with an adhoc SQL column in ``col`` is blocked.
``ChartDataFilterSchema.col`` is ``fields.Raw``, so an attacker can pass
an adhoc column dict that reaches ``adhoc_column_to_sqla`` and executes
arbitrary SQL in the WHERE clause.
"""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
adhoc_col: Any = {
"expressionType": "SQL",
"sqlExpression": "1; DROP TABLE users--",
"label": "x",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "!=", "val": "z"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_structured_filter_stored_adhoc_col_allowed(
mocker: MockerFixture,
) -> None:
"""Cross-filter with an adhoc SQL column matching a stored chart dimension
is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"columns": [
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
],
}
adhoc_col: Any = {
"sqlExpression": "YEAR(order_date)",
"label": "order_year",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_adhoc_col_from_sibling_chart_allowed(
mocker: MockerFixture,
) -> None:
"""Cross-filter with an adhoc SQL column from a sibling chart on the same
dashboard is allowed."""
from superset.models.dashboard import Dashboard
# Target chart (chart B) has no custom SQL columns.
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {"metrics": ["count"]}
# Source chart (chart A) has the custom SQL dimension.
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
],
}
# Dashboard contains both charts.
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=True,
)
adhoc_col: Any = {
"sqlExpression": "YEAR(order_date)",
"label": "order_year",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_rejected_for_unauthorized_dashboard(
mocker: MockerFixture,
) -> None:
"""Cross-filter lookup must not use a dashboard the guest has no access to."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {}
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=False,
)
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 999}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_rejected_when_chart_not_on_dashboard(
mocker: MockerFixture,
) -> None:
"""Cross-filter lookup must verify the target chart belongs to the dashboard."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 99 # not on the dashboard
stored_chart.params_dict = {}
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart] # stored_chart not here
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=True,
)
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 99, "dashboardId": 10}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_sibling_expressions_cannot_inject_where_having(
mocker: MockerFixture,
) -> None:
"""Sibling chart column expressions must not legitimize novel WHERE/HAVING."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {}
# Sibling has a column expression that an attacker tries to use as WHERE.
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [
{"sqlExpression": "(SELECT secret FROM users LIMIT 1)", "label": "x"},
],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
# Attacker injects the sibling expression into extras.where.
query = QueryObject(
extras={"where": "(SELECT secret FROM users LIMIT 1)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_collect_allowed_sql_includes_scalar_column_params(
mocker: MockerFixture,
) -> None:
"""Scalar column params like x_axis contribute their sqlExpression."""
from superset.security.manager import _collect_allowed_sql
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"x_axis": {"sqlExpression": "DATE_TRUNC('month', ts)", "label": "m"},
"groupby": [{"sqlExpression": "UPPER(country)", "label": "c"}],
}
_, col_allowed = _collect_allowed_sql(stored_chart, None)
assert "DATE_TRUNC('month', ts)" in col_allowed
assert "UPPER(country)" in col_allowed
def test_sql_filters_structured_filter_string_col_allowed(
mocker: MockerFixture,
) -> None:
"""Structured filter with a plain string column is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(
filters=[{"col": "status", "op": "==", "val": "active"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_empty_filter_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""The ``(1 = 0)`` sentinel from a required-but-empty native filter is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(1 = 0)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_double_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""Two required-but-empty filters compose ``(1 = 0) AND (1 = 0)``."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(1 = 0) AND (1 = 0)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_stored_clause_plus_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""A stored SQL filter composed with the empty-filter sentinel is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject(
extras={"where": "(region = 'EMEA') AND (1 = 0)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_non_dict_adhoc_filter_skipped(
mocker: MockerFixture,
) -> None:
"""Non-dict items in adhoc_filters are skipped, not 500."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {
"slice_id": 1,
"adhoc_filters": ["not_a_dict", 42, None],
}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_raise_for_access_guest_user_sql_filter_injection_blocked(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""Guest user injecting SQL via extras.where is rejected by raise_for_access."""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
subject_user_100 = mocker.MagicMock(id=1000)
mocker.patch(
"superset.subjects.utils.get_user_subject",
return_value=subject_user_100,
)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {"metrics": stored_metrics}
tab_state = TabState(user_id=100)
assert sm.is_editor(tab_state) is False
query_context.form_data = {"slice_id": 42, "metrics": stored_metrics}
query_context.queries = [
QueryObject(
metrics=stored_metrics, # type: ignore
extras={"where": "1=1 UNION SELECT password FROM users"},
)
]
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)
def test_sql_filters_cache_replay_skips_check(
mocker: MockerFixture,
) -> None:
"""Cache-replay requests skip the SQL filter check."""
query_context = mocker.MagicMock()
query_context._from_cache_replay = True
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(injected SQL)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_column_expression_cannot_become_where(
mocker: MockerFixture,
) -> None:
"""A chart's column sqlExpression must not be injectable as extras.where."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"columns": [
{
"sqlExpression": "(SELECT secret FROM users LIMIT 1)",
"label": "x",
},
],
}
query = QueryObject(
extras={"where": "((SELECT secret FROM users LIMIT 1))"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_unbalanced_parens_rejected(
mocker: MockerFixture,
) -> None:
"""Unbalanced parens in extras.where are rejected (403, not 500)."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(a) AND (b"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)