mirror of
https://github.com/apache/superset.git
synced 2026-08-25 09:31:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ddafa6b5 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+64
-82
@@ -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": {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user