mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
13 Commits
showtime-m
...
fix/chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d796fc37de | ||
|
|
c5a683a589 | ||
|
|
f915b46be7 | ||
|
|
186aa99ecb | ||
|
|
2f0e4861a4 | ||
|
|
d51753dfdc | ||
|
|
543ad04ca0 | ||
|
|
00e3682aaf | ||
|
|
004101a752 | ||
|
|
568f34d6d8 | ||
|
|
a0cf798409 | ||
|
|
88ea96d417 | ||
|
|
c88438ad35 |
@@ -297,7 +297,7 @@ pre-commit run eslint # Frontend linting
|
||||
## Platform-Specific Instructions
|
||||
|
||||
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
|
||||
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
|
||||
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
|
||||
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
|
||||
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
|
||||
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"webpack": "^5.107.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
127
docs/yarn.lock
127
docs/yarn.lock
@@ -4812,32 +4812,21 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.60.0", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz#8fc1e0a950c43270eaf0212dc060f7edaa42f9cf"
|
||||
integrity sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==
|
||||
"@typescript-eslint/eslint-plugin@8.60.1", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
|
||||
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/type-utils" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
"@typescript-eslint/scope-manager" "8.60.1"
|
||||
"@typescript-eslint/type-utils" "8.60.1"
|
||||
"@typescript-eslint/utils" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.0.tgz#38d611b8e658cb10850d4975e8a175a222fbcd6a"
|
||||
integrity sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/parser@^8.60.1":
|
||||
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
|
||||
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
|
||||
@@ -4848,15 +4837,6 @@
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.0.tgz#b82ab12e64d005d0c7163d1240c432381f1bde0f"
|
||||
integrity sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.60.0"
|
||||
"@typescript-eslint/types" "^8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
|
||||
@@ -4866,14 +4846,6 @@
|
||||
"@typescript-eslint/types" "^8.60.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz#7617a4617c043fe235dcf066f9a40f106cfd2fd5"
|
||||
integrity sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
|
||||
@@ -4882,12 +4854,7 @@
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz#3af78c48956227a407dea9626b8db8ca53f130d2"
|
||||
integrity sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.60.1", "@typescript-eslint/tsconfig-utils@^8.60.0":
|
||||
"@typescript-eslint/tsconfig-utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
|
||||
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
|
||||
@@ -4897,47 +4864,27 @@
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
|
||||
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
|
||||
|
||||
"@typescript-eslint/type-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz#6971a61bc4f3a1b2df45dcc14e26a43a88a4cb6a"
|
||||
integrity sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==
|
||||
"@typescript-eslint/type-utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
|
||||
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
"@typescript-eslint/utils" "8.60.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.0.tgz#e77ad768e933263b1960b2fe79de75fe1cc6e7db"
|
||||
integrity sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==
|
||||
|
||||
"@typescript-eslint/types@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
|
||||
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
|
||||
|
||||
"@typescript-eslint/types@^8.60.0", "@typescript-eslint/types@^8.60.1":
|
||||
"@typescript-eslint/types@^8.60.1":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
|
||||
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz#c102196a44414481190041c99eea1d854e66001b"
|
||||
integrity sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.60.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
|
||||
@@ -4953,23 +4900,15 @@
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.0.tgz#6110cddaef87606ae4ca6f8bf81bb5949fc8e098"
|
||||
integrity sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==
|
||||
"@typescript-eslint/utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
|
||||
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz#f2c41eedd3d7b03b808369fb2e3fb40a93783ec2"
|
||||
integrity sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
"@typescript-eslint/scope-manager" "8.60.1"
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.60.1":
|
||||
version "8.60.1"
|
||||
@@ -14450,15 +14389,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.60.0:
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.0.tgz#6686fecb1f4f367c0bf0075828e93b7ecacbc62b"
|
||||
integrity sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==
|
||||
typescript-eslint@^8.60.1:
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.1.tgz#13db05c6eabb89669deec44545b788a0e9aee640"
|
||||
integrity sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.60.0"
|
||||
"@typescript-eslint/parser" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.60.1"
|
||||
"@typescript-eslint/parser" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
"@typescript-eslint/utils" "8.60.1"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -89,7 +89,7 @@ dependencies = [
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"pygeohash",
|
||||
"pyarrow>=16.1.0, <21", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyarrow>=24.0.0, <25", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.0, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <6.0",
|
||||
@@ -447,6 +447,7 @@ requirement_txt_file = "requirements/base.txt"
|
||||
authorized_licenses = [
|
||||
"academic free license (afl)",
|
||||
"any-osi",
|
||||
"apache-2.0",
|
||||
"apache license 2.0",
|
||||
"apache software",
|
||||
"apache software, bsd",
|
||||
|
||||
@@ -30,7 +30,7 @@ cryptography>=46.0.7,<47.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.3.11,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=20.0.0,<21.0.0
|
||||
pyarrow>=24.0.0,<25.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
pyopenssl>=26.0.0,<27.0.0
|
||||
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File
|
||||
|
||||
@@ -294,7 +294,7 @@ prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==20.0.0
|
||||
pyarrow==24.0.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -715,7 +715,7 @@ psycopg2-binary==2.9.12
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp
|
||||
pyarrow==20.0.0
|
||||
pyarrow==24.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"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
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Change Log
|
||||
|
||||
@@ -17,8 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
|
||||
import { Component, ComponentClass, WeakValidationMap } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type {
|
||||
ComponentType,
|
||||
WeakValidationMap,
|
||||
ForwardRefExoticComponent,
|
||||
PropsWithoutRef,
|
||||
RefAttributes,
|
||||
} from 'react';
|
||||
|
||||
// TODO: Note that id and className can collide between Props and ReactifyProps
|
||||
// leading to (likely) unexpected behaviors. We should either require Props to not
|
||||
@@ -49,66 +61,103 @@ export interface RenderFuncType<Props> {
|
||||
propTypes?: WeakValidationMap<Props & ReactifyProps>;
|
||||
}
|
||||
|
||||
export interface ReactifiedComponentRef {
|
||||
container?: HTMLDivElement;
|
||||
}
|
||||
|
||||
export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
|
||||
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
|
||||
>;
|
||||
|
||||
// Return the widest public type that covers "use it as a React component" so
|
||||
// TypeScript JSX callers and `ComponentType<...>`-typed variables still compile;
|
||||
// callers with explicit `ComponentClass<...>` annotations must widen to
|
||||
// `ComponentType`. Those wanting the forwardRef surface can narrow to
|
||||
// `ReactifiedComponent<Props>` explicitly.
|
||||
export default function reactify<Props extends object>(
|
||||
renderFn: RenderFuncType<Props>,
|
||||
callbacks?: LifeCycleCallbacks,
|
||||
): ComponentClass<Props & ReactifyProps> {
|
||||
class ReactifiedComponent extends Component<Props & ReactifyProps> {
|
||||
container?: HTMLDivElement;
|
||||
): ComponentType<Props & ReactifyProps> {
|
||||
const ReactifiedComponent = forwardRef<
|
||||
ReactifiedComponentRef,
|
||||
Props & ReactifyProps
|
||||
>(function ReactifiedComponent(props, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Keep the latest props available to the unmount callback — legacy
|
||||
// consumers read values off `this.props` (e.g. ReactNVD3 uses id).
|
||||
// Update the ref in a layout effect rather than during render so the
|
||||
// assignment only happens for committed renders (safe under Concurrent
|
||||
// Mode) and is in place before the passive unmount effect reads it.
|
||||
const propsRef = useRef(props);
|
||||
useLayoutEffect(() => {
|
||||
propsRef.current = props;
|
||||
});
|
||||
|
||||
constructor(props: Props & ReactifyProps) {
|
||||
super(props);
|
||||
this.setContainerRef = this.setContainerRef.bind(this);
|
||||
}
|
||||
// Expose container via ref for external access
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get container() {
|
||||
return containerRef.current ?? undefined;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.container = undefined;
|
||||
if (callbacks?.componentWillUnmount) {
|
||||
callbacks.componentWillUnmount.bind(this)();
|
||||
// Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// `forwardRef` widens the props parameter to `PropsWithoutRef<...>`,
|
||||
// which TypeScript can't narrow back to `Props & ReactifyProps` when
|
||||
// `Props` is a generic `object`. The values are identical at runtime,
|
||||
// so assert the original prop shape for `renderFn`.
|
||||
renderFn(
|
||||
containerRef.current,
|
||||
props as Readonly<Props & ReactifyProps>,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setContainerRef(ref: HTMLDivElement) {
|
||||
this.container = ref;
|
||||
}
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (callbacks?.componentWillUnmount) {
|
||||
// Preserve legacy behavior where `this` was a component instance
|
||||
// exposing `props`. The class version cleared `this.container`
|
||||
// before invoking componentWillUnmount, so mirror that here to
|
||||
// prevent callbacks from touching a DOM node that's being torn
|
||||
// down.
|
||||
callbacks.componentWillUnmount.call({
|
||||
container: undefined,
|
||||
props: propsRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
execute() {
|
||||
if (this.container) {
|
||||
renderFn(this.container, this.props);
|
||||
}
|
||||
}
|
||||
const { id, className } = props;
|
||||
|
||||
render() {
|
||||
const { id, className } = this.props;
|
||||
|
||||
return <div ref={this.setContainerRef} id={id} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
|
||||
ReactifiedComponent;
|
||||
return <div ref={containerRef} id={id} className={className} />;
|
||||
});
|
||||
|
||||
if (renderFn.displayName) {
|
||||
ReactifiedClass.displayName = renderFn.displayName;
|
||||
ReactifiedComponent.displayName = renderFn.displayName;
|
||||
}
|
||||
// eslint-disable-next-line react/forbid-foreign-prop-types
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwardRef static field types don't line up with renderFn's validator types
|
||||
const result = ReactifiedComponent as any;
|
||||
|
||||
if (renderFn.propTypes) {
|
||||
ReactifiedClass.propTypes = {
|
||||
...ReactifiedClass.propTypes,
|
||||
result.propTypes = {
|
||||
...result.propTypes,
|
||||
...renderFn.propTypes,
|
||||
};
|
||||
}
|
||||
|
||||
if (renderFn.defaultProps) {
|
||||
ReactifiedClass.defaultProps = renderFn.defaultProps;
|
||||
result.defaultProps = renderFn.defaultProps;
|
||||
}
|
||||
|
||||
return ReactifiedComponent;
|
||||
return result as unknown as ComponentType<Props & ReactifyProps>;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { reactify } from '@superset-ui/core';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { RenderFuncType } from '../../../src/chart/components/reactify';
|
||||
|
||||
describe('reactify(renderFn)', () => {
|
||||
@@ -52,48 +52,41 @@ describe('reactify(renderFn)', () => {
|
||||
componentWillUnmount: willUnmountCb,
|
||||
});
|
||||
|
||||
class TestComponent extends PureComponent<{}, { content: string }> {
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
this.state = { content: 'abc' };
|
||||
}
|
||||
function TestComponent() {
|
||||
const [content, setContent] = useState('abc');
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.setState({ content: 'def' });
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setContent('def');
|
||||
}, 10);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const { content } = this.state;
|
||||
|
||||
return <TheChart id="test" content={content} />;
|
||||
}
|
||||
return <TheChart id="test" content={content} />;
|
||||
}
|
||||
|
||||
class AnotherTestComponent extends PureComponent<{}, {}> {
|
||||
render() {
|
||||
return <TheChartWithWillUnmountHook id="another_test" />;
|
||||
}
|
||||
function AnotherTestComponent() {
|
||||
return <TheChartWithWillUnmountHook id="another_test" />;
|
||||
}
|
||||
|
||||
test('returns a React component class', () =>
|
||||
new Promise(done => {
|
||||
render(<TestComponent />);
|
||||
beforeEach(() => {
|
||||
(renderFn as jest.Mock).mockClear();
|
||||
willUnmountCb.mockClear();
|
||||
});
|
||||
|
||||
expect(renderFn).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('abc')).toBeInTheDocument();
|
||||
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
|
||||
setTimeout(() => {
|
||||
expect(renderFn).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText('def')).toBeInTheDocument();
|
||||
expect(screen.getByText('def').parentNode).toHaveAttribute(
|
||||
'id',
|
||||
'test',
|
||||
);
|
||||
done(undefined);
|
||||
}, 20);
|
||||
}));
|
||||
test('returns a React component and re-renders on prop changes', async () => {
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(renderFn).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('abc')).toBeInTheDocument();
|
||||
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('def')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
|
||||
expect(renderFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
describe('displayName', () => {
|
||||
test('has displayName if renderFn.displayName is defined', () => {
|
||||
expect(TheChart.displayName).toEqual('BoldText');
|
||||
@@ -126,20 +119,16 @@ describe('reactify(renderFn)', () => {
|
||||
expect(AnotherChart.defaultProps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('does not try to render if not mounted', () => {
|
||||
test('calls renderFn when container is set', () => {
|
||||
const anotherRenderFn = jest.fn();
|
||||
const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call
|
||||
// @ts-expect-error
|
||||
new AnotherChart({ id: 'test' }).execute();
|
||||
expect(anotherRenderFn).not.toHaveBeenCalled();
|
||||
const AnotherChart = reactify(anotherRenderFn);
|
||||
const { unmount } = render(<AnotherChart id="test" />);
|
||||
expect(anotherRenderFn).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
test('calls willUnmount hook when it is provided', () => {
|
||||
const { unmount } = render(<AnotherTestComponent />);
|
||||
unmount();
|
||||
expect(willUnmountCb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('calls willUnmount hook when it is provided', () =>
|
||||
new Promise(done => {
|
||||
const { unmount } = render(<AnotherTestComponent />);
|
||||
setTimeout(() => {
|
||||
unmount();
|
||||
expect(willUnmountCb).toHaveBeenCalledTimes(1);
|
||||
done(undefined);
|
||||
}, 20);
|
||||
}));
|
||||
});
|
||||
|
||||
498
superset-websocket/package-lock.json
generated
498
superset-websocket/package-lock.json
generated
@@ -37,7 +37,7 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0"
|
||||
"typescript-eslint": "^8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
@@ -1844,17 +1844,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
"integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
|
||||
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/type-utils": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/type-utils": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -1867,7 +1867,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -1907,7 +1907,7 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
|
||||
@@ -1929,7 +1929,7 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
|
||||
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
|
||||
@@ -1947,7 +1947,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
|
||||
@@ -1964,185 +1964,16 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
|
||||
"integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.0",
|
||||
"@typescript-eslint/types": "^8.60.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
|
||||
"integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
|
||||
"integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
|
||||
"integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -2159,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
|
||||
"integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2173,16 +2004,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
|
||||
"integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.60.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2240,16 +2071,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
|
||||
"integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
|
||||
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0"
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2264,13 +2095,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
|
||||
"integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6357,41 +6188,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
|
||||
"integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
|
||||
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
|
||||
"integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"debug": "^4.4.3"
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -8121,16 +7927,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
"integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
|
||||
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/type-utils": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/type-utils": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -8155,158 +7961,65 @@
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.1",
|
||||
"@typescript-eslint/types": "^8.60.1",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
|
||||
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
|
||||
"integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.0",
|
||||
"@typescript-eslint/types": "^8.60.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.1",
|
||||
"@typescript-eslint/types": "^8.60.1",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
|
||||
"integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
|
||||
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0"
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
|
||||
"integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@typescript-eslint/type-utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
|
||||
"integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
|
||||
"integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
|
||||
"integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.60.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -8341,24 +8054,24 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/utils": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
|
||||
"integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
|
||||
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0"
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
|
||||
"integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -11308,30 +11021,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"typescript-eslint": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
|
||||
"integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
|
||||
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/utils": "8.60.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
|
||||
"integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.60.0",
|
||||
"@typescript-eslint/types": "8.60.0",
|
||||
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
}
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1"
|
||||
}
|
||||
},
|
||||
"uglify-js": {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0"
|
||||
"typescript-eslint": "^8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
|
||||
@@ -120,8 +120,13 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
if not self._model:
|
||||
raise ChartNotFoundError()
|
||||
|
||||
# Check and update ownership; when only updating query context we ignore
|
||||
# ownership so the update can be performed by report workers
|
||||
# Check and update ownership; when only updating query context we relax
|
||||
# the ownership requirement so that non-owners (report workers and any
|
||||
# viewer whose UI lazily backfills a missing query_context) can perform
|
||||
# the update. We still require access to the chart in that case, so a
|
||||
# user cannot rewrite the query_context of a chart they cannot access
|
||||
# (raise_for_access permits admins, owners, and users with access to the
|
||||
# chart's datasource).
|
||||
if not is_query_context_update(self._properties):
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
@@ -134,6 +139,11 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
raise ChartForbiddenError() from ex
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
else:
|
||||
try:
|
||||
security_manager.raise_for_access(chart=self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise ChartForbiddenError() from ex
|
||||
|
||||
# validate tags
|
||||
try:
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import DatasourceNotFoundValidationError
|
||||
from superset.commands.security.utils import raise_for_datasource_access
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.security import RLSDAO
|
||||
@@ -50,5 +51,6 @@ class CreateRLSRuleCommand(BaseCommand):
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
raise_for_datasource_access(tables)
|
||||
self._properties["roles"] = roles
|
||||
self._properties["tables"] = tables
|
||||
|
||||
@@ -23,8 +23,9 @@ from superset.commands.security.exceptions import (
|
||||
RLSRuleNotFoundError,
|
||||
RuleDeleteFailedError,
|
||||
)
|
||||
from superset.commands.security.utils import raise_for_datasource_access
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.daos.security import RLSDAO
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
class DeleteRLSRuleCommand(BaseCommand):
|
||||
def __init__(self, model_ids: list[int]):
|
||||
self._model_ids = model_ids
|
||||
self._models: list[ReportSchedule] = []
|
||||
self._models: list[RowLevelSecurityFilter] = []
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=RuleDeleteFailedError))
|
||||
def run(self) -> None:
|
||||
@@ -45,3 +46,7 @@ class DeleteRLSRuleCommand(BaseCommand):
|
||||
self._models = RLSDAO.find_by_ids(self._model_ids)
|
||||
if not self._models or len(self._models) != len(self._model_ids):
|
||||
raise RLSRuleNotFoundError()
|
||||
# Apply the same datasource access check as create/update: a caller may
|
||||
# only delete a rule if they can access every datasource it references.
|
||||
for rule in self._models:
|
||||
raise_for_datasource_access(rule.tables)
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
|
||||
from superset.commands.exceptions import CommandException, DeleteFailedError
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
DeleteFailedError,
|
||||
ForbiddenError,
|
||||
)
|
||||
|
||||
|
||||
class RLSRuleNotFoundError(CommandException):
|
||||
@@ -25,5 +29,9 @@ class RLSRuleNotFoundError(CommandException):
|
||||
message = _("RLS Rule not found.")
|
||||
|
||||
|
||||
class RLSDatasourceForbiddenError(ForbiddenError):
|
||||
message = _("You don't have access to one or more of the referenced datasources.")
|
||||
|
||||
|
||||
class RuleDeleteFailedError(DeleteFailedError):
|
||||
message = _("RLS rules could not be deleted.")
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import Any, Optional
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import DatasourceNotFoundValidationError
|
||||
from superset.commands.security.exceptions import RLSRuleNotFoundError
|
||||
from superset.commands.security.utils import raise_for_datasource_access
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
|
||||
from superset.daos.security import RLSDAO
|
||||
@@ -57,5 +58,6 @@ class UpdateRLSRuleCommand(BaseCommand):
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
raise_for_datasource_access(tables)
|
||||
self._properties["roles"] = roles
|
||||
self._properties["tables"] = tables
|
||||
|
||||
42
superset/commands/security/utils.py
Normal file
42
superset/commands/security/utils.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from superset.commands.security.exceptions import RLSDatasourceForbiddenError
|
||||
from superset.extensions import security_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
|
||||
|
||||
def raise_for_datasource_access(tables: Iterable[SqlaTable]) -> None:
|
||||
"""Enforce datasource access for every table referenced by an RLS rule.
|
||||
|
||||
A caller may only create, update, or delete an RLS rule if they can access
|
||||
every datasource it references. This mirrors the standard datasource access
|
||||
checks applied elsewhere and is shared by the RLS rule commands to keep the
|
||||
enforcement consistent.
|
||||
|
||||
:raises RLSDatasourceForbiddenError: if the caller cannot access one or more
|
||||
of the referenced datasources.
|
||||
"""
|
||||
for table in tables:
|
||||
if not security_manager.can_access_datasource(datasource=table):
|
||||
raise RLSDatasourceForbiddenError()
|
||||
@@ -66,6 +66,20 @@ class EmbeddedView(BaseSupersetView):
|
||||
if not is_referrer_allowed:
|
||||
abort(403)
|
||||
|
||||
# Defense in depth: when the browser sends a Sec-Fetch-Dest header,
|
||||
# require an embeddable destination (iframe/frame) or a direct
|
||||
# document/fetch load, rather than e.g. an <img>/<script>/<object> tag.
|
||||
# The header is unforgeable by page script; an absent header (older
|
||||
# browsers / non-browser clients) is allowed for compatibility.
|
||||
sec_fetch_dest = request.headers.get("Sec-Fetch-Dest")
|
||||
if sec_fetch_dest and sec_fetch_dest not in {
|
||||
"iframe",
|
||||
"frame",
|
||||
"document",
|
||||
"empty",
|
||||
}:
|
||||
abort(403)
|
||||
|
||||
# Log in as an anonymous user, just for this view.
|
||||
# This view needs to be visible to all users,
|
||||
# and building the page fails if g.user and/or ctx.user aren't present.
|
||||
|
||||
@@ -60,6 +60,9 @@ from superset.mcp_service.mcp_config import (
|
||||
default_user_resolver,
|
||||
get_mcp_api_key_enabled,
|
||||
)
|
||||
from superset.mcp_service.utils.error_sanitization import (
|
||||
sanitize_for_log as _sanitize_for_log,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
@@ -90,6 +93,81 @@ class MCPNoAuthSourceError(ValueError):
|
||||
"""
|
||||
|
||||
|
||||
# Maps a tool's method permission to the OAuth-style token scope it requires.
|
||||
# Used by ``check_tool_permission`` for scope-aware authorization: enforcement
|
||||
# is the INTERSECTION of token scopes and DB RBAC. Only applied when the token
|
||||
# actually carries scopes — see ``_token_scope_allows``.
|
||||
#
|
||||
# SECURITY: this map must cover EVERY method permission used by an MCP tool.
|
||||
# A scoped token presented for a method that is NOT in this map is denied
|
||||
# (fail closed) rather than allowed, so adding a tool with a new custom
|
||||
# permission cannot silently bypass scope enforcement. ``execute_sql_query``
|
||||
# is a privileged, write-class operation and therefore requires the write
|
||||
# scope. When introducing a new method permission, add it here.
|
||||
_METHOD_TO_REQUIRED_SCOPE = {
|
||||
"read": "superset:read",
|
||||
"write": "superset:write",
|
||||
"delete": "superset:write",
|
||||
# SQL execution (execute_sql, get_chart_sql) runs arbitrary queries and is
|
||||
# treated as a write-class privileged operation for scope purposes.
|
||||
"execute_sql_query": "superset:write",
|
||||
}
|
||||
|
||||
|
||||
def _get_token_scopes() -> set[str] | None:
|
||||
"""Return the set of scopes on the current JWT access token, or None.
|
||||
|
||||
Returns None when there is no JWT context or the token carries no scopes,
|
||||
so callers can fall back to RBAC-only behavior (back-compat for API-key and
|
||||
scope-less JWT deployments). Returns a (possibly populated) set only when
|
||||
the token explicitly advertises scopes.
|
||||
"""
|
||||
try:
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
except Exception: # noqa: BLE001 - no JWT context for this request
|
||||
return None
|
||||
|
||||
if access_token is None:
|
||||
return None
|
||||
|
||||
scopes = getattr(access_token, "scopes", None)
|
||||
if not scopes:
|
||||
# Token present but no scopes advertised: do NOT enforce scope checks.
|
||||
return None
|
||||
return {str(s) for s in scopes}
|
||||
|
||||
|
||||
def _token_scope_allows(method_permission_name: str) -> bool:
|
||||
"""Return whether the current token's scopes permit the given method.
|
||||
|
||||
Back-compat: returns True (allow) when the token carries no scopes or there
|
||||
is no JWT context, so deployments not using scopes keep RBAC-only behavior.
|
||||
Only when the token advertises scopes is the mapped required scope enforced.
|
||||
"""
|
||||
token_scopes = _get_token_scopes()
|
||||
if token_scopes is None:
|
||||
return True
|
||||
required_scope = _METHOD_TO_REQUIRED_SCOPE.get(method_permission_name)
|
||||
if required_scope is None:
|
||||
# Fail closed: a scoped token was presented for a method permission that
|
||||
# is not in the scope map. Rather than silently bypassing scope
|
||||
# enforcement, deny it so an unmapped (e.g. newly added custom) method
|
||||
# permission cannot be reached by a scoped token. Map the permission in
|
||||
# ``_METHOD_TO_REQUIRED_SCOPE`` to grant access.
|
||||
logger.warning(
|
||||
"Denying scoped token for unmapped method permission '%s'; "
|
||||
"add it to _METHOD_TO_REQUIRED_SCOPE to grant scoped access.",
|
||||
method_permission_name,
|
||||
)
|
||||
return False
|
||||
return required_scope in token_scopes
|
||||
|
||||
|
||||
class MCPPermissionDeniedError(PermissionError):
|
||||
"""Raised when user lacks required RBAC permission for an MCP tool.
|
||||
|
||||
@@ -117,6 +195,39 @@ class MCPPermissionDeniedError(PermissionError):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _log_scope_denial(
|
||||
func: Callable[..., Any],
|
||||
method_permission_name: str,
|
||||
permission_str: str,
|
||||
class_permission_name: str,
|
||||
*,
|
||||
log_denial: bool,
|
||||
) -> None:
|
||||
"""Log a scope-based denial for a tool the user has RBAC access to.
|
||||
|
||||
Extracted from ``check_tool_permission`` to keep that function's
|
||||
cyclomatic complexity in check.
|
||||
"""
|
||||
required_scope = _METHOD_TO_REQUIRED_SCOPE.get(method_permission_name)
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
"Scope denied for user %s: token lacks required scope "
|
||||
"'%s' for %s on %s (tool: %s)",
|
||||
_sanitize_for_log(g.user.username),
|
||||
required_scope,
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
func.__name__,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Tool hidden for user %s: token lacks required scope '%s' (tool: %s)",
|
||||
_sanitize_for_log(g.user.username),
|
||||
required_scope,
|
||||
func.__name__,
|
||||
)
|
||||
|
||||
|
||||
def check_tool_permission(func: Callable[..., Any], *, log_denial: bool = True) -> bool:
|
||||
"""Check if the current user has RBAC permission for an MCP tool.
|
||||
|
||||
@@ -173,6 +284,24 @@ def check_tool_permission(func: Callable[..., Any], *, log_denial: bool = True)
|
||||
permission_str, class_permission_name
|
||||
)
|
||||
|
||||
# Scope-aware authorization: enforce the INTERSECTION of token scopes
|
||||
# and DB RBAC. A tool is allowed only if the user has the RBAC
|
||||
# permission AND the token carries the required scope.
|
||||
#
|
||||
# Back-compat: scope enforcement applies ONLY when the token actually
|
||||
# advertises scopes. Tokens/deployments that don't use scopes (API keys,
|
||||
# scope-less JWTs, dev-mode) fall through to RBAC-only behavior — see
|
||||
# ``_token_scope_allows``.
|
||||
if has_permission and not _token_scope_allows(method_permission_name):
|
||||
_log_scope_denial(
|
||||
func,
|
||||
method_permission_name,
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
log_denial=log_denial,
|
||||
)
|
||||
return False
|
||||
|
||||
if not has_permission:
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
@@ -312,7 +441,33 @@ def _resolve_user_from_jwt_context(app: Any) -> User | None:
|
||||
" processing as JWT"
|
||||
)
|
||||
|
||||
# Multi-issuer safety: when more than one issuer is trusted, a bare
|
||||
# username/email lookup is NOT issuer-scoped, so two issuers that mint the
|
||||
# same username/email claim would resolve to the same Superset user.
|
||||
#
|
||||
# Single-issuer deployments (the common case) are safe — the issuer is
|
||||
# already pinned by the verifier, so the username space is unambiguous and
|
||||
# we keep the existing lookup key to avoid breaking them. For multi-issuer
|
||||
# configs we warn: operators should provide an issuer-aware MCP_USER_RESOLVER
|
||||
# that derives a compound (iss + sub) identity. This is the least-breaking
|
||||
# correct option (warn, don't change the key out from under existing
|
||||
# single-issuer deployments).
|
||||
configured_issuer = app.config.get("MCP_JWT_ISSUER")
|
||||
if isinstance(configured_issuer, (list, tuple, set)) and len(configured_issuer) > 1:
|
||||
if not app.config.get("MCP_USER_RESOLVER"):
|
||||
token_iss = claims.get("iss") if isinstance(claims, dict) else None
|
||||
logger.warning(
|
||||
"Multiple JWT issuers are trusted (MCP_JWT_ISSUER is a list) but "
|
||||
"the default user resolver maps token claims to Superset users by "
|
||||
"username/email without binding the issuer (iss=%s). Distinct "
|
||||
"issuers minting the same username/email will collide. Configure an "
|
||||
"issuer-aware MCP_USER_RESOLVER to derive a compound (iss+sub) "
|
||||
"identity.",
|
||||
_sanitize_for_log(token_iss),
|
||||
)
|
||||
|
||||
# Use configurable resolver or default
|
||||
|
||||
resolver = app.config.get("MCP_USER_RESOLVER", default_user_resolver)
|
||||
username = resolver(app, access_token)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Provides step-by-step JWT validation with tiered server-side logging:
|
||||
HTTP responses always return generic errors per RFC 6750 Section 3.1.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import html as html_module
|
||||
import logging
|
||||
@@ -58,6 +59,41 @@ from superset.utils import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Algorithms that are never acceptable for bearer-token verification.
|
||||
# "none" (unsigned tokens) must never be honored — accepting it would let any
|
||||
# caller forge claims. Comparison is case-insensitive to catch "None"/"NONE".
|
||||
_FORBIDDEN_ALGORITHMS = frozenset({"none"})
|
||||
|
||||
|
||||
def _warn_on_weak_jwt_config(
|
||||
audience: Any,
|
||||
algorithm: Any,
|
||||
) -> None:
|
||||
"""Emit startup warnings when a JWT verifier is configured permissively.
|
||||
|
||||
These are config-gated soft warnings, not hard failures: a verifier is only
|
||||
ever constructed when ``MCP_AUTH_ENABLED`` is True and JWT keys are present
|
||||
(see ``create_default_mcp_auth_factory``). We warn — rather than refuse to
|
||||
start — so existing single-service deployments that intentionally omit an
|
||||
audience or rely on JWKS-advertised algorithms keep working. Operators who
|
||||
want strict enforcement should set ``MCP_JWT_AUDIENCE`` and
|
||||
``MCP_JWT_ALGORITHM``.
|
||||
"""
|
||||
if not audience:
|
||||
logger.warning(
|
||||
"MCP JWT verifier configured without an audience "
|
||||
"(MCP_JWT_AUDIENCE unset): audience validation is DISABLED. "
|
||||
"Tokens minted for other services may be accepted. Set "
|
||||
"MCP_JWT_AUDIENCE to bind tokens to this service."
|
||||
)
|
||||
if not algorithm:
|
||||
logger.warning(
|
||||
"MCP JWT verifier configured without a pinned signing algorithm "
|
||||
"(MCP_JWT_ALGORITHM unset): the algorithm header is not pinned. "
|
||||
"Set MCP_JWT_ALGORITHM to the algorithm your IdP uses. Unsigned "
|
||||
"('none') tokens are always rejected regardless of this setting."
|
||||
)
|
||||
|
||||
|
||||
# Thread-safe storage for the specific JWT failure reason.
|
||||
# Set by DetailedJWTVerifier.load_access_token() on failure,
|
||||
@@ -355,6 +391,33 @@ class MCPJWTVerifier(JWTVerifier):
|
||||
page is active regardless of which verifier variant is configured.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Capture the explicit algorithm kwarg before super().__init__() can
|
||||
# coerce it (the factory defaults it to "RS256" when MCP_JWT_ALGORITHM
|
||||
# is unset, so self.algorithm is always truthy post-construction).
|
||||
explicit_algorithm = kwargs.get("algorithm")
|
||||
super().__init__(*args, **kwargs)
|
||||
# Surface permissive auth configuration at startup. Config-gated:
|
||||
# a verifier is only built when auth is enabled (see mcp_config).
|
||||
# Prefer the raw MCP_JWT_ALGORITHM config value over the constructor
|
||||
# kwarg because the factory always supplies a non-None algorithm
|
||||
# default; falling back to the kwarg lets unit tests that construct
|
||||
# verifiers directly (without an app context) also get the warning
|
||||
# when no algorithm is pinned.
|
||||
from flask import current_app
|
||||
|
||||
try:
|
||||
config_algorithm = current_app.config.get("MCP_JWT_ALGORITHM")
|
||||
except RuntimeError:
|
||||
# No Flask application context (e.g. unit tests constructing the
|
||||
# verifier directly). Fall back to the explicit constructor arg.
|
||||
config_algorithm = None
|
||||
|
||||
_warn_on_weak_jwt_config(
|
||||
audience=getattr(self, "audience", None),
|
||||
algorithm=config_algorithm or explicit_algorithm,
|
||||
)
|
||||
|
||||
def get_middleware(self) -> list[Any]:
|
||||
return [
|
||||
Middleware(
|
||||
@@ -435,6 +498,19 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
return None
|
||||
|
||||
token_alg = header.get("alg")
|
||||
# Always reject unsigned ("none") tokens, even when no algorithm
|
||||
# is pinned. An unsigned token has no integrity guarantee, so its
|
||||
# claims (sub, scopes, ...) are fully attacker-controlled.
|
||||
if isinstance(token_alg, str) and token_alg.lower() in (
|
||||
_FORBIDDEN_ALGORITHMS
|
||||
):
|
||||
reason = "Algorithm not allowed"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug(
|
||||
"Rejected forbidden algorithm: token uses '%s'",
|
||||
_sanitize_for_log(token_alg),
|
||||
)
|
||||
return None
|
||||
if self.algorithm and token_alg != self.algorithm:
|
||||
reason = "Algorithm mismatch"
|
||||
_jwt_failure_reason.set(reason)
|
||||
@@ -445,13 +521,32 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 2: Get verification key (static or JWKS)
|
||||
# Step 2: Get verification key (static or JWKS).
|
||||
#
|
||||
# For remote JWKS the upstream verifier performs a network fetch and
|
||||
# is expected to normalize transport failures (timeouts, connection
|
||||
# errors, non-200 responses, SSRF blocks) into ValueError. We do not
|
||||
# rely on that normalization alone: any retrieval failure — including
|
||||
# a raw httpx error, an asyncio timeout, or an OS-level connection
|
||||
# error that escapes the upstream conversion — must fail CLOSED and
|
||||
# reject the token, never fall through to "skip verification" or a
|
||||
# 500. Catching these here guarantees a fetch failure can never be
|
||||
# treated as a successful (or skipped) signature check.
|
||||
try:
|
||||
verification_key = await self._get_verification_key(token)
|
||||
except (httpx.HTTPError, OSError, TimeoutError) as e:
|
||||
# Transient failure reaching or reading the JWKS endpoint.
|
||||
except (
|
||||
httpx.HTTPError,
|
||||
asyncio.TimeoutError,
|
||||
ConnectionError,
|
||||
OSError,
|
||||
) as e:
|
||||
# Transient failure reaching or reading the JWKS endpoint
|
||||
# (timeouts, connection errors, non-200 responses, SSRF blocks).
|
||||
# Treat it as an authentication failure (return None) instead of
|
||||
# letting the network error propagate as an unexpected exception.
|
||||
# ConnectionError is a subclass of OSError and asyncio.TimeoutError
|
||||
# aliases TimeoutError; they are listed explicitly to make the
|
||||
# fail-closed contract for raw transport errors unambiguous.
|
||||
reason = "JWKS verification key unavailable"
|
||||
_jwt_failure_reason.set(reason)
|
||||
# WARNING carries only the generic category (per the module's
|
||||
@@ -463,7 +558,9 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
except ValueError as e:
|
||||
reason = "Failed to get verification key"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug("Failed to get verification key: %s", e)
|
||||
logger.debug(
|
||||
"Failed to get verification key (%s): %s", type(e).__name__, e
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 3: Decode and verify signature
|
||||
@@ -510,6 +607,21 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 4b: Check not-before (RFC 7519 Section 4.1.5). ``decode``
|
||||
# alone does not validate temporal claims here (claims are read
|
||||
# individually rather than via ``JWTClaims.validate``), so a token
|
||||
# whose ``nbf`` is in the future must be rejected explicitly, just
|
||||
# like ``exp`` above.
|
||||
nbf = claims.get("nbf")
|
||||
if nbf is not None and nbf > time.time():
|
||||
reason = "Token not yet valid"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug(
|
||||
"Token not yet valid for client '%s': nbf is in the future",
|
||||
_sanitize_for_log(client_id),
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 5: Validate issuer
|
||||
if self.issuer:
|
||||
iss = claims.get("iss")
|
||||
|
||||
@@ -86,6 +86,32 @@ def destringify(obj: str) -> Any:
|
||||
return json.loads(obj)
|
||||
|
||||
|
||||
def stringify_extension_columns(table: pa.Table) -> pa.Table:
|
||||
"""
|
||||
Replace Arrow extension-typed columns with their string representation.
|
||||
|
||||
Superset cannot render Arrow extension types natively (see
|
||||
``superset.utils.core.GenericDataType``). The most common case is the
|
||||
canonical ``uuid`` type: PyArrow >= 21 infers Python ``uuid.UUID`` values as
|
||||
that extension type (16-byte binary), which ``Table.to_pandas()`` surfaces as
|
||||
raw bytes. Stringifying here keeps such columns readable (UUID values become
|
||||
their canonical hex form). Plain binary/BLOB columns are not extension types
|
||||
and are left untouched.
|
||||
"""
|
||||
for index in range(table.num_columns):
|
||||
field = table.schema.field(index)
|
||||
if isinstance(field.type, pa.BaseExtensionType):
|
||||
stringified = pa.array(
|
||||
[
|
||||
None if value is None else str(value)
|
||||
for value in table.column(index).to_pylist()
|
||||
],
|
||||
type=pa.string(),
|
||||
)
|
||||
table = table.set_column(index, field.name, stringified)
|
||||
return table
|
||||
|
||||
|
||||
def convert_to_string(value: Any) -> str:
|
||||
"""
|
||||
Used to ensure column names from the cursor description are strings.
|
||||
@@ -238,7 +264,13 @@ class SupersetResultSet:
|
||||
if not pa_data:
|
||||
column_names = []
|
||||
|
||||
self.table = pa.Table.from_arrays(pa_data, names=column_names)
|
||||
# PyArrow >= 21 infers Python `uuid.UUID` values as the Arrow `uuid`
|
||||
# extension type rather than raising (which previously routed them
|
||||
# through the stringification fallback above). Stringify any extension
|
||||
# columns so they render as readable text instead of raw bytes.
|
||||
self.table = stringify_extension_columns(
|
||||
pa.Table.from_arrays(pa_data, names=column_names)
|
||||
)
|
||||
self._type_dict: dict[str, Any] = {}
|
||||
try:
|
||||
# The driver may not be passing a cursor.description
|
||||
|
||||
@@ -31,7 +31,10 @@ from superset.commands.exceptions import (
|
||||
)
|
||||
from superset.commands.security.create import CreateRLSRuleCommand
|
||||
from superset.commands.security.delete import DeleteRLSRuleCommand
|
||||
from superset.commands.security.exceptions import RLSRuleNotFoundError
|
||||
from superset.commands.security.exceptions import (
|
||||
RLSDatasourceForbiddenError,
|
||||
RLSRuleNotFoundError,
|
||||
)
|
||||
from superset.commands.security.update import UpdateRLSRuleCommand
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
@@ -185,6 +188,8 @@ class RLSRestApi(BaseSupersetModelRestApi):
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -216,6 +221,13 @@ class RLSRestApi(BaseSupersetModelRestApi):
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except RLSDatasourceForbiddenError as ex:
|
||||
logger.warning(
|
||||
"Forbidden datasource while creating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
)
|
||||
return self.response_403()
|
||||
except SQLAlchemyError as ex:
|
||||
logger.error(
|
||||
"Error creating RLS rule %s: %s",
|
||||
@@ -302,6 +314,13 @@ class RLSRestApi(BaseSupersetModelRestApi):
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except RLSDatasourceForbiddenError as ex:
|
||||
logger.warning(
|
||||
"Forbidden datasource while updating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
)
|
||||
return self.response_403()
|
||||
except SQLAlchemyError as ex:
|
||||
logger.error(
|
||||
"Error updating RLS rule %s: %s",
|
||||
|
||||
@@ -56,6 +56,7 @@ from superset.common.utils.time_range_utils import get_since_until_from_query_ob
|
||||
from superset.connectors.sqla.models import BaseDatasource
|
||||
from superset.constants import NO_TIME_RANGE
|
||||
from superset.models.helpers import QueryResult
|
||||
from superset.result_set import stringify_extension_columns
|
||||
from superset.superset_typing import AdhocColumn
|
||||
from superset.utils.core import (
|
||||
FilterOperator,
|
||||
@@ -121,7 +122,7 @@ def get_results(query_object: QueryObject) -> QueryResult:
|
||||
main_query = queries[0]
|
||||
main_result = dispatcher(main_query)
|
||||
|
||||
main_df = main_result.results.to_pandas()
|
||||
main_df = stringify_extension_columns(main_result.results).to_pandas()
|
||||
|
||||
# Collect all requests (SQL queries, HTTP requests, etc.) for troubleshooting
|
||||
all_requests = list(main_result.requests)
|
||||
@@ -155,7 +156,7 @@ def get_results(query_object: QueryObject) -> QueryResult:
|
||||
# Add this query's requests to the collection
|
||||
all_requests.extend(result.requests)
|
||||
|
||||
offset_df = result.results.to_pandas()
|
||||
offset_df = stringify_extension_columns(result.results).to_pandas()
|
||||
|
||||
# Handle empty results - add NaN columns directly instead of merging
|
||||
# This avoids dtype mismatch issues with empty DataFrames
|
||||
@@ -229,7 +230,7 @@ def map_semantic_result_to_query_result(
|
||||
|
||||
return QueryResult(
|
||||
# Core data
|
||||
df=semantic_result.results.to_pandas(),
|
||||
df=stringify_extension_columns(semantic_result.results).to_pandas(),
|
||||
query=query_str,
|
||||
duration=duration,
|
||||
# Template filters - not applicable to semantic layers
|
||||
|
||||
@@ -864,12 +864,11 @@ class Superset(BaseSupersetView):
|
||||
)
|
||||
if url_params := state.get("urlParams"):
|
||||
for param_key, param_val in url_params:
|
||||
if param_key == "native_filters":
|
||||
# native_filters doesnt need to be encoded here
|
||||
url = f"{url}&native_filters={param_val}"
|
||||
else:
|
||||
params = parse.urlencode([(param_key, param_val)])
|
||||
url = f"{url}&{params}"
|
||||
# URL-encode every param value (including native_filters) so a
|
||||
# value containing '&'/'#'/'=' cannot inject extra parameters
|
||||
# into the redirect target. Flask decodes the value back on read.
|
||||
params = parse.urlencode([(param_key, param_val)])
|
||||
url = f"{url}&{params}"
|
||||
if original_params := request.query_string.decode():
|
||||
url = f"{url}&{original_params}"
|
||||
if hash_ := state.get("anchor", state.get("hash")):
|
||||
|
||||
@@ -25,6 +25,7 @@ from flask import g # noqa: F401
|
||||
from superset import db, security_manager
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartNotFoundError,
|
||||
WarmUpCacheChartNotFoundError,
|
||||
)
|
||||
@@ -452,6 +453,43 @@ class TestChartsUpdateCommand(SupersetTestCase):
|
||||
assert len(chart.owners) == 1
|
||||
assert chart.owners[0] == admin
|
||||
|
||||
@patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.security.manager.g")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_query_context_update_requires_chart_access(
|
||||
self, mock_sm_g, mock_core_g, mock_update_g, mock_find_by_id
|
||||
) -> None:
|
||||
"""
|
||||
A query_context-only update relaxes the ownership requirement but must
|
||||
still require access to the chart. We bypass the DAO ``ChartFilter``
|
||||
base filter (by patching ``find_by_id`` to return the chart directly)
|
||||
so the request reaches the new explicit ``raise_for_access`` check, and
|
||||
assert that a non-owner with no access to the chart's datasource is
|
||||
rejected with ``ChartForbiddenError``. This deterministically exercises
|
||||
the new branch and would fail on master, where the check is absent.
|
||||
"""
|
||||
chart = db.session.query(Slice).filter_by(slice_name="Energy Sankey").one()
|
||||
pk = chart.id
|
||||
admin = security_manager.find_user(username="admin")
|
||||
chart.owners = [admin]
|
||||
db.session.commit()
|
||||
|
||||
# Return the chart directly, bypassing ChartFilter, so the command's
|
||||
# own raise_for_access gate is what denies the request.
|
||||
mock_find_by_id.return_value = chart
|
||||
|
||||
# gamma has no access to the energy datasource and does not own the chart
|
||||
gamma = security_manager.find_user(username="gamma")
|
||||
mock_core_g.user = mock_sm_g.user = mock_update_g.user = gamma
|
||||
json_obj = {
|
||||
"query_context_generation": True,
|
||||
"query_context": json.dumps({"foo": "bar"}),
|
||||
}
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
UpdateChartCommand(pk, json_obj).run()
|
||||
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.security.manager.g")
|
||||
|
||||
@@ -23,7 +23,7 @@ import logging
|
||||
import random
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import parse_qs, quote, urlsplit
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -909,6 +909,34 @@ class TestCore(SupersetTestCase):
|
||||
assert resp.headers["Location"] == expected_url
|
||||
assert resp.status_code == 302
|
||||
|
||||
@mock.patch("superset.views.core.request")
|
||||
@mock.patch(
|
||||
"superset.commands.dashboard.permalink.get.GetDashboardPermalinkCommand.run"
|
||||
)
|
||||
def test_dashboard_permalink_native_filters_encoded(
|
||||
self, get_dashboard_permalink_mock, request_mock
|
||||
):
|
||||
# A native_filters value containing reserved characters must be
|
||||
# percent-encoded in the redirect target so it cannot inject extra
|
||||
# query parameters into the Location header.
|
||||
request_mock.query_string = b""
|
||||
native_filters_value = "value&injected=evil#frag"
|
||||
get_dashboard_permalink_mock.return_value = {
|
||||
"dashboardId": 1,
|
||||
"state": {"urlParams": [["native_filters", native_filters_value]]},
|
||||
}
|
||||
self.login(ADMIN_USERNAME)
|
||||
resp = self.client.get("superset/dashboard/p/123/")
|
||||
|
||||
location = resp.headers["Location"]
|
||||
assert resp.status_code == 302
|
||||
# The reserved characters are encoded, so no extra params/anchors leak in.
|
||||
assert "native_filters=value%26injected%3Devil%23frag" in location
|
||||
assert "injected=evil" not in location
|
||||
# Round-trips back to the original value when decoded.
|
||||
parsed = parse_qs(urlsplit(location).query)
|
||||
assert parsed["native_filters"] == [native_filters_value]
|
||||
|
||||
|
||||
class TestLocalePatch(SupersetTestCase):
|
||||
MOCK_LANGUAGES = (
|
||||
|
||||
@@ -105,3 +105,36 @@ def test_get_embedded_dashboard_non_found(client: FlaskClient[Any]): # noqa: F8
|
||||
uri = "embedded/bad-uuid" # noqa: F541
|
||||
response = client.get(uri)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@mock.patch.dict(
|
||||
"superset.extensions.feature_flag_manager._feature_flags",
|
||||
EMBEDDED_SUPERSET=True,
|
||||
)
|
||||
def test_get_embedded_dashboard_rejects_bad_sec_fetch_dest(
|
||||
client: FlaskClient[Any], # noqa: F811
|
||||
):
|
||||
dash = db.session.query(Dashboard).filter_by(slug="births").first()
|
||||
embedded = EmbeddedDashboardDAO.upsert(dash, [])
|
||||
db.session.flush()
|
||||
uri = f"embedded/{embedded.uuid}"
|
||||
# A non-embeddable destination (e.g. loaded via <img>/<script>) is rejected.
|
||||
response = client.get(uri, headers={"Sec-Fetch-Dest": "image"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@mock.patch.dict(
|
||||
"superset.extensions.feature_flag_manager._feature_flags",
|
||||
EMBEDDED_SUPERSET=True,
|
||||
)
|
||||
def test_get_embedded_dashboard_allows_iframe_sec_fetch_dest(
|
||||
client: FlaskClient[Any], # noqa: F811
|
||||
):
|
||||
dash = db.session.query(Dashboard).filter_by(slug="births").first()
|
||||
embedded = EmbeddedDashboardDAO.upsert(dash, [])
|
||||
db.session.flush()
|
||||
uri = f"embedded/{embedded.uuid}"
|
||||
response = client.get(uri, headers={"Sec-Fetch-Dest": "iframe"})
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -33,6 +33,16 @@ def _ownership_exc() -> SupersetSecurityException:
|
||||
)
|
||||
|
||||
|
||||
def _access_exc() -> SupersetSecurityException:
|
||||
return SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.CHART_SECURITY_ACCESS_ERROR,
|
||||
message="User does not have access to this chart",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_update_chart_ownership_enforced_for_regular_update(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
@@ -54,20 +64,70 @@ def test_update_chart_ownership_enforced_for_regular_update(
|
||||
def test_update_chart_query_context_skips_ownership_check(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Query-context-only updates skip ownership so report workers can save context."""
|
||||
"""Query-context-only updates skip the ownership check (so report workers can
|
||||
save context) but still require access to the chart."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
raise_for_ownership = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_ownership",
|
||||
side_effect=_ownership_exc(),
|
||||
)
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_access",
|
||||
)
|
||||
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
find_by_id.assert_called_once_with(1)
|
||||
# ownership is relaxed, but chart access is still enforced
|
||||
raise_for_ownership.assert_not_called()
|
||||
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
|
||||
|
||||
|
||||
def test_update_chart_query_context_requires_chart_access(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A query-context-only update by someone without access to the chart is
|
||||
rejected, even though the ownership check is relaxed for this path."""
|
||||
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_access",
|
||||
side_effect=_access_exc(),
|
||||
)
|
||||
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
|
||||
def test_update_chart_query_context_non_owner_with_access_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-owner who *does* have access to the chart (e.g. an alpha user with
|
||||
datasource access, or a report worker) can perform a query-context-only
|
||||
backfill: ownership is relaxed and ``raise_for_access`` does not deny."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
raise_for_ownership = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_ownership",
|
||||
side_effect=_ownership_exc(),
|
||||
)
|
||||
# access check passes (no exception) -> the non-owner is permitted
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_access",
|
||||
)
|
||||
|
||||
# Should not raise: the update is allowed despite the user not being an owner
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
raise_for_ownership.assert_not_called()
|
||||
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
|
||||
|
||||
|
||||
def test_update_chart_owner_can_perform_regular_update(
|
||||
|
||||
16
tests/unit_tests/commands/security/__init__.py
Normal file
16
tests/unit_tests/commands/security/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
196
tests/unit_tests/commands/security/rls_test.py
Normal file
196
tests/unit_tests/commands/security/rls_test.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# 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.
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.security.create import CreateRLSRuleCommand
|
||||
from superset.commands.security.delete import DeleteRLSRuleCommand
|
||||
from superset.commands.security.exceptions import RLSDatasourceForbiddenError
|
||||
from superset.commands.security.update import UpdateRLSRuleCommand
|
||||
|
||||
|
||||
def _mock_tables(*table_ids: int) -> list[MagicMock]:
|
||||
tables = []
|
||||
for table_id in table_ids:
|
||||
table = MagicMock()
|
||||
table.id = table_id
|
||||
tables.append(table)
|
||||
return tables
|
||||
|
||||
|
||||
def _patch_query(module: str, tables: list[MagicMock]):
|
||||
"""Patch db.session.query(...).filter(...).all() to return ``tables``."""
|
||||
query = MagicMock()
|
||||
query.filter.return_value.all.return_value = tables
|
||||
return patch(f"{module}.db.session.query", return_value=query)
|
||||
|
||||
|
||||
def test_create_rls_rule_forbidden_when_no_datasource_access() -> None:
|
||||
tables = _mock_tables(1)
|
||||
|
||||
with (
|
||||
_patch_query("superset.commands.security.create", tables),
|
||||
patch(
|
||||
"superset.commands.security.create.populate_roles",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=False,
|
||||
) as can_access,
|
||||
):
|
||||
command = CreateRLSRuleCommand({"tables": [1], "roles": []})
|
||||
with pytest.raises(RLSDatasourceForbiddenError):
|
||||
command.validate()
|
||||
|
||||
can_access.assert_called_once_with(datasource=tables[0])
|
||||
|
||||
|
||||
def test_create_rls_rule_allowed_when_datasource_access() -> None:
|
||||
tables = _mock_tables(1, 2)
|
||||
|
||||
with (
|
||||
_patch_query("superset.commands.security.create", tables),
|
||||
patch(
|
||||
"superset.commands.security.create.populate_roles",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=True,
|
||||
) as can_access,
|
||||
):
|
||||
command = CreateRLSRuleCommand({"tables": [1, 2], "roles": []})
|
||||
command.validate()
|
||||
|
||||
# Access is checked for every referenced datasource.
|
||||
assert can_access.call_count == 2
|
||||
assert command._properties["tables"] == tables
|
||||
|
||||
|
||||
def test_create_rls_rule_forbidden_if_any_datasource_denied() -> None:
|
||||
tables = _mock_tables(1, 2)
|
||||
|
||||
with (
|
||||
_patch_query("superset.commands.security.create", tables),
|
||||
patch(
|
||||
"superset.commands.security.create.populate_roles",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
side_effect=[True, False],
|
||||
),
|
||||
):
|
||||
command = CreateRLSRuleCommand({"tables": [1, 2], "roles": []})
|
||||
with pytest.raises(RLSDatasourceForbiddenError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_update_rls_rule_forbidden_when_no_datasource_access() -> None:
|
||||
tables = _mock_tables(1)
|
||||
|
||||
with (
|
||||
_patch_query("superset.commands.security.update", tables),
|
||||
patch(
|
||||
"superset.commands.security.update.RLSDAO.find_by_id",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.update.populate_roles",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=False,
|
||||
) as can_access,
|
||||
):
|
||||
command = UpdateRLSRuleCommand(1, {"tables": [1], "roles": []})
|
||||
with pytest.raises(RLSDatasourceForbiddenError):
|
||||
command.validate()
|
||||
|
||||
can_access.assert_called_once_with(datasource=tables[0])
|
||||
|
||||
|
||||
def test_update_rls_rule_allowed_when_datasource_access() -> None:
|
||||
tables = _mock_tables(1)
|
||||
|
||||
with (
|
||||
_patch_query("superset.commands.security.update", tables),
|
||||
patch(
|
||||
"superset.commands.security.update.RLSDAO.find_by_id",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.update.populate_roles",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=True,
|
||||
) as can_access,
|
||||
):
|
||||
command = UpdateRLSRuleCommand(1, {"tables": [1], "roles": []})
|
||||
command.validate()
|
||||
|
||||
can_access.assert_called_once_with(datasource=tables[0])
|
||||
assert command._properties["tables"] == tables
|
||||
|
||||
|
||||
def test_delete_rls_rule_forbidden_when_no_datasource_access() -> None:
|
||||
tables = _mock_tables(1)
|
||||
rule = MagicMock()
|
||||
rule.tables = tables
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.commands.security.delete.RLSDAO.find_by_ids",
|
||||
return_value=[rule],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=False,
|
||||
) as can_access,
|
||||
):
|
||||
command = DeleteRLSRuleCommand([1])
|
||||
with pytest.raises(RLSDatasourceForbiddenError):
|
||||
command.validate()
|
||||
|
||||
can_access.assert_called_once_with(datasource=tables[0])
|
||||
|
||||
|
||||
def test_delete_rls_rule_allowed_when_datasource_access() -> None:
|
||||
tables = _mock_tables(1, 2)
|
||||
rule = MagicMock()
|
||||
rule.tables = tables
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.commands.security.delete.RLSDAO.find_by_ids",
|
||||
return_value=[rule],
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=True,
|
||||
) as can_access,
|
||||
):
|
||||
command = DeleteRLSRuleCommand([1])
|
||||
command.validate()
|
||||
|
||||
# Access is checked for every datasource referenced by the rule.
|
||||
assert can_access.call_count == 2
|
||||
@@ -246,6 +246,24 @@ def _make_mock_tool(
|
||||
return tool
|
||||
|
||||
|
||||
def _patch_token_scopes(scopes):
|
||||
"""Patch the JWT access-token lookup used by ``_get_token_scopes``.
|
||||
|
||||
``scopes=None`` simulates no JWT context / no token; a list simulates a
|
||||
token that advertises those scopes; an empty list simulates a token with
|
||||
no scopes (treated as scope-less -> RBAC-only).
|
||||
"""
|
||||
if scopes is None:
|
||||
token = None
|
||||
else:
|
||||
token = MagicMock()
|
||||
token.scopes = scopes
|
||||
return patch(
|
||||
"fastmcp.server.dependencies.get_access_token",
|
||||
return_value=token,
|
||||
)
|
||||
|
||||
|
||||
def test_visibility_returns_true_when_rbac_disabled(app_context, app) -> None:
|
||||
"""is_tool_visible_to_current_user returns True when RBAC is disabled."""
|
||||
app.config["MCP_RBAC_ENABLED"] = False
|
||||
@@ -343,3 +361,120 @@ def test_visibility_data_model_metadata_allowed(app_context) -> None:
|
||||
result = is_tool_visible_to_current_user(tool)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
# -- Scope-aware authorization (intersection of token scopes and RBAC) --
|
||||
|
||||
|
||||
def test_scope_denies_when_token_lacks_required_scope(app_context) -> None:
|
||||
"""RBAC grants but the token does not carry the required write scope: deny."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:read"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_scope_allows_when_token_has_required_scope(app_context) -> None:
|
||||
"""RBAC grants and the token carries the required write scope: allow."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:read", "superset:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_scope_falls_back_to_rbac_when_token_has_no_scopes(app_context) -> None:
|
||||
"""Token present but with no scopes: RBAC-only behavior (back-compat)."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes([]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
# No scopes advertised -> scope check is skipped, RBAC grant stands.
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_scope_falls_back_to_rbac_when_no_jwt_context(app_context) -> None:
|
||||
"""No JWT context at all (e.g. API key / dev mode): RBAC-only behavior."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="read")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(None),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_scope_read_denied_when_token_lacks_read_scope(app_context) -> None:
|
||||
"""A read tool is denied when the token only carries an unrelated scope."""
|
||||
g.user = MagicMock(username="viewer")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="read")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["some:other-scope"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_scope_denies_unmapped_method_for_scoped_token(app_context) -> None:
|
||||
"""A scoped token presented for a method permission that is NOT in the
|
||||
scope map fails closed (denied), even when RBAC grants, so an unmapped
|
||||
custom permission cannot silently bypass scope enforcement."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="some_custom_perm")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:read", "superset:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
"""SQL execution (execute_sql_query) is a write-class operation: a scoped
|
||||
token must carry the write scope, and a read-only scope is denied."""
|
||||
g.user = MagicMock(username="analyst")
|
||||
func = _make_tool_func(class_perm="SQLLab", method_perm="execute_sql_query")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with patch("superset.mcp_service.auth.security_manager", mock_sm):
|
||||
with _patch_token_scopes(["superset:read"]):
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:write"]):
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
@@ -603,3 +603,95 @@ def test_setup_user_context_allows_active_user(app) -> None:
|
||||
result = _setup_user_context()
|
||||
assert result is active_user
|
||||
assert g.user is active_user
|
||||
|
||||
|
||||
# -- Multi-issuer binding guard --
|
||||
|
||||
|
||||
def test_multi_issuer_warns_without_custom_resolver(app, caplog) -> None:
|
||||
"""When multiple issuers are trusted and no issuer-aware resolver is set,
|
||||
a WARNING is emitted about unbound (non-issuer-scoped) user resolution."""
|
||||
import logging
|
||||
|
||||
mock_user = _make_mock_user("alice")
|
||||
token = _make_access_token(claims={"sub": "alice", "iss": "issuer-a"})
|
||||
|
||||
with app.app_context():
|
||||
app.config["MCP_JWT_ISSUER"] = ["issuer-a", "issuer-b"]
|
||||
try:
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
patch(
|
||||
"fastmcp.server.dependencies.get_access_token", return_value=token
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.auth.load_user_with_relationships",
|
||||
return_value=mock_user,
|
||||
),
|
||||
):
|
||||
result = _resolve_user_from_jwt_context(app)
|
||||
finally:
|
||||
app.config.pop("MCP_JWT_ISSUER", None)
|
||||
|
||||
assert result is not None
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("Multiple JWT issuers are trusted" in m for m in warnings)
|
||||
|
||||
|
||||
def test_single_issuer_does_not_warn(app, caplog) -> None:
|
||||
"""A single configured issuer is safe and emits no multi-issuer warning."""
|
||||
import logging
|
||||
|
||||
mock_user = _make_mock_user("alice")
|
||||
token = _make_access_token(claims={"sub": "alice", "iss": "issuer-a"})
|
||||
|
||||
with app.app_context():
|
||||
app.config["MCP_JWT_ISSUER"] = "issuer-a"
|
||||
try:
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
patch(
|
||||
"fastmcp.server.dependencies.get_access_token", return_value=token
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.auth.load_user_with_relationships",
|
||||
return_value=mock_user,
|
||||
),
|
||||
):
|
||||
_resolve_user_from_jwt_context(app)
|
||||
finally:
|
||||
app.config.pop("MCP_JWT_ISSUER", None)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert not any("Multiple JWT issuers are trusted" in m for m in warnings)
|
||||
|
||||
|
||||
def test_multi_issuer_no_warn_with_custom_resolver(app, caplog) -> None:
|
||||
"""A custom MCP_USER_RESOLVER (assumed issuer-aware) suppresses the
|
||||
multi-issuer warning."""
|
||||
import logging
|
||||
|
||||
mock_user = _make_mock_user("alice")
|
||||
token = _make_access_token(claims={"sub": "alice", "iss": "issuer-a"})
|
||||
|
||||
with app.app_context():
|
||||
app.config["MCP_JWT_ISSUER"] = ["issuer-a", "issuer-b"]
|
||||
app.config["MCP_USER_RESOLVER"] = MagicMock(return_value="alice")
|
||||
try:
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
patch(
|
||||
"fastmcp.server.dependencies.get_access_token", return_value=token
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.auth.load_user_with_relationships",
|
||||
return_value=mock_user,
|
||||
),
|
||||
):
|
||||
_resolve_user_from_jwt_context(app)
|
||||
finally:
|
||||
app.config.pop("MCP_JWT_ISSUER", None)
|
||||
app.config.pop("MCP_USER_RESOLVER", None)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert not any("Multiple JWT issuers are trusted" in m for m in warnings)
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
|
||||
"""Tests for DetailedJWTVerifier and related middleware."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from authlib.jose.errors import BadSignatureError, DecodeError, ExpiredTokenError
|
||||
|
||||
@@ -168,6 +170,29 @@ async def test_expired_token(hs256_verifier):
|
||||
assert "user1" not in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_with_future_nbf_rejected(hs256_verifier):
|
||||
"""Token whose nbf is in the future should report not-yet-valid."""
|
||||
future_nbf = int(time.time()) + 3600
|
||||
claims = {
|
||||
"sub": "user1",
|
||||
"iss": "test-issuer",
|
||||
"aud": "test-audience",
|
||||
"exp": int(time.time()) + 7200,
|
||||
"nbf": future_nbf,
|
||||
}
|
||||
token = _make_token({"alg": "HS256", "typ": "JWT"}, claims)
|
||||
|
||||
with patch.object(hs256_verifier.jwt, "decode", return_value=claims):
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
reason = _jwt_failure_reason.get()
|
||||
assert reason == "Token not yet valid"
|
||||
# Claim values must not leak into the contextvar reason
|
||||
assert "user1" not in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issuer_mismatch(hs256_verifier):
|
||||
"""Token with wrong issuer should report issuer mismatch."""
|
||||
@@ -424,6 +449,64 @@ async def test_verification_key_failure(hs256_verifier):
|
||||
assert "JWKS endpoint unreachable" not in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"network_error",
|
||||
[
|
||||
httpx.ConnectError("connection refused"),
|
||||
httpx.ConnectTimeout("connect timed out"),
|
||||
httpx.ReadTimeout("read timed out"),
|
||||
httpx.HTTPStatusError(
|
||||
"500 Server Error",
|
||||
request=httpx.Request("GET", "https://idp.example/jwks"),
|
||||
response=httpx.Response(500),
|
||||
),
|
||||
asyncio.TimeoutError("overall timeout"),
|
||||
ConnectionError("connection reset"),
|
||||
OSError("dns failure"),
|
||||
],
|
||||
ids=[
|
||||
"connect_error",
|
||||
"connect_timeout",
|
||||
"read_timeout",
|
||||
"http_500",
|
||||
"asyncio_timeout",
|
||||
"connection_error",
|
||||
"os_error",
|
||||
],
|
||||
)
|
||||
async def test_jwks_network_error_fails_closed(hs256_verifier, network_error):
|
||||
"""A network failure while fetching the JWKS must reject the token.
|
||||
|
||||
Remote JWKS verification performs a network call. If that call times out,
|
||||
is refused, or returns a non-200, verification cannot complete — the token
|
||||
must be rejected (fail CLOSED), never accepted and never surfaced as an
|
||||
unhandled 500. This covers raw transport exceptions that could escape the
|
||||
upstream conversion to ValueError.
|
||||
"""
|
||||
token = _make_token(
|
||||
{"alg": "HS256", "typ": "JWT", "kid": "key-1"},
|
||||
{"sub": "user1"},
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
hs256_verifier,
|
||||
"_get_verification_key",
|
||||
side_effect=network_error,
|
||||
):
|
||||
# Must NOT raise — must resolve to a clean auth failure.
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
# Fail closed: no access token granted.
|
||||
assert result is None
|
||||
# Generic, non-leaking failure reason recorded for the 401 path. Raw
|
||||
# transport errors are categorized as a JWKS retrieval failure.
|
||||
reason = _jwt_failure_reason.get()
|
||||
assert reason == "JWKS verification key unavailable"
|
||||
# Underlying error detail must not leak into the reason surfaced to clients.
|
||||
assert str(network_error) not in (reason or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contextvar_cleared_on_success(hs256_verifier):
|
||||
"""Contextvar should be reset to None before successful validation."""
|
||||
@@ -865,3 +948,137 @@ def test_sanitize_for_log_escapes_newlines():
|
||||
assert _sanitize_for_log("a\rb\tc") == "a\\rb\\tc"
|
||||
# Backslashes are escaped first so escapes are unambiguous
|
||||
assert _sanitize_for_log("a\\nb") == "a\\\\nb"
|
||||
|
||||
|
||||
# -- Strict-mode hardening: algorithm and audience config enforcement --
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_algorithm_none_rejected(hs256_verifier):
|
||||
"""Unsigned ('none') tokens must always be rejected, even though the
|
||||
verifier pins HS256 — defense in depth against alg confusion."""
|
||||
token = _make_token(
|
||||
{"alg": "none", "typ": "JWT"},
|
||||
{"sub": "user1", "iss": "test-issuer", "aud": "test-audience"},
|
||||
)
|
||||
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "Algorithm not allowed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_algorithm_none_rejected_when_no_algorithm_pinned():
|
||||
"""When no algorithm is pinned, a 'none' token is still rejected."""
|
||||
verifier = DetailedJWTVerifier(
|
||||
public_key="test-secret-key-for-hs256-tokens",
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
required_scopes=[],
|
||||
)
|
||||
# Header alg is case-insensitively matched against the forbidden set.
|
||||
token = _make_token(
|
||||
{"alg": "NONE", "typ": "JWT"},
|
||||
{"sub": "user1", "iss": "test-issuer", "aud": "test-audience"},
|
||||
)
|
||||
|
||||
result = await verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "Algorithm not allowed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_algorithm_null_rejected(hs256_verifier):
|
||||
"""A header with ``"alg": null`` (JSON null -> Python ``None``) bypasses the
|
||||
forbidden-set check (which is ``str``-typed), but the algorithm-mismatch
|
||||
guard still rejects it because the verifier pins a concrete algorithm."""
|
||||
claims = {"sub": "user1", "iss": "test-issuer", "aud": "test-audience"}
|
||||
token = _make_token({"alg": None, "typ": "JWT"}, claims)
|
||||
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "Algorithm mismatch"
|
||||
|
||||
|
||||
def test_algorithm_invariant_is_pinned_after_construction():
|
||||
"""The mismatch defense (and thus the ``alg: null`` rejection above) relies
|
||||
on ``self.algorithm`` always being truthy post-construction. Pin that
|
||||
invariant: the factory defaults the algorithm to ``RS256`` when
|
||||
``MCP_JWT_ALGORITHM`` is unset, so a constructed verifier never has a falsy
|
||||
algorithm."""
|
||||
verifier = DetailedJWTVerifier(
|
||||
public_key="test-secret-key-for-hs256-tokens",
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
algorithm="HS256",
|
||||
required_scopes=[],
|
||||
)
|
||||
assert verifier.algorithm is not None
|
||||
assert verifier.algorithm
|
||||
|
||||
|
||||
def test_warns_when_audience_not_configured(caplog):
|
||||
"""Constructing a verifier without an audience logs a clear WARNING that
|
||||
audience validation is disabled (config-gated, not a hard failure)."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
DetailedJWTVerifier(
|
||||
public_key="test-secret-key-for-hs256-tokens",
|
||||
issuer="test-issuer",
|
||||
audience=None,
|
||||
algorithm="HS256",
|
||||
required_scopes=[],
|
||||
)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("audience validation is DISABLED" in m for m in warnings)
|
||||
|
||||
|
||||
def test_warns_when_algorithm_not_configured(caplog):
|
||||
"""A verifier with a falsy algorithm logs a WARNING that the algorithm is
|
||||
not pinned. (fastmcp's JWTVerifier coerces ``algorithm=None`` to a default,
|
||||
so we exercise the helper directly with an empty algorithm.)"""
|
||||
from superset.mcp_service.jwt_verifier import _warn_on_weak_jwt_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_warn_on_weak_jwt_config(audience="test-audience", algorithm=None)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("without a pinned signing algorithm" in m for m in warnings)
|
||||
|
||||
|
||||
def test_warns_when_algorithm_not_configured_via_constructor(caplog):
|
||||
"""Constructing a verifier with no pinned algorithm (no app context, so the
|
||||
constructor falls back to the explicit ``algorithm`` kwarg) must still emit
|
||||
the weak-config WARNING. This exercises the ``config_algorithm or
|
||||
explicit_algorithm`` fallback path in ``__init__``, not just the helper."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
DetailedJWTVerifier(
|
||||
public_key="test-secret-key-for-hs256-tokens",
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
algorithm=None,
|
||||
required_scopes=[],
|
||||
)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("without a pinned signing algorithm" in m for m in warnings)
|
||||
|
||||
|
||||
def test_no_weak_config_warning_when_fully_configured(caplog):
|
||||
"""A fully configured verifier (audience + algorithm) emits no weak-config
|
||||
warnings."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
DetailedJWTVerifier(
|
||||
public_key="test-secret-key-for-hs256-tokens",
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
algorithm="HS256",
|
||||
required_scopes=[],
|
||||
)
|
||||
|
||||
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert not any("audience validation is DISABLED" in m for m in warnings)
|
||||
assert not any("without a pinned signing algorithm" in m for m in warnings)
|
||||
|
||||
@@ -25,7 +25,11 @@ from numpy.core.multiarray import array
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
from superset.result_set import stringify_values, SupersetResultSet
|
||||
from superset.result_set import (
|
||||
stringify_extension_columns,
|
||||
stringify_values,
|
||||
SupersetResultSet,
|
||||
)
|
||||
from superset.superset_typing import DbapiResult
|
||||
from superset.utils import json as superset_json
|
||||
|
||||
@@ -385,3 +389,64 @@ def test_array_data_type_preserved() -> None:
|
||||
assert df["arr"].iloc[0] == [1, 2, 3]
|
||||
assert isinstance(df["arr"].iloc[0], list)
|
||||
assert df["arr"].iloc[2] is None
|
||||
|
||||
|
||||
def test_uuid_column_is_stringified() -> None:
|
||||
"""
|
||||
UUID columns must render as readable strings, not raw bytes.
|
||||
|
||||
PyArrow >= 21 infers Python ``uuid.UUID`` values as the canonical ``uuid``
|
||||
extension type (16-byte binary) instead of raising while building the array.
|
||||
That bypasses the stringification fallback, so without explicit handling the
|
||||
values surface in the results grid as garbled bytes / ``[bytes]``.
|
||||
``SupersetResultSet`` must stringify any Arrow extension type.
|
||||
|
||||
Regression test for the pyarrow 20 -> 24 upgrade.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
ids = [
|
||||
uuid.UUID("f4787a4f-2541-4f8a-9b5e-1e2d3c4b5a6f"),
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000000"),
|
||||
]
|
||||
data = [(ids[0],), (ids[1],)]
|
||||
description = [("uuid", "uuid", None, None, None, None, True)]
|
||||
result_set = SupersetResultSet(data, description, BaseEngineSpec) # type: ignore
|
||||
|
||||
df = result_set.to_pandas_df()
|
||||
assert df["uuid"].tolist() == [str(i) for i in ids]
|
||||
# values are readable UUID strings, not raw bytes
|
||||
assert all(value is None or isinstance(value, str) for value in df["uuid"].tolist())
|
||||
|
||||
|
||||
def test_stringify_extension_columns() -> None:
|
||||
"""
|
||||
``stringify_extension_columns`` converts Arrow extension columns (e.g. the
|
||||
canonical ``uuid`` type) to readable strings while leaving plain binary and
|
||||
other columns untouched. This is the shared helper used by both
|
||||
``SupersetResultSet`` and the semantic-layers mapper.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
first = uuid.UUID("f4787a4f-2541-4f8a-9b5e-1e2d3c4b5a6f")
|
||||
uuid_col = pa.ExtensionArray.from_storage(
|
||||
pa.uuid(), pa.array([first.bytes, None], pa.binary(16))
|
||||
)
|
||||
table = pa.table(
|
||||
{
|
||||
"id": uuid_col,
|
||||
"blob": pa.array([b"\x89PNG", None], pa.binary()),
|
||||
"n": pa.array([1, 2]),
|
||||
}
|
||||
)
|
||||
|
||||
result = stringify_extension_columns(table)
|
||||
|
||||
# uuid extension -> readable string column
|
||||
assert pa.types.is_string(result.schema.field("id").type)
|
||||
assert result.column("id").to_pylist() == [str(first), None]
|
||||
# plain binary BLOBs and other types are left untouched
|
||||
assert pa.types.is_binary(result.schema.field("blob").type)
|
||||
assert pa.types.is_integer(result.schema.field("n").type)
|
||||
|
||||
Reference in New Issue
Block a user