Compare commits

..

13 Commits

Author SHA1 Message Date
Evan
d796fc37de test(chart): address review — deterministic access test + nits
- Patch ChartDAO.find_by_id in the integration test to bypass the
  ChartFilter base filter so the new raise_for_access gate is what
  denies; assert ChartForbiddenError only (fails on master).
- Add -> None, fix mock param names, filter by slice_name.
- Pin raise_for_access(chart=...) in unit tests; add _access_exc()
  helper so the access-denied case uses an access error, not an
  ownership error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:00:23 -07:00
Evan
c5a683a589 test(chart): add positive query_context backfill test for non-owner with access
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:57:55 -07:00
Claude Code
f915b46be7 fix(chart): reconcile tests with the query_context access check
Rebased on master and updated the tests for the new raise_for_access on the
query_context-only update path:
- the unit test now mocks raise_for_access (so it no longer hits an unmocked
  g.user) and asserts access is enforced while ownership is relaxed; adds a
  forbidden-access case.
- the integration test also accepts ChartNotFoundError, since a no-access user
  is filtered by the DAO access filter before the explicit check is reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:37:46 -07:00
Evan
186aa99ecb fix(chart): add missing mock parameter in test_query_context_update_requires_chart_access
Three @patch decorators but only two function parameters meant the mock for
superset.commands.chart.update.g was never captured and its .user was never
set. Added mock_u_g as the third parameter and set mock_u_g.user = gamma so
all three g objects are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:34:00 -07:00
Claude Code
2f0e4861a4 fix(chart): require chart access for query_context-only updates
UpdateChartCommand skips the ownership check for "query_context-only" updates
(payload == {query_context, query_context_generation:true}) so report workers
and the UI's lazy query_context backfill can run as non-owners. But it skipped
ALL authorization, so any user with can_write on Chart could rewrite the
query_context of a chart they don't own (CWE-639).

Replace the unconditional skip with security_manager.raise_for_access(chart=...)
on that path. That still permits the legitimate non-owner flows (admins, owners,
and any user with access to the chart's datasource — which includes viewers who
can render the chart and the report executor), while rejecting users who cannot
access the chart at all.

DRAFT: needs CI / manual validation that the report-execution screenshot path
(executor user) passes raise_for_access in all configurations before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:34:00 -07:00
Evan Rusackas
d51753dfdc chore(lint): convert reactify.tsx to function component (#39458)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:18:03 -07:00
dependabot[bot]
543ad04ca0 chore(deps): bump pyarrow from 20.0.0 to 24.0.0 (#39756)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:51:33 -07:00
Evan Rusackas
00e3682aaf fix(dashboard): URL-encode native_filters in permalink redirect (#40660)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 11:37:08 -07:00
Evan Rusackas
004101a752 fix(rls): apply standard datasource access checks in RLS rule commands (#40650)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 11:24:12 -07:00
Evan Rusackas
568f34d6d8 fix(mcp): enforce audience, algorithm, issuer binding, and token scopes (strict mode) (#40653)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 11:08:20 -07:00
Evan Rusackas
a0cf798409 fix(embedded): add Sec-Fetch-Dest defense-in-depth check on the embedded view (#40667)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 11:08:08 -07:00
dependabot[bot]
88ea96d417 chore(deps-dev): bump typescript-eslint from 8.60.0 to 8.60.1 in /docs (#40891)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:07:41 -07:00
dependabot[bot]
c88438ad35 chore(deps-dev): bump typescript-eslint from 8.60.0 to 8.60.1 in /superset-websocket (#40887)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:07:04 -07:00
35 changed files with 1577 additions and 620 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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",

View File

@@ -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

View 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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>;
}

View File

@@ -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);
}));
});

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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

View 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()

View File

@@ -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.

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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")):

View File

@@ -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")

View File

@@ -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 = (

View File

@@ -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

View File

@@ -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(

View 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.

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)