mirror of
https://github.com/apache/superset.git
synced 2026-08-26 01:51:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ddafa6b5 |
@@ -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.
|
||||
|
||||
|
||||
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",
|
||||
|
||||
+1
-60
@@ -30,7 +30,6 @@ import { connect, ConnectedProps } from 'react-redux';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import { Radio } from '@superset-ui/core/components/Radio';
|
||||
import { formatSpecifier } from 'd3-format';
|
||||
import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
@@ -828,64 +827,6 @@ function EditorsSelector({
|
||||
const ResultTable =
|
||||
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
|
||||
|
||||
// D3's '%' and 'p' types both multiply by 100; parsed via d3-format's own
|
||||
// grammar so garbage like "foo%" is rejected rather than matched by suffix.
|
||||
export const isPercentD3Format = (d3format?: string): boolean => {
|
||||
const trimmed = d3format?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { type } = formatSpecifier(trimmed);
|
||||
return type === '%' || type === 'p';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Matches the outermost COUNT(...) call's parens by depth, so a ratio like
|
||||
// `COUNT(*) / COUNT(*)` isn't misclassified but a nested call like
|
||||
// `COUNT(DISTINCT COALESCE(a, b))` is still recognized.
|
||||
export const isCountExpression = (expression?: string): boolean => {
|
||||
const trimmed = expression?.trim();
|
||||
if (!trimmed || !/^count\(/i.test(trimmed) || !trimmed.endsWith(')')) {
|
||||
return false;
|
||||
}
|
||||
let depth = 0;
|
||||
for (let i = trimmed.indexOf('('); i < trimmed.length; i += 1) {
|
||||
if (trimmed[i] === '(') {
|
||||
depth += 1;
|
||||
} else if (trimmed[i] === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return i === trimmed.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
|
||||
if (
|
||||
!isCountExpression(item.expression) ||
|
||||
!isPercentD3Format(item.d3format)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This metric is a count, but its D3 format is a percentage. ' +
|
||||
'Percent formats multiply the value by 100, which will make a ' +
|
||||
'raw count render as a misleadingly large number.',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Redux connector types
|
||||
interface QueryPayload {
|
||||
client_id?: string;
|
||||
@@ -2229,7 +2170,7 @@ function DatasourceEditor({
|
||||
}}
|
||||
expandFieldset={
|
||||
<FormContainer>
|
||||
<Fieldset compact renderWarning={renderMetricFormatWarning}>
|
||||
<Fieldset compact>
|
||||
<Field
|
||||
fieldKey="expression"
|
||||
label={t('SQL expression')}
|
||||
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { isCountExpression, isPercentD3Format } from '../DatasourceEditor';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
const WARNING_TEXT = /D3 format is a percentage/i;
|
||||
|
||||
// Negative assertions must wait past TextControl's debounce, or they pass
|
||||
// before the value even commits.
|
||||
const waitPastDebounce = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, Constants.FAST_DEBOUNCE + 50);
|
||||
});
|
||||
|
||||
test('isCountExpression matches a COUNT(...) call, including nested calls', () => {
|
||||
expect(isCountExpression('COUNT(*)')).toBe(true);
|
||||
expect(isCountExpression('count( * )')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT name)')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT COALESCE(a, b))')).toBe(true);
|
||||
expect(isCountExpression('COUNT(*) / COUNT(*)')).toBe(false);
|
||||
expect(isCountExpression('COUNT(*) * 100')).toBe(false);
|
||||
expect(isCountExpression('SUM(num)')).toBe(false);
|
||||
expect(isCountExpression(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('isPercentD3Format accepts only a valid D3 percent/p spec', () => {
|
||||
expect(isPercentD3Format('.0%')).toBe(true);
|
||||
expect(isPercentD3Format(',.2%')).toBe(true);
|
||||
expect(isPercentD3Format('.1p')).toBe(true);
|
||||
expect(isPercentD3Format('foo%')).toBe(false);
|
||||
expect(isPercentD3Format('.0%garbage%')).toBe(false);
|
||||
expect(isPercentD3Format(',.0f')).toBe(false);
|
||||
expect(isPercentD3Format(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
|
||||
test('warns when a percent D3 format is set on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
// Rows sort by metric id descending, so `COUNT(*)` (id 7) is first.
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a non-percent format on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
|
||||
|
||||
await waitPastDebounce();
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a percent format on a non-COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
// Rows sort by metric id descending, so id 1 (`SUM(...)`) sorts last.
|
||||
await userEvent.click(expandToggles[6]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitPastDebounce();
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a ratio built from COUNT, e.g. COUNT(*) / COUNT(*)', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
...baseProps.datasource.metrics,
|
||||
{
|
||||
id: 99,
|
||||
uuid: 'metric-99-uuid',
|
||||
expression: 'COUNT(*) / COUNT(*)',
|
||||
verbose_name: 'ratio',
|
||||
metric_name: 'ratio',
|
||||
metric_type: 'count',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
// The appended metric (id 99) sorts first.
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitPastDebounce();
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a garbage format string that merely ends in %', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), 'foo%');
|
||||
|
||||
await waitPastDebounce();
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -28,7 +28,6 @@ export interface FieldsetProps {
|
||||
item?: Record<string, any>;
|
||||
title?: ReactNode;
|
||||
compact?: boolean;
|
||||
renderWarning?: (item: Record<string, any>) => ReactNode;
|
||||
}
|
||||
|
||||
type fieldKeyType = string | number;
|
||||
@@ -39,7 +38,6 @@ export default function Fieldset({
|
||||
item = {},
|
||||
title = null,
|
||||
compact = false,
|
||||
renderWarning,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
@@ -80,7 +78,6 @@ export default function Fieldset({
|
||||
</Typography.Title>
|
||||
)}
|
||||
|
||||
{renderWarning?.(item)}
|
||||
{recurseReactClone(children, Field, propExtender)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -522,7 +522,7 @@ const ExtraOptions = ({
|
||||
onChange={onInputChange}
|
||||
>
|
||||
{t(
|
||||
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, Snowflake and Google Sheets)',
|
||||
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, and Google Sheets)',
|
||||
)}
|
||||
</Checkbox>
|
||||
<InfoTooltip
|
||||
@@ -532,10 +532,7 @@ const ExtraOptions = ({
|
||||
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
|
||||
'service account, but impersonate the currently logged on user via ' +
|
||||
'hive.server2.proxy.user property. If Databricks, uses OAuth2 to ' +
|
||||
'authenticate as the currently logged on user. If Snowflake or Google ' +
|
||||
'Sheets, and OAuth authentication is configured for the database, will ' +
|
||||
'run the queries as the currently logged on user via their own OAuth ' +
|
||||
'credentials.',
|
||||
'authenticate as the currently logged on user.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ from werkzeug.local import LocalProxy
|
||||
# form.
|
||||
flask_appbuilder.Model.__allow_unmapped__ = True
|
||||
|
||||
# pandas >= 2.2 advertises a minimum SQLAlchemy of 2.0 and silently ignores
|
||||
# older installations, breaking DataFrame.to_sql / read_sql with SQLAlchemy
|
||||
# 1.4 engines. Its SQL layer still works with 1.4, so restore support until
|
||||
# Superset itself requires SQLAlchemy >= 2. Must run before any pandas SQL IO.
|
||||
from superset.utils.pandas_sqlalchemy_compat import ( # noqa: E402
|
||||
restore_pandas_sqlalchemy_support,
|
||||
)
|
||||
|
||||
restore_pandas_sqlalchemy_support()
|
||||
|
||||
from superset.app import create_app # noqa: E402, F401
|
||||
from superset.extensions import ( # noqa: E402
|
||||
appbuilder, # noqa: F401
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,23 +20,21 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, Callable, cast, Optional, TYPE_CHECKING, TypedDict
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING, TypedDict
|
||||
from urllib import parse
|
||||
|
||||
from apispec import APISpec
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from flask import current_app as app, has_request_context
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from marshmallow import fields, Schema
|
||||
from sqlalchemy import text, types
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from superset import is_feature_enabled, security_manager
|
||||
from superset.constants import TimeGrain
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.db_engine_specs.base import (
|
||||
@@ -46,63 +44,13 @@ from superset.db_engine_specs.base import (
|
||||
)
|
||||
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import OAuth2TokenRefreshError
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.superset_typing import (
|
||||
OAuth2ClientConfig,
|
||||
OAuth2State,
|
||||
)
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user_agent, QuerySource
|
||||
from superset.utils.oauth2 import encode_oauth2_state, generate_code_challenge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
|
||||
try:
|
||||
from snowflake.connector.errors import DatabaseError
|
||||
except ImportError:
|
||||
# Use a distinct sentinel type when snowflake is not installed to avoid
|
||||
# matching unrelated exception types (using `Exception` would be too broad).
|
||||
class _SnowflakeDatabaseError(Exception):
|
||||
"""Sentinel type to stand in for snowflake.connector.errors.DatabaseError."""
|
||||
|
||||
pass
|
||||
|
||||
DatabaseError = _SnowflakeDatabaseError
|
||||
|
||||
|
||||
class CustomSnowflakeAuthErrorMeta(type):
|
||||
"""
|
||||
Metaclass whose ``__instancecheck__`` matches Snowflake's invalid/expired
|
||||
OAuth access-token error, so ``CustomSnowflakeAuthError`` can be used as the
|
||||
``oauth2_exception`` that triggers the OAuth2 re-auth dance.
|
||||
|
||||
This is only honored via ``isinstance()`` (the path used by
|
||||
``BaseEngineSpec.needs_oauth2()``); ``except`` clauses do not call
|
||||
``__instancecheck__``, so it must not be relied on for exception catching.
|
||||
"""
|
||||
|
||||
def __instancecheck__(cls, instance: object) -> bool:
|
||||
"""
|
||||
Match Snowflake's invalid/expired OAuth token error, whether it arrives
|
||||
wrapped by SQLAlchemy (e.g. ``Engine``-based execution) or as the raw
|
||||
DBAPI exception — ``BaseEngineSpec.execute()`` runs against a bare
|
||||
cursor and never wraps it, so both shapes must be handled here.
|
||||
"""
|
||||
orig: object = instance
|
||||
if isinstance(instance, SqlalchemyDatabaseError):
|
||||
orig = cast(SqlalchemyDatabaseError, instance).orig
|
||||
|
||||
return isinstance(orig, DatabaseError) and "Invalid OAuth access token" in str(
|
||||
orig
|
||||
)
|
||||
|
||||
|
||||
class CustomSnowflakeAuthError(DatabaseError, metaclass=CustomSnowflakeAuthErrorMeta):
|
||||
"""Snowflake OAuth error type matched via the metaclass above (see note there)."""
|
||||
|
||||
|
||||
# Regular expressions to catch custom errors
|
||||
OBJECT_DOES_NOT_EXIST_REGEX = re.compile(
|
||||
r"Object (?P<object>.*?) does not exist or not authorized."
|
||||
@@ -212,7 +160,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
|
||||
encrypted_extra_sensitive_fields = {
|
||||
"$.auth_params.privatekey_body": "Private Key Body",
|
||||
"$.auth_params.privatekey_pass": "Private Key Password",
|
||||
"$.oauth2_client_info.secret": "OAuth2 Client Secret",
|
||||
}
|
||||
|
||||
_time_grain_expressions = {
|
||||
@@ -251,126 +198,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
|
||||
),
|
||||
}
|
||||
|
||||
# OAuth 2.0 support
|
||||
supports_oauth2: bool = True
|
||||
# `CustomSnowflakeAuthError` is only matched via `isinstance()` (see the
|
||||
# metaclass docstring above), so it's paired with `OAuth2TokenRefreshError`
|
||||
# (a real subclass) to keep `refresh_oauth2_token`'s `except` clause working.
|
||||
oauth2_exception: type[Exception] | tuple[type[Exception], ...] = (
|
||||
CustomSnowflakeAuthError,
|
||||
OAuth2TokenRefreshError,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_oauth2_enabled(cls) -> bool:
|
||||
"""
|
||||
Return whether OAuth2 authentication is enabled.
|
||||
"""
|
||||
|
||||
# When alerts or reports connect to the database in the background,
|
||||
# OAuth2 authentication fails; therefore, OAuth2 authentication is disabled
|
||||
# for background execution.
|
||||
if not has_request_context():
|
||||
return False
|
||||
|
||||
return (
|
||||
cls.supports_oauth2
|
||||
and cls.engine_name in app.config["DATABASE_OAUTH2_CLIENTS"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_oauth2_config(cls) -> OAuth2ClientConfig | None:
|
||||
"""
|
||||
Build the DB engine spec level OAuth2 client config.
|
||||
"""
|
||||
if not cls.is_oauth2_enabled():
|
||||
return None
|
||||
|
||||
return super().get_oauth2_config()
|
||||
|
||||
@classmethod
|
||||
def impersonate_user(
|
||||
cls,
|
||||
database: Database,
|
||||
username: str | None,
|
||||
user_token: str | None,
|
||||
url: URL,
|
||||
engine_kwargs: dict[str, Any],
|
||||
) -> tuple[URL, dict[str, Any]]:
|
||||
"""
|
||||
Modify URL and/or engine kwargs to impersonate a different user.
|
||||
"""
|
||||
connect_args: dict[str, Any] = engine_kwargs.setdefault("connect_args", {})
|
||||
|
||||
# When test_connection is executed (i.e., when validate_default_parameters is
|
||||
# set to True in connect_args), authentication via OAuth is not performed.
|
||||
#
|
||||
# ``database.is_oauth2_enabled()`` returns True for a database-level OAuth2
|
||||
# client (``encrypted_extra.oauth2_client_info``) regardless of request
|
||||
# context, unlike the app-config-based check in ``is_oauth2_enabled()``
|
||||
# above. Background executions (alerts/reports) have no per-user token, so
|
||||
# ``has_request_context()`` must be checked explicitly here too, or OAuth
|
||||
# gets switched on with no token to send.
|
||||
if (
|
||||
not connect_args.get("validate_default_parameters", False)
|
||||
and has_request_context()
|
||||
and database.is_oauth2_enabled()
|
||||
):
|
||||
url = url.update_query_dict({"authenticator": "oauth"})
|
||||
connect_args["authenticator"] = "oauth"
|
||||
|
||||
if user_token:
|
||||
if username is not None:
|
||||
if is_feature_enabled("IMPERSONATE_WITH_EMAIL_PREFIX"):
|
||||
# ``Database._get_sqla_engine()`` has already looked
|
||||
# up the login and substituted the email prefix into
|
||||
# ``username`` before calling this method when this
|
||||
# flag is on. Looking it up again here as if it were
|
||||
# still the login would fail whenever the two differ,
|
||||
# leaving the default/service-account username paired
|
||||
# with this user's OAuth token. Use it as given.
|
||||
url = url.set(username=username)
|
||||
else:
|
||||
user = security_manager.find_user(username=username)
|
||||
if user and user.email:
|
||||
url = url.set(username=user.email)
|
||||
|
||||
url = url.update_query_dict({"token": user_token})
|
||||
|
||||
return url, engine_kwargs
|
||||
|
||||
@classmethod
|
||||
def get_oauth2_authorization_uri(
|
||||
cls,
|
||||
config: OAuth2ClientConfig,
|
||||
state: OAuth2State,
|
||||
code_verifier: str | None = None, # pylint: disable=unused-argument
|
||||
) -> str:
|
||||
"""
|
||||
Return URI for initial OAuth2 request.
|
||||
"""
|
||||
uri = config["authorization_request_uri"]
|
||||
# When calling the Snowflake OAuth authorization endpoint for a custom client,
|
||||
# specify only the query parameters documented in the URL below.
|
||||
# Adding unsupported parameters
|
||||
# (e.g., `prompt` as used in BaseEngineSpec.get_oauth2_authorization_uri)
|
||||
# will cause an error.
|
||||
# https://docs.snowflake.com/user-guide/oauth-custom#query-parameters
|
||||
params: dict[str, str] = {
|
||||
"scope": config["scope"],
|
||||
"response_type": "code",
|
||||
"state": encode_oauth2_state(state),
|
||||
"redirect_uri": config["redirect_uri"],
|
||||
"client_id": config["id"],
|
||||
}
|
||||
|
||||
# Add PKCE parameters (RFC 7636) if code_verifier is provided
|
||||
if code_verifier:
|
||||
params["code_challenge"] = generate_code_challenge(code_verifier)
|
||||
params["code_challenge_method"] = "S256"
|
||||
|
||||
return parse.urljoin(uri, "?" + parse.urlencode(params))
|
||||
|
||||
@staticmethod
|
||||
def get_extra_params(
|
||||
database: Database, source: QuerySource | None = None
|
||||
@@ -621,18 +448,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
|
||||
database: "Database",
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
# To use OAuth authentication, a database connection must first be created using
|
||||
# another authenticator (typically key-pair authentication)
|
||||
# with “Impersonate logged in user” enabled.
|
||||
# Key-pair authentication is used for connection tests,
|
||||
# while OAuth authentication is used when executing actual queries,
|
||||
# such as in SQL Lab or dashboards.
|
||||
# Therefore, when using OAuth authentication, the key-pair authentication
|
||||
# settings are not loaded, and the connection is established using OAuth only.
|
||||
connect_args: dict[str, Any] = params.get("connect_args") or {}
|
||||
if connect_args.get("authenticator") == "oauth":
|
||||
return
|
||||
|
||||
if not database.encrypted_extra:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Compatibility shim letting pandas >= 2.2 use SQLAlchemy 1.4 engines.
|
||||
|
||||
pandas 2.2 raised its advertised minimum SQLAlchemy version to 2.0 as a
|
||||
support-policy change. When an older SQLAlchemy is installed, pandas does not
|
||||
fail loudly: ``pandas.io.sql`` silently pretends SQLAlchemy is absent, treats
|
||||
Engine/Connection arguments as raw DBAPI connections, and falls back to its
|
||||
sqlite-only code path, breaking every ``DataFrame.to_sql`` / ``read_sql``
|
||||
call site (dataset uploads, example data loading, annotation queries, filter
|
||||
values).
|
||||
|
||||
The pandas SQL layer itself still works with SQLAlchemy 1.4 because it only
|
||||
uses the API subset common to SQLAlchemy 1.4 and 2.x. Lowering the advertised
|
||||
minimum back to the pandas 2.1 value restores the working behavior.
|
||||
|
||||
This module is obsolete once Superset requires SQLAlchemy >= 2; at that point
|
||||
the patch becomes a no-op and the module (and its call site in
|
||||
``superset/__init__.py``) can be deleted.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import sqlalchemy
|
||||
from packaging.version import Version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The last pandas release line to support SQLAlchemy 1.4 (pandas 2.1)
|
||||
# required at least this version.
|
||||
_SQLALCHEMY_MINIMUM = "1.4.16"
|
||||
|
||||
|
||||
def restore_pandas_sqlalchemy_support() -> None:
|
||||
"""Lower pandas' advertised SQLAlchemy minimum so 1.4 engines work.
|
||||
|
||||
Only applies when the installed SQLAlchemy predates 2.0 and pandas
|
||||
advertises a 2.x minimum; in every other combination this is a no-op.
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
if Version(sqlalchemy.__version__) >= Version("2.0.0"):
|
||||
# pandas supports SQLAlchemy 2.x natively; nothing to patch.
|
||||
return
|
||||
|
||||
try:
|
||||
from pandas.compat import _optional
|
||||
except ImportError:
|
||||
# The private module moved in a newer pandas; SQL IO with a pre-2.0
|
||||
# SQLAlchemy will misbehave, so make the situation diagnosable.
|
||||
logger.warning(
|
||||
"Could not adjust pandas' minimum SQLAlchemy version; "
|
||||
"DataFrame.to_sql/read_sql may not accept SQLAlchemy %s engines",
|
||||
sqlalchemy.__version__,
|
||||
)
|
||||
return
|
||||
|
||||
advertised = _optional.VERSIONS.get("sqlalchemy")
|
||||
if advertised and Version(advertised) > Version(_SQLALCHEMY_MINIMUM):
|
||||
_optional.VERSIONS["sqlalchemy"] = _SQLALCHEMY_MINIMUM
|
||||
logger.debug(
|
||||
"Lowered pandas' minimum SQLAlchemy version from %s to %s so "
|
||||
"pandas SQL IO keeps working with the installed SQLAlchemy %s",
|
||||
advertised,
|
||||
_SQLALCHEMY_MINIMUM,
|
||||
sqlalchemy.__version__,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -715,33 +715,12 @@ def _patch_bq_fetch_deps(
|
||||
mocker: MockerFixture, max_mb: int = 200
|
||||
) -> tuple[mock.MagicMock, mock.MagicMock]:
|
||||
"""Helper to patch Flask g and current_app for BigQuery fetch_data tests."""
|
||||
# `new_callable=mock.MagicMock` is pinned explicitly rather than relying on
|
||||
# ``mocker.patch``'s auto-detection of the mock class. That detection
|
||||
# inspects whatever object currently sits at the patched attribute, so if
|
||||
# an earlier test in the same session ever leaves an ``AsyncMock`` there
|
||||
# (e.g. an improperly torn-down patch), every subsequent patch of the same
|
||||
# attribute -- even ones created fresh here -- would also become an
|
||||
# ``AsyncMock``, since ``AsyncMock`` classifies its own non-dunder child
|
||||
# attributes as ``AsyncMock`` too. Pinning the callable sidesteps that
|
||||
# self-perpetuating class inference entirely.
|
||||
flask_g = mocker.patch(
|
||||
"superset.db_engine_specs.bigquery.g", new_callable=mock.MagicMock
|
||||
)
|
||||
app = mocker.patch(
|
||||
"superset.db_engine_specs.bigquery.current_app", new_callable=mock.MagicMock
|
||||
)
|
||||
flask_g = mocker.patch("superset.db_engine_specs.bigquery.g")
|
||||
app = mocker.patch("superset.db_engine_specs.bigquery.current_app")
|
||||
# Make current_app truthy and .config.get() return a plain int
|
||||
app.__bool__ = mock.Mock(return_value=True)
|
||||
app.config = mock.MagicMock()
|
||||
app.config.get = mock.Mock(return_value=max_mb)
|
||||
# ``fetch_data`` only records ``g.bq_memory_limited*`` when
|
||||
# ``has_request_context()`` is true. Outside of a real Flask request
|
||||
# (as in these unit tests) that's always false, so without patching it
|
||||
# the assignments never happen and the mocked ``g`` attributes stay
|
||||
# unset child mocks instead of the expected booleans/counts.
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.bigquery.has_request_context", return_value=True
|
||||
)
|
||||
return flask_g, app
|
||||
|
||||
|
||||
|
||||
@@ -23,11 +23,9 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine.url import make_url, URL
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
from superset.app import SupersetApp
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.superset_typing import OAuth2ClientConfig
|
||||
from superset.utils import json
|
||||
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
|
||||
from tests.unit_tests.fixtures.common import dttm # noqa: F401
|
||||
@@ -352,30 +350,6 @@ def test_mask_encrypted_extra() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_mask_encrypted_extra_oauth2_client_secret() -> None:
|
||||
"""
|
||||
The database-level OAuth2 client secret must be masked in
|
||||
``masked_encrypted_extra``, matching the other engine specs supporting
|
||||
the same ``oauth2_client_info`` path (gsheets, trino) -- otherwise a
|
||||
database editor can read it back unmasked.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
|
||||
config = json.dumps(
|
||||
{
|
||||
"auth_method": "oauth2",
|
||||
"oauth2_client_info": {"id": "client-id", "secret": "my-secret"},
|
||||
}
|
||||
)
|
||||
|
||||
assert SnowflakeEngineSpec.mask_encrypted_extra(config) == json.dumps(
|
||||
{
|
||||
"auth_method": "oauth2",
|
||||
"oauth2_client_info": {"id": "client-id", "secret": "XXXXXXXXXX"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mask_encrypted_extra_no_fields() -> None:
|
||||
"""
|
||||
Test that the private key is masked when the database is edited.
|
||||
@@ -488,278 +462,3 @@ def test_unmask_encrypted_extra() -> None:
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth2_config() -> OAuth2ClientConfig:
|
||||
"""
|
||||
Config for Snowflake OAuth2.
|
||||
"""
|
||||
return {
|
||||
"id": "snowflake-oauth2-client-id",
|
||||
"secret": "snowflake-oauth2-client-secret",
|
||||
"scope": "refresh_token",
|
||||
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
|
||||
"authorization_request_uri": "https://snowflake.oauth2.example/oauth/authorize",
|
||||
"token_request_uri": "https://snowflake.oauth2.example/oauth/token-request",
|
||||
"request_content_type": "data",
|
||||
}
|
||||
|
||||
|
||||
def test_get_oauth2_token(
|
||||
mocker: MockerFixture,
|
||||
oauth2_config: OAuth2ClientConfig,
|
||||
) -> None:
|
||||
"""
|
||||
Test `get_oauth2_token`.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
|
||||
requests: mock.MagicMock = mocker.patch("superset.db_engine_specs.base.requests")
|
||||
requests.post().json.return_value = {
|
||||
"access_token": "access-token",
|
||||
"expires_in": 3600,
|
||||
"scope": "scope",
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "refresh-token",
|
||||
}
|
||||
|
||||
assert SnowflakeEngineSpec.get_oauth2_token(oauth2_config, "code") == {
|
||||
"access_token": "access-token",
|
||||
"expires_in": 3600,
|
||||
"scope": "scope",
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "refresh-token",
|
||||
}
|
||||
requests.post.assert_called_with(
|
||||
"https://snowflake.oauth2.example/oauth/token-request",
|
||||
data={
|
||||
"code": "code",
|
||||
"client_id": "snowflake-oauth2-client-id",
|
||||
"client_secret": "snowflake-oauth2-client-secret",
|
||||
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def test_impersonate_user(app: SupersetApp, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that Snowflake supports user impersonation.
|
||||
|
||||
Impersonation only applies within a request context (see
|
||||
``test_impersonate_user_outside_request_context`` below for the
|
||||
background-execution case), so these assertions run inside one.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
from superset.models.core import Database
|
||||
|
||||
database: Database = Database(sqlalchemy_uri="snowflake://abc")
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.snowflake.SnowflakeEngineSpec.is_oauth2_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
with app.test_request_context("/some/place/"):
|
||||
assert SnowflakeEngineSpec.impersonate_user(
|
||||
database=database,
|
||||
username=None,
|
||||
user_token=None,
|
||||
url=make_url("snowflake://user:pass@account/database_name/default"),
|
||||
engine_kwargs={
|
||||
"connect_args": {
|
||||
"validate_default_parameters": True,
|
||||
},
|
||||
},
|
||||
) == (
|
||||
make_url("snowflake://user:pass@account/database_name/default"),
|
||||
{"connect_args": {"validate_default_parameters": True}},
|
||||
)
|
||||
|
||||
assert SnowflakeEngineSpec.impersonate_user(
|
||||
database=database,
|
||||
username=None,
|
||||
user_token=None,
|
||||
url=make_url("snowflake://user:pass@account/database_name/default"),
|
||||
engine_kwargs={},
|
||||
) == (
|
||||
make_url(
|
||||
"snowflake://user:pass@account/database_name/default?authenticator=oauth"
|
||||
),
|
||||
{"connect_args": {"authenticator": "oauth"}},
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.snowflake.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.find_user",
|
||||
return_value=mocker.MagicMock(email="impersonated_user@example.com"),
|
||||
)
|
||||
assert SnowflakeEngineSpec.impersonate_user(
|
||||
database=database,
|
||||
username="impersonated_user",
|
||||
user_token="test_token", # noqa: S106
|
||||
url=make_url("snowflake://user:pass@account/database_name/default"),
|
||||
engine_kwargs={},
|
||||
) == (
|
||||
make_url(
|
||||
"snowflake://impersonated_user:pass@account/database_name/default?authenticator=oauth&token=test_token"
|
||||
),
|
||||
{"connect_args": {"authenticator": "oauth"}},
|
||||
)
|
||||
|
||||
|
||||
def test_impersonate_user_email_prefix_uses_username_directly(
|
||||
app: SupersetApp, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""
|
||||
With IMPERSONATE_WITH_EMAIL_PREFIX enabled, ``Database._get_sqla_engine()``
|
||||
has already substituted the email prefix for the login username before
|
||||
calling ``impersonate_user`` -- the value it passes in is no longer a
|
||||
lookupable login. Re-looking it up as a username (the pre-fix behavior)
|
||||
fails whenever the login differs from the prefix, silently leaving the
|
||||
default/service-account username paired with the impersonated user's
|
||||
OAuth token instead of failing loudly. The fixed code must use the given
|
||||
value directly and must not call ``find_user`` at all in this branch.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
from superset.models.core import Database
|
||||
|
||||
database: Database = Database(sqlalchemy_uri="snowflake://abc")
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.snowflake.SnowflakeEngineSpec.is_oauth2_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.snowflake.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
find_user = mocker.patch("superset.security_manager.find_user")
|
||||
|
||||
with app.test_request_context("/some/place/"):
|
||||
# "jdoe" is the email prefix Database._get_sqla_engine() already
|
||||
# derived; the login it derived it from ("jdoe123", say) is gone by
|
||||
# this point and must not be re-derived here.
|
||||
result = SnowflakeEngineSpec.impersonate_user(
|
||||
database=database,
|
||||
username="jdoe",
|
||||
user_token="test_token", # noqa: S106
|
||||
url=make_url("snowflake://user:pass@account/database_name/default"),
|
||||
engine_kwargs={},
|
||||
)
|
||||
|
||||
assert result == (
|
||||
make_url(
|
||||
"snowflake://jdoe:pass@account/database_name/default?authenticator=oauth&token=test_token"
|
||||
),
|
||||
{"connect_args": {"authenticator": "oauth"}},
|
||||
)
|
||||
find_user.assert_not_called()
|
||||
|
||||
|
||||
def test_impersonate_user_outside_request_context(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Background executions (alerts/reports) have no per-user token, so OAuth
|
||||
impersonation must not engage outside a request context — even when
|
||||
``database.is_oauth2_enabled()`` returns True because of a
|
||||
database-level OAuth2 client config, which (unlike the app-config-based
|
||||
check) isn't itself request-context-aware.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
from superset.models.core import Database
|
||||
|
||||
database: Database = Database(sqlalchemy_uri="snowflake://abc")
|
||||
mocker.patch.object(Database, "is_oauth2_enabled", return_value=True)
|
||||
|
||||
url: URL = make_url("snowflake://user:pass@account/database_name/default")
|
||||
assert SnowflakeEngineSpec.impersonate_user(
|
||||
database=database,
|
||||
username=None,
|
||||
user_token="test_token", # noqa: S106
|
||||
url=url,
|
||||
engine_kwargs={},
|
||||
) == (url, {"connect_args": {}})
|
||||
|
||||
|
||||
def test_custom_snowflake_auth_error_matches_raw_dbapi_exception() -> None:
|
||||
"""
|
||||
`BaseEngineSpec.execute()` runs against a bare DBAPI cursor, so the
|
||||
exception it sees is the raw Snowflake error, never wrapped by
|
||||
SQLAlchemy. `CustomSnowflakeAuthError` must still recognize it so the
|
||||
OAuth2 re-auth dance triggers for SQL Lab queries.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import (
|
||||
CustomSnowflakeAuthError,
|
||||
DatabaseError,
|
||||
)
|
||||
|
||||
raw_error: Exception = DatabaseError("250001: Invalid OAuth access token.")
|
||||
assert isinstance(raw_error, CustomSnowflakeAuthError)
|
||||
|
||||
|
||||
def test_custom_snowflake_auth_error_matches_sqlalchemy_wrapped_exception() -> None:
|
||||
"""
|
||||
Some call sites execute through SQLAlchemy's `Engine`, which wraps the
|
||||
original DBAPI exception in `sqlalchemy.exc.DatabaseError.orig`.
|
||||
`CustomSnowflakeAuthError` must keep matching this shape too.
|
||||
"""
|
||||
from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError
|
||||
|
||||
from superset.db_engine_specs.snowflake import (
|
||||
CustomSnowflakeAuthError,
|
||||
DatabaseError,
|
||||
)
|
||||
|
||||
wrapped_error: SqlalchemyDatabaseError = SqlalchemyDatabaseError(
|
||||
statement="SELECT 1",
|
||||
params=None,
|
||||
orig=DatabaseError("250001: Invalid OAuth access token."),
|
||||
)
|
||||
assert isinstance(wrapped_error, CustomSnowflakeAuthError)
|
||||
|
||||
|
||||
def test_custom_snowflake_auth_error_does_not_match_unrelated_errors() -> None:
|
||||
"""
|
||||
Other Snowflake DB errors, and non-Snowflake exceptions, must not be
|
||||
mistaken for an expired OAuth token.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import (
|
||||
CustomSnowflakeAuthError,
|
||||
DatabaseError,
|
||||
)
|
||||
|
||||
assert not isinstance(
|
||||
DatabaseError("Object FOO does not exist."), CustomSnowflakeAuthError
|
||||
)
|
||||
assert not isinstance(
|
||||
ValueError("Invalid OAuth access token."), CustomSnowflakeAuthError
|
||||
)
|
||||
|
||||
|
||||
def test_snowflake_oauth2_exception_catches_refresh_token_error() -> None:
|
||||
"""
|
||||
`refresh_oauth2_token()` catches failures from the (unoverridden) base
|
||||
`get_oauth2_fresh_token()` with `except db_engine_spec.oauth2_exception`.
|
||||
That base method raises `OAuth2TokenRefreshError`, which isn't related to
|
||||
`CustomSnowflakeAuthError` by real subclassing, so `oauth2_exception` must
|
||||
include it directly -- an `except` clause never triggers the metaclass's
|
||||
`__instancecheck__`, unlike `isinstance()`.
|
||||
"""
|
||||
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
|
||||
from superset.exceptions import OAuth2TokenRefreshError
|
||||
|
||||
try:
|
||||
raise OAuth2TokenRefreshError("refresh token revoked")
|
||||
except SnowflakeEngineSpec.oauth2_exception:
|
||||
pass
|
||||
else:
|
||||
pytest.fail(
|
||||
"OAuth2TokenRefreshError must be caught by "
|
||||
"SnowflakeEngineSpec.oauth2_exception"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -465,93 +465,86 @@ def test_get_sql_results_oauth2(mocker: MockerFixture, app) -> None:
|
||||
"""
|
||||
Test that `get_sql_results` works with OAuth2.
|
||||
"""
|
||||
# Pushed/popped manually (rather than via a ``with`` block) so the
|
||||
# ``finally`` below still pops it if an assertion fails, preventing the
|
||||
# request context from leaking into later tests in the same session.
|
||||
app_context = app.test_request_context()
|
||||
app_context.push()
|
||||
|
||||
try:
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.base.uuid4",
|
||||
return_value=UUID("fb11f528-6eba-4a8a-837e-6b0d39ee9187"),
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.base.generate_code_verifier",
|
||||
return_value="xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ",
|
||||
)
|
||||
mocker.patch("superset.daos.key_value.KeyValueDAO.delete_expired_entries")
|
||||
mocker.patch("superset.daos.key_value.KeyValueDAO.create_entry")
|
||||
mocker.patch("superset.db_engine_specs.base.db.session.commit")
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.base.uuid4",
|
||||
return_value=UUID("fb11f528-6eba-4a8a-837e-6b0d39ee9187"),
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.base.generate_code_verifier",
|
||||
return_value="xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ",
|
||||
)
|
||||
mocker.patch("superset.daos.key_value.KeyValueDAO.delete_expired_entries")
|
||||
mocker.patch("superset.daos.key_value.KeyValueDAO.create_entry")
|
||||
mocker.patch("superset.db_engine_specs.base.db.session.commit")
|
||||
|
||||
g = mocker.patch("superset.db_engine_specs.base.g")
|
||||
g.user = mocker.MagicMock()
|
||||
g.user.id = 42
|
||||
g = mocker.patch("superset.db_engine_specs.base.g")
|
||||
g.user = mocker.MagicMock()
|
||||
g.user.id = 42
|
||||
|
||||
database = Database(
|
||||
id=1,
|
||||
database_name="my_db",
|
||||
sqlalchemy_uri="sqlite://",
|
||||
encrypted_extra=json.dumps(oauth2_client_info),
|
||||
)
|
||||
database.db_engine_spec.oauth2_exception = OAuth2Error
|
||||
get_sqla_engine = mocker.patch.object(database, "get_sqla_engine")
|
||||
get_sqla_engine().__enter__().raw_connection.side_effect = OAuth2Error(
|
||||
"OAuth2 required"
|
||||
)
|
||||
database = Database(
|
||||
id=1,
|
||||
database_name="my_db",
|
||||
sqlalchemy_uri="sqlite://",
|
||||
encrypted_extra=json.dumps(oauth2_client_info),
|
||||
)
|
||||
database.db_engine_spec.oauth2_exception = OAuth2Error
|
||||
get_sqla_engine = mocker.patch.object(database, "get_sqla_engine")
|
||||
get_sqla_engine().__enter__().raw_connection.side_effect = OAuth2Error(
|
||||
"OAuth2 required"
|
||||
)
|
||||
|
||||
# `limit` and `select_as_cta_used` must match the real `Query` model's
|
||||
# defaults (nullable Integer -> None, Boolean default=False) so that
|
||||
# `apply_limit` -- called unconditionally before the mocked OAuth2 error
|
||||
# is ever reached -- doesn't try to compare an unconfigured MagicMock
|
||||
# against an int.
|
||||
query = mocker.MagicMock(
|
||||
select_as_cta=False,
|
||||
select_as_cta_used=False,
|
||||
limit=None,
|
||||
database=database,
|
||||
)
|
||||
mocker.patch("superset.sql_lab.get_query", return_value=query)
|
||||
# `limit` and `select_as_cta_used` must match the real `Query` model's
|
||||
# defaults (nullable Integer -> None, Boolean default=False) so that
|
||||
# `apply_limit` -- called unconditionally before the mocked OAuth2 error
|
||||
# is ever reached -- doesn't try to compare an unconfigured MagicMock
|
||||
# against an int.
|
||||
query = mocker.MagicMock(
|
||||
select_as_cta=False,
|
||||
select_as_cta_used=False,
|
||||
limit=None,
|
||||
database=database,
|
||||
)
|
||||
mocker.patch("superset.sql_lab.get_query", return_value=query)
|
||||
|
||||
payload = get_sql_results(query_id=1, rendered_query="SELECT 1")
|
||||
assert payload["status"] == QueryStatus.FAILED
|
||||
assert payload["error"] == "You don't have permission to access the data."
|
||||
assert len(payload["errors"]) == 1
|
||||
payload = get_sql_results(query_id=1, rendered_query="SELECT 1")
|
||||
assert payload["status"] == QueryStatus.FAILED
|
||||
assert payload["error"] == "You don't have permission to access the data."
|
||||
assert len(payload["errors"]) == 1
|
||||
|
||||
error = payload["errors"][0]
|
||||
assert error["message"] == "You don't have permission to access the data."
|
||||
assert error["error_type"] == SupersetErrorType.OAUTH2_REDIRECT
|
||||
assert error["level"] == ErrorLevel.WARNING
|
||||
assert error["extra"]["tab_id"] == "fb11f528-6eba-4a8a-837e-6b0d39ee9187"
|
||||
assert (
|
||||
error["extra"]["redirect_uri"]
|
||||
== "http://example.com/api/v1/database/oauth2/"
|
||||
)
|
||||
error = payload["errors"][0]
|
||||
assert error["message"] == "You don't have permission to access the data."
|
||||
assert error["error_type"] == SupersetErrorType.OAUTH2_REDIRECT
|
||||
assert error["level"] == ErrorLevel.WARNING
|
||||
assert error["extra"]["tab_id"] == "fb11f528-6eba-4a8a-837e-6b0d39ee9187"
|
||||
assert (
|
||||
error["extra"]["redirect_uri"] == "http://example.com/api/v1/database/oauth2/"
|
||||
)
|
||||
|
||||
# Parse the OAuth2 authorization URL and verify components individually,
|
||||
# since the JWT state and PKCE code_challenge are computed deterministically
|
||||
# from mocked inputs but their exact encoding depends on library internals.
|
||||
url = urlparse(error["extra"]["url"])
|
||||
assert url.scheme == "https"
|
||||
assert url.netloc == "abcd1234.snowflakecomputing.com"
|
||||
assert url.path == "/oauth/authorize"
|
||||
# Parse the OAuth2 authorization URL and verify components individually,
|
||||
# since the JWT state and PKCE code_challenge are computed deterministically
|
||||
# from mocked inputs but their exact encoding depends on library internals.
|
||||
url = urlparse(error["extra"]["url"])
|
||||
assert url.scheme == "https"
|
||||
assert url.netloc == "abcd1234.snowflakecomputing.com"
|
||||
assert url.path == "/oauth/authorize"
|
||||
|
||||
params = parse_qs(url.query)
|
||||
assert params["scope"] == ["refresh_token session:role:USERADMIN"]
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["redirect_uri"] == ["http://example.com/api/v1/database/oauth2/"]
|
||||
assert params["client_id"] == ["my_client_id"]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
params = parse_qs(url.query)
|
||||
assert params["scope"] == ["refresh_token session:role:USERADMIN"]
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["redirect_uri"] == ["http://example.com/api/v1/database/oauth2/"]
|
||||
assert params["client_id"] == ["my_client_id"]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
|
||||
# Verify PKCE code_challenge matches the mocked code_verifier
|
||||
from superset.utils.oauth2 import generate_code_challenge
|
||||
# Verify PKCE code_challenge matches the mocked code_verifier
|
||||
from superset.utils.oauth2 import generate_code_challenge
|
||||
|
||||
expected_code_challenge = generate_code_challenge(
|
||||
"xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ"
|
||||
)
|
||||
assert params["code_challenge"] == [expected_code_challenge]
|
||||
finally:
|
||||
app_context.pop()
|
||||
expected_code_challenge = generate_code_challenge(
|
||||
"xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ"
|
||||
)
|
||||
assert params["code_challenge"] == [expected_code_challenge]
|
||||
|
||||
|
||||
def test_apply_rls(mocker: MockerFixture) -> None:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, types
|
||||
|
||||
from superset.utils.pandas_sqlalchemy_compat import (
|
||||
restore_pandas_sqlalchemy_support,
|
||||
)
|
||||
|
||||
|
||||
def test_to_sql_accepts_sqlalchemy_engine_and_dtypes() -> None:
|
||||
"""
|
||||
``DataFrame.to_sql`` must accept a SQLAlchemy engine plus SQLAlchemy
|
||||
``dtype`` objects regardless of the installed pandas/SQLAlchemy combo.
|
||||
|
||||
This is the exact call shape used by dataset uploads
|
||||
(``BaseEngineSpec.df_to_sql``), example data loading, and the test data
|
||||
loaders; it breaks when pandas silently rejects the installed SQLAlchemy
|
||||
as too old (pandas >= 2.2 with SQLAlchemy 1.x) and no compat shim is
|
||||
applied.
|
||||
"""
|
||||
restore_pandas_sqlalchemy_support()
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"name": ["a", "b"],
|
||||
"num": [1, 2],
|
||||
"ds": pd.to_datetime(["2021-01-01", "2021-01-02"]),
|
||||
}
|
||||
)
|
||||
df.to_sql(
|
||||
"birth_names",
|
||||
engine,
|
||||
index=False,
|
||||
dtype={"ds": types.DateTime(), "name": types.String(255)},
|
||||
method="multi",
|
||||
chunksize=100,
|
||||
)
|
||||
df.to_sql("birth_names", engine, index=False, if_exists="replace")
|
||||
|
||||
result = pd.read_sql_query("SELECT name, num FROM birth_names", engine)
|
||||
assert result["name"].tolist() == ["a", "b"]
|
||||
assert result["num"].tolist() == [1, 2]
|
||||
|
||||
|
||||
def test_restore_pandas_sqlalchemy_support_is_idempotent() -> None:
|
||||
from pandas.compat import _optional
|
||||
|
||||
restore_pandas_sqlalchemy_support()
|
||||
first = _optional.VERSIONS.get("sqlalchemy")
|
||||
restore_pandas_sqlalchemy_support()
|
||||
assert _optional.VERSIONS.get("sqlalchemy") == first
|
||||
Reference in New Issue
Block a user