mirror of
https://github.com/apache/superset.git
synced 2026-08-26 10:01:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8b320f33e | ||
|
|
63060e224d | ||
|
|
6b58b24f1e | ||
|
|
89f9b4aea2 |
@@ -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",
|
||||
|
||||
@@ -1138,7 +1138,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
?.backgroundColor || backgroundColor;
|
||||
arrow =
|
||||
column.label === comparisonLabels[0]
|
||||
? basicColorColumnFormatters[row.index]?.[column.key]?.mainArrow
|
||||
? (basicColorColumnFormatters[row.index]?.[column.key]
|
||||
?.mainArrow ?? arrow)
|
||||
: '';
|
||||
}
|
||||
const rowSurfaceColor =
|
||||
@@ -1194,30 +1195,36 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
`;
|
||||
|
||||
let arrowStyles = css`
|
||||
color: ${
|
||||
// Plain inline style (rather than the `css` prop) so the arrow's
|
||||
// color is guaranteed to apply regardless of whether the consuming
|
||||
// app's build wires up the emotion JSX pragma for the `css` prop --
|
||||
// notably, this codebase's own Jest/Babel config does not, which
|
||||
// silently no-ops any `css` prop on a plain DOM element.
|
||||
let arrowStyles: CSSProperties = {
|
||||
color:
|
||||
basicColorFormatters &&
|
||||
basicColorFormatters[row.index]?.[originKey]?.arrowColor ===
|
||||
ColorSchemeEnum.Green
|
||||
? theme.colorSuccess
|
||||
: theme.colorError
|
||||
};
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`;
|
||||
: theme.colorError,
|
||||
marginRight: theme.sizeUnit,
|
||||
};
|
||||
|
||||
if (
|
||||
basicColorColumnFormatters &&
|
||||
basicColorColumnFormatters?.length > 0
|
||||
) {
|
||||
arrowStyles = css`
|
||||
color: ${
|
||||
basicColorColumnFormatters[row.index]?.[column.key]
|
||||
?.arrowColor === ColorSchemeEnum.Green
|
||||
? theme.colorSuccess
|
||||
: theme.colorError
|
||||
const columnArrowColor =
|
||||
basicColorColumnFormatters[row.index]?.[column.key]?.arrowColor;
|
||||
if (columnArrowColor) {
|
||||
arrowStyles = {
|
||||
color:
|
||||
columnArrowColor === ColorSchemeEnum.Green
|
||||
? theme.colorSuccess
|
||||
: theme.colorError,
|
||||
marginRight: theme.sizeUnit,
|
||||
};
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const cellProps = {
|
||||
@@ -1302,12 +1309,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
className="dt-truncate-cell"
|
||||
style={columnWidth ? { width: columnWidth } : undefined}
|
||||
>
|
||||
{arrow && <span css={arrowStyles}>{arrow}</span>}
|
||||
{arrow && <span style={arrowStyles}>{arrow}</span>}
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{arrow && <span css={arrowStyles}>{arrow}</span>}
|
||||
{arrow && <span style={arrowStyles}>{arrow}</span>}
|
||||
{text}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2111,7 +2111,14 @@ describe('plugin-chart-table', () => {
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<TableChart {...propsWithMissingFormatterEntry} sticky={false} />,
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...propsWithMissingFormatterEntry}
|
||||
sticky={false}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
@@ -2125,8 +2132,24 @@ describe('plugin-chart-table', () => {
|
||||
'rgba(0, 150, 0, 0.2)',
|
||||
);
|
||||
|
||||
// the row missing a formatter entry still renders its raw value
|
||||
expect(screen.getAllByTitle('110').length).toBeGreaterThan(0);
|
||||
// the row missing a formatter entry falls back to the row-level
|
||||
// comparison arrow instead of losing it: before the fix, this row's
|
||||
// arrow was silently cleared (and its color, computed the same way,
|
||||
// would have flipped to the "decrease" color) whenever the
|
||||
// column-specific lookup for this row was undefined.
|
||||
const arrowCell = screen
|
||||
.getAllByTitle('110')
|
||||
.find(cell => cell.querySelector('span'));
|
||||
expect(arrowCell).toHaveTextContent('↑110');
|
||||
expect(getComputedStyle(arrowCell!).background).toContain(
|
||||
'rgba(0, 150, 0, 0.2)',
|
||||
);
|
||||
// the fallback arrow itself must also keep the "increase" color --
|
||||
// asserting only the cell background would still pass if the arrow's
|
||||
// own color had regressed to the "decrease" color.
|
||||
expect(arrowCell!.querySelector('span')).toHaveStyle({
|
||||
color: supersetTheme.colorSuccess,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves client-side search text across temporal table rerenders', async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,13 +32,11 @@ from superset.commands.chart.exceptions import (
|
||||
DashboardsForbiddenError,
|
||||
DashboardsNotFoundValidationError,
|
||||
)
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.commands.utils import get_datasource_by_id, populate_subjects
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils import json
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,16 +71,6 @@ class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
|
||||
# Validate/Populate datasource
|
||||
try:
|
||||
# Slice.datasource only ever resolves the ``table`` relationship
|
||||
# (see Slice.datasource in superset/models/slice.py), so a chart
|
||||
# pointed at any other datasource_type would "create"
|
||||
# successfully but could never actually render. Reject those
|
||||
# up front instead of failing later -- either at this lookup
|
||||
# (SavedQuery/Query have no ``.name`` attribute, so accessing it
|
||||
# below raises an unhandled AttributeError) or silently, by
|
||||
# producing a permanently broken chart.
|
||||
if datasource_type != DatasourceType.TABLE:
|
||||
raise DatasourceTypeInvalidError()
|
||||
datasource = get_datasource_by_id(datasource_id, datasource_type)
|
||||
self._properties["datasource_name"] = datasource.name
|
||||
security_manager.raise_for_access(datasource=datasource)
|
||||
|
||||
@@ -35,7 +35,6 @@ from superset.commands.chart.exceptions import (
|
||||
DashboardsNotFoundValidationError,
|
||||
DatasourceTypeUpdateRequiredValidationError,
|
||||
)
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.commands.utils import (
|
||||
compute_subjects,
|
||||
get_datasource_by_id,
|
||||
@@ -50,7 +49,6 @@ from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
@@ -223,22 +221,8 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
exceptions.append(ex)
|
||||
|
||||
# Validate/Populate datasource
|
||||
# An empty datasource_type was already flagged above via
|
||||
# DatasourceTypeUpdateRequiredValidationError; skip this block so
|
||||
# we don't clobber that message with DatasourceTypeInvalidError.
|
||||
if datasource_id is not None and datasource_type:
|
||||
if datasource_id is not None:
|
||||
try:
|
||||
# Slice.datasource only ever resolves the ``table``
|
||||
# relationship (see Slice.datasource in
|
||||
# superset/models/slice.py), so repointing a chart at any
|
||||
# other datasource_type would "succeed" but leave the chart
|
||||
# permanently unable to render. Reject those up front
|
||||
# instead of failing later -- either at this lookup
|
||||
# (SavedQuery/Query have no ``.name`` attribute, so
|
||||
# accessing it below raises an unhandled AttributeError) or
|
||||
# silently.
|
||||
if datasource_type != DatasourceType.TABLE:
|
||||
raise DatasourceTypeInvalidError()
|
||||
datasource = get_datasource_by_id(datasource_id, datasource_type)
|
||||
self._properties["datasource_name"] = datasource.name
|
||||
security_manager.raise_for_access(datasource=datasource)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -35,14 +35,12 @@ from superset.extensions import cache_manager, db, security_manager
|
||||
from superset.models.core import Database, FavStar, FavStarClassName
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.reports.models import ReportSchedule, ReportScheduleType
|
||||
from superset.subjects.models import Subject
|
||||
from superset.subjects.types import SubjectType
|
||||
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_example_default_schema
|
||||
from superset.utils.database import get_example_database
|
||||
from tests.integration_tests.base_api_tests import ApiEditorsTestCaseMixin
|
||||
from tests.integration_tests.base_tests import (
|
||||
subjects_from_users,
|
||||
@@ -662,47 +660,6 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response == {"message": {"datasource_id": ["Datasource does not exist"]}}
|
||||
|
||||
def test_create_chart_from_saved_query_rejected_cleanly(self):
|
||||
"""
|
||||
Chart API: creating a chart with datasource_type="saved_query" must
|
||||
fail with a clean validation error, not the unhandled 500 "Fatal
|
||||
error" reported in apache/superset#29697. Slice.datasource only
|
||||
ever resolves the "table" relationship, so even a chart that
|
||||
"created" successfully with this datasource_type could never
|
||||
actually render -- "saved_query" is a real, existing row here
|
||||
(not a bad ID), reproducing the original report exactly rather
|
||||
than a not-found case.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
example_db = get_example_database()
|
||||
saved_query = SavedQuery(
|
||||
db_id=example_db.id,
|
||||
label="issue-29697-repro",
|
||||
schema=get_example_default_schema(),
|
||||
sql="SELECT 1 AS value",
|
||||
)
|
||||
db.session.add(saved_query)
|
||||
db.session.commit()
|
||||
saved_query_id = saved_query.id
|
||||
|
||||
chart_data = {
|
||||
"slice_name": "issue-29697-repro-chart",
|
||||
"datasource_id": saved_query_id,
|
||||
"datasource_type": "saved_query",
|
||||
"viz_type": "table",
|
||||
}
|
||||
try:
|
||||
rv = self.post_assert_metric("/api/v1/chart/", chart_data, "post")
|
||||
|
||||
assert rv.status_code == 422
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response == {
|
||||
"message": {"datasource_type": ["Datasource type is invalid"]}
|
||||
}
|
||||
finally:
|
||||
db.session.delete(db.session.query(SavedQuery).get(saved_query_id))
|
||||
db.session.commit()
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_create_chart_validate_user_is_dashboard_editor(self):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,154 +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.
|
||||
"""Unit tests for CreateChartCommand.
|
||||
|
||||
Regression coverage for apache/superset#29697: POST /api/v1/chart/ with
|
||||
datasource_type="saved_query" (or "query") crashes with an unhandled
|
||||
AttributeError -- reported to API clients as an opaque 500 "Fatal error" --
|
||||
because SavedQuery and Query models have no ``.name`` attribute, and because
|
||||
Slice.datasource only ever resolves a ``table``-typed datasource, so even a
|
||||
successfully created chart of another type could never actually render.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def _base_mocks(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.DashboardDAO.find_by_ids", return_value=[]
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.populate_subjects",
|
||||
side_effect=lambda properties, exceptions: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
|
||||
def test_create_chart_rejects_non_table_datasource_type(
|
||||
mocker: MockerFixture, datasource_type: str
|
||||
) -> None:
|
||||
"""A chart can only ever query a table-backed datasource -- Slice.datasource
|
||||
only ever resolves the ``table`` relationship, so any other type would
|
||||
produce a chart that "creates" successfully but can never render.
|
||||
|
||||
The two types fail differently before this fix, which is exactly why
|
||||
both are covered here:
|
||||
- "saved_query": SavedQuery has no ``.name`` attribute, so validation
|
||||
crashes with an unhandled AttributeError -- surfaced to API clients as
|
||||
an opaque 500 "Fatal error" (apache/superset#29697).
|
||||
- "query": Query *does* define a synthetic ``.name`` property (used for
|
||||
CTAS table naming, not as a real display name), so this one doesn't
|
||||
crash -- it silently "succeeds" and creates a chart with a nonsense
|
||||
name and a datasource that Slice.datasource can never resolve.
|
||||
|
||||
``get_datasource_by_id`` is mocked with ``spec=`` the real model classes
|
||||
so accessing ``.name`` on the mock behaves exactly like the real ORM
|
||||
objects do if the new guard doesn't stop the code from getting there;
|
||||
``raise_for_access`` is mocked to a no-op so nothing downstream masks
|
||||
that behavior.
|
||||
"""
|
||||
from superset.models.sql_lab import Query, SavedQuery
|
||||
|
||||
_base_mocks(mocker)
|
||||
model_cls = SavedQuery if datasource_type == "saved_query" else Query
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=mocker.MagicMock(spec=model_cls),
|
||||
)
|
||||
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": datasource_type,
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
# The invalid type must be rejected before ever touching the datasource
|
||||
# lookup, not caught incidentally by some downstream failure.
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
|
||||
def test_create_chart_accepts_table_datasource(mocker: MockerFixture) -> None:
|
||||
"""The one supported datasource_type must keep working."""
|
||||
_base_mocks(mocker)
|
||||
datasource = mocker.MagicMock(name="table_datasource")
|
||||
datasource.name = "my_table"
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=datasource,
|
||||
)
|
||||
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
|
||||
|
||||
cmd = CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": "table",
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
)
|
||||
cmd.validate()
|
||||
|
||||
assert cmd._properties["datasource_name"] == "my_table"
|
||||
|
||||
|
||||
def test_create_chart_datasource_access_denied_still_raises_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The invalid-type guard must not shadow the existing access-denied path
|
||||
for a legitimately table-typed datasource the user can't access."""
|
||||
_base_mocks(mocker)
|
||||
datasource = mocker.MagicMock()
|
||||
datasource.name = "my_table"
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.get_datasource_by_id",
|
||||
return_value=datasource,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.chart.create.security_manager.raise_for_access",
|
||||
side_effect=SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="No access",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
CreateChartCommand(
|
||||
{
|
||||
"datasource_id": 11,
|
||||
"datasource_type": "table",
|
||||
"slice_name": "some_name",
|
||||
"viz_type": "table",
|
||||
}
|
||||
).validate()
|
||||
@@ -17,13 +17,8 @@
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartInvalidError,
|
||||
DatasourceTypeUpdateRequiredValidationError,
|
||||
)
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
from superset.commands.exceptions import DatasourceTypeInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils import json
|
||||
@@ -243,71 +238,3 @@ def test_update_chart_query_context_without_datasource_is_allowed(
|
||||
1,
|
||||
{"query_context": query_context, "query_context_generation": True},
|
||||
).validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
|
||||
def test_update_chart_rejects_repointing_to_non_table_datasource(
|
||||
mocker: MockerFixture, datasource_type: str
|
||||
) -> None:
|
||||
"""Repointing a chart's datasource_id must be rejected the same way
|
||||
CreateChartCommand rejects it (apache/superset#29697): Slice.datasource
|
||||
only ever resolves the ``table`` relationship, so repointing at a
|
||||
saved_query or query datasource would "succeed" but leave the chart
|
||||
permanently unable to render -- or, for saved_query specifically, crash
|
||||
on SavedQuery's missing ``.name`` attribute before that point is even
|
||||
reached. This is a regular (non-query-context) update, so it goes
|
||||
through editorship + compute_subjects, unlike the query-context-only
|
||||
tests above."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
|
||||
mocker.patch(
|
||||
"superset.commands.chart.update.compute_subjects",
|
||||
side_effect=lambda model, properties, exceptions: None,
|
||||
)
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.update.get_datasource_by_id"
|
||||
)
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
UpdateChartCommand(
|
||||
1, {"datasource_id": 11, "datasource_type": datasource_type}
|
||||
).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
|
||||
def test_update_chart_missing_datasource_type_keeps_required_error(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When datasource_id is given without datasource_type, the response
|
||||
must keep reporting DatasourceTypeUpdateRequiredValidationError
|
||||
("Datasource type is required") rather than having it overwritten by
|
||||
DatasourceTypeInvalidError ("Datasource type is invalid") -- both
|
||||
exceptions key their message under ``datasource_type``, and
|
||||
normalized_messages() only keeps the last one written for a given key."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
|
||||
mocker.patch(
|
||||
"superset.commands.chart.update.compute_subjects",
|
||||
side_effect=lambda model, properties, exceptions: None,
|
||||
)
|
||||
get_datasource_by_id = mocker.patch(
|
||||
"superset.commands.chart.update.get_datasource_by_id"
|
||||
)
|
||||
|
||||
with pytest.raises(ChartInvalidError) as exc_info:
|
||||
UpdateChartCommand(1, {"datasource_id": 11}).validate()
|
||||
|
||||
assert any(
|
||||
isinstance(ex, DatasourceTypeUpdateRequiredValidationError)
|
||||
for ex in exc_info.value._exceptions
|
||||
)
|
||||
assert not any(
|
||||
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
|
||||
)
|
||||
get_datasource_by_id.assert_not_called()
|
||||
|
||||
@@ -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