mirror of
https://github.com/apache/superset.git
synced 2026-08-18 22:21:17 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34cd50cc48 | ||
|
|
c0ab5f3385 | ||
|
|
7d4f30574f | ||
|
|
e4ea6e23d8 | ||
|
|
a13a5f1af4 | ||
|
|
f994602096 | ||
|
|
086b4af65d | ||
|
|
fd063d17bf | ||
|
|
60e1802c52 | ||
|
|
2d1daac11a | ||
|
|
3c90bdc6f0 | ||
|
|
ebab31adc2 | ||
|
|
936f073b9a | ||
|
|
d12320239a | ||
|
|
8ef7be5788 | ||
|
|
72458ab26f | ||
|
|
5105f13726 | ||
|
|
bdafb6c330 | ||
|
|
b4d79462ec | ||
|
|
49374f1fe5 | ||
|
|
f766de6d0d | ||
|
|
ed20e729d0 | ||
|
|
13eb47a1da | ||
|
|
98136d547c | ||
|
|
e2070d79dc | ||
|
|
2807f1b0e8 | ||
|
|
c9c230142b | ||
|
|
2f8875aaef | ||
|
|
6e270df4a2 | ||
|
|
97eafd6140 | ||
|
|
3ed97f9691 | ||
|
|
5105899810 | ||
|
|
d917071708 | ||
|
|
afde126d9a | ||
|
|
b3a9b9beb4 | ||
|
|
70ba9c9552 | ||
|
|
98276cd1f3 | ||
|
|
cdeca0c179 | ||
|
|
aaf9eba161 |
@@ -24,6 +24,8 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
|
||||
|
||||
### OAuth2 database callback metrics include their outcome
|
||||
|
||||
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
@@ -31,6 +33,7 @@ The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
|
||||
the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
|
||||
@@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page:
|
||||
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
|
||||
2. Expand the **API Keys** section
|
||||
3. Click **+ API Key**
|
||||
4. Enter a name and (optionally) an expiration date
|
||||
4. Enter a name and optionally select resource scopes
|
||||
5. Copy the generated token — it is shown only once
|
||||
|
||||
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
|
||||
@@ -415,6 +415,18 @@ Authorization: Bearer <your-api-key>
|
||||
|
||||
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
|
||||
|
||||
#### API Key Scopes
|
||||
|
||||
The creation dialog can restrict an API key to MCP resource actions such as
|
||||
`superset:dashboard:read` or `superset:chart:write`. A scope is an additional
|
||||
restriction: it never grants a permission that the creating user does not
|
||||
already have through Superset RBAC. Write scopes also cover update and delete
|
||||
operations for that resource; `superset:sqllab:write` covers SQL execution.
|
||||
|
||||
Keys created without scopes retain legacy RBAC-only behavior. The scoped-key
|
||||
restrictions described here are enforced by the MCP server; regular REST API
|
||||
routes continue to apply their existing Superset RBAC checks.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **CI/CD pipelines** — automated chart/dashboard exports and imports
|
||||
|
||||
+7
-7
@@ -61,9 +61,9 @@
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
@@ -89,14 +89,14 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
Vendored
+44
-32
@@ -3407,22 +3407,26 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"editors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_managed_externally": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"owners": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.User2"
|
||||
},
|
||||
"published": {
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"roles": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Role"
|
||||
},
|
||||
"slug": {
|
||||
"maxLength": 255,
|
||||
"nullable": true,
|
||||
@@ -3432,10 +3436,10 @@
|
||||
"readOnly": true
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"readOnly": true
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"url": {
|
||||
"readOnly": true
|
||||
@@ -3444,21 +3448,46 @@
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"viewers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject1"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Role": {
|
||||
"DashboardRestApi.get_list.Subject": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"maxLength": 64,
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Subject1": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Tag": {
|
||||
@@ -3511,23 +3540,6 @@
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.User2": {
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.post": {
|
||||
"properties": {
|
||||
"certification_details": {
|
||||
@@ -16506,7 +16518,7 @@
|
||||
},
|
||||
"result": {
|
||||
"items": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
+262
-244
@@ -1142,6 +1142,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
|
||||
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
|
||||
|
||||
"@babel/runtime@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2"
|
||||
integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==
|
||||
|
||||
"@babel/template@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
|
||||
@@ -3170,100 +3175,100 @@
|
||||
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz#8b66dbfa7b796139e719063fc0e44084e80a1c15"
|
||||
integrity sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz#3f5b9d3ba944f42ad3fa2697b9fef88a8c9d4ce0"
|
||||
integrity sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==
|
||||
"@oxfmt/binding-android-arm-eabi@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz#136176dc94fdc41e21415cc770d86f5066282e0f"
|
||||
integrity sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==
|
||||
|
||||
"@oxfmt/binding-android-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz#4c7e2c567f645ed051be100318e9e3f716630c1b"
|
||||
integrity sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==
|
||||
"@oxfmt/binding-android-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz#10bc42457179210061c801122a64304619e3bdab"
|
||||
integrity sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==
|
||||
|
||||
"@oxfmt/binding-darwin-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz#6c8007ae65ed17f9d1ecc6c680da19ec19276c67"
|
||||
integrity sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==
|
||||
"@oxfmt/binding-darwin-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz#5f9084d9a760a1836387f8970a7f9d614ec3d909"
|
||||
integrity sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==
|
||||
|
||||
"@oxfmt/binding-darwin-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz#0661a0274e8625921c5a054aeb21a36251946e6b"
|
||||
integrity sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==
|
||||
"@oxfmt/binding-darwin-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz#badd4a02218a9a62319817d5c337b30159a54a21"
|
||||
integrity sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==
|
||||
|
||||
"@oxfmt/binding-freebsd-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz#f3345001102ac3e6c2947920d6d1676e9cf97e75"
|
||||
integrity sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==
|
||||
"@oxfmt/binding-freebsd-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz#a17261e95c8ebef1f76d8aaac746a64fdb6ba51e"
|
||||
integrity sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz#ddc03bc2a899f2071d6706c06dfdec3a7f3e8b5a"
|
||||
integrity sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz#baeee34bb08e0769af878623f442e83bc0aacd7a"
|
||||
integrity sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz#5e82208d612c4caf64ada75e129e34d1a9eefb2c"
|
||||
integrity sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz#e70d5697ec4b6bb5f87a3f019e01b3f956b8e44b"
|
||||
integrity sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz#eb379bc58aa962e753d58b4cc68ff4081bc19a5d"
|
||||
integrity sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz#638a8ed4f3d256c50aeb6d2c19cfc65792c902e1"
|
||||
integrity sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz#dc1c62510405e874bf6a53a34f548032eb6dfed7"
|
||||
integrity sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==
|
||||
"@oxfmt/binding-linux-arm64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz#af5a9b787f5233f27a3360ad56235fc1b011f760"
|
||||
integrity sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz#9f9afee327090024db86b70ec81a57ad06bb2f00"
|
||||
integrity sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz#c1a211206134a5577e355a495989e0d733218d60"
|
||||
integrity sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz#4427d42ee3bad0e55b38dc76fe14a7e5318c360c"
|
||||
integrity sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz#4863f0311e5c1b88f75ef822959b3ca4fd938937"
|
||||
integrity sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz#a117f82909f075cf07c333842d89a5638429e21d"
|
||||
integrity sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz#ad05a017d12553e2f544743c4940adb552aa1d1c"
|
||||
integrity sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz#3fac79fefe7ffc3f0a9393678ebd782aac918fcd"
|
||||
integrity sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz#2803f539db15bc66db115888fa8f84d6531ed2b9"
|
||||
integrity sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz#8da207bef27941f0265c129d1c7c82c7cf91d1ce"
|
||||
integrity sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==
|
||||
"@oxfmt/binding-linux-x64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz#c22a06a60ae2d6b3de522095e0c50a816040a033"
|
||||
integrity sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz#e55cf9b7c8c2204fdbb5d4818f8c5ba02aa49360"
|
||||
integrity sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==
|
||||
"@oxfmt/binding-linux-x64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz#48d3eeaf8e3757f638cf92de5ee4858befc9c0a3"
|
||||
integrity sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz#4998769ee1b5894efcd6cb99729d5a75f4c09dd1"
|
||||
integrity sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==
|
||||
"@oxfmt/binding-openharmony-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz#02be9e140ae35ba30f52bdce27612fece4a01ab3"
|
||||
integrity sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz#e09eaabdde76c885c4f8a190518c2eb2de08548a"
|
||||
integrity sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz#2226eaf52b6345a2cb926499216b2486cf0dbec2"
|
||||
integrity sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz#f36e306308923977365270d8b26290f4ca2fcfa5"
|
||||
integrity sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz#58d263bb5ecd7330c02f9dcd8cda10f66e42e74b"
|
||||
integrity sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz#bb6545e581d5ee7111084dbabeec7fe548bae418"
|
||||
integrity sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==
|
||||
"@oxfmt/binding-win32-x64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz#02a166c8a8049c55d0096d1ba9d8e73f3a4d26a7"
|
||||
integrity sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==
|
||||
|
||||
"@parcel/watcher-android-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
@@ -3532,13 +3537,13 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.4"
|
||||
|
||||
"@rc-component/cascader@~1.17.0":
|
||||
version "1.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.17.0.tgz#52c0eceada2c7b4b37ebe822c19a6544b9562edf"
|
||||
integrity sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==
|
||||
"@rc-component/cascader@~1.22.0":
|
||||
version "1.22.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.22.0.tgz#eec0b6f4d2df5903aa12cfed321a578705926937"
|
||||
integrity sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -3614,14 +3619,14 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/image@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.9.0.tgz#110785d735d20336afcdbac84e8fbfd059a7a44e"
|
||||
integrity sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==
|
||||
"@rc-component/image@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.10.0.tgz#5d7a82d20e4c91f75875ea64eb1dadd7af676b1d"
|
||||
integrity sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/portal" "^2.1.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input-number@~1.6.2":
|
||||
@@ -3633,7 +3638,7 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input@~1.3.0", "@rc-component/input@~1.3.1":
|
||||
"@rc-component/input@~1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/input/-/input-1.3.1.tgz#230b8b59cdde8521d50f0eede63ddacb61cc0cd3"
|
||||
integrity sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==
|
||||
@@ -3642,15 +3647,27 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.10.0.tgz#46b1117cfb0c716b476e97f342555eccc2f41c97"
|
||||
integrity sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==
|
||||
"@rc-component/listy@~1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/listy/-/listy-1.2.3.tgz#e9c8ef4f409c231b44dded37e63ae2875bbe0334"
|
||||
integrity sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/portal" "^2.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.3.1"
|
||||
"@rc-component/virtual-list" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
|
||||
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
|
||||
@@ -3724,7 +3741,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
"@rc-component/portal@^2.0.0", "@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/portal/-/portal-2.2.1.tgz#37c34b4c8cd73f53cc7072c96dd0e9ac332669ec"
|
||||
integrity sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==
|
||||
@@ -3772,10 +3789,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.8.0", "@rc-component/select@~1.8.2":
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.8.2.tgz#f016992dae5c57186535512d73783e2fc7e4c59e"
|
||||
integrity sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==
|
||||
"@rc-component/select@~1.10.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
|
||||
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
@@ -3807,10 +3824,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/table@~1.10.4":
|
||||
version "1.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.10.4.tgz#8c4e33bc150aa39f579c15426421348a789de326"
|
||||
integrity sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==
|
||||
"@rc-component/table@~1.11.0":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
|
||||
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
|
||||
dependencies:
|
||||
"@rc-component/context" "^2.0.1"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
@@ -3818,10 +3835,10 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.11.0.tgz#c157b2fadcdc2f3ab6c69d0098f73e03c6aa0c12"
|
||||
integrity sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==
|
||||
"@rc-component/tabs@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
|
||||
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
@@ -3830,13 +3847,13 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tooltip@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz#c8cf15c6773218a5a36271467f06e663f99c28e7"
|
||||
integrity sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==
|
||||
"@rc-component/tooltip@~1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
|
||||
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
|
||||
dependencies:
|
||||
"@rc-component/trigger" "^3.7.1"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/trigger" "^3.10.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tour@~2.4.0":
|
||||
@@ -3849,27 +3866,27 @@
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz#9080cdf1d28f2ddd6d8a4879b7aa90d3170f7db9"
|
||||
integrity sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==
|
||||
"@rc-component/tree-select@~1.16.0":
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
|
||||
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree@~1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.2.tgz#4b0c13564314eff61ca948c18ef923b87c9d7e44"
|
||||
integrity sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==
|
||||
"@rc-component/tree@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.4.0.tgz#c0031180e681389bf0bdcb867a0087525b45c8a9"
|
||||
integrity sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
"@rc-component/virtual-list" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15":
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86"
|
||||
integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==
|
||||
@@ -3888,7 +3905,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
|
||||
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
|
||||
@@ -3906,6 +3923,16 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/virtual-list@^1.4.0":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz#71c5844a8d6bd5b3501dfb66419d3a4612b2bb18"
|
||||
integrity sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^8.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.1"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@redocly/ajv@^8.18.1":
|
||||
version "8.18.3"
|
||||
resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.18.3.tgz#a925753d9a33375219f1b2ba91aef320f9929577"
|
||||
@@ -5658,110 +5685,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.66.0", "@typescript-eslint/eslint-plugin@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz#76e86aa5a2459fbf5bbd7a839c0dc0cce1d56224"
|
||||
integrity sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==
|
||||
"@typescript-eslint/eslint-plugin@8.67.0", "@typescript-eslint/eslint-plugin@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
|
||||
integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/type-utils" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/type-utils" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.66.0", "@typescript-eslint/parser@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.66.0.tgz#88e3865ecf73b0118134e7cb831da87a961a57a1"
|
||||
integrity sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==
|
||||
"@typescript-eslint/parser@8.67.0", "@typescript-eslint/parser@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
|
||||
integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.66.0.tgz#828f788895df52d9eb2b543445a3a5a13e35ab4e"
|
||||
integrity sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==
|
||||
"@typescript-eslint/project-service@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
|
||||
integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.66.0"
|
||||
"@typescript-eslint/types" "^8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.67.0"
|
||||
"@typescript-eslint/types" "^8.67.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz#4fffcc6ebd0df9fe7983c0256967567ea6f5ac63"
|
||||
integrity sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==
|
||||
"@typescript-eslint/scope-manager@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
|
||||
integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz#3a89066c507aa30541dc176804685b4b444e1e52"
|
||||
integrity sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.66.0":
|
||||
"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
|
||||
integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz#b2315303eca72fad9afa7be4f58f053c8f2a0479"
|
||||
integrity sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==
|
||||
"@typescript-eslint/type-utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
|
||||
integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.66.0.tgz#3cacab94d3b564c1d48c56eb37b89f89a6d48479"
|
||||
integrity sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==
|
||||
|
||||
"@typescript-eslint/types@^8.66.0":
|
||||
"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
|
||||
integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz#1a38c3a97dc6c669b66d585d7f90ebc4fbb32a50"
|
||||
integrity sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==
|
||||
"@typescript-eslint/typescript-estree@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
|
||||
integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/project-service" "8.67.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.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/utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.66.0.tgz#e277d67427043cdca2580ee91aa62921e4689969"
|
||||
integrity sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==
|
||||
"@typescript-eslint/utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
|
||||
integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/scope-manager" "8.67.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz#4c494e94745fb2724a4f37a310091e56b644d18a"
|
||||
integrity sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==
|
||||
"@typescript-eslint/visitor-keys@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
|
||||
integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -6164,10 +6181,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.4:
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
|
||||
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
|
||||
antd@^6.6.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
|
||||
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6176,7 +6193,7 @@ antd@^6.5.4:
|
||||
"@ant-design/icons" "^6.3.2"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
"@babel/runtime" "^7.29.2"
|
||||
"@rc-component/cascader" "~1.17.0"
|
||||
"@rc-component/cascader" "~1.22.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
@@ -6184,10 +6201,11 @@ antd@^6.5.4:
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.3"
|
||||
"@rc-component/form" "~1.8.6"
|
||||
"@rc-component/image" "~1.9.0"
|
||||
"@rc-component/image" "~1.10.0"
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/mentions" "~1.10.0"
|
||||
"@rc-component/listy" "~1.2.3"
|
||||
"@rc-component/mentions" "~1.11.0"
|
||||
"@rc-component/menu" "~1.4.1"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
@@ -6199,16 +6217,16 @@ antd@^6.5.4:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.8.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.10.4"
|
||||
"@rc-component/tabs" "~1.11.0"
|
||||
"@rc-component/tooltip" "~1.4.0"
|
||||
"@rc-component/table" "~1.11.0"
|
||||
"@rc-component/tabs" "~1.12.0"
|
||||
"@rc-component/tooltip" "~1.5.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/tree-select" "~1.11.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/tree-select" "~1.16.0"
|
||||
"@rc-component/trigger" "^3.10.1"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.12.0"
|
||||
@@ -6504,10 +6522,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.12"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz#42ac48770bf73d292f60ce8ba4dc5e7ebb242ec3"
|
||||
integrity sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.13"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
|
||||
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6745,10 +6763,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
|
||||
version "1.0.30001807"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
|
||||
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001809"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b"
|
||||
integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -12244,32 +12262,32 @@ oxc-resolver@^11.19.1:
|
||||
"@oxc-resolver/binding-win32-arm64-msvc" "11.23.0"
|
||||
"@oxc-resolver/binding-win32-x64-msvc" "11.23.0"
|
||||
|
||||
oxfmt@^0.62.0:
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.62.0.tgz#9945728022d26dc0a1d5bc486db112e7e340507a"
|
||||
integrity sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==
|
||||
oxfmt@^0.63.0:
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.63.0.tgz#c7338e6c43a68d5cf8dc61c08b617d77cb54e323"
|
||||
integrity sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==
|
||||
dependencies:
|
||||
tinypool "2.1.0"
|
||||
optionalDependencies:
|
||||
"@oxfmt/binding-android-arm-eabi" "0.62.0"
|
||||
"@oxfmt/binding-android-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.62.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.62.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.62.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-android-arm-eabi" "0.63.0"
|
||||
"@oxfmt/binding-android-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.63.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.63.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.63.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.63.0"
|
||||
|
||||
p-cancelable@^3.0.0:
|
||||
version "3.0.0"
|
||||
@@ -15467,15 +15485,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.66.0:
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.66.0.tgz#0809b6d25c8a0924690ba30dc1f05607093c11fb"
|
||||
integrity sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==
|
||||
typescript-eslint@^8.67.0:
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.67.0.tgz#1e92de09ee0ff2d96cc0848f5e9f345ea930d963"
|
||||
integrity sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.66.0"
|
||||
"@typescript-eslint/parser" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.67.0"
|
||||
"@typescript-eslint/parser" "8.67.0"
|
||||
"@typescript-eslint/typescript-estree" "8.67.0"
|
||||
"@typescript-eslint/utils" "8.67.0"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
import isodate
|
||||
import pyarrow as pa
|
||||
@@ -90,6 +91,8 @@ class Dimension:
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: Grain | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
@@ -121,6 +124,9 @@ class Metric:
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
d3format: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pyarrow as pa
|
||||
from superset_core.semantic_layers.types import Dimension, Metric
|
||||
|
||||
|
||||
def test_dimension_metadata_is_not_part_of_identity() -> None:
|
||||
first = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
metadata={"display_name": "Region"},
|
||||
)
|
||||
second = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Sales region",
|
||||
metadata={"display_name": "Sales region"},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_metadata_is_not_part_of_identity() -> None:
|
||||
first = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
metadata={"unit": {"kind": "currency", "code": "USD"}},
|
||||
)
|
||||
second = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Revenue",
|
||||
d3format=",.0f",
|
||||
metadata={"unit": {"kind": "currency", "code": "EUR"}},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_accepts_superset_presentation_fields() -> None:
|
||||
metric = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
)
|
||||
|
||||
assert metric.verbose_name == "Total revenue"
|
||||
assert metric.d3format == "$,.2f"
|
||||
|
||||
|
||||
def test_dimension_accepts_superset_presentation_fields() -> None:
|
||||
dimension = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
)
|
||||
|
||||
assert dimension.verbose_name == "Region"
|
||||
|
||||
|
||||
def test_metadata_defaults_are_not_shared() -> None:
|
||||
first = Metric("first", "first", pa.int64(), "COUNT(*)")
|
||||
second = Metric("second", "second", pa.int64(), "COUNT(*)")
|
||||
|
||||
first.metadata["display_name"] = "First"
|
||||
|
||||
assert second.metadata == {}
|
||||
Generated
+342
-622
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.4",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -176,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -266,7 +266,7 @@
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -277,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -289,19 +289,19 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -331,8 +331,8 @@
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -349,7 +349,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.10",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
+6
-4
@@ -107,12 +107,14 @@ const getAllSelectOptions = () =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectOptions = () =>
|
||||
@@ -644,7 +646,7 @@ test('does not add a new option if the option already exists', async () => {
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
getElementByClassName('.ant-select-dropdown-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
@@ -1398,7 +1400,7 @@ test('appends page>1 results during an active search and discards them when sear
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
'.ant-select-dropdown-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
|
||||
@@ -93,12 +93,14 @@ const deselectAllButtonText = (length: number) =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const getAllSelectOptions = () =>
|
||||
|
||||
@@ -19,19 +19,71 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { sanitizeHtml } from './html';
|
||||
|
||||
export type TooltipTruncationMode = 'off' | 'end' | 'start' | 'middle';
|
||||
|
||||
export const TRUNCATION_MAX_CHARS = 40;
|
||||
|
||||
const TRUNCATION_STYLE = `
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const NOWRAP_STYLE = `
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Shortens plain text so a tooltip label stays readable, placing the ellipsis
|
||||
* where the caller asked for it.
|
||||
*
|
||||
* Only 'start' and 'middle' slice. 'end' is handled by CSS in tooltipHtml, and
|
||||
* 'off' means no truncation at all, so both return the input untouched.
|
||||
*
|
||||
* The input must be plain text. Callers are responsible for truncating before
|
||||
* any markup (such as the ECharts series marker) is prepended, and before
|
||||
* sanitization — slicing a string that already contains markup would cut into
|
||||
* a tag.
|
||||
*/
|
||||
export function truncateLabel(
|
||||
text: string,
|
||||
mode: TooltipTruncationMode = 'end',
|
||||
): string {
|
||||
if (
|
||||
(mode !== 'start' && mode !== 'middle') ||
|
||||
text.length <= TRUNCATION_MAX_CHARS
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const budget = TRUNCATION_MAX_CHARS - 1;
|
||||
if (mode === 'start') {
|
||||
return `…${text.slice(-budget)}`;
|
||||
}
|
||||
const head = Math.ceil(budget / 2);
|
||||
const tail = Math.floor(budget / 2);
|
||||
return `${text.slice(0, head)}…${text.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function getTruncationStyle(mode: TooltipTruncationMode): string {
|
||||
if (mode === 'end') {
|
||||
return TRUNCATION_STYLE;
|
||||
}
|
||||
if (mode === 'off') {
|
||||
return '';
|
||||
}
|
||||
// 'start' and 'middle' are already sliced upstream; keep them on one line.
|
||||
return NOWRAP_STYLE;
|
||||
}
|
||||
|
||||
export function tooltipHtml(
|
||||
data: string[][],
|
||||
title?: string,
|
||||
focusedRow?: number,
|
||||
truncation: TooltipTruncationMode = 'end',
|
||||
) {
|
||||
const truncationStyle = getTruncationStyle(truncation);
|
||||
const titleRow = title
|
||||
? `<span style="font-weight: 700;${TRUNCATION_STYLE}">${title}</span>`
|
||||
? `<span style="font-weight: 700;${truncationStyle}">${title}</span>`
|
||||
: '';
|
||||
return sanitizeHtml(`
|
||||
<div>
|
||||
@@ -46,7 +98,7 @@ export function tooltipHtml(
|
||||
const cellStyle = `
|
||||
text-align: ${j > 0 ? 'right' : 'left'};
|
||||
padding-left: ${j === 0 ? 0 : 16}px;
|
||||
${TRUNCATION_STYLE}
|
||||
${truncationStyle}
|
||||
`;
|
||||
return `<td style="${cellStyle}">${cell}</td>`;
|
||||
});
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeHtml, tooltipHtml } from '@superset-ui/core';
|
||||
import {
|
||||
sanitizeHtml,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
TRUNCATION_MAX_CHARS,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const TITLE_STYLE =
|
||||
'style="font-weight: 700;max-width:300px;overflow:hidden;text-overflow:ellipsis;"';
|
||||
@@ -182,3 +187,88 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
expect(html).toContain('padding-left:16px');
|
||||
expect(html).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
describe('truncateLabel', () => {
|
||||
const long = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
|
||||
|
||||
test('returns text unchanged for off and end', () => {
|
||||
expect(truncateLabel(long, 'off')).toBe(long);
|
||||
expect(truncateLabel(long, 'end')).toBe(long);
|
||||
});
|
||||
|
||||
test('defaults to end, which does not slice', () => {
|
||||
expect(truncateLabel(long)).toBe(long);
|
||||
});
|
||||
|
||||
test('truncates the start, keeping the distinguishing suffix', () => {
|
||||
expect(truncateLabel(long, 'start')).toBe(
|
||||
'…-us-east-1-service-checkout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'start')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('truncates the middle, keeping both ends', () => {
|
||||
expect(truncateLabel(long, 'middle')).toBe(
|
||||
'prod-us-east-1-servi…heckout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'middle')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('leaves text at or under the limit untouched', () => {
|
||||
const atLimit = 'x'.repeat(TRUNCATION_MAX_CHARS);
|
||||
expect(truncateLabel(atLimit, 'start')).toBe(atLimit);
|
||||
expect(truncateLabel(atLimit, 'middle')).toBe(atLimit);
|
||||
expect(truncateLabel('short', 'start')).toBe('short');
|
||||
expect(truncateLabel('', 'middle')).toBe('');
|
||||
});
|
||||
|
||||
test('truncates text one character over the limit', () => {
|
||||
const overLimit = 'x'.repeat(TRUNCATION_MAX_CHARS + 1);
|
||||
expect(truncateLabel(overLimit, 'start')).toBe(
|
||||
`…${'x'.repeat(TRUNCATION_MAX_CHARS - 1)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltipHtml truncation modes', () => {
|
||||
const rows = [['label', 'value']];
|
||||
|
||||
// sanitizeHtml normalizes spacing inside style attributes, and it does so
|
||||
// differently across versions, so compare with whitespace stripped.
|
||||
const styles = (
|
||||
title: string | undefined,
|
||||
truncation?: 'off' | 'end' | 'start' | 'middle',
|
||||
) => removeWhitespaces(tooltipHtml(rows, title, undefined, truncation));
|
||||
|
||||
test('emits the 300px cap for end and for the default', () => {
|
||||
expect(styles('Title', 'end')).toContain('max-width:300px');
|
||||
expect(tooltipHtml(rows, 'Title')).toBe(
|
||||
tooltipHtml(rows, 'Title', undefined, 'end'),
|
||||
);
|
||||
});
|
||||
|
||||
test('emits no truncation style for off', () => {
|
||||
const html = styles('Title', 'off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).not.toContain('text-overflow');
|
||||
expect(html).not.toContain('white-space');
|
||||
});
|
||||
|
||||
test.each(['start', 'middle'] as const)(
|
||||
'emits nowrap instead of a cap for %s',
|
||||
mode => {
|
||||
const html = styles('Title', mode);
|
||||
expect(html).toContain('white-space:nowrap');
|
||||
expect(html).not.toContain('max-width');
|
||||
},
|
||||
);
|
||||
|
||||
test('never slices cell text itself, whatever the mode', () => {
|
||||
const longCell = 'y'.repeat(TRUNCATION_MAX_CHARS + 20);
|
||||
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
|
||||
expect(tooltipHtml([[longCell]], undefined, undefined, mode)).toContain(
|
||||
longCell,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -40,6 +40,7 @@ import {
|
||||
TimeseriesChartDataResponseResult,
|
||||
TimeseriesDataRecord,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -207,6 +208,7 @@ export default function transformProps(
|
||||
zoomable,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
tooltipTruncation,
|
||||
xAxisBounds,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
@@ -907,13 +909,19 @@ export default function transformProps(
|
||||
formatter: primarySeries.has(key)
|
||||
? tooltipFormatter
|
||||
: tooltipFormatterSecondary,
|
||||
truncation: tooltipTruncation,
|
||||
});
|
||||
rows.push(row);
|
||||
if (key === focusedSeries) {
|
||||
focusedRow = rows.length - 1;
|
||||
}
|
||||
});
|
||||
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
|
||||
return tooltipHtml(
|
||||
rows,
|
||||
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
|
||||
focusedRow,
|
||||
tooltipTruncation,
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ContributionType,
|
||||
TimeFormatter,
|
||||
AxisType,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
BaseChartProps,
|
||||
@@ -59,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
timeGrainSqla?: TimeGranularity;
|
||||
forceMaxInterval?: boolean;
|
||||
tooltipTimeFormat?: string;
|
||||
tooltipTruncation?: TooltipTruncationMode;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
@@ -108,6 +110,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
yAxisFormatSecondary: TIMESERIES_DEFAULTS.yAxisFormat,
|
||||
yAxisTitleSecondary: DEFAULT_TITLE_FORM_DATA.yAxisTitle,
|
||||
tooltipTimeFormat: TIMESERIES_DEFAULTS.tooltipTimeFormat,
|
||||
tooltipTruncation: TIMESERIES_DEFAULTS.tooltipTruncation,
|
||||
xAxisBounds: TIMESERIES_DEFAULTS.xAxisBounds,
|
||||
xAxisForceCategorical: TIMESERIES_DEFAULTS.xAxisForceCategorical,
|
||||
xAxisTimeFormat: TIMESERIES_DEFAULTS.xAxisTimeFormat,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
tooltipTruncation: 'end',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
xAxisNumberFormat: 'SMART_NUMBER',
|
||||
truncateXAxis: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DTTM_ALIAS,
|
||||
ensureIsArray,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
getCustomFormatter,
|
||||
getMetricLabel,
|
||||
getNumberFormatter,
|
||||
@@ -303,6 +304,7 @@ export default function transformProps(
|
||||
tooltipSortByMetric,
|
||||
showTooltipTotal,
|
||||
showTooltipPercentage,
|
||||
tooltipTruncation,
|
||||
truncateXAxis,
|
||||
truncateYAxis,
|
||||
xAxis: xAxisOrig,
|
||||
@@ -1449,6 +1451,7 @@ export default function transformProps(
|
||||
seriesName: key,
|
||||
formatter,
|
||||
marker,
|
||||
truncation: tooltipTruncation,
|
||||
});
|
||||
|
||||
const annotationRow = annotationLayers.some(
|
||||
@@ -1482,7 +1485,12 @@ export default function transformProps(
|
||||
}
|
||||
rows.push(totalRow);
|
||||
}
|
||||
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
|
||||
return tooltipHtml(
|
||||
rows,
|
||||
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
|
||||
focusedRow,
|
||||
tooltipTruncation,
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
QueryFormMetric,
|
||||
TimeFormatter,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
BaseChartProps,
|
||||
@@ -82,6 +83,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
showTooltipTotal?: boolean;
|
||||
showTooltipPercentage?: boolean;
|
||||
tooltipTruncation?: TooltipTruncationMode;
|
||||
truncateXAxis: boolean;
|
||||
truncateYAxis: boolean;
|
||||
yAxisFormat?: string;
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart, { isReportScreenshotMode } from './Echart';
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import Echart, {
|
||||
ECHARTS_HOST_CLASS,
|
||||
ECHARTS_RENDER_FINISHED_CLASS,
|
||||
isReportScreenshotMode,
|
||||
} from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
@@ -272,3 +276,31 @@ test('keeps animation enabled when not in report screenshot mode', async () => {
|
||||
const lastOptions = mockChart.setOption.mock.calls.at(-1)?.[0];
|
||||
expect(lastOptions.animation).not.toBe(false);
|
||||
});
|
||||
|
||||
test('tags the ECharts canvas host with the readiness-gate class', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
expect(container.querySelector(`.${ECHARTS_HOST_CLASS}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('marks the host painted only on the ECharts `finished` event', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
|
||||
const host = container.querySelector(`.${ECHARTS_HOST_CLASS}`) as HTMLElement;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
// `setOption` ran during mount, which clears the marker; `finished` has not
|
||||
// fired yet, so the host must NOT be flagged as painted.
|
||||
expect(host).not.toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
|
||||
// Simulate ECharts completing its draw -> the host is flagged painted.
|
||||
trigger('finished');
|
||||
expect(host).toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
|
||||
@@ -138,6 +138,15 @@ export function isReportScreenshotMode(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
|
||||
// `echarts-host` marks the canvas host element; `echarts-render-finished` is
|
||||
// toggled OFF before each setOption and ON in the ECharts `finished` event --
|
||||
// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
|
||||
// both fire pre-paint). The readiness gate treats a host that lacks
|
||||
// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
|
||||
export const ECHARTS_HOST_CLASS = 'echarts-host';
|
||||
export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
|
||||
|
||||
function Echart(
|
||||
{
|
||||
width,
|
||||
@@ -201,6 +210,11 @@ function Echart(
|
||||
width,
|
||||
height,
|
||||
});
|
||||
// Paint marker for the report-screenshot readiness gate. `finished`
|
||||
// is the only event that guarantees the canvas is fully drawn.
|
||||
chartRef.current.on('finished', () => {
|
||||
divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
}
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
@@ -321,6 +335,9 @@ function Echart(
|
||||
}
|
||||
)?.dataZoom
|
||||
: undefined;
|
||||
// Clear the paint marker before (re)drawing; the `finished` handler
|
||||
// re-adds it once the new frame is fully rendered.
|
||||
divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
@@ -412,7 +429,14 @@ function Echart(
|
||||
handleSizeChange({ width, height });
|
||||
}, [width, height, handleSizeChange]);
|
||||
|
||||
return <Styles ref={divRef} height={height} width={width} />;
|
||||
return (
|
||||
<Styles
|
||||
ref={divRef}
|
||||
className={ECHARTS_HOST_CLASS}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Echart);
|
||||
|
||||
@@ -315,6 +315,27 @@ const tooltipPercentageControl: ControlSetItem = {
|
||||
},
|
||||
};
|
||||
|
||||
const tooltipTruncationControl: ControlSetItem = {
|
||||
name: 'tooltipTruncation',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: false,
|
||||
label: t('Truncate labels'),
|
||||
renderTrigger: true,
|
||||
default: 'end',
|
||||
clearable: false,
|
||||
choices: [
|
||||
['off', t('Off')],
|
||||
['end', t('End')],
|
||||
['start', t('Start')],
|
||||
['middle', t('Middle')],
|
||||
],
|
||||
description: t(
|
||||
'Where to place the ellipsis when a tooltip label is too long. Choose Off to always show the full label, or Start when labels share a common prefix.',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const richTooltipSection: ControlSetRow[] = [
|
||||
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||
[richTooltipControl],
|
||||
@@ -322,6 +343,7 @@ export const richTooltipSection: ControlSetRow[] = [
|
||||
[tooltipPercentageControl],
|
||||
[tooltipSortByMetricControl],
|
||||
[tooltipTimeFormatControl],
|
||||
[tooltipTruncationControl],
|
||||
];
|
||||
|
||||
const sortSeriesType: ControlSetItem = {
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DataRecord, DTTM_ALIAS, ValueFormatter } from '@superset-ui/core';
|
||||
import {
|
||||
DataRecord,
|
||||
DTTM_ALIAS,
|
||||
truncateLabel,
|
||||
TooltipTruncationMode,
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import type { OptionName, SeriesOption } from 'echarts/types/src/util/types';
|
||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||
import {
|
||||
@@ -91,12 +97,16 @@ export const formatForecastTooltipSeries = ({
|
||||
forecastUpper,
|
||||
marker,
|
||||
formatter,
|
||||
truncation = 'end',
|
||||
}: ForecastValue & {
|
||||
seriesName: string;
|
||||
marker: TooltipMarker;
|
||||
formatter: ValueFormatter;
|
||||
truncation?: TooltipTruncationMode;
|
||||
}): string[] => {
|
||||
const name = `${marker}${sanitizeHtml(seriesName)}`;
|
||||
// Truncate before sanitizing and before the marker is prepended: slicing a
|
||||
// string that already contains markup would cut into the marker's tag.
|
||||
const name = `${marker}${sanitizeHtml(truncateLabel(seriesName, truncation))}`;
|
||||
let value = typeof observation === 'number' ? formatter(observation) : '';
|
||||
// Use finite-number checks rather than truthiness so that legitimate
|
||||
// zero values (e.g. a forecast that crosses zero, or a confidence bound of
|
||||
|
||||
+57
@@ -27,6 +27,7 @@ import {
|
||||
VizType,
|
||||
ChartDataResponseResult,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
@@ -1295,3 +1296,59 @@ test('y-axis title position: non-Left sets nameLocation to end', () => {
|
||||
expect(yAxis[1].nameGap).toEqual(30);
|
||||
expect(yAxis[1].nameLocation).toEqual('end');
|
||||
});
|
||||
describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
|
||||
const buildTooltip = (tooltipTruncation?: TooltipTruncationMode) => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
...(tooltipTruncation ? { tooltipTruncation } : {}),
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const { formatter } = echartOptions.tooltip as {
|
||||
formatter: (params: unknown) => string;
|
||||
};
|
||||
// richTooltip is false in this fixture, so the trigger is 'item' and the
|
||||
// formatter receives a single param object rather than an array.
|
||||
return formatter({
|
||||
seriesId: longSeriesName,
|
||||
seriesName: longSeriesName,
|
||||
value: [599616000000, 1],
|
||||
marker,
|
||||
});
|
||||
};
|
||||
|
||||
test('keeps full text with the CSS cap by default', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('removes the cap and keeps full text when off', () => {
|
||||
const html = buildTooltip('off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('drops the shared prefix when truncating from the start', () => {
|
||||
const html = buildTooltip('start');
|
||||
expect(html).not.toContain('prod-us-east');
|
||||
expect(html).toContain('latency-p99');
|
||||
expect(html).toContain('background-color:#1f77b4');
|
||||
});
|
||||
|
||||
test('keeps both ends when truncating the middle', () => {
|
||||
const html = buildTooltip('middle');
|
||||
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
expect(html).not.toContain(longSeriesName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
TimeseriesAnnotationLayer,
|
||||
ChartDataResponseResult,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
@@ -2437,3 +2438,94 @@ test('honors the snake_case flag the compare-chart migration stores in params',
|
||||
[BASE_TIMESTAMP + 300000000, 2],
|
||||
]);
|
||||
});
|
||||
describe('EchartsTimeseries tooltip truncation', () => {
|
||||
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
|
||||
const buildTooltip = (
|
||||
tooltipTruncation?: TooltipTruncationMode,
|
||||
xValue: string | number = 599616000000,
|
||||
) => {
|
||||
const chartProps = new ChartProps({
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo'],
|
||||
viz_type: 'my_viz',
|
||||
...(tooltipTruncation ? { tooltipTruncation } : {}),
|
||||
} as SqlaFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ [longSeriesName]: 1, __timestamp: 599616000000 },
|
||||
{ [longSeriesName]: 3, __timestamp: 599916000000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const { echartOptions } = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const { formatter } = echartOptions.tooltip as {
|
||||
formatter: (params: unknown) => string;
|
||||
};
|
||||
return formatter([
|
||||
{
|
||||
seriesId: longSeriesName,
|
||||
seriesName: longSeriesName,
|
||||
value: [xValue, 1],
|
||||
marker,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
test('applies the CSS cap and keeps full text by default', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html).toContain(longSeriesName);
|
||||
// sanitizeHtml normalizes spacing inside style attributes, so compare with
|
||||
// whitespace stripped rather than hard-coding one version's formatting.
|
||||
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
test('removes the cap and keeps full text when off', () => {
|
||||
const html = buildTooltip('off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('drops the shared prefix when truncating from the start', () => {
|
||||
const html = buildTooltip('start');
|
||||
expect(html).not.toContain('prod-us-east');
|
||||
expect(html).toContain('latency-p99');
|
||||
expect(html.replace(/\s/g, '')).toContain('white-space:nowrap');
|
||||
});
|
||||
|
||||
test('keeps both ends when truncating the middle', () => {
|
||||
const html = buildTooltip('middle');
|
||||
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
expect(html).not.toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('preserves the echarts marker in every mode', () => {
|
||||
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
|
||||
expect(buildTooltip(mode)).toContain('background-color:#1f77b4');
|
||||
});
|
||||
});
|
||||
|
||||
test('truncates a long non-temporal x-axis title', () => {
|
||||
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
|
||||
const html = buildTooltip('start', longCategory);
|
||||
expect(html).not.toContain(longCategory);
|
||||
expect(html).toContain('cohort-2026');
|
||||
});
|
||||
|
||||
test('leaves a long title alone in the default mode', () => {
|
||||
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
|
||||
expect(buildTooltip(undefined, longCategory)).toContain(longCategory);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getNumberFormatter, NumberFormats } from '@superset-ui/core';
|
||||
import {
|
||||
getNumberFormatter,
|
||||
NumberFormats,
|
||||
TRUNCATION_MAX_CHARS,
|
||||
} from '@superset-ui/core';
|
||||
import { SeriesOption } from 'echarts';
|
||||
import {
|
||||
extractForecastSeriesContext,
|
||||
@@ -411,3 +415,52 @@ test('formatForecastTooltipSeries should skip non-finite forecast values', () =>
|
||||
}),
|
||||
).toEqual(['<img>qwerty', '10']);
|
||||
});
|
||||
|
||||
describe('formatForecastTooltipSeries truncation', () => {
|
||||
const marker =
|
||||
'<span style="display:inline-block;width:10px;height:10px;background-color:#1f77b4;"></span>';
|
||||
const longName = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
|
||||
const intFormatter = getNumberFormatter(NumberFormats.INTEGER);
|
||||
|
||||
const format = (truncation?: 'off' | 'end' | 'start' | 'middle') =>
|
||||
formatForecastTooltipSeries({
|
||||
seriesName: longName,
|
||||
observation: 1,
|
||||
marker,
|
||||
formatter: intFormatter,
|
||||
...(truncation ? { truncation } : {}),
|
||||
})[0];
|
||||
|
||||
test('leaves the name intact by default and for off/end', () => {
|
||||
expect(format()).toContain(longName);
|
||||
expect(format('off')).toContain(longName);
|
||||
expect(format('end')).toContain(longName);
|
||||
});
|
||||
|
||||
test('slices the start of the name without harming the marker', () => {
|
||||
const cell = format('start');
|
||||
expect(cell).toContain(marker);
|
||||
expect(cell).toContain('…-us-east-1-service-checkout-latency-p99');
|
||||
expect(cell).not.toContain('prod-us-east');
|
||||
});
|
||||
|
||||
test('slices the middle of the name without harming the marker', () => {
|
||||
const cell = format('middle');
|
||||
expect(cell).toContain(marker);
|
||||
expect(cell).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
});
|
||||
|
||||
test('measures the budget against the name, not the marker markup', () => {
|
||||
// The marker alone is far longer than the budget. If truncation were
|
||||
// applied to the concatenated cell, a short name would be mangled.
|
||||
expect(marker.length).toBeGreaterThan(TRUNCATION_MAX_CHARS);
|
||||
const [cell] = formatForecastTooltipSeries({
|
||||
seriesName: 'cpu',
|
||||
observation: 1,
|
||||
marker,
|
||||
formatter: intFormatter,
|
||||
truncation: 'start',
|
||||
});
|
||||
expect(cell).toBe(`${marker}cpu`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
"supercluster": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
const item = await waitFor(() =>
|
||||
within(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
document.querySelector('.rc-virtual-list')!,
|
||||
document.querySelector('.ant-select-dropdown-list')!,
|
||||
).getByText(option),
|
||||
);
|
||||
await userEvent.click(item);
|
||||
|
||||
+5
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
@@ -35,6 +36,10 @@ import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
// Opening the context menu logs an event, and an unmatched request makes
|
||||
// fetch-mock throw inside the component.
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
jest.mock(
|
||||
'../DrillDetail/DrillDetailPane',
|
||||
() =>
|
||||
|
||||
@@ -135,6 +135,15 @@ describe('dashboardState actions', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('saveDashboardRequest', () => {
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardState: { hasUnsavedChanges: false },
|
||||
@@ -227,6 +236,89 @@ describe('dashboardState actions', () => {
|
||||
const { body } = putStub.mock.calls[0][0];
|
||||
expect(body).toBe(JSON.stringify(confirmedDashboardData));
|
||||
});
|
||||
|
||||
test('warns about the overwrite values when a diff is detected', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toBe(
|
||||
'Please confirm the overwrite values.',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('reports the actual error when the overwrite precheck fails', async () => {
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('precheck exploded'));
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'precheck exploded',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('when FeatureFlag.CONFIRM_DASHBOARD_DIFF is disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
test('never runs the overwrite precheck', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
|
||||
expect(getStub).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: '/api/v1/dashboard/192' }),
|
||||
);
|
||||
});
|
||||
|
||||
// An unexpected failure used to reach the overwrite-confirm handler,
|
||||
// which reported it as "Please confirm the overwrite values." even with
|
||||
// the feature flag off, hiding the real error.
|
||||
test('reports the actual error when the update throws unexpectedly', async () => {
|
||||
putStub.mockRestore();
|
||||
putStub = jest.spyOn(SupersetClient, 'put').mockImplementation(() => {
|
||||
throw new Error('unexpected boom');
|
||||
});
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'unexpected boom',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should navigate to the new dashboard after Save As', async () => {
|
||||
@@ -379,15 +471,6 @@ describe('dashboardState actions', () => {
|
||||
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
|
||||
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
|
||||
// generic status-derived toast. See #42239.
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
putStub.mockRestore();
|
||||
|
||||
@@ -646,6 +646,7 @@ export function saveDashboardRequest(
|
||||
};
|
||||
|
||||
const onError = async (response: Response): Promise<void> => {
|
||||
logging.error(response);
|
||||
const { error, message } = await getClientErrorObject(response);
|
||||
let errorText = t('Sorry, an unknown error occurred');
|
||||
|
||||
@@ -689,64 +690,64 @@ export function saveDashboardRequest(
|
||||
}),
|
||||
};
|
||||
|
||||
const updateDashboard = (): Promise<JsonObject | void> =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
})
|
||||
.then(response => onUpdateSuccess(response))
|
||||
.catch(response => onError(response));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
resolve();
|
||||
return;
|
||||
const updateDashboard = async (): Promise<JsonObject | void> => {
|
||||
try {
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
});
|
||||
return await onUpdateSuccess(response);
|
||||
} catch (error) {
|
||||
return onError(error as Response);
|
||||
}
|
||||
};
|
||||
|
||||
// precheck for overwrite items
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
}).then((response: JsonObject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
return updateDashboard();
|
||||
}
|
||||
|
||||
// precheck for overwrite items
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
})
|
||||
.then((response: JsonObject) => {
|
||||
const dashboard = (response.json as JsonObject).result as JsonObject;
|
||||
const overwriteConfirmItems = getOverwriteItems(
|
||||
dashboard,
|
||||
updatedDashboard,
|
||||
);
|
||||
if (overwriteConfirmItems.length > 0) {
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
return reject(overwriteConfirmItems);
|
||||
if (overwriteConfirmItems.length === 0) {
|
||||
return updateDashboard();
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
})
|
||||
.then(updateDashboard)
|
||||
.catch((overwriteConfirmItems: JsonObject[]) => {
|
||||
const errorText = t('Please confirm the overwrite values.');
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, {
|
||||
dashboard_id: id,
|
||||
items: overwriteConfirmItems,
|
||||
}),
|
||||
);
|
||||
dispatch(addDangerToast(errorText));
|
||||
});
|
||||
dispatch(addDangerToast(t('Please confirm the overwrite values.')));
|
||||
return undefined;
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
// changing the data as the endpoint requires
|
||||
if (
|
||||
|
||||
+37
-1
@@ -16,8 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
ChartCustomizationType,
|
||||
type ChartCustomization,
|
||||
} from '@superset-ui/core';
|
||||
import { LabeledValue } from '@superset-ui/core/components';
|
||||
import { createLabelSortComparator } from './GroupByFilterCard';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import GroupByFilterCard, {
|
||||
createLabelSortComparator,
|
||||
} from './GroupByFilterCard';
|
||||
|
||||
jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// Never resolves, pinning the card in its column-loading state.
|
||||
cachedSupersetGet: jest.fn(() => new Promise(() => {})),
|
||||
}));
|
||||
|
||||
const apple: LabeledValue = { value: 'a', label: 'Apple' };
|
||||
const banana: LabeledValue = { value: 'b', label: 'Banana' };
|
||||
@@ -39,3 +51,27 @@ test('preserves source order when sortAscending is unset', () => {
|
||||
expect(compare(apple, banana)).toBe(0);
|
||||
expect(compare(banana, apple)).toBe(0);
|
||||
});
|
||||
|
||||
const groupByCustomization: ChartCustomization = {
|
||||
id: 'groupby-1',
|
||||
name: 'Group By',
|
||||
filterType: 'filter_groupby',
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
targets: [{ datasetId: 1 }],
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
};
|
||||
|
||||
test('renders the column-loading spinner small and muted', async () => {
|
||||
render(<GroupByFilterCard customizationItem={groupByCustomization} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
dataMask: {},
|
||||
nativeFilters: { filters: {} },
|
||||
},
|
||||
});
|
||||
const spinner = await screen.findByTestId('loading-indicator');
|
||||
expect(spinner).toHaveClass('inline');
|
||||
expect(spinner).toHaveStyle({ opacity: 0.25, width: '40px' });
|
||||
});
|
||||
|
||||
+1
-1
@@ -645,7 +645,7 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Loading position="inline" />
|
||||
<Loading position="inline" size="s" muted />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ const typeIntoSelect = async (text: string) => {
|
||||
const findOption = (text: string) =>
|
||||
waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const virtualList = document.querySelector('.rc-virtual-list');
|
||||
const virtualList = document.querySelector('.ant-select-dropdown-list');
|
||||
if (!virtualList) {
|
||||
throw new Error('Virtual list not found');
|
||||
}
|
||||
|
||||
+10
@@ -72,3 +72,13 @@ test('omits datasourceType when undefined', () => {
|
||||
});
|
||||
expect(target).not.toHaveProperty('datasourceType');
|
||||
});
|
||||
|
||||
test('omits datasourceType when there is no dataset', () => {
|
||||
// The modal stamps a hidden ``datasourceType`` field on every filter form,
|
||||
// including dataset-less types. Without a dataset there is nothing for it to
|
||||
// describe, and emitting it would diverge from the ``{}`` target the import
|
||||
// and seed paths write.
|
||||
expect(
|
||||
buildNativeFilterTarget({ datasourceType: DatasourceType.Table }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
+8
-4
@@ -33,9 +33,9 @@ export interface TargetFormInputs {
|
||||
* Build the ``NativeFilterTarget`` carried by a native filter or chart
|
||||
* customization from its form inputs.
|
||||
*
|
||||
* Consolidates what used to live in three places — ``filterTransformer``,
|
||||
* ``customizationTransformer``, and ``createHandleSave`` — so changes to the
|
||||
* target shape only need to happen here.
|
||||
* Consolidates what used to live in ``filterTransformer`` and
|
||||
* ``customizationTransformer`` so changes to the target shape only need to
|
||||
* happen here.
|
||||
*/
|
||||
export function buildNativeFilterTarget(
|
||||
formInputs: TargetFormInputs,
|
||||
@@ -49,7 +49,11 @@ export function buildNativeFilterTarget(
|
||||
: formInputs.dataset;
|
||||
}
|
||||
|
||||
if (formInputs.datasourceType) {
|
||||
// ``datasourceType`` describes the selected dataset, so it only belongs on a
|
||||
// target that has one. Emitting it for a dataset-less filter (e.g.
|
||||
// ``filter_time``) would make a UI save serialize a target the import and
|
||||
// seed paths write as ``{}``.
|
||||
if (formInputs.dataset != null && formInputs.datasourceType) {
|
||||
target.datasourceType = formInputs.datasourceType;
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartCustomization, ChartCustomizationType } from '@superset-ui/core';
|
||||
import { ChartCustomizationsFormItem } from '../types';
|
||||
import { transformCustomizationForSave } from './customizationTransformer';
|
||||
|
||||
const baseFormItem = {
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
requiredFirst: {},
|
||||
defaultValue: null,
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
sortMetric: null,
|
||||
description: '',
|
||||
// form-only field that must never leak into the saved customization
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
test('serializes a dataset-less customization into a full ChartCustomization', () => {
|
||||
// Customization plugins declaring ``datasourceCount: 0`` render no dataset
|
||||
// control, so their form item carries neither ``dataset`` nor ``targets``.
|
||||
const formItem = {
|
||||
...baseFormItem,
|
||||
name: 'Layer visibility',
|
||||
filterType: 'customization_deckgl_layer_visibility',
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-abc',
|
||||
formItem,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('serializes a dataset-backed customization into a full ChartCustomization', () => {
|
||||
const formItem = {
|
||||
...baseFormItem,
|
||||
name: 'Group by',
|
||||
filterType: 'customization_dynamic_group_by',
|
||||
dataset: { value: 42, label: 'sales' },
|
||||
column: 'region',
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-def',
|
||||
formItem,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('passes an already-saved ChartCustomization through untouched', () => {
|
||||
const saved: ChartCustomization = {
|
||||
id: 'CHART_CUSTOMIZATION-ghi',
|
||||
name: 'Group by',
|
||||
filterType: 'customization_dynamic_group_by',
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
targets: [{ datasetId: 42, column: { name: 'region' } }],
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
controlValues: {},
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: ' needs trim ',
|
||||
chartsInScope: [1, 2],
|
||||
tabsInScope: ['TAB-1'],
|
||||
};
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-ghi',
|
||||
saved,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result.chartsInScope).toEqual([1, 2]);
|
||||
expect(result.tabsInScope).toEqual(['TAB-1']);
|
||||
expect(result.description).toBe('needs trim');
|
||||
});
|
||||
+4
-1
@@ -69,7 +69,10 @@ function isDividerType(
|
||||
function isFormInput(
|
||||
formInputs: ChartCustomizationFormOrSaved,
|
||||
): formInputs is ChartCustomizationsFormItem {
|
||||
return 'dataset' in formInputs && typeof formInputs.dataset === 'object';
|
||||
// Mirrors `filterTransformer`: a saved customization always carries a
|
||||
// serialized `targets` array, and dataset-less types (e.g. the deck.gl layer
|
||||
// visibility customization) have no `dataset` to discriminate on.
|
||||
return !('targets' in formInputs);
|
||||
}
|
||||
|
||||
function transformCustomizationDivider(
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core';
|
||||
import { NativeFiltersFormItem } from '../types';
|
||||
import { transformFilterForSave } from './filterTransformer';
|
||||
|
||||
const baseFormItem = {
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
requiredFirst: {},
|
||||
defaultValue: null,
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
description: '',
|
||||
// form-only fields that must never leak into the saved filter
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as NativeFiltersFormItem;
|
||||
|
||||
test('serializes a dataset-less filter (filter_time) into a full Filter', () => {
|
||||
// A ``filter_time`` filter has no dataset/column controls, so its form item
|
||||
// carries neither a ``dataset`` nor a ``targets`` key. It must still be
|
||||
// transformed like any other native filter rather than persisted verbatim.
|
||||
const formItem: NativeFiltersFormItem = {
|
||||
...baseFormItem,
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
dependencies: ['NATIVE_FILTER-parent'],
|
||||
// the modal stamps this on every filter form, dataset or not
|
||||
datasourceType: DatasourceType.Table,
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-abc',
|
||||
formItem,
|
||||
) as Filter;
|
||||
|
||||
// Keys the bug used to strip are present and well-formed. The target matches
|
||||
// the ``{}`` the import and seed paths write, so one logical filter has one
|
||||
// serialization regardless of provenance.
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
|
||||
|
||||
// Form-only keys must not leak into the persisted config.
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
expect(result).not.toHaveProperty('dependencies');
|
||||
// Empty requiredFirst collapses to undefined instead of the raw form object.
|
||||
expect(result.requiredFirst).toBeUndefined();
|
||||
|
||||
// A dataset-less filter has no sort metric control, so the persisted document
|
||||
// must not gain a ``sortMetric`` key it never had. Asserted on the serialized
|
||||
// form because ``undefined`` values survive in the object but not in JSON.
|
||||
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty('sortMetric');
|
||||
|
||||
expect(result.name).toBe('Time Range');
|
||||
expect(result.filterType).toBe('filter_time');
|
||||
});
|
||||
|
||||
test('serializes a dataset-backed filter (filter_select) into a full Filter', () => {
|
||||
const formItem: NativeFiltersFormItem = {
|
||||
...baseFormItem,
|
||||
name: 'Region',
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: 42, label: 'sales' },
|
||||
column: 'region',
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-def',
|
||||
formItem,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.cascadeParentIds).toEqual([]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('passes an already-saved Filter through untouched (aside from trimming)', () => {
|
||||
// Values coming from the stored filter config map (e.g. cascade-parent
|
||||
// cleanup) already carry a ``targets`` array and must be preserved as-is.
|
||||
const savedFilter: Filter = {
|
||||
id: 'NATIVE_FILTER-ghi',
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
targets: [{}],
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: ['NATIVE_FILTER-parent'],
|
||||
controlValues: {},
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: ' needs trim ',
|
||||
chartsInScope: [1, 2],
|
||||
tabsInScope: ['TAB-1'],
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-ghi',
|
||||
savedFilter,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
|
||||
expect(result.chartsInScope).toEqual([1, 2]);
|
||||
expect(result.tabsInScope).toEqual(['TAB-1']);
|
||||
expect(result.description).toBe('needs trim');
|
||||
});
|
||||
|
||||
test('rebuilds a saved filter whose targets were already stripped', () => {
|
||||
// Dashboards affected by this bug hold ``filter_time`` entries with no
|
||||
// ``targets``. They no longer match the saved-filter branch, so they take the
|
||||
// form-item path and are repaired on the next save. ``cascadeParentIds`` is
|
||||
// read from the form's ``dependencies``, which such an entry does not carry —
|
||||
// the same write that stripped ``targets`` stripped ``cascadeParentIds`` too.
|
||||
const strippedFilter = {
|
||||
id: 'NATIVE_FILTER-jkl',
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: { timeShift: false },
|
||||
description: '',
|
||||
requiredFirst: { 'NATIVE_FILTER-jkl': true },
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as NativeFiltersFormItem;
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-jkl',
|
||||
strippedFilter,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.requiredFirst).toBe(true);
|
||||
expect(result.cascadeParentIds).toEqual([]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
+5
-2
@@ -67,7 +67,10 @@ function isDividerType(
|
||||
function isFormInput(
|
||||
formInputs: NativeFilterFormOrSaved,
|
||||
): formInputs is NativeFiltersFormItem {
|
||||
return 'dataset' in formInputs;
|
||||
// A saved filter always carries a serialized `targets` array; a form item
|
||||
// never does. Keying this off `dataset` misclassified filter types with no
|
||||
// dataset control (e.g. `filter_time`) as already saved.
|
||||
return !('targets' in formInputs);
|
||||
}
|
||||
|
||||
function transformDivider(
|
||||
@@ -115,7 +118,7 @@ function transformFormInput(
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
sortMetric: formInputs.sortMetric ?? null,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
requiredFirst: formInputs.requiredFirst
|
||||
? Object.values(formInputs.requiredFirst).find(rf => rf)
|
||||
: undefined,
|
||||
|
||||
@@ -18,21 +18,15 @@
|
||||
*/
|
||||
import type { FormInstance } from '@superset-ui/core/components';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
import {
|
||||
FilterConfiguration,
|
||||
NativeFilterType,
|
||||
NativeFilterTarget,
|
||||
Filter,
|
||||
Divider,
|
||||
ChartCustomizationType,
|
||||
ChartCustomizationConfiguration,
|
||||
ChartCustomization,
|
||||
ChartCustomizationDivider,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { buildNativeFilterTarget } from './transformers/buildTarget';
|
||||
import {
|
||||
ChartCustomizationsForm,
|
||||
FilterChangesType,
|
||||
@@ -101,70 +95,6 @@ export const validateForm = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandleSave =
|
||||
(
|
||||
saveForm: Function,
|
||||
filterChanges: FilterChangesType,
|
||||
values: NativeFiltersForm,
|
||||
filterConfigMap: Record<string, Filter | Divider>,
|
||||
) =>
|
||||
async () => {
|
||||
const transformFilter = (id: string) => {
|
||||
const formInputs = values.filters?.[id] || filterConfigMap[id];
|
||||
if (!formInputs) {
|
||||
return undefined;
|
||||
}
|
||||
if (formInputs.type === NativeFilterType.Divider) {
|
||||
return {
|
||||
id,
|
||||
type: NativeFilterType.Divider,
|
||||
scope: {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
},
|
||||
title: formInputs.title,
|
||||
description: formInputs.description,
|
||||
};
|
||||
}
|
||||
|
||||
const target: Partial<NativeFilterTarget> =
|
||||
buildNativeFilterTarget(formInputs);
|
||||
|
||||
return {
|
||||
id,
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
controlValues: formInputs.controlValues ?? {},
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
...(formInputs.time_grains?.length
|
||||
? { time_grains: formInputs.time_grains }
|
||||
: {}),
|
||||
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
|
||||
rf => rf,
|
||||
),
|
||||
name: formInputs.name,
|
||||
filterType: formInputs.filterType,
|
||||
targets: [target],
|
||||
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
|
||||
cascadeParentIds: formInputs.dependencies || [],
|
||||
scope: formInputs.scope,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
type: formInputs.type,
|
||||
description: (formInputs.description || '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const transformedModified = filterChanges.modified
|
||||
.map(transformFilter)
|
||||
.filter(Boolean);
|
||||
|
||||
const newFilterChanges = {
|
||||
...filterChanges,
|
||||
modified: transformedModified,
|
||||
};
|
||||
await saveForm(newFilterChanges);
|
||||
};
|
||||
|
||||
export const createHandleRemoveItem =
|
||||
(
|
||||
setRemovedFilters: (
|
||||
|
||||
+10
-6
@@ -214,7 +214,9 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -226,7 +228,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Identifier');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('User Identifier')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Creation Date')).not.toBeInTheDocument();
|
||||
@@ -234,7 +236,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Date')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Update')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
@@ -288,7 +290,9 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Net Profit')).not.toBeInTheDocument();
|
||||
@@ -298,7 +302,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Rate');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Discount Rate')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
@@ -306,7 +310,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'profit');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Net Profit')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Profit Margin')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
|
||||
+10
-6
@@ -340,7 +340,9 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Gross Revenue')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Average Price')).not.toBeInTheDocument();
|
||||
@@ -352,7 +354,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Unique');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Unique Users')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -360,7 +362,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'total');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Count')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Total Quantity')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -421,7 +423,9 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
|
||||
await userEvent.type(columnCombobox, 'product');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Product Title')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -435,7 +439,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, 'Modified');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -445,7 +449,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Timestamp')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -27,8 +27,15 @@ import {
|
||||
Input,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import {
|
||||
API_KEY_SCOPE_OPTIONS,
|
||||
getApiKeyScopesHelpText,
|
||||
serializeApiKeyScopes,
|
||||
} from './apiKeyScopes';
|
||||
|
||||
interface ApiKeyCreateModalProps {
|
||||
show: boolean;
|
||||
@@ -38,6 +45,7 @@ interface ApiKeyCreateModalProps {
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export function ApiKeyCreateModal({
|
||||
@@ -62,9 +70,13 @@ export function ApiKeyCreateModal({
|
||||
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const scopes = serializeApiKeyScopes(values.scopes);
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: '/api/v1/security/api_keys/',
|
||||
jsonPayload: values,
|
||||
jsonPayload: {
|
||||
name: values.name,
|
||||
...(scopes && { scopes }),
|
||||
},
|
||||
});
|
||||
const key = response.json?.result?.key;
|
||||
if (!key) {
|
||||
@@ -83,7 +95,7 @@ export function ApiKeyCreateModal({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
await copyTextToClipboard(() => Promise.resolve(createdKey));
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
@@ -170,6 +182,24 @@ export function ApiKeyCreateModal({
|
||||
placeholder={t('e.g., CI/CD Pipeline, Analytics Script')}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="scopes"
|
||||
label={t('MCP scopes')}
|
||||
help={getApiKeyScopesHelpText()}
|
||||
>
|
||||
<Select
|
||||
name="scopes"
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
options={API_KEY_SCOPE_OPTIONS}
|
||||
placeholder={t('Select MCP resource scopes (optional)')}
|
||||
data-test="api-key-scopes-select"
|
||||
getPopupContainer={(trigger: HTMLElement) =>
|
||||
trigger.closest<HTMLElement>('.ant-modal-container') ?? trigger
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,19 @@ export function ApiKeyList() {
|
||||
key: 'status',
|
||||
render: (_: unknown, record: ApiKey) => getStatusBadge(record),
|
||||
},
|
||||
{
|
||||
title: t('MCP scopes'),
|
||||
dataIndex: 'scopes',
|
||||
key: 'scopes',
|
||||
render: (scopes: string | null) =>
|
||||
scopes ? (
|
||||
<Tooltip title={scopes}>
|
||||
<Tag>{t('%s MCP scopes', scopes.split(',').length)}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t('RBAC only')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
API_KEY_SCOPE_OPTIONS,
|
||||
getApiKeyScopesHelpText,
|
||||
serializeApiKeyScopes,
|
||||
} from './apiKeyScopes';
|
||||
|
||||
test('offers read and write scopes for every supported resource', () => {
|
||||
expect(API_KEY_SCOPE_OPTIONS).toHaveLength(32);
|
||||
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
|
||||
label: 'superset:dashboard:read',
|
||||
value: 'superset:dashboard:read',
|
||||
});
|
||||
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
|
||||
label: 'superset:sqllab:write',
|
||||
value: 'superset:sqllab:write',
|
||||
});
|
||||
});
|
||||
|
||||
test('serializes selected scopes for the FAB API', () => {
|
||||
expect(
|
||||
serializeApiKeyScopes(['superset:dashboard:read', 'superset:chart:write']),
|
||||
).toBe('superset:dashboard:read,superset:chart:write');
|
||||
expect(serializeApiKeyScopes([])).toBeUndefined();
|
||||
expect(serializeApiKeyScopes()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('explains that scopes apply to MCP rather than REST APIs', () => {
|
||||
expect(getApiKeyScopesHelpText()).toContain('MCP resources');
|
||||
expect(getApiKeyScopesHelpText()).toContain(
|
||||
'do not restrict REST API requests',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
const API_KEY_SCOPE_RESOURCES = [
|
||||
'annotation',
|
||||
'chart',
|
||||
'dashboard',
|
||||
'database',
|
||||
'dataset',
|
||||
'explore',
|
||||
'query',
|
||||
'report',
|
||||
'role',
|
||||
'rls',
|
||||
'savedquery',
|
||||
'sqllab',
|
||||
'tag',
|
||||
'task',
|
||||
'theme',
|
||||
'user',
|
||||
] as const;
|
||||
|
||||
const API_KEY_SCOPE_ACTIONS = ['read', 'write'] as const;
|
||||
|
||||
export const API_KEY_SCOPE_OPTIONS = API_KEY_SCOPE_RESOURCES.flatMap(resource =>
|
||||
API_KEY_SCOPE_ACTIONS.map(action => {
|
||||
const value = `superset:${resource}:${action}`;
|
||||
return { label: value, value };
|
||||
}),
|
||||
);
|
||||
|
||||
export const serializeApiKeyScopes = (scopes?: string[]) =>
|
||||
scopes?.length ? scopes.join(',') : undefined;
|
||||
|
||||
export const getApiKeyScopesHelpText = () =>
|
||||
t(
|
||||
'Limit which MCP resources and actions this key can access. These scopes do not restrict REST API requests and never grant permissions the user does not already have. Leave empty for legacy RBAC-only behavior.',
|
||||
);
|
||||
@@ -88,9 +88,9 @@ test('PermissionsField shows a permission matched by its raw name even though th
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await within(document.querySelector('.rc-virtual-list')!).findByText(
|
||||
'stg silver',
|
||||
),
|
||||
await within(
|
||||
document.querySelector('.ant-select-dropdown-list')!,
|
||||
).findByText('stg silver'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Generated
+156
-156
@@ -15,24 +15,24 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.2"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
@@ -310,9 +310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-android-arm-eabi": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz",
|
||||
"integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz",
|
||||
"integrity": "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -327,9 +327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-android-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -344,9 +344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-darwin-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -361,9 +361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-darwin-x64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz",
|
||||
"integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz",
|
||||
"integrity": "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -378,9 +378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-freebsd-x64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz",
|
||||
"integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz",
|
||||
"integrity": "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -395,9 +395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz",
|
||||
"integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz",
|
||||
"integrity": "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -412,9 +412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz",
|
||||
"integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz",
|
||||
"integrity": "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -429,9 +429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -449,9 +449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-arm64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -469,9 +469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -489,9 +489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -509,9 +509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -529,9 +529,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -549,9 +549,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-x64-gnu": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz",
|
||||
"integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz",
|
||||
"integrity": "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -569,9 +569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-linux-x64-musl": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz",
|
||||
"integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz",
|
||||
"integrity": "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -589,9 +589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-openharmony-arm64": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz",
|
||||
"integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz",
|
||||
"integrity": "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -606,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -623,9 +623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -640,9 +640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxfmt/binding-win32-x64-msvc": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz",
|
||||
"integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz",
|
||||
"integrity": "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1044,9 +1044,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1070,17 +1070,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
||||
"integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/type-utils": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/type-utils": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -1093,22 +1093,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
|
||||
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1124,14 +1124,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
|
||||
"integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
|
||||
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.66.0",
|
||||
"@typescript-eslint/types": "^8.66.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.67.0",
|
||||
"@typescript-eslint/types": "^8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1146,14 +1146,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
|
||||
"integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
|
||||
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0"
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1164,9 +1164,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
|
||||
"integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1181,15 +1181,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
|
||||
"integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1206,9 +1206,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
|
||||
"integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
|
||||
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1220,16 +1220,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
|
||||
"integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
|
||||
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.66.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/visitor-keys": "8.66.0",
|
||||
"@typescript-eslint/project-service": "8.67.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1248,16 +1248,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
|
||||
"integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
|
||||
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0"
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1272,13 +1272,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
|
||||
"integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
|
||||
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1689,9 +1689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -2709,9 +2709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxfmt": {
|
||||
"version": "0.62.0",
|
||||
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz",
|
||||
"integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==",
|
||||
"version": "0.63.0",
|
||||
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.63.0.tgz",
|
||||
"integrity": "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2727,25 +2727,25 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxfmt/binding-android-arm-eabi": "0.62.0",
|
||||
"@oxfmt/binding-android-arm64": "0.62.0",
|
||||
"@oxfmt/binding-darwin-arm64": "0.62.0",
|
||||
"@oxfmt/binding-darwin-x64": "0.62.0",
|
||||
"@oxfmt/binding-freebsd-x64": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm-musleabihf": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-arm64-musl": "0.62.0",
|
||||
"@oxfmt/binding-linux-ppc64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-riscv64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-riscv64-musl": "0.62.0",
|
||||
"@oxfmt/binding-linux-s390x-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-x64-gnu": "0.62.0",
|
||||
"@oxfmt/binding-linux-x64-musl": "0.62.0",
|
||||
"@oxfmt/binding-openharmony-arm64": "0.62.0",
|
||||
"@oxfmt/binding-win32-arm64-msvc": "0.62.0",
|
||||
"@oxfmt/binding-win32-ia32-msvc": "0.62.0",
|
||||
"@oxfmt/binding-win32-x64-msvc": "0.62.0"
|
||||
"@oxfmt/binding-android-arm-eabi": "0.63.0",
|
||||
"@oxfmt/binding-android-arm64": "0.63.0",
|
||||
"@oxfmt/binding-darwin-arm64": "0.63.0",
|
||||
"@oxfmt/binding-darwin-x64": "0.63.0",
|
||||
"@oxfmt/binding-freebsd-x64": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm-musleabihf": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-arm64-musl": "0.63.0",
|
||||
"@oxfmt/binding-linux-ppc64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-riscv64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-riscv64-musl": "0.63.0",
|
||||
"@oxfmt/binding-linux-s390x-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-x64-gnu": "0.63.0",
|
||||
"@oxfmt/binding-linux-x64-musl": "0.63.0",
|
||||
"@oxfmt/binding-openharmony-arm64": "0.63.0",
|
||||
"@oxfmt/binding-win32-arm64-msvc": "0.63.0",
|
||||
"@oxfmt/binding-win32-ia32-msvc": "0.63.0",
|
||||
"@oxfmt/binding-win32-x64-msvc": "0.63.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
@@ -3211,16 +3211,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
|
||||
"integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
|
||||
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.66.0",
|
||||
"@typescript-eslint/parser": "8.66.0",
|
||||
"@typescript-eslint/typescript-estree": "8.66.0",
|
||||
"@typescript-eslint/utils": "8.66.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3520,9 +3520,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -23,24 +23,24 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.2"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -21,7 +21,7 @@ from functools import partial
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from superset import db
|
||||
from superset import db, security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import DatabaseNotFoundError
|
||||
from superset.daos.database import DatabaseUserOAuth2TokensDAO
|
||||
@@ -31,6 +31,7 @@ from superset.exceptions import OAuth2Error
|
||||
from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
|
||||
from superset.models.core import Database, DatabaseUserOAuth2Tokens
|
||||
from superset.superset_typing import OAuth2State
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.utils.oauth2 import decode_oauth2_state
|
||||
|
||||
@@ -121,6 +122,14 @@ class OAuth2StoreTokenCommand(BaseCommand):
|
||||
|
||||
self._state = decode_oauth2_state(self._parameters["state"])
|
||||
|
||||
# Bind the callback to the current session: require an authenticated,
|
||||
# non-guest user whose id matches the one carried in the state.
|
||||
user_id = get_user_id()
|
||||
if user_id is None or security_manager.is_guest_user():
|
||||
raise OAuth2Error("The OAuth2 callback requires an authenticated user")
|
||||
if user_id != self._state["user_id"]:
|
||||
raise OAuth2Error("The OAuth2 state belongs to a different user")
|
||||
|
||||
if database := DatabaseUserOAuth2TokensDAO.get_database(
|
||||
self._state["database_id"]
|
||||
):
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import gzip
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from http.client import HTTPConnection, HTTPResponse, HTTPSConnection
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
@@ -47,7 +50,7 @@ from superset.models.helpers import ChildMultipleResultsFound
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user
|
||||
from superset.utils.network import is_safe_host
|
||||
from superset.utils.network import is_safe_host, is_safe_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +79,47 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _raise_for_unsafe_peer(sock: socket.socket) -> None:
|
||||
"""
|
||||
Validate that an established connection's actual peer is publicly
|
||||
routable, so the address reached matches the policy applied to the host.
|
||||
"""
|
||||
peer = sock.getpeername()[0]
|
||||
if not is_safe_ip(ipaddress.ip_address(peer)):
|
||||
raise DatasetForbiddenDataURI()
|
||||
|
||||
|
||||
class _PeerValidatingHTTPConnection(HTTPConnection):
|
||||
"""HTTP connection that validates the peer address on connect."""
|
||||
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
_raise_for_unsafe_peer(self.sock)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPSConnection(HTTPSConnection):
|
||||
"""HTTPS connection that validates the peer address after the handshake."""
|
||||
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
_raise_for_unsafe_peer(self.sock)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPHandler(request.HTTPHandler):
|
||||
"""Opens HTTP connections through the peer-validating connection class."""
|
||||
|
||||
def http_open(self, req: request.Request) -> HTTPResponse:
|
||||
return self.do_open(_PeerValidatingHTTPConnection, req)
|
||||
|
||||
|
||||
class _PeerValidatingHTTPSHandler(request.HTTPSHandler):
|
||||
"""Opens HTTPS connections through the peer-validating connection class."""
|
||||
|
||||
def https_open(self, req: request.Request) -> HTTPResponse:
|
||||
context = self._context # type: ignore[attr-defined]
|
||||
return self.do_open(_PeerValidatingHTTPSConnection, req, context=context)
|
||||
|
||||
|
||||
CHUNKSIZE = 512
|
||||
VARCHAR = re.compile(r"VARCHAR\((\d+)\)", re.IGNORECASE)
|
||||
|
||||
@@ -581,7 +625,17 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
|
||||
validate_data_uri(data_uri)
|
||||
logger.info("Downloading data from %s", data_uri)
|
||||
opener = request.build_opener(_ValidatingRedirectHandler)
|
||||
handlers: list[request.BaseHandler | type[request.BaseHandler]] = [
|
||||
_ValidatingRedirectHandler
|
||||
]
|
||||
if not app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"]:
|
||||
# Also enforce the policy at the socket layer: re-check the peer of
|
||||
# every connection, including each redirect hop. Disable proxies so the
|
||||
# connection is made directly to the destination and the peer check
|
||||
# validates the destination address rather than a proxy's.
|
||||
handlers.append(request.ProxyHandler({}))
|
||||
handlers.extend([_PeerValidatingHTTPHandler, _PeerValidatingHTTPSHandler])
|
||||
opener = request.build_opener(*handlers)
|
||||
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
if data_uri.endswith(".gz"):
|
||||
data = gzip.open(data)
|
||||
|
||||
@@ -137,6 +137,29 @@ def resolve_executor_user(model: ReportSchedule) -> tuple["User", str]:
|
||||
return user, username
|
||||
|
||||
|
||||
def _should_build_execution_context(model: ReportSchedule) -> bool:
|
||||
"""
|
||||
Whether an execution should run under a :class:`ReportExecutionContext`.
|
||||
|
||||
Reports always do — their behavior is unchanged. Alerts join them only when
|
||||
they deliver a rendered PNG/PDF screenshot to recipients, which happens when
|
||||
``ALERTS_ATTACH_REPORTS`` is enabled. Delivered screenshots must fail closed:
|
||||
the context selects the fail-closed readiness predicate and disables
|
||||
partial-tile fallback, so a blank or incomplete capture raises instead of
|
||||
being delivered.
|
||||
|
||||
CSV/text alerts, alerts without the attach flag, the non-delivered
|
||||
query-context capture, and UI thumbnails are deliberately excluded and keep
|
||||
their lenient capture contract.
|
||||
"""
|
||||
if model.type == ReportScheduleType.REPORT:
|
||||
return True
|
||||
return model.report_format in (
|
||||
ReportDataFormat.PNG,
|
||||
ReportDataFormat.PDF,
|
||||
) and feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
|
||||
|
||||
|
||||
def log_report_delivery_phase(
|
||||
report_context: ReportExecutionContext | None,
|
||||
recipient_type: ReportRecipientType | None,
|
||||
@@ -1972,13 +1995,13 @@ class ReportSuccessState(BaseReportState):
|
||||
|
||||
try:
|
||||
self.send()
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
if self._handle_retry_or_error(str(ex), ex):
|
||||
except Exception as first_ex: # pylint: disable=broad-except
|
||||
if self._handle_retry_or_error(str(first_ex), first_ex):
|
||||
return # retry scheduled — exit cleanly
|
||||
|
||||
try:
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=str(ex)
|
||||
ReportState.ERROR, error_message=str(first_ex)
|
||||
)
|
||||
except (ReportScheduleUnexpectedError, SQLAlchemyError) as logging_ex:
|
||||
# Logging failed (likely StaleDataError), but we still want to
|
||||
@@ -1991,7 +2014,45 @@ class ReportSuccessState(BaseReportState):
|
||||
exc_info=True,
|
||||
)
|
||||
# Re-raise the original exception, not the logging failure
|
||||
raise ex from logging_ex
|
||||
raise first_ex from logging_ex
|
||||
|
||||
# A delivery failure from the Success/Grace path must notify the
|
||||
# owner just like the first-run path (ReportNotTriggeredErrorState).
|
||||
# Without this, a schedule whose previous run succeeded would fail
|
||||
# silently — e.g. once a screenshot capture starts failing closed.
|
||||
# The error grace period still throttles repeated notifications.
|
||||
if not self.is_in_error_grace_period():
|
||||
second_error_message = REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
|
||||
try:
|
||||
self.send_error(
|
||||
f"Error occurred for {self._report_schedule.type}:"
|
||||
f" {self._report_schedule.name}",
|
||||
str(first_ex),
|
||||
)
|
||||
except SupersetErrorsException as second_ex:
|
||||
second_error_message = ";".join(
|
||||
[error.message for error in second_ex.errors]
|
||||
)
|
||||
except ReportScheduleUnexpectedError:
|
||||
# send_error failed due to logging issue; log and continue
|
||||
# to raise the original error
|
||||
logger.warning(
|
||||
"Failed to send error notification due to database issue",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as second_ex: # pylint: disable=broad-except
|
||||
second_error_message = str(second_ex)
|
||||
finally:
|
||||
try:
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=second_error_message
|
||||
)
|
||||
except ReportScheduleUnexpectedError:
|
||||
# Logging failed again; log it but don't hide first_ex
|
||||
logger.warning(
|
||||
"Failed to log final error state due to database issue",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
# send() succeeded — clear retry state and log success. Any execution
|
||||
@@ -2058,13 +2119,18 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise ReportScheduleExecuteUnexpectedError()
|
||||
|
||||
if self._model.type == ReportScheduleType.REPORT:
|
||||
# Reports always run under an execution context; alerts join them
|
||||
# only when they deliver a rendered screenshot, so a blank/partial
|
||||
# capture fails closed instead of being delivered. Ownership and
|
||||
# terminal-error persistence remain report-only recovery semantics.
|
||||
if _should_build_execution_context(self._model):
|
||||
# An invocation that enters on WORKING is a duplicate or stale
|
||||
# recovery, not the owner that created the active row. Its state
|
||||
# handler may terminalize a stale execution, but the command
|
||||
# boundary must never infer ownership from a replayed UUID.
|
||||
owns_report_working_state = (
|
||||
self._model.last_state != ReportState.WORKING
|
||||
self._model.type == ReportScheduleType.REPORT
|
||||
and self._model.last_state != ReportState.WORKING
|
||||
)
|
||||
total_seconds = resolve_report_execution_budget_seconds(
|
||||
app.config,
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import db
|
||||
from superset.commands.streaming_export.base import BaseStreamingCSVExportCommand
|
||||
@@ -86,6 +87,15 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
|
||||
),
|
||||
status=403,
|
||||
) from ex
|
||||
except TemplateError as ex:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=str(ex),
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=400,
|
||||
) from ex
|
||||
|
||||
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
|
||||
"""
|
||||
|
||||
@@ -58,6 +58,7 @@ from superset.utils.core import (
|
||||
get_column_name,
|
||||
get_column_names_from_columns,
|
||||
get_column_names_from_metrics,
|
||||
get_user_id,
|
||||
is_adhoc_column,
|
||||
is_adhoc_metric,
|
||||
)
|
||||
@@ -270,6 +271,11 @@ class QueryContextProcessor:
|
||||
datasource = self._qc_datasource
|
||||
extra_cache_keys = datasource.get_extra_cache_keys(query_obj.to_dict())
|
||||
|
||||
# Annotation data is cached on the same entry as the dataframe, so the
|
||||
# key must also bind the annotation sources' security context.
|
||||
if query_obj and query_obj.annotation_layers:
|
||||
kwargs["annotation_context"] = self._annotation_cache_context(query_obj)
|
||||
|
||||
cache_key = (
|
||||
query_obj.cache_key(
|
||||
datasource=datasource.uid,
|
||||
@@ -283,6 +289,32 @@ class QueryContextProcessor:
|
||||
)
|
||||
return cache_key
|
||||
|
||||
def _annotation_cache_context(self, query_obj: QueryObject) -> dict[str, Any]:
|
||||
"""
|
||||
Cache-key material binding cached annotation data to its security
|
||||
context.
|
||||
|
||||
Annotation payloads are fetched per requesting user and stored on the
|
||||
same cache entry as the dataframe, so the key also binds the requesting
|
||||
user and, for chart-backed layers, the RLS clauses of the referenced
|
||||
chart's datasource.
|
||||
"""
|
||||
source_rls: dict[str, list[str] | None] = {}
|
||||
for layer in query_obj.annotation_layers:
|
||||
if layer.get("sourceType") not in ("line", "table"):
|
||||
continue
|
||||
layer_value = layer.get("value")
|
||||
chart = (
|
||||
ChartDAO.find_by_id(layer_value) if layer_value is not None else None
|
||||
)
|
||||
annotation_datasource = chart.datasource if chart else None
|
||||
source_rls[str(layer.get("value"))] = (
|
||||
security_manager.get_rls_cache_key(annotation_datasource)
|
||||
if annotation_datasource
|
||||
else None
|
||||
)
|
||||
return {"user_id": get_user_id(), "source_rls": source_rls}
|
||||
|
||||
def get_query_result(self, query_object: QueryObject) -> QueryResult:
|
||||
"""
|
||||
Returns a pandas dataframe based on the query object.
|
||||
@@ -636,6 +668,11 @@ class QueryContextProcessor:
|
||||
if layer["sourceType"] == "NATIVE"
|
||||
]
|
||||
layer_ids = [layer["value"] for layer in annotation_layers]
|
||||
# Enforce the annotation read permission before returning layer records.
|
||||
if layer_ids and not security_manager.can_access("can_read", "Annotation"):
|
||||
raise QueryObjectValidationError(
|
||||
_("You don't have access to annotation layers")
|
||||
)
|
||||
layer_objects = {
|
||||
layer_object.id: layer_object
|
||||
for layer_object in AnnotationLayerDAO.find_by_ids(layer_ids)
|
||||
@@ -645,6 +682,15 @@ class QueryContextProcessor:
|
||||
for layer in annotation_layers:
|
||||
layer_id = layer["value"]
|
||||
layer_name = layer["name"]
|
||||
# A request may reference a layer id that does not exist; treat it
|
||||
# as a validation error rather than failing on the missing key.
|
||||
if (layer_object := layer_objects.get(layer_id)) is None:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Annotation layer with ID %(layer_id)s was not found",
|
||||
layer_id=layer_id,
|
||||
)
|
||||
)
|
||||
columns = [
|
||||
"start_dttm",
|
||||
"end_dttm",
|
||||
@@ -652,7 +698,6 @@ class QueryContextProcessor:
|
||||
"long_descr",
|
||||
"json_metadata",
|
||||
]
|
||||
layer_object = layer_objects[layer_id]
|
||||
records = [
|
||||
{column: getattr(annotation, column) for column in columns}
|
||||
for annotation in layer_object.annotation
|
||||
|
||||
@@ -418,7 +418,8 @@ class DashboardRestApi(
|
||||
result:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
$ref: >-
|
||||
#/components/schemas/{{self.__class__.__name__}}.get_list
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
|
||||
@@ -165,12 +165,31 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.debug("Unable to load SQLAlchemy dialect %s: %s", ep.name, ex)
|
||||
else:
|
||||
backend = dialect.name
|
||||
# A third-party entry point can load successfully yet not resolve to
|
||||
# a usable dialect. Validate the same dialect contract as the native
|
||||
# loop so malformed connectors are neither advertised nor allowed to
|
||||
# abort the whole enumeration.
|
||||
backend = getattr(dialect, "name", None)
|
||||
if (
|
||||
not isinstance(dialect, type)
|
||||
or not issubclass(dialect, DefaultDialect)
|
||||
or not isinstance(backend, (str, bytes))
|
||||
or not hasattr(dialect, "driver")
|
||||
or dialect.driver == "adodbapi"
|
||||
):
|
||||
logger.warning(
|
||||
"Skipping SQLAlchemy dialect entry point %r: %r did not "
|
||||
"resolve to a usable dialect (%r)",
|
||||
ep.name,
|
||||
ep.value,
|
||||
dialect,
|
||||
)
|
||||
continue
|
||||
if isinstance(backend, bytes):
|
||||
backend = backend.decode()
|
||||
backend = backend_replacements.get(backend, backend)
|
||||
|
||||
driver = getattr(dialect, "driver", dialect.name)
|
||||
driver = dialect.driver
|
||||
if isinstance(driver, bytes):
|
||||
driver = driver.decode()
|
||||
drivers[backend].add(driver)
|
||||
|
||||
@@ -26,8 +26,12 @@ if TYPE_CHECKING:
|
||||
# Matches only the static asset endpoint:
|
||||
# /api/v1/extensions/<publisher>/<name>/<path:file>, where the file portion may
|
||||
# contain nested segments (worker / WASM / chunk subfolders).
|
||||
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info) endpoints.
|
||||
_ASSET_PATH_RE: re.Pattern[str] = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/.+$")
|
||||
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info)
|
||||
# endpoints, nor the per-user storage endpoints under
|
||||
# /<publisher>/<name>/storage/, whose responses must keep ``Vary: Cookie``.
|
||||
_ASSET_PATH_RE: re.Pattern[str] = re.compile(
|
||||
r"^/api/v1/extensions/[^/]+/[^/]+/(?!storage/).+$"
|
||||
)
|
||||
|
||||
|
||||
class ExtensionCacheMiddleware:
|
||||
|
||||
@@ -92,10 +92,16 @@ class ExtensionStorageRestApi(BaseApi):
|
||||
route_base = "/api/v1/extensions"
|
||||
|
||||
def response(self, status_code: int, **kwargs: Any) -> Response:
|
||||
"""Helper method to create JSON responses."""
|
||||
"""Helper method to create JSON responses.
|
||||
|
||||
Stored values are scoped to the requesting user, so responses are
|
||||
marked non-cacheable.
|
||||
"""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify(kwargs), status_code
|
||||
response = jsonify(kwargs)
|
||||
response.cache_control.no_store = True
|
||||
return response, status_code
|
||||
|
||||
def response_404(self, message: str = "Not found") -> Response:
|
||||
"""Helper method to create 404 responses."""
|
||||
|
||||
@@ -1272,6 +1272,34 @@ def get_dataset_id_from_context(metric_key: str) -> int:
|
||||
raise SupersetTemplateException(exc_message)
|
||||
|
||||
|
||||
def guest_user_can_access_dataset(dataset: SqlaTable) -> bool:
|
||||
"""
|
||||
Whether the current guest (embedded) user may read the given dataset.
|
||||
|
||||
Guest access is granted per dashboard, so the dataset must back at least
|
||||
one chart on a dashboard the guest token covers; a ``datasets`` allowlist
|
||||
on the token further restricts the reachable IDs.
|
||||
|
||||
:param dataset: a dataset resolved without the DAO base filter.
|
||||
:returns: whether the guest user may read the dataset.
|
||||
"""
|
||||
guest_user = security_manager.get_current_guest_user_if_guest()
|
||||
if not guest_user:
|
||||
return False
|
||||
|
||||
allowed_datasets: list[int] | None = guest_user.guest_token.get("datasets")
|
||||
if allowed_datasets is not None and (
|
||||
not isinstance(allowed_datasets, list) or dataset.id not in allowed_datasets
|
||||
):
|
||||
return False
|
||||
|
||||
return any(
|
||||
security_manager.has_guest_access(dashboard)
|
||||
for slc in dataset.slices
|
||||
for dashboard in slc.dashboards
|
||||
)
|
||||
|
||||
|
||||
def metric_macro(
|
||||
env: Environment,
|
||||
context: dict[str, Any],
|
||||
@@ -1294,8 +1322,9 @@ def metric_macro(
|
||||
if not dataset_id:
|
||||
dataset_id = get_dataset_id_from_context(metric_key)
|
||||
|
||||
# Embedded user access is validated at the dashboard level, so we bypass
|
||||
# the regular DAO filter for them
|
||||
# Embedded (guest) user access is validated at the dashboard level, so the
|
||||
# regular DAO filter is bypassed for them and dashboard-level scope is
|
||||
# enforced explicitly below.
|
||||
dataset = DatasetDAO.find_by_id(
|
||||
dataset_id,
|
||||
skip_base_filter=security_manager.is_guest_user(),
|
||||
@@ -1303,6 +1332,11 @@ def metric_macro(
|
||||
if not dataset:
|
||||
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
|
||||
|
||||
# With the base filter skipped, scope a guest to datasets reachable through
|
||||
# a dashboard their token grants; reuse the not-found error for consistency.
|
||||
if security_manager.is_guest_user() and not guest_user_can_access_dataset(dataset):
|
||||
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
|
||||
|
||||
metrics: dict[str, str] = {
|
||||
metric.metric_name: metric.expression for metric in dataset.metrics
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ from superset.mcp_service.session_scope import _mcp_session_token
|
||||
from superset.mcp_service.utils.error_sanitization import (
|
||||
sanitize_for_log as _sanitize_for_log,
|
||||
)
|
||||
from superset.security.api_key_scopes import (
|
||||
get_resource_scope,
|
||||
METHOD_PERMISSION_SCOPE_ACTION,
|
||||
RESOURCE_SCOPE_NAME as RESOURCE_SCOPE_NAME,
|
||||
)
|
||||
from superset.security.guest_token import GuestUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -126,19 +131,24 @@ class MCPNoAuthSourceError(ValueError):
|
||||
# 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",
|
||||
# "get" is the read-class permission FAB registers on its security API
|
||||
# views (User/Role) — those views have no can_read, so tools targeting
|
||||
# them declare method_permission_name="get".
|
||||
"get": "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",
|
||||
method: f"superset:{action}"
|
||||
for method, action in METHOD_PERMISSION_SCOPE_ACTION.items()
|
||||
}
|
||||
|
||||
|
||||
def _required_resource_scope(
|
||||
class_permission_name: str, method_permission_name: str
|
||||
) -> str | None:
|
||||
"""Compute the ``superset:<resource>:<action>`` scope string for a tool.
|
||||
|
||||
Returns None if either the resource or the action isn't mapped — callers
|
||||
must treat that as "no per-resource scope available," not as a grant;
|
||||
the flat ``_METHOD_TO_REQUIRED_SCOPE`` fallback still applies in that case
|
||||
(see ``_token_scope_allows``).
|
||||
"""
|
||||
return get_resource_scope(class_permission_name, method_permission_name)
|
||||
|
||||
|
||||
def _get_token_scopes() -> set[str] | None:
|
||||
"""Return the set of scopes on the current JWT access token, or None.
|
||||
|
||||
@@ -154,8 +164,13 @@ def _get_token_scopes() -> set[str] | None:
|
||||
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
except Exception: # noqa: BLE001 - no JWT context for this request
|
||||
return None
|
||||
except Exception: # noqa: BLE001 - fail closed on token-context errors
|
||||
logger.exception("Unable to resolve MCP access-token scopes")
|
||||
# ``None`` means that no scoped credential was presented and enables
|
||||
# legacy RBAC-only behavior. An empty set instead makes every scope
|
||||
# check fail, so an unexpected context error cannot erase restrictions
|
||||
# carried by a credential.
|
||||
return set()
|
||||
|
||||
if access_token is None:
|
||||
return None
|
||||
@@ -167,12 +182,21 @@ def _get_token_scopes() -> set[str] | None:
|
||||
return {str(s) for s in scopes}
|
||||
|
||||
|
||||
def _token_scope_allows(method_permission_name: str) -> bool:
|
||||
def _token_scope_allows(
|
||||
method_permission_name: str, class_permission_name: str | None = None
|
||||
) -> 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.
|
||||
|
||||
The per-resource scope (``superset:<resource>:<action>``, derived via
|
||||
``_required_resource_scope``) is an ALTERNATIVE grant path alongside the
|
||||
flat method scope: a token carrying either the flat scope
|
||||
(e.g. ``superset:read``) or the matching per-resource scope
|
||||
(e.g. ``superset:dashboard:read``) is allowed, so already-issued
|
||||
flat-scoped tokens keep working unchanged.
|
||||
"""
|
||||
token_scopes = _get_token_scopes()
|
||||
if token_scopes is None:
|
||||
@@ -190,7 +214,15 @@ def _token_scope_allows(method_permission_name: str) -> bool:
|
||||
method_permission_name,
|
||||
)
|
||||
return False
|
||||
return required_scope in token_scopes
|
||||
if required_scope in token_scopes:
|
||||
return True
|
||||
if class_permission_name is not None:
|
||||
resource_scope = _required_resource_scope(
|
||||
class_permission_name, method_permission_name
|
||||
)
|
||||
if resource_scope is not None and resource_scope in token_scopes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MCPPermissionDeniedError(PermissionError):
|
||||
@@ -234,12 +266,20 @@ def _log_scope_denial(
|
||||
cyclomatic complexity in check.
|
||||
"""
|
||||
required_scope = _METHOD_TO_REQUIRED_SCOPE.get(method_permission_name)
|
||||
resource_scope = _required_resource_scope(
|
||||
class_permission_name, method_permission_name
|
||||
)
|
||||
scope_desc = (
|
||||
resource_scope
|
||||
or required_scope
|
||||
or f"unmapped method permission '{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,
|
||||
scope_desc,
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
func.__name__,
|
||||
@@ -248,7 +288,7 @@ def _log_scope_denial(
|
||||
logger.debug(
|
||||
"Tool hidden for user %s: token lacks required scope '%s' (tool: %s)",
|
||||
_sanitize_for_log(g.user.username),
|
||||
required_scope,
|
||||
scope_desc,
|
||||
func.__name__,
|
||||
)
|
||||
|
||||
@@ -354,8 +394,13 @@ def check_tool_permission( # noqa: C901
|
||||
)
|
||||
return False
|
||||
|
||||
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
|
||||
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
|
||||
|
||||
# Token capabilities and user RBAC are independent restrictions.
|
||||
# Disabling RBAC must not discard scopes explicitly carried by a key.
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
return _token_scope_allows(method_permission_name, class_permission_name)
|
||||
|
||||
if not hasattr(g, "user") or not g.user:
|
||||
if log_denial:
|
||||
@@ -368,7 +413,6 @@ def check_tool_permission( # noqa: C901
|
||||
)
|
||||
return False
|
||||
|
||||
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
|
||||
if not class_permission_name:
|
||||
# No RBAC configured for this tool; allow by default. This is a
|
||||
# supported configuration (a protected tool may intentionally
|
||||
@@ -382,9 +426,17 @@ def check_tool_permission( # noqa: C901
|
||||
"class_permission_name; allowing access without an RBAC check",
|
||||
func.__name__,
|
||||
)
|
||||
if not _token_scope_allows(method_permission_name):
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
"Scope denied for permission-less tool %s: token lacks "
|
||||
"flat scope for method %s",
|
||||
func.__name__,
|
||||
method_permission_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
|
||||
permission_str = f"{PERMISSION_PREFIX}{method_permission_name}"
|
||||
|
||||
has_permission = security_manager.can_access(
|
||||
@@ -399,7 +451,9 @@ def check_tool_permission( # noqa: C901
|
||||
# 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):
|
||||
if has_permission and not _token_scope_allows(
|
||||
method_permission_name, class_permission_name
|
||||
):
|
||||
_log_scope_denial(
|
||||
func,
|
||||
method_permission_name,
|
||||
@@ -462,7 +516,7 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
return False
|
||||
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
return check_tool_permission(tool_func, log_denial=False)
|
||||
|
||||
from superset.mcp_service.privacy import (
|
||||
tool_requires_data_model_metadata_access,
|
||||
@@ -475,10 +529,6 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
):
|
||||
return False
|
||||
|
||||
class_permission_name = getattr(tool_func, CLASS_PERMISSION_ATTR, None)
|
||||
if not class_permission_name:
|
||||
return True
|
||||
|
||||
return check_tool_permission(tool_func, log_denial=False)
|
||||
|
||||
except (AttributeError, RuntimeError, ValueError):
|
||||
|
||||
@@ -113,15 +113,19 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
self._api_key_prefixes = tuple(valid)
|
||||
|
||||
def _validate_api_key_sync(self, token: str) -> str | None:
|
||||
"""Validate an API key against FAB and return the user's username.
|
||||
def _validate_api_key_sync(self, token: str) -> tuple[str, list[str]] | None:
|
||||
"""Validate an API key against FAB and return (username, scopes).
|
||||
|
||||
Runs synchronously inside a thread executor. Pushes a fresh Flask
|
||||
app context so that FAB's SecurityManager can access the database.
|
||||
|
||||
Returns the username on success, or ``None`` if the key is invalid,
|
||||
FAB does not support ``validate_api_key``, or an unexpected error
|
||||
occurs (fail closed).
|
||||
``scopes`` is the key's own ``ApiKey.scopes`` column, parsed from
|
||||
FAB's comma-separated string storage format into a list (empty list
|
||||
if the key has no scopes set, matching the "no scopes advertised"
|
||||
convention used elsewhere in this module and in ``auth.py``).
|
||||
|
||||
Returns ``None`` if the key is invalid, FAB does not support
|
||||
``validate_api_key``, or an unexpected error occurs (fail closed).
|
||||
"""
|
||||
if self._app is None:
|
||||
return None
|
||||
@@ -135,12 +139,21 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
return None
|
||||
user = sm.validate_api_key(token)
|
||||
username = user.username if user else None
|
||||
# Unbind the local reference so this frame no longer points at
|
||||
# the raw token (defense-in-depth). Python does not zero the
|
||||
# underlying string memory on rebind.
|
||||
token = "" # noqa: S105
|
||||
return username
|
||||
if user is None:
|
||||
return None
|
||||
username = user.username
|
||||
scopes_str = (
|
||||
sm.get_api_key_scopes(token)
|
||||
if hasattr(sm, "get_api_key_scopes")
|
||||
else None
|
||||
)
|
||||
scopes = (
|
||||
[s.strip() for s in scopes_str.split(",") if s.strip()]
|
||||
if scopes_str
|
||||
else []
|
||||
)
|
||||
token = "" # noqa: S105 -- unbind raw token, defense-in-depth
|
||||
return username, scopes
|
||||
except Exception: # noqa: BLE001 — catch-all: DB errors, FAB internals, etc.
|
||||
logger.warning(
|
||||
"API key transport validation failed unexpectedly; rejecting token",
|
||||
@@ -168,21 +181,25 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
if any(token.startswith(prefix) for prefix in self._api_key_prefixes):
|
||||
if self._app is not None:
|
||||
loop = asyncio.get_running_loop()
|
||||
username = await loop.run_in_executor(
|
||||
result = await loop.run_in_executor(
|
||||
None, self._validate_api_key_sync, token
|
||||
)
|
||||
if username is None:
|
||||
if result is None:
|
||||
logger.debug(
|
||||
"API key rejected at transport layer (invalid or expired)"
|
||||
)
|
||||
return None
|
||||
username, key_scopes = result
|
||||
logger.debug(
|
||||
"API key validated at transport layer for user=%s", username
|
||||
)
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="api_key",
|
||||
scopes=list(self.required_scopes or []),
|
||||
# Preserve the key's own scopes exactly. An empty list
|
||||
# means "no scopes advertised" and therefore retains the
|
||||
# RBAC-only behavior for existing unscoped API keys.
|
||||
scopes=key_scopes,
|
||||
claims={
|
||||
API_KEY_PASSTHROUGH_CLAIM: True,
|
||||
API_KEY_VALIDATED_USERNAME_CLAIM: username,
|
||||
@@ -190,10 +207,11 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
|
||||
# No app configured: fall back to prefix-only pass-through so
|
||||
# ``_resolve_user_from_api_key`` handles DB validation.
|
||||
# NOTE: ``MCP_REQUIRED_SCOPES`` is intentionally not enforced for
|
||||
# API-key auth — FAB API keys do not carry scopes. Authorization is
|
||||
# enforced downstream via ``check_tool_permission`` (RBAC).
|
||||
# ``_resolve_user_from_api_key`` handles DB validation. Without an
|
||||
# app there is no DB access here, so the key's own ApiKey.scopes
|
||||
# cannot be read — the verifier-global required_scopes are used
|
||||
# instead. Authorization is still enforced downstream via
|
||||
# ``check_tool_permission`` (RBAC).
|
||||
logger.debug("API key token detected (prefix match), passing through")
|
||||
return AccessToken(
|
||||
token=token,
|
||||
|
||||
@@ -653,10 +653,9 @@ def _build_composite_verifier(
|
||||
if api_key_enabled:
|
||||
if required_scopes := app.config.get("MCP_REQUIRED_SCOPES", []):
|
||||
logger.warning(
|
||||
"MCP_REQUIRED_SCOPES is configured but API key tokens bypass "
|
||||
"scope enforcement. API key holders gain access regardless of "
|
||||
"MCP_REQUIRED_SCOPES=%r. Enforce per-key authorization via FAB "
|
||||
"roles/RBAC instead.",
|
||||
"MCP_REQUIRED_SCOPES=%r is configured, but API key tokens use "
|
||||
"the scopes stored on each key instead. Unscoped API keys "
|
||||
"retain legacy RBAC-only behavior.",
|
||||
required_scopes,
|
||||
)
|
||||
raw_prefixes: str | Sequence[str] = app.config.get(
|
||||
|
||||
@@ -30,7 +30,7 @@ from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.auth import MCPPermissionDeniedError
|
||||
from superset.mcp_service.auth import _token_scope_allows, MCPPermissionDeniedError
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CHART_DEFAULT_COLUMNS,
|
||||
CHART_SEARCH_COLUMNS,
|
||||
@@ -235,9 +235,10 @@ async def get_schema(
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
|
||||
security_manager.can_access("can_read", class_permission)
|
||||
):
|
||||
rbac_allows = not current_app.config.get(
|
||||
"MCP_RBAC_ENABLED", True
|
||||
) or security_manager.can_access("can_read", class_permission)
|
||||
if not (rbac_allows and _token_scope_allows("read", class_permission)):
|
||||
user_str = getattr(getattr(g, "user", None), "username", None)
|
||||
logger.warning(
|
||||
"get_schema RBAC denied: user=%s type=%s view=%s",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
"""Canonical resource and action mappings for scoped API keys."""
|
||||
|
||||
# Map FAB method permissions used by MCP tools to the coarser actions supported
|
||||
# by API-key scopes. Keep this explicit so an unknown permission fails closed.
|
||||
METHOD_PERMISSION_SCOPE_ACTION: dict[str, str] = {
|
||||
"read": "read",
|
||||
"get": "read",
|
||||
"write": "write",
|
||||
"update": "write",
|
||||
"delete": "write",
|
||||
"execute_sql_query": "write",
|
||||
}
|
||||
|
||||
# Map MCP/FAB class permission names to stable public resource slugs. These
|
||||
# cannot be derived by lowercasing because several names contain spaces or use
|
||||
# public spellings that differ from their internal class names.
|
||||
RESOURCE_SCOPE_NAME: dict[str, str] = {
|
||||
"Annotation": "annotation",
|
||||
"Chart": "chart",
|
||||
"Dashboard": "dashboard",
|
||||
"Database": "database",
|
||||
"Dataset": "dataset",
|
||||
"Explore": "explore",
|
||||
"Query": "query",
|
||||
"ReportSchedule": "report",
|
||||
"Role": "role",
|
||||
"Row Level Security": "rls",
|
||||
"SavedQuery": "savedquery",
|
||||
"SQLLab": "sqllab",
|
||||
"Tag": "tag",
|
||||
"Task": "task",
|
||||
"Theme": "theme",
|
||||
"User": "user",
|
||||
}
|
||||
|
||||
RESOURCE_SCOPE_CLASS: dict[str, str] = {
|
||||
resource: class_name for class_name, resource in RESOURCE_SCOPE_NAME.items()
|
||||
}
|
||||
RESOURCE_SCOPE_ACTIONS: frozenset[str] = frozenset(
|
||||
METHOD_PERMISSION_SCOPE_ACTION.values()
|
||||
)
|
||||
SCOPE_ACTION_METHOD_PERMISSIONS: dict[str, tuple[str, ...]] = {
|
||||
action: tuple(
|
||||
method
|
||||
for method, mapped_action in METHOD_PERMISSION_SCOPE_ACTION.items()
|
||||
if mapped_action == action
|
||||
)
|
||||
for action in RESOURCE_SCOPE_ACTIONS
|
||||
}
|
||||
|
||||
|
||||
def get_resource_scope(
|
||||
class_permission_name: str, method_permission_name: str
|
||||
) -> str | None:
|
||||
"""Return the resource scope required by a FAB class/method permission."""
|
||||
resource = RESOURCE_SCOPE_NAME.get(class_permission_name)
|
||||
action = METHOD_PERMISSION_SCOPE_ACTION.get(method_permission_name)
|
||||
if resource is None or action is None:
|
||||
return None
|
||||
return f"superset:{resource}:{action}"
|
||||
+237
-27
@@ -17,6 +17,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""A set of constants and methods to manage permissions and security"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -36,7 +37,7 @@ from urllib.parse import quote
|
||||
|
||||
from flask import current_app, Flask, g, has_app_context, Request, Response
|
||||
from flask_appbuilder import Model
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from flask_appbuilder.api import expose, permission_name, protect, safe
|
||||
from flask_appbuilder.models.filters import BaseFilter
|
||||
from flask_appbuilder.security.manager import AUTH_REMOTE_USER
|
||||
from flask_appbuilder.security.sqla.apis import GroupApi, RoleApi, UserApi
|
||||
@@ -394,8 +395,11 @@ class SupersetUserApi(UserApi):
|
||||
"""
|
||||
Overriding the UserApi to sync Subject rows, filter excluded users,
|
||||
handle deletion constraints, and add audit logging.
|
||||
UserApi has custom post/put that bypass hooks, so we override them
|
||||
and sync after the parent method succeeds.
|
||||
|
||||
The Subject sync happens in ``pre_add``/``pre_update``, which FAB calls
|
||||
*before* the commit that ``self.datamodel.add``/``edit`` issues -- so the
|
||||
sync rides that same commit rather than needing one of its own after the
|
||||
fact.
|
||||
"""
|
||||
|
||||
base_filters = [["username", ExcludeUsersFilter, lambda: []]]
|
||||
@@ -415,6 +419,45 @@ class SupersetUserApi(UserApi):
|
||||
"changed_on",
|
||||
]
|
||||
|
||||
def pre_add(self, item: Model) -> None:
|
||||
"""Hash the password (FAB's own ``pre_add``), then sync the user's
|
||||
``Subject`` row before FAB's own commit.
|
||||
|
||||
``UserApi.post`` calls ``pre_add`` *before* ``self.datamodel.add``,
|
||||
which is what actually issues the commit -- so flushing the new user
|
||||
here (to obtain its id) and syncing its ``Subject`` row alongside it
|
||||
means both writes ride the same transaction and commit together,
|
||||
instead of the subject sync needing a second, separate commit after
|
||||
the fact.
|
||||
"""
|
||||
super().pre_add(item)
|
||||
from superset.daos.user import UserDAO
|
||||
|
||||
self.datamodel.session.add(item)
|
||||
self.datamodel.session.flush()
|
||||
UserDAO._sync_subject(item)
|
||||
|
||||
def pre_update(self, item: Model, data: dict[str, Any]) -> None:
|
||||
"""Same reasoning as ``pre_add``: ``UserApi.put`` calls ``pre_update``
|
||||
before ``self.datamodel.edit`` commits, so the subject sync lands in
|
||||
that same transaction.
|
||||
"""
|
||||
super().pre_update(item, data)
|
||||
from superset.daos.user import UserDAO
|
||||
|
||||
UserDAO._sync_subject(item)
|
||||
|
||||
if data.get("password"):
|
||||
# An admin-initiated password change via this endpoint must
|
||||
# invalidate the target account's other outstanding sessions,
|
||||
# the same as the self-service ``/me/`` path and the two
|
||||
# password-reset views.
|
||||
from superset.security.session_invalidation import (
|
||||
invalidate_sessions_for_user,
|
||||
)
|
||||
|
||||
invalidate_sessions_for_user(item.id)
|
||||
|
||||
@expose("/", methods=["POST"])
|
||||
@protect()
|
||||
@safe
|
||||
@@ -430,17 +473,7 @@ class SupersetUserApi(UserApi):
|
||||
500:
|
||||
description: Server error
|
||||
"""
|
||||
response = super().post()
|
||||
if response.status_code == 201:
|
||||
from superset.daos.user import UserDAO
|
||||
|
||||
user_id = response.json.get("id")
|
||||
if user_id:
|
||||
user = self.datamodel.session.get(self.datamodel.obj, user_id)
|
||||
if user:
|
||||
UserDAO._sync_subject(user)
|
||||
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
|
||||
return response
|
||||
return super().post()
|
||||
|
||||
@expose("/<pk>", methods=["PUT"])
|
||||
@protect()
|
||||
@@ -464,15 +497,42 @@ class SupersetUserApi(UserApi):
|
||||
500:
|
||||
description: Server error
|
||||
"""
|
||||
response = super().put(pk)
|
||||
if response.status_code == 200:
|
||||
from superset.daos.user import UserDAO
|
||||
return super().put(pk)
|
||||
|
||||
user = self.datamodel.get(pk, self._base_filters)
|
||||
if user:
|
||||
UserDAO._sync_subject(user)
|
||||
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
|
||||
return response
|
||||
@expose("/<int:pk>/sessions", methods=["DELETE"])
|
||||
@protect()
|
||||
@permission_name("put")
|
||||
@safe
|
||||
def terminate_sessions(self, pk: int) -> Response:
|
||||
"""Terminate a user's outstanding sessions without disabling their account.
|
||||
---
|
||||
delete:
|
||||
parameters:
|
||||
- in: path
|
||||
name: pk
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
200:
|
||||
description: Sessions terminated
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
from superset.security.session_invalidation import invalidate_sessions_for_user
|
||||
|
||||
user = self.datamodel.get(pk, self._base_filters)
|
||||
if not user:
|
||||
return self.response_404()
|
||||
|
||||
invalidate_sessions_for_user(user.id)
|
||||
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
|
||||
_log_audit_event(
|
||||
"UserSessionsTerminated",
|
||||
{"target_username": user.username, "target_user_id": user.id},
|
||||
)
|
||||
return self.response(200, message="User sessions terminated.")
|
||||
|
||||
def pre_delete(self, item: Model) -> None:
|
||||
from superset.daos.user import UserDAO
|
||||
@@ -754,15 +814,24 @@ def _native_filter_query_modified(
|
||||
query: Any, allowed_columns: set[str], allowed_metrics: set[str]
|
||||
) -> bool:
|
||||
"""Whether a single query in a native-filter request reads beyond its targets."""
|
||||
# Columns and group-by may only reference target column(s); adhoc (free-form
|
||||
# SQL) columns cannot be validated, so reject them.
|
||||
for key in ("columns", "groupby"):
|
||||
# Columns, group-by, and series columns may only reference target column(s);
|
||||
# adhoc (free-form SQL) columns cannot be validated, so reject them.
|
||||
for key in ("columns", "groupby", "series_columns"):
|
||||
for col in getattr(query, key, None) or []:
|
||||
if not isinstance(col, str) or col not in allowed_columns:
|
||||
return True
|
||||
for metric in getattr(query, "metrics", None) or []:
|
||||
if not _native_filter_term_allowed(metric, allowed_columns, allowed_metrics):
|
||||
return True
|
||||
# A series-limit metric ranks the top-N groups in the inner query, so it is
|
||||
# a value-returning term and is validated like a metric. ``QueryObject``
|
||||
# renames the deprecated ``timeseries_limit_metric`` payload key onto this
|
||||
# attribute, so both spellings are covered.
|
||||
series_limit_metric = getattr(query, "series_limit_metric", None)
|
||||
if series_limit_metric and not _native_filter_term_allowed(
|
||||
series_limit_metric, allowed_columns, allowed_metrics
|
||||
):
|
||||
return True
|
||||
# order-by entries are ``(expression, asc)`` pairs.
|
||||
for order in getattr(query, "orderby", None) or []:
|
||||
expr = order[0] if isinstance(order, (list, tuple)) and order else order
|
||||
@@ -784,8 +853,9 @@ def _native_filter_request_modified(query_context: "QueryContext") -> bool:
|
||||
A native filter may only read the column(s) it targets on the dashboard it
|
||||
belongs to. The request is treated as modified (and therefore rejected for
|
||||
guest users) when it cannot be tied to a native filter on the requesting
|
||||
dashboard, or when any value-returning term (column, group-by, metric, or
|
||||
order-by) references something other than a target column, a simple
|
||||
dashboard, or when any value-returning term (column, group-by, series
|
||||
column, metric, series-limit metric, or order-by) references something
|
||||
other than a target column, a simple
|
||||
aggregate over a target column, or the filter's configured sort metric.
|
||||
Free-form SQL terms and saved metrics other than the configured sort metric
|
||||
are rejected. Row-restricting clauses (``filter``/``extras``) are not
|
||||
@@ -1555,9 +1625,23 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
bypassed. We distinguish the two by comparing the acting user
|
||||
(``g.user``) against the target ``userid``: they match for a
|
||||
self-service reset and differ for an admin reset.
|
||||
|
||||
Also stamps the session-invalidation epoch for the target user, so
|
||||
any session for the account that predates this reset stops working --
|
||||
regardless of which of the two paths triggered it.
|
||||
"""
|
||||
super().reset_password(userid, password)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import db
|
||||
from superset.security.session_invalidation import invalidate_sessions_for_user
|
||||
|
||||
invalidate_sessions_for_user(int(userid))
|
||||
# ``super().reset_password`` (FAB's ``update_user``) already committed
|
||||
# its own change in a separate transaction, so the epoch stamp above
|
||||
# needs its own commit too, rather than riding an existing one.
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
acting_user = getattr(g, "user", None)
|
||||
acting_user_id = getattr(acting_user, "id", None)
|
||||
# ``userid`` arrives as a string (the ``pk`` request arg) on the admin
|
||||
@@ -4297,6 +4381,15 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
child_slice_id=slice_id,
|
||||
parent_slice=parent_slc,
|
||||
)
|
||||
# Bind the request to the child
|
||||
# chart's own datasource, mirroring
|
||||
# the direct-chart leg above.
|
||||
and (
|
||||
child_slc := self.session.query(Slice)
|
||||
.filter(Slice.id == slice_id)
|
||||
.one_or_none()
|
||||
)
|
||||
and child_slc.datasource == datasource
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -4926,6 +5019,123 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
raw_token, secret, algorithms=[algo], audience=audience
|
||||
)
|
||||
|
||||
def get_api_key_scopes(self, api_key_string: str) -> Optional[str]:
|
||||
"""Return the ``scopes`` value for a validated API key.
|
||||
|
||||
FAB's ``validate_api_key`` resolves the matching ``ApiKey`` row
|
||||
internally (by lookup hash) but only returns the associated
|
||||
``User`` — the row's ``scopes`` column is otherwise unreachable by
|
||||
callers. This repeats the same cheap, indexed lookup so MCP's
|
||||
``CompositeTokenVerifier`` can propagate per-key scopes instead of
|
||||
silently falling back to verifier-global scopes. Call only after
|
||||
``validate_api_key`` has already succeeded for this token — this
|
||||
method does not itself verify the key hash or active status.
|
||||
"""
|
||||
lookup = self._compute_lookup_hash(api_key_string) # type: ignore[attr-defined]
|
||||
api_key = (
|
||||
self.session.query(self.api_key_model) # type: ignore[attr-defined]
|
||||
.filter(self.api_key_model.lookup_hash == lookup)
|
||||
.one_or_none()
|
||||
)
|
||||
return api_key.scopes if api_key else None
|
||||
|
||||
def _validate_requested_api_key_scopes(
|
||||
self, user: Any, scopes: Optional[str]
|
||||
) -> None:
|
||||
"""Raise if ``scopes`` would grant a user more than their own RBAC.
|
||||
|
||||
Enforces the "intersection, never broader" rule confirmed for this
|
||||
feature: a user must never be able to mint a token scoped beyond
|
||||
what their own role already permits, even if they hand-author the
|
||||
scopes string themselves at issuance time.
|
||||
|
||||
Per-resource scopes (``superset:<resource>:<action>``) are checked
|
||||
against the user's actual ``can_<method>`` RBAC grant for that
|
||||
resource. Flat scopes (``superset:read``/``superset:write``, the
|
||||
pre-per-resource form) can only be self-issued by Admins — a flat
|
||||
scope grants a method across every resource, and there's no single
|
||||
RBAC check that soundly proves a non-Admin has that for "every
|
||||
resource," so it's rejected for anyone else rather than guessed at.
|
||||
Unrecognized scope strings are rejected outright (fail closed).
|
||||
|
||||
NOTE: this only prevents the request from being honored; it does
|
||||
not (yet) produce a clean 400 response, since FAB's ``ApiKeyApi``
|
||||
has no validation hook this can plug into without replacing the API
|
||||
registration entirely. Raising here surfaces as a 500 via FAB's
|
||||
``@safe`` decorator until that's addressed — tracked as a known
|
||||
follow-up, not silently accepted.
|
||||
"""
|
||||
if not scopes:
|
||||
return
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from superset.security.api_key_scopes import (
|
||||
RESOURCE_SCOPE_ACTIONS,
|
||||
RESOURCE_SCOPE_CLASS,
|
||||
SCOPE_ACTION_METHOD_PERMISSIONS,
|
||||
)
|
||||
|
||||
admin_role_name = get_conf()["AUTH_ROLE_ADMIN"]
|
||||
is_admin = any(
|
||||
role.name == admin_role_name for role in getattr(user, "roles", [])
|
||||
)
|
||||
for raw_scope in scopes.split(","):
|
||||
scope = raw_scope.strip()
|
||||
if not scope:
|
||||
continue
|
||||
parts = scope.split(":")
|
||||
if len(parts) == 3 and parts[0] == "superset":
|
||||
_, resource_slug, action = parts
|
||||
class_permission_name = RESOURCE_SCOPE_CLASS.get(resource_slug)
|
||||
if class_permission_name is None:
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' names an unrecognized "
|
||||
f"resource '{resource_slug}'"
|
||||
)
|
||||
if action not in RESOURCE_SCOPE_ACTIONS:
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' names an unrecognized "
|
||||
f"action '{action}'"
|
||||
)
|
||||
if any(
|
||||
self._has_view_access(user, f"can_{method}", class_permission_name)
|
||||
for method in SCOPE_ACTION_METHOD_PERMISSIONS[action]
|
||||
):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' exceeds the issuing user's "
|
||||
"own permissions"
|
||||
)
|
||||
if (
|
||||
len(parts) == 2
|
||||
and parts[0] == "superset"
|
||||
and parts[1] in RESOURCE_SCOPE_ACTIONS
|
||||
and is_admin
|
||||
):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' is not a recognized "
|
||||
"superset:<resource>:<action> scope, or requires Admin to "
|
||||
"self-issue as a flat scope"
|
||||
)
|
||||
|
||||
def create_api_key(
|
||||
self,
|
||||
user: Any,
|
||||
name: str,
|
||||
scopes: Optional[str] = None,
|
||||
expires_on: Optional[datetime.datetime] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Create a new API key, enforcing the scope-intersection rule.
|
||||
|
||||
Thin wrapper around FAB's ``SecurityManager.create_api_key`` — see
|
||||
``_validate_requested_api_key_scopes`` for the actual check. FAB's
|
||||
base implementation is otherwise unchanged.
|
||||
"""
|
||||
self._validate_requested_api_key_scopes(user, scopes)
|
||||
return super().create_api_key( # type: ignore[misc]
|
||||
user=user, name=name, scopes=scopes, expires_on=expires_on
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_guest_user(user: Optional[Any] = None) -> bool:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -41,7 +41,7 @@ from typing import Any, Optional
|
||||
from flask import flash, session
|
||||
from flask_babel import gettext as __
|
||||
from flask_login import current_user, logout_user
|
||||
from sqlalchemy import event, inspect
|
||||
from sqlalchemy import event, inspect, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
@@ -163,9 +163,20 @@ def invalidate_user_sessions(connection: Any, user_id: int) -> None:
|
||||
)
|
||||
|
||||
def _stamp_existing() -> int:
|
||||
# Guard against two concurrent writers regressing the epoch: a
|
||||
# transaction that computed an earlier ``now`` can reach this UPDATE
|
||||
# after one with a later ``now`` has already committed. Only apply
|
||||
# the write when it would advance (or initialize) the stored value,
|
||||
# so the epoch is monotonic regardless of commit order.
|
||||
return connection.execute(
|
||||
table.update()
|
||||
.where(table.c.user_id == user_id)
|
||||
.where(
|
||||
or_(
|
||||
table.c.sessions_invalidated_at.is_(None),
|
||||
table.c.sessions_invalidated_at < now,
|
||||
)
|
||||
)
|
||||
.values(sessions_invalidated_at=now, changed_on=now)
|
||||
).rowcount
|
||||
|
||||
@@ -187,6 +198,23 @@ def invalidate_user_sessions(connection: Any, user_id: int) -> None:
|
||||
_stamp_existing()
|
||||
|
||||
|
||||
def invalidate_sessions_for_user(user_id: int) -> None:
|
||||
"""Stamp the invalidation epoch for ``user_id`` from ordinary application code.
|
||||
|
||||
Convenience wrapper around ``invalidate_user_sessions`` for callers that
|
||||
don't have the raw ``Connection`` the ``after_update`` event listener
|
||||
receives -- e.g. a password-change flow. The stamp is written through the
|
||||
current session's own connection, so it participates in whatever
|
||||
transaction the caller's other pending changes belong to; it is not
|
||||
committed here, so the caller's own commit (or the next flush that
|
||||
triggers one) is what makes it durable.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.extensions import db
|
||||
|
||||
invalidate_user_sessions(db.session.connection(), user_id)
|
||||
|
||||
|
||||
def _stamp_epoch_on_disable(_mapper: Any, connection: Any, target: Any) -> None:
|
||||
history = inspect(target).attrs.active.history
|
||||
# Only act when ``active`` actually changed to False — ignore the
|
||||
|
||||
@@ -324,7 +324,9 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
MetricMetadata(
|
||||
metric_name=metric.name,
|
||||
expression=metric.definition,
|
||||
verbose_name=metric.verbose_name,
|
||||
description=metric.description,
|
||||
d3format=metric.d3format,
|
||||
)
|
||||
for metric in self.implementation.get_metrics()
|
||||
]
|
||||
@@ -357,6 +359,7 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
is_dttm=pa.types.is_date(dimension.type)
|
||||
or pa.types.is_time(dimension.type)
|
||||
or pa.types.is_timestamp(dimension.type),
|
||||
verbose_name=dimension.verbose_name,
|
||||
description=dimension.description,
|
||||
expression=None,
|
||||
extra=json.dumps(
|
||||
@@ -372,6 +375,19 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
|
||||
@property
|
||||
def data(self) -> ExplorableData:
|
||||
dimensions = self._unique_dimensions
|
||||
metrics = list(self.implementation.get_metrics())
|
||||
verbose_map = {
|
||||
**{metric.name: metric.verbose_name or metric.name for metric in metrics},
|
||||
**{
|
||||
dimension.name: dimension.verbose_name or dimension.name
|
||||
for dimension in dimensions
|
||||
},
|
||||
}
|
||||
column_formats = {
|
||||
metric.name: metric.d3format for metric in metrics if metric.d3format
|
||||
}
|
||||
|
||||
return {
|
||||
# core
|
||||
"id": self.id,
|
||||
@@ -399,16 +415,16 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"python_date_format": None,
|
||||
"type": str(dimension.type),
|
||||
"type_generic": get_column_type(dimension.type),
|
||||
"verbose_name": None,
|
||||
"verbose_name": dimension.verbose_name,
|
||||
"warning_markdown": None,
|
||||
}
|
||||
for dimension in self._unique_dimensions
|
||||
for dimension in dimensions
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"certification_details": None,
|
||||
"certified_by": None,
|
||||
"d3format": None,
|
||||
"d3format": metric.d3format,
|
||||
"description": metric.description,
|
||||
"expression": metric.definition,
|
||||
"id": None,
|
||||
@@ -417,14 +433,14 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"metric_name": metric.name,
|
||||
"warning_markdown": None,
|
||||
"warning_text": None,
|
||||
"verbose_name": None,
|
||||
"verbose_name": metric.verbose_name,
|
||||
}
|
||||
for metric in self.implementation.get_metrics()
|
||||
for metric in metrics
|
||||
],
|
||||
"database": {},
|
||||
"parent": {"name": self.semantic_layer.name},
|
||||
# UI features
|
||||
"verbose_map": {},
|
||||
"verbose_map": verbose_map,
|
||||
"order_by_choices": [],
|
||||
"filter_select": True,
|
||||
"filter_select_enabled": True,
|
||||
@@ -436,11 +452,11 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
"column_types": [
|
||||
get_column_type(dimension.type) for dimension in self._unique_dimensions
|
||||
get_column_type(dimension.type) for dimension in dimensions
|
||||
],
|
||||
"column_names": [dimension.name for dimension in self._unique_dimensions],
|
||||
"column_names": [dimension.name for dimension in dimensions],
|
||||
# rare
|
||||
"column_formats": {},
|
||||
"column_formats": column_formats,
|
||||
"datasource_name": self.name,
|
||||
"perm": self.perm,
|
||||
"offset": self.offset,
|
||||
|
||||
@@ -129,7 +129,12 @@ class TaskContext(CoreTaskContext):
|
||||
"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
|
||||
fresh_task = TaskDAO.find_one_or_none(uuid=self._task_uuid)
|
||||
# Internal executor path: load the running task itself, keyed on a
|
||||
# UUID this instance already holds, not a user-requested lookup;
|
||||
# see TaskFilter for the request-scoped vs. internal-plumbing split.
|
||||
fresh_task = TaskDAO.find_one_or_none(
|
||||
uuid=self._task_uuid, skip_base_filter=True
|
||||
)
|
||||
if not fresh_task:
|
||||
raise ValueError(f"Task {self._task_uuid} not found")
|
||||
|
||||
|
||||
@@ -167,6 +167,12 @@ class TaskWrapper(Generic[P]):
|
||||
return value is discarded.
|
||||
|
||||
Direct calls execute synchronously, .schedule() runs async via Celery.
|
||||
|
||||
The status-refresh reads below pass ``skip_base_filter=True`` to
|
||||
``TaskDAO.find_one_or_none`` because they read back the task this
|
||||
executor itself submitted, keyed on the UUID it already holds -- not
|
||||
a task requested by a user. See ``TaskFilter`` for the request-scoped
|
||||
vs. internal-plumbing split.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -378,7 +384,7 @@ class TaskWrapper(Generic[P]):
|
||||
task.uuid,
|
||||
)
|
||||
# Return task in current state (caller can check status)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
def _execute_inline(
|
||||
@@ -422,7 +428,7 @@ class TaskWrapper(Generic[P]):
|
||||
set_ended_at=True,
|
||||
).run()
|
||||
# Refresh to get updated task
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
# Atomic transition: PENDING → IN_PROGRESS (set started_at for duration
|
||||
@@ -441,7 +447,7 @@ class TaskWrapper(Generic[P]):
|
||||
self.name,
|
||||
task_uuid,
|
||||
)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return refreshed if refreshed else task
|
||||
|
||||
# Update cached status (no DB read needed - we just wrote IN_PROGRESS)
|
||||
@@ -520,7 +526,7 @@ class TaskWrapper(Generic[P]):
|
||||
)
|
||||
|
||||
# Refresh once at end to return current state
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return final_task if final_task else task
|
||||
|
||||
except Exception as ex:
|
||||
@@ -542,7 +548,7 @@ class TaskWrapper(Generic[P]):
|
||||
)
|
||||
|
||||
# Refresh once at end to return current state
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return final_task if final_task else task
|
||||
|
||||
finally:
|
||||
@@ -552,7 +558,9 @@ class TaskWrapper(Generic[P]):
|
||||
# Publish completion notification for any waiters
|
||||
# Use final_task if set by try/except, otherwise refresh (fallback)
|
||||
if final_task is None:
|
||||
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
final_task = TaskDAO.find_one_or_none(
|
||||
uuid=task_uuid, skip_base_filter=True
|
||||
)
|
||||
if final_task and final_task.status in TERMINAL_STATES:
|
||||
TaskManager.publish_completion(task_uuid, final_task.status)
|
||||
|
||||
|
||||
@@ -33,20 +33,35 @@ class TaskFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
owned and shared tasks. Unsubscribing removes visibility.
|
||||
|
||||
Admins see all tasks without filtering.
|
||||
|
||||
This filter applies to request-scoped reads only -- the REST API and
|
||||
the MCP task tools -- where a task's visibility to the requesting
|
||||
principal matters. Internal task-executor and scheduler code that
|
||||
reads back the state of a task it already owns (e.g. polling for the
|
||||
terminal status of the task it is currently executing) calls the DAO
|
||||
with ``skip_base_filter=True`` instead: that code isn't presenting
|
||||
task data to a user, and the UUID it operates on is never
|
||||
caller-supplied, so the visibility check doesn't apply.
|
||||
"""
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
"""Apply the filter to the query."""
|
||||
from sqlalchemy import and_, select
|
||||
from flask import has_request_context
|
||||
from sqlalchemy import and_, false, select
|
||||
|
||||
from superset import security_manager
|
||||
from superset.models.task_subscribers import TaskSubscriber
|
||||
from superset.models.tasks import Task
|
||||
|
||||
# If user is admin or no user_id, return unfiltered query.
|
||||
# This typically applies to background tasks and system operations
|
||||
user_id = get_user_id()
|
||||
if not user_id or security_manager.is_admin():
|
||||
if not user_id:
|
||||
# Within a request, a principal without a user id gets no tasks;
|
||||
# background jobs run outside a request context and are unfiltered.
|
||||
if has_request_context():
|
||||
return query.filter(false())
|
||||
return query
|
||||
|
||||
if security_manager.is_admin():
|
||||
return query
|
||||
|
||||
is_subscribed = (
|
||||
|
||||
@@ -259,10 +259,15 @@ class TaskManager:
|
||||
return remaining if remaining > 0 else 0
|
||||
|
||||
def get_task() -> "Task | None":
|
||||
# Reads back the task named by the caller's own task_uuid, not
|
||||
# a user-requested lookup; see TaskFilter for the
|
||||
# request-scoped vs. internal-plumbing split.
|
||||
if app and not has_app_context():
|
||||
with app.app_context():
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
return TaskDAO.find_one_or_none(
|
||||
uuid=task_uuid, skip_base_filter=True
|
||||
)
|
||||
return TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
|
||||
# Check current state first
|
||||
task = get_task()
|
||||
@@ -478,7 +483,9 @@ class TaskManager:
|
||||
"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
|
||||
task = TaskDAO.find_one_or_none(uuid=task_uuid)
|
||||
# Internal control-flow check on the task the executor is already
|
||||
# running, not a user-facing lookup; see TaskFilter.
|
||||
task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
|
||||
return task is not None and task.status in ABORT_STATES
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -311,7 +311,11 @@ def execute_task( # noqa: C901
|
||||
# Convert string UUID to native UUID (Celery deserializes as string)
|
||||
native_uuid = UUID(task_uuid)
|
||||
|
||||
task = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
# Internal executor path: load the task Celery was dispatched to run,
|
||||
# keyed on the UUID passed at enqueue time, not a user-requested
|
||||
# lookup; see TaskFilter for the request-scoped vs. internal-plumbing
|
||||
# split. The refreshes below load the same task for the same reason.
|
||||
task = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
if not task:
|
||||
logger.error("Task %s not found in metastore", task_uuid)
|
||||
return {"status": "error", "message": "Task not found"}
|
||||
@@ -346,7 +350,7 @@ def execute_task( # noqa: C901
|
||||
task_type,
|
||||
task_uuid,
|
||||
)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
return {
|
||||
"status": refreshed.status if refreshed else "unknown",
|
||||
"task_uuid": task_uuid,
|
||||
@@ -489,7 +493,7 @@ def execute_task( # noqa: C901
|
||||
)
|
||||
|
||||
# Refresh to get final status for return value and completion notification
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
|
||||
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
|
||||
final_status = refreshed.status if refreshed else "unknown"
|
||||
|
||||
# Publish completion notification for any waiters (e.g., sync callers)
|
||||
|
||||
@@ -140,11 +140,17 @@ def is_safe_redirect_url(url: str) -> bool:
|
||||
# following a Location header).
|
||||
stripped = _URL_STRIPPED_CONTROL_CHARS.sub("", url.strip())
|
||||
|
||||
# Block protocol-relative URLs
|
||||
if stripped.startswith("//") or stripped.startswith("\\\\"):
|
||||
# WHATWG URL parsers treat backslashes as forward slashes in special
|
||||
# schemes, while urllib does not. Normalize backslashes to slashes before
|
||||
# every structural check, mirroring Django's
|
||||
# ``url_has_allowed_host_and_scheme``.
|
||||
normalized = stripped.replace("\\", "/")
|
||||
|
||||
# Block protocol-relative URLs (any leading mix of slash and backslash)
|
||||
if normalized.startswith("//"):
|
||||
return False
|
||||
|
||||
parsed = urlparse(stripped)
|
||||
parsed = urlparse(normalized)
|
||||
|
||||
# Relative paths are safe
|
||||
if not parsed.scheme and not parsed.netloc:
|
||||
|
||||
@@ -44,6 +44,19 @@ PORT_TIMEOUT = 5
|
||||
PING_TIMEOUT = 5
|
||||
|
||||
|
||||
def is_safe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""
|
||||
Return True if a single IP address is public and globally routable.
|
||||
|
||||
IPv4-mapped IPv6 addresses (e.g. ``::ffff:127.0.0.1``) are unwrapped so
|
||||
they are checked against the IPv4 unsafe networks rather than bypassing
|
||||
them.
|
||||
"""
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
return ip.is_global and not any(ip in net for net in _SSRF_UNSAFE_NETWORKS)
|
||||
|
||||
|
||||
def is_safe_host(host: str) -> bool:
|
||||
"""
|
||||
Return True if ``host`` resolves exclusively to public, globally-routable
|
||||
@@ -52,6 +65,10 @@ def is_safe_host(host: str) -> bool:
|
||||
Returns False if any resolved address falls within a private, loopback,
|
||||
link-local, or otherwise non-routable range. An unresolvable host also
|
||||
returns False.
|
||||
|
||||
Name resolution here is independent of the resolution performed when a
|
||||
connection is later opened, so callers that go on to fetch from ``host``
|
||||
should also validate the connected peer address (see ``is_safe_ip``).
|
||||
"""
|
||||
try:
|
||||
results = socket.getaddrinfo(host, None)
|
||||
@@ -64,11 +81,7 @@ def is_safe_host(host: str) -> bool:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
except ValueError:
|
||||
return False
|
||||
# Unwrap IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) so they
|
||||
# are checked against the IPv4 unsafe networks rather than bypassing.
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
if not ip.is_global or any(ip in net for net in _SSRF_UNSAFE_NETWORKS):
|
||||
if not is_safe_ip(ip):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -144,6 +144,21 @@ TERMINAL_MARKER_SELECTOR = (
|
||||
)
|
||||
CHART_ID_CLASS_PATTERN = r"\bdashboard-chart-id-(\d+)\b"
|
||||
|
||||
# ECharts paint marker. The frontend
|
||||
# (plugins/plugin-chart-echarts/src/components/Echart.tsx) tags the canvas host
|
||||
# ``.echarts-host`` and adds ``.echarts-render-finished`` only in the ECharts
|
||||
# ``finished`` event -- the sole signal that the canvas is fully painted.
|
||||
# ``.slice_container`` alone is a pre-paint signal (it mounts when data arrives,
|
||||
# before the canvas is drawn; chartStatus/onRenderSuccess fire pre-paint too), so
|
||||
# a holder that still contains an unpainted host is treated as not-yet-rendered and
|
||||
# the report screenshot waits for it instead of capturing a blank chart. Only
|
||||
# ECharts hosts are gated; DOM/SVG vizzes paint on commit and non-ECharts canvas
|
||||
# vizzes (deck.gl/mapbox/etc.) have no ``.echarts-host`` so they are unaffected.
|
||||
ECHARTS_UNPAINTED_HOST_SELECTOR = r".echarts-host:not(.echarts-render-finished)"
|
||||
CHART_ERROR_OR_EMPTY_SELECTOR = (
|
||||
f"{ALERT_SELECTOR}, {EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}"
|
||||
)
|
||||
|
||||
# Shared body for holder readiness and timeout diagnostics. A holder is ready
|
||||
# only after a terminal marker appears and its loading marker disappears.
|
||||
UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
@@ -158,8 +173,19 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
'{SLICE_CONTAINER_SELECTOR}'
|
||||
) !== null;
|
||||
const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null;
|
||||
const isReady = holder.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
|
||||
if (stillLoading || !isReady) {{
|
||||
const hasErrorOrEmpty = holder.querySelector(
|
||||
'{CHART_ERROR_OR_EMPTY_SELECTOR}'
|
||||
) !== null;
|
||||
const hasUnpaintedEchart = holder.querySelector(
|
||||
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
|
||||
) !== null;
|
||||
// Ready = a settled error/empty/missing state, or a slice container
|
||||
// whose ECharts canvas has finished painting. An unpainted ECharts host
|
||||
// keeps the holder unready so a blank chart is never captured.
|
||||
const isReady = !stillLoading && (
|
||||
hasErrorOrEmpty || (hasSliceContainer && !hasUnpaintedEchart)
|
||||
);
|
||||
if (!isReady) {{
|
||||
const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/);
|
||||
const chartId = chartIdMatch ? chartIdMatch[1] : null;
|
||||
let state;
|
||||
@@ -167,6 +193,8 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
|
||||
state = 'spinner_mounted';
|
||||
}} else if (stillLoading) {{
|
||||
state = 'waiting_on_database';
|
||||
}} else if (hasSliceContainer && hasUnpaintedEchart) {{
|
||||
state = 'mounted_unpainted';
|
||||
}} else {{
|
||||
state = 'nothing_mounted';
|
||||
}}
|
||||
@@ -208,6 +236,11 @@ FIND_CHART_HOLDER_STATES_JS = f"""
|
||||
) !== null) {{
|
||||
return {{ chartId, state: 'empty' }};
|
||||
}}
|
||||
if (hasSliceContainer && holder.querySelector(
|
||||
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
|
||||
) !== null) {{
|
||||
return {{ chartId, state: 'mounted_unpainted' }};
|
||||
}}
|
||||
if (hasSliceContainer) {{
|
||||
return {{ chartId, state: 'rendered' }};
|
||||
}}
|
||||
@@ -237,7 +270,8 @@ CHART_CONTAINER_READY_JS = f"""
|
||||
const chart = document.querySelector('.chart-container');
|
||||
return chart !== null
|
||||
&& chart.querySelector('{LOADING_SELECTOR}') === null
|
||||
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
|
||||
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null
|
||||
&& chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') === null;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -249,6 +283,9 @@ CHART_CONTAINER_STATE_JS = f"""
|
||||
const chart = document.querySelector('.chart-container');
|
||||
if (chart === null) {{ return 'missing'; }}
|
||||
if (chart.querySelector('{LOADING_SELECTOR}') !== null) {{ return 'loading'; }}
|
||||
if (chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') !== null) {{
|
||||
return 'mounted_unpainted';
|
||||
}}
|
||||
if (chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null) {{
|
||||
return 'terminal';
|
||||
}}
|
||||
|
||||
@@ -27,8 +27,9 @@ class CustomTagsOptimizationMixin:
|
||||
|
||||
When enabled via config, this mixin:
|
||||
1. Configures list_columns to use custom_tags (filtered relationship)
|
||||
2. Rewrites frontend requests from 'tags.*' to 'custom_tags.*'
|
||||
3. Transforms responses to rename 'custom_tags' back to 'tags'
|
||||
2. Exposes custom_tags as tags in the response schema
|
||||
3. Rewrites frontend requests from 'tags.*' to 'custom_tags.*'
|
||||
4. Transforms responses to rename 'custom_tags' back to 'tags'
|
||||
|
||||
This provides SQL query optimization (97% reduction) while maintaining
|
||||
frontend compatibility.
|
||||
@@ -62,6 +63,18 @@ class CustomTagsOptimizationMixin:
|
||||
self._custom_tags_only = current_app.config.get(config_key, False)
|
||||
self.list_columns = custom_columns if self._custom_tags_only else full_columns
|
||||
|
||||
def _init_model_schemas(self) -> None:
|
||||
"""Keep the optimized relationship's public schema name stable."""
|
||||
super()._init_model_schemas() # type: ignore[misc]
|
||||
|
||||
list_model_schema = getattr(self, "list_model_schema", None)
|
||||
if (
|
||||
self._custom_tags_only
|
||||
and list_model_schema
|
||||
and "custom_tags" in list_model_schema.fields
|
||||
):
|
||||
list_model_schema.fields["custom_tags"].data_key = "tags"
|
||||
|
||||
def get_list(self, **kwargs: Any) -> Response:
|
||||
"""Override to rewrite request parameters for custom_tags optimization.
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ from superset.common.query_context_factory import QueryContextFactory
|
||||
from superset.common.utils.query_cache_manager import QueryCacheManager
|
||||
from superset.constants import CacheRegion
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.utils.core import extract_dataframe_dtypes, QueryStatus
|
||||
from superset.utils.core import (
|
||||
apply_max_row_limit,
|
||||
extract_dataframe_dtypes,
|
||||
QueryStatus,
|
||||
)
|
||||
from superset.views.datasource.schemas import SamplesPayloadSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,9 +49,11 @@ def get_limit_clause(page: Optional[int], per_page: Optional[int]) -> dict[str,
|
||||
|
||||
if isinstance(page, int) and isinstance(per_page, int):
|
||||
limit = int(per_page)
|
||||
if limit < 0 or limit > samples_row_limit:
|
||||
if limit < 0:
|
||||
# reset limit value if input is invalid
|
||||
limit = samples_row_limit
|
||||
elif limit:
|
||||
limit = apply_max_row_limit(limit)
|
||||
|
||||
offset = max((int(page) - 1) * limit, 0)
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@ from flask_appbuilder.security.decorators import protect
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from werkzeug.security import generate_password_hash
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.daos.user import UserDAO
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.security.session_invalidation import invalidate_sessions_for_user
|
||||
from superset.utils.slack import get_user_avatar, SlackClientError
|
||||
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
|
||||
from superset.views.users.schemas import CurrentUserPutSchema, UserResponseSchema
|
||||
@@ -49,12 +50,45 @@ class CurrentUserRestApi(BaseSupersetApi):
|
||||
def pre_update(self, item: User, data: Dict[str, Any]) -> None:
|
||||
item.changed_on = datetime.now()
|
||||
item.changed_by_fk = g.user.id
|
||||
# Pop unconditionally: this key is only meaningful for verifying a
|
||||
# password change below, and it isn't a real column on the user
|
||||
# model -- it must never reach ``UserDAO.update``'s ``setattr`` loop.
|
||||
current_password = data.pop("current_password", None)
|
||||
if "password" in data and data["password"]:
|
||||
# An account with no password set yet (e.g. provisioned via an
|
||||
# external auth backend) has nothing to prove knowledge of; for
|
||||
# every other account, the caller must confirm the existing
|
||||
# password before it can be replaced.
|
||||
proof_ok = (
|
||||
item.password
|
||||
and current_password
|
||||
and check_password_hash(item.password, current_password)
|
||||
)
|
||||
if item.password and not proof_ok:
|
||||
raise ValidationError(
|
||||
{"current_password": ["Incorrect current password."]}
|
||||
)
|
||||
# Compute and assign the hash, then drop the plaintext from
|
||||
# ``data`` -- it is passed to ``UserDAO.update`` as ``attributes``
|
||||
# right after this, and ``BaseDAO.update`` sets every key in it
|
||||
# via ``setattr``. Leaving the plaintext in would overwrite the
|
||||
# hash just assigned below with the raw value.
|
||||
new_password = data.pop("password")
|
||||
item.password = generate_password_hash(
|
||||
password=data["password"],
|
||||
password=new_password,
|
||||
method=app.config.get("FAB_PASSWORD_HASH_METHOD", "scrypt"),
|
||||
salt_length=app.config.get("FAB_PASSWORD_HASH_SALT_LENGTH", 16),
|
||||
)
|
||||
# A changed password invalidates any other outstanding session
|
||||
# for this account.
|
||||
invalidate_sessions_for_user(item.id)
|
||||
elif "password" in data:
|
||||
# A falsy value (e.g. an empty string, which the complexity
|
||||
# validator lets through when password complexity is disabled)
|
||||
# skips the block above, but the key must still never reach
|
||||
# ``UserDAO.update``'s ``setattr`` loop -- it would blank out
|
||||
# the account's stored hash.
|
||||
data.pop("password")
|
||||
|
||||
@expose("/", methods=("GET",))
|
||||
@protect()
|
||||
|
||||
@@ -14,17 +14,22 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.security.sqla.apis.user.schema import User
|
||||
from flask_appbuilder.security.sqla.apis.user.validator import (
|
||||
PasswordComplexityValidator,
|
||||
)
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow import fields, Schema, validates_schema, ValidationError
|
||||
from marshmallow.fields import Boolean, Integer, String
|
||||
from marshmallow.validate import Length
|
||||
|
||||
first_name_description = "The current user's first name"
|
||||
last_name_description = "The current user's last name"
|
||||
password_description = "The current user's password for authentication" # noqa: S105
|
||||
# Required, and verified against the account's existing password, whenever
|
||||
# ``password`` is included in the payload.
|
||||
current_password_description = "The current user's existing password" # noqa: S105
|
||||
|
||||
|
||||
class UserGroupSchema(Schema):
|
||||
@@ -64,3 +69,24 @@ class CurrentUserPutSchema(Schema):
|
||||
validate=[PasswordComplexityValidator()],
|
||||
metadata={"description": password_description},
|
||||
)
|
||||
current_password = fields.String(
|
||||
required=False,
|
||||
load_only=True,
|
||||
metadata={"description": current_password_description},
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_current_password_required_with_password(
|
||||
self, data: dict[str, Any], **kwargs: object
|
||||
) -> None:
|
||||
"""Require ``current_password`` whenever ``password`` is being set.
|
||||
|
||||
This only checks that the field was supplied -- whether it actually
|
||||
matches the account's existing password is verified against the
|
||||
database in ``CurrentUserRestApi.pre_update``, which has access to
|
||||
the user record this schema doesn't.
|
||||
"""
|
||||
if data.get("password") and not data.get("current_password"):
|
||||
raise ValidationError(
|
||||
{"current_password": ["This field is required to change the password."]}
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
"""Unit tests for Superset"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import rison
|
||||
@@ -42,6 +43,57 @@ class TestOpenApiSpec(SupersetTestCase):
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
validate(response)
|
||||
|
||||
def test_dashboard_list_uses_generated_response_schema(self):
|
||||
"""Keep the generated dashboard list contract aligned with published docs.
|
||||
|
||||
If an intentional list schema change breaks this test, regenerate
|
||||
``docs/static/resources/openapi.json`` with ``superset update-api-docs``
|
||||
under the production-default configuration, then review the focused
|
||||
dashboard diff.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/_openapi")
|
||||
|
||||
assert rv.status_code == 200
|
||||
generated_spec = json.loads(rv.data.decode("utf-8"))
|
||||
published_spec_path = (
|
||||
Path(__file__).parents[2] / "docs" / "static" / "resources" / "openapi.json"
|
||||
)
|
||||
published_spec = json.loads(published_spec_path.read_text(encoding="utf-8"))
|
||||
|
||||
generated_result_items = generated_spec["paths"]["/api/v1/dashboard/"]["get"][
|
||||
"responses"
|
||||
]["200"]["content"]["application/json"]["schema"]["properties"]["result"][
|
||||
"items"
|
||||
]
|
||||
published_result_items = published_spec["paths"]["/api/v1/dashboard/"]["get"][
|
||||
"responses"
|
||||
]["200"]["content"]["application/json"]["schema"]["properties"]["result"][
|
||||
"items"
|
||||
]
|
||||
|
||||
assert (
|
||||
generated_result_items
|
||||
== published_result_items
|
||||
== {"$ref": "#/components/schemas/DashboardRestApi.get_list"}
|
||||
)
|
||||
|
||||
schema_prefix = "DashboardRestApi.get_list"
|
||||
generated_schemas = {
|
||||
name: schema
|
||||
for name, schema in generated_spec["components"]["schemas"].items()
|
||||
if name == schema_prefix or name.startswith(f"{schema_prefix}.")
|
||||
}
|
||||
published_schemas = {
|
||||
name: schema
|
||||
for name, schema in published_spec["components"]["schemas"].items()
|
||||
if name == schema_prefix or name.startswith(f"{schema_prefix}.")
|
||||
}
|
||||
|
||||
assert generated_schemas == published_schemas, (
|
||||
"Dashboard list OpenAPI components changed; regenerate the published spec"
|
||||
)
|
||||
|
||||
def test_info_endpoint(self):
|
||||
"""
|
||||
API: Test info endpoint
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
# isort:skip_file
|
||||
"""Unit tests for Superset"""
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -606,7 +605,10 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0]
|
||||
)
|
||||
self.login(ADMIN_USERNAME)
|
||||
with freeze_time(datetime.now()):
|
||||
# Freeze relative to the persisted timestamp so database-specific
|
||||
# timestamp precision cannot make the humanized value age into the
|
||||
# next bucket while the request is being handled.
|
||||
with freeze_time(saved_query.changed_on):
|
||||
uri = f"api/v1/saved_query/{saved_query.id}"
|
||||
rv = self.get_assert_metric(uri, "get")
|
||||
assert rv.status_code == 200
|
||||
|
||||
@@ -74,6 +74,7 @@ def test_validate_success(
|
||||
mock_parameters: OAuth2ProviderResponseSchema,
|
||||
) -> None:
|
||||
mocker.patch("superset.utils.oauth2.decode_oauth2_state", return_value=mock_state)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
"get_database",
|
||||
@@ -95,6 +96,7 @@ def test_validate_database_not_found(
|
||||
"superset.utils.oauth2.decode_oauth2_state",
|
||||
return_value={"database_id": 999},
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(DatabaseUserOAuth2TokensDAO, "get_database", return_value=None)
|
||||
|
||||
command = OAuth2StoreTokenCommand(mock_parameters)
|
||||
@@ -120,6 +122,7 @@ def test_run_success(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
"find_one_or_none",
|
||||
@@ -155,6 +158,7 @@ def test_run_logs_token_exchange_failure(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
mock_database.db_engine_spec.get_oauth2_token.side_effect = HTTPError(
|
||||
"provider-payload-sentinel"
|
||||
)
|
||||
@@ -188,6 +192,7 @@ def test_run_existing_token(
|
||||
"get_database",
|
||||
return_value=mock_database,
|
||||
)
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=1)
|
||||
existing_token = MagicMock()
|
||||
mocker.patch.object(
|
||||
DatabaseUserOAuth2TokensDAO,
|
||||
@@ -208,3 +213,23 @@ def test_run_existing_token(
|
||||
assert result == "new_token"
|
||||
mock_delete.assert_called_once_with([existing_token])
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
def test_validate_rejects_state_not_bound_to_session(
|
||||
mocker: MockerFixture,
|
||||
mock_parameters: OAuth2ProviderResponseSchema,
|
||||
) -> None:
|
||||
"""
|
||||
The callback must only store tokens for the user who initiated the
|
||||
dance: a state minted for another user, or presented without an
|
||||
authenticated session, is rejected before any token exchange.
|
||||
"""
|
||||
command = OAuth2StoreTokenCommand(mock_parameters)
|
||||
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=2)
|
||||
with pytest.raises(OAuth2Error):
|
||||
command.validate()
|
||||
|
||||
mocker.patch("superset.commands.database.oauth2.get_user_id", return_value=None)
|
||||
with pytest.raises(OAuth2Error):
|
||||
command.validate()
|
||||
|
||||
@@ -46,6 +46,7 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleXlsxFailedError,
|
||||
)
|
||||
from superset.commands.report.execute import (
|
||||
_should_build_execution_context,
|
||||
BaseReportState,
|
||||
log_report_delivery_phase,
|
||||
persist_owned_report_execution_terminal_error,
|
||||
@@ -3747,6 +3748,8 @@ def test_success_state_send_error_logs_and_reraises(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "send", side_effect=RuntimeError("send boom"))
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mocker.patch.object(state, "send_error")
|
||||
mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
|
||||
with pytest.raises(RuntimeError, match="send boom"):
|
||||
@@ -3808,6 +3811,46 @@ def test_get_notification_content_alert_no_flag_skips_attachment(
|
||||
assert content.text is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schedule_type", "report_format", "attach_flag", "expected"),
|
||||
[
|
||||
# Reports always run under an execution context, regardless of format.
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.PNG, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.PDF, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.CSV, False, True),
|
||||
(ReportScheduleType.REPORT, ReportDataFormat.TEXT, False, True),
|
||||
# Alerts that deliver a rendered screenshot fail closed only when the
|
||||
# ALERTS_ATTACH_REPORTS flag is on (otherwise no artifact is attached).
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PNG, True, True),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PDF, True, True),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PNG, False, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.PDF, False, False),
|
||||
# CSV/text/xlsx alerts never deliver a rendered screenshot; they stay
|
||||
# lenient even with the attach flag on.
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.CSV, True, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.TEXT, True, False),
|
||||
(ReportScheduleType.ALERT, ReportDataFormat.XLSX, True, False),
|
||||
],
|
||||
)
|
||||
@patch("superset.commands.report.execute.feature_flag_manager")
|
||||
def test_should_build_execution_context(
|
||||
mock_ff: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
schedule_type: ReportScheduleType,
|
||||
report_format: ReportDataFormat,
|
||||
attach_flag: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Only reports and rendered-screenshot alerts run fail closed under a
|
||||
ReportExecutionContext; CSV/text alerts and flag-off alerts stay lenient."""
|
||||
mock_ff.is_feature_enabled.return_value = attach_flag
|
||||
model = mocker.Mock(spec=ReportSchedule)
|
||||
model.type = schedule_type
|
||||
model.report_format = report_format
|
||||
|
||||
assert _should_build_execution_context(model) is expected
|
||||
|
||||
|
||||
def test_create_log_success_commits(mocker: MockerFixture) -> None:
|
||||
"""Successful create_log creates a log entry and commits."""
|
||||
schedule = mocker.Mock(spec=ReportSchedule)
|
||||
@@ -4217,6 +4260,139 @@ def test_success_state_error_logged_when_send_error_raises(
|
||||
assert ReportState.ERROR in states
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schedule_type",
|
||||
[ReportScheduleType.REPORT, ReportScheduleType.ALERT],
|
||||
)
|
||||
def test_success_state_send_failure_notifies_owner(
|
||||
mocker: MockerFixture,
|
||||
schedule_type: ReportScheduleType,
|
||||
) -> None:
|
||||
"""A delivery failure from the Success/Grace path must notify the owner,
|
||||
mirroring the first-run (ReportNotTriggeredErrorState) path — otherwise a
|
||||
previously-successful schedule fails silently (e.g. once a screenshot
|
||||
capture starts failing closed)."""
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=schedule_type
|
||||
)
|
||||
# No retries configured (the default), so _handle_retry_or_error returns
|
||||
# False immediately without sending anything.
|
||||
mocker.patch.object(state, "is_in_grace_period", return_value=False)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(state, "send_error")
|
||||
if schedule_type == ReportScheduleType.ALERT:
|
||||
mocker.patch(
|
||||
"superset.commands.report.execute.AlertCommand"
|
||||
).return_value.run.return_value = (True, "triggered")
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_called_once()
|
||||
# The owner-notification path must also persist a terminal ERROR state,
|
||||
# not leave the schedule stuck in WORKING (mirrors how the grace-period
|
||||
# sibling test asserts the recorded terminal state).
|
||||
assert mock_update.call_args_list[-1].args[0] == ReportState.ERROR
|
||||
|
||||
|
||||
def test_success_state_send_failure_skips_notification_in_error_grace(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When inside the error grace period, the Success/Grace path logs ERROR
|
||||
but suppresses the (throttled) error notification."""
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=True)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(state, "send_error")
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_not_called()
|
||||
states = [call.args[0] for call in mock_update.call_args_list]
|
||||
assert ReportState.ERROR in states
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_kind", "expected_message"),
|
||||
[
|
||||
("superset_errors", "smtp down;retry failed"),
|
||||
("generic", "smtp down"),
|
||||
],
|
||||
)
|
||||
def test_success_state_send_error_failure_overwrites_marker(
|
||||
mocker: MockerFixture,
|
||||
failure_kind: str,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
"""When the Success/Grace path's own error notification fails, the
|
||||
placeholder marker is overwritten with the real failure message before
|
||||
ERROR is logged -- mirroring the first-run (ReportNotTriggeredErrorState)
|
||||
path. A SupersetErrorsException contributes its joined error messages; any
|
||||
other exception contributes its ``str()``."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetErrorsException
|
||||
|
||||
if failure_kind == "superset_errors":
|
||||
send_error_exc: Exception = SupersetErrorsException(
|
||||
[
|
||||
SupersetError(
|
||||
message="smtp down",
|
||||
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
SupersetError(
|
||||
message="retry failed",
|
||||
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
send_error_exc = RuntimeError("smtp down")
|
||||
|
||||
state = _make_state_instance(
|
||||
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
|
||||
)
|
||||
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
|
||||
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
|
||||
mock_send_error = mocker.patch.object(
|
||||
state, "send_error", side_effect=send_error_exc
|
||||
)
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"send",
|
||||
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
|
||||
state.next()
|
||||
|
||||
mock_send_error.assert_called_once()
|
||||
# The placeholder marker must be replaced by the real notification failure
|
||||
# before the terminal ERROR row is written.
|
||||
final_call = mock_update.call_args_list[-1]
|
||||
assert final_call.args[0] == ReportState.ERROR
|
||||
assert final_call.kwargs.get("error_message") == expected_message
|
||||
assert (
|
||||
final_call.kwargs.get("error_message")
|
||||
!= REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
|
||||
)
|
||||
|
||||
|
||||
def test_get_url_for_csv_uses_post_processed_type(
|
||||
app: SupersetApp,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -21,6 +21,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import g
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.sql_lab.streaming_export_command import (
|
||||
@@ -133,6 +134,24 @@ def test_validate_access_denied(mock_db, mock_query):
|
||||
assert exc_info.value.status == 403
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.streaming_export_command.db")
|
||||
def test_validate_jinja_template_error(mock_db, mock_query):
|
||||
"""Test validate converts a Jinja TemplateError into a 400 error."""
|
||||
mock_query_result = mock_db.session.query.return_value.filter_by.return_value
|
||||
mock_query_result.one_or_none.return_value = mock_query
|
||||
mock_query.raise_for_access.side_effect = TemplateSyntaxError(
|
||||
"unexpected end of template", lineno=1
|
||||
)
|
||||
|
||||
command = StreamingSqlResultExportCommand("test_client_123")
|
||||
|
||||
with pytest.raises(SupersetErrorException) as exc_info:
|
||||
command.validate()
|
||||
|
||||
assert exc_info.value.error.error_type == SupersetErrorType.GENERIC_COMMAND_ERROR
|
||||
assert exc_info.value.status == 400
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.streaming_export_command.db")
|
||||
def test_validate_success(mock_db, mock_query):
|
||||
"""Test successful validation."""
|
||||
|
||||
@@ -27,6 +27,7 @@ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultTyp
|
||||
from superset.common.chart_data_timing import QueryDataResult, QueryTiming
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.common.query_context_processor import QueryContextProcessor
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
from superset.utils.core import GenericDataType
|
||||
from superset.utils.date_parser import get_past_or_future
|
||||
|
||||
@@ -98,6 +99,25 @@ def processor(mock_query_context):
|
||||
return processor
|
||||
|
||||
|
||||
def test_query_cache_key_binds_annotation_data_to_requesting_user(processor):
|
||||
"""The cache key for annotated queries must differ per requesting user."""
|
||||
query_obj = MagicMock()
|
||||
query_obj.annotation_layers = [{"sourceType": "NATIVE", "name": "a", "value": 1}]
|
||||
with (
|
||||
patch(
|
||||
"superset.common.query_context_processor.get_user_id",
|
||||
side_effect=[1, 2],
|
||||
),
|
||||
patch("superset.common.query_context_processor.security_manager"),
|
||||
):
|
||||
processor.query_cache_key(query_obj)
|
||||
processor.query_cache_key(query_obj)
|
||||
contexts = [
|
||||
call.kwargs["annotation_context"] for call in query_obj.cache_key.call_args_list
|
||||
]
|
||||
assert contexts[0] != contexts[1]
|
||||
|
||||
|
||||
def test_get_data_table_like(processor, mock_query_context):
|
||||
df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]})
|
||||
coltypes = [GenericDataType.NUMERIC, GenericDataType.STRING]
|
||||
@@ -2377,3 +2397,26 @@ def test_relative_offset_preserves_inner_bounds(
|
||||
# for #40501. Without the fix, inner_from/to_dttm == shifted dates.
|
||||
assert captured[0]["inner_from_dttm"] == pd.Timestamp("2026-05-01")
|
||||
assert captured[0]["inner_to_dttm"] == pd.Timestamp("2026-05-28")
|
||||
|
||||
|
||||
def test_get_native_annotation_data_requires_annotation_read_access():
|
||||
"""Native annotation layers are only served to users who can read them."""
|
||||
query_obj = MagicMock()
|
||||
query_obj.annotation_layers = [{"sourceType": "NATIVE", "name": "a", "value": 1}]
|
||||
with (
|
||||
patch(
|
||||
"superset.common.query_context_processor.security_manager"
|
||||
) as security_manager_mock,
|
||||
patch(
|
||||
"superset.common.query_context_processor.AnnotationLayerDAO.find_by_ids",
|
||||
return_value=[],
|
||||
) as find_by_ids_mock,
|
||||
):
|
||||
# ``can_access`` is synchronous; force a plain Mock so the patched
|
||||
# manager doesn't hand back a truthy coroutine that slips past the
|
||||
# ``not can_access(...)`` guard.
|
||||
security_manager_mock.can_access = MagicMock(return_value=False)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
QueryContextProcessor.get_native_annotation_data(query_obj)
|
||||
security_manager_mock.can_access.assert_called_once_with("can_read", "Annotation")
|
||||
find_by_ids_mock.assert_not_called()
|
||||
|
||||
@@ -19,6 +19,7 @@ from collections.abc import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm.session import Session
|
||||
from superset_core.tasks.types import TaskProperties, TaskScope, TaskStatus
|
||||
|
||||
@@ -395,9 +396,15 @@ def test_remove_subscriber_not_subscribed(session_with_task: Session) -> None:
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_status(session_with_task: Session) -> None:
|
||||
def test_get_status(session_with_task: Session, mocker: MockerFixture) -> None:
|
||||
"""Test get_status returns status string when task found by UUID"""
|
||||
from superset.daos.tasks import TaskDAO
|
||||
from superset.models.task_subscribers import TaskSubscriber
|
||||
|
||||
# get_status enforces the TaskFilter, so the polling user must be
|
||||
# authenticated and subscribed to see the task.
|
||||
mocker.patch("superset.tasks.filters.get_user_id", return_value=TEST_USER_ID)
|
||||
mocker.patch("superset.security_manager.is_admin", return_value=False)
|
||||
|
||||
task = create_task(
|
||||
session_with_task,
|
||||
@@ -405,6 +412,8 @@ def test_get_status(session_with_task: Session) -> None:
|
||||
task_key="status-task",
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
)
|
||||
session_with_task.add(TaskSubscriber(task_id=task.id, user_id=TEST_USER_ID))
|
||||
session_with_task.flush()
|
||||
|
||||
result = TaskDAO.get_status(task.uuid)
|
||||
|
||||
|
||||
@@ -710,6 +710,10 @@ def test_oauth2_happy_path(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
@@ -786,6 +790,10 @@ def test_oauth2_permissions(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
@@ -867,6 +875,10 @@ def test_oauth2_multiple_tokens(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.oauth2.get_user_id",
|
||||
return_value=1,
|
||||
)
|
||||
state: OAuth2State = {
|
||||
"user_id": 1,
|
||||
"database_id": 1,
|
||||
|
||||
@@ -2239,3 +2239,79 @@ def test_import_restore_blocked_by_active_twin_at_incoming_identity(
|
||||
assert "another active dataset" in str(excinfo.value)
|
||||
# Check-before-mutate: the failed import leaves the row soft-deleted.
|
||||
assert existing.deleted_at is not None
|
||||
|
||||
|
||||
def test_peer_validating_connection_blocks_rebound_peer() -> None:
|
||||
"""
|
||||
The import fetch validates the connected peer address, so a hostname that
|
||||
passes ``is_safe_host`` and then re-resolves to an internal address (DNS
|
||||
rebinding) is rejected before any request bytes are sent.
|
||||
"""
|
||||
from http.client import HTTPConnection
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.commands.dataset.exceptions import DatasetForbiddenDataURI
|
||||
from superset.commands.dataset.importers.v1.utils import (
|
||||
_PeerValidatingHTTPConnection,
|
||||
)
|
||||
|
||||
sock = MagicMock()
|
||||
sock.getpeername.return_value = ("169.254.169.254", 80)
|
||||
|
||||
with patch.object(
|
||||
HTTPConnection, "connect", lambda self: setattr(self, "sock", sock)
|
||||
):
|
||||
conn = _PeerValidatingHTTPConnection("rebinder.example.com")
|
||||
with pytest.raises(DatasetForbiddenDataURI):
|
||||
conn.connect()
|
||||
|
||||
|
||||
def test_load_data_disables_proxy_when_internal_urls_disallowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
``load_data`` builds its opener with an explicit no-proxy handler when
|
||||
internal data URLs are disallowed, so a configured HTTP(S) proxy can't
|
||||
intercept the connection the peer check validates.
|
||||
"""
|
||||
from superset.commands.dataset.importers.v1.utils import load_data
|
||||
|
||||
current_app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"] = False
|
||||
|
||||
mocker.patch("superset.commands.dataset.importers.v1.utils.validate_data_uri")
|
||||
mocker.patch(
|
||||
"superset.examples.helpers.normalize_example_data_url",
|
||||
side_effect=lambda uri: uri,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils._convert_temporal_columns"
|
||||
)
|
||||
mocker.patch("superset.commands.dataset.importers.v1.utils.db.session.connection")
|
||||
mock_df = Mock()
|
||||
mock_df.keys.return_value = []
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils.pd.read_csv",
|
||||
return_value=mock_df,
|
||||
)
|
||||
mock_opener = Mock()
|
||||
mock_opener.open.return_value = io.BytesIO(b"")
|
||||
mock_build_opener = mocker.patch(
|
||||
"superset.commands.dataset.importers.v1.utils.request.build_opener",
|
||||
return_value=mock_opener,
|
||||
)
|
||||
|
||||
dataset = Mock(spec=SqlaTable)
|
||||
dataset.columns = []
|
||||
dataset.table_name = "my_table"
|
||||
dataset.schema = None
|
||||
|
||||
database = Mock(spec=Database)
|
||||
database.sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
|
||||
load_data("https://example.org/data.csv", dataset, database)
|
||||
|
||||
handlers = mock_build_opener.call_args.args
|
||||
assert any(
|
||||
isinstance(handler, request.ProxyHandler) and not handler.proxies # type: ignore[attr-defined]
|
||||
for handler in handlers
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine.default import DefaultDialect
|
||||
|
||||
from superset.db_engine_specs import get_available_engine_specs
|
||||
|
||||
@@ -50,6 +51,92 @@ def test_get_available_engine_specs(mocker: MockerFixture) -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_get_available_engine_specs_skips_malformed_dialect_entry_point(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A third-party ``sqlalchemy.dialects`` entry point that loads successfully but
|
||||
does not resolve to a usable dialect (e.g. a module with no ``name`` or a
|
||||
named class that does not implement the dialect contract) must be skipped.
|
||||
|
||||
Regression test: an unguarded ``dialect.name`` there aborted the whole
|
||||
enumeration with ``AttributeError``, which 500s every page that builds the
|
||||
bootstrap payload (e.g. ``/welcome/``), not just that one connector.
|
||||
"""
|
||||
import types
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([]),
|
||||
)
|
||||
|
||||
malformed_ep = mocker.MagicMock()
|
||||
malformed_ep.name = "bogus"
|
||||
malformed_ep.value = "bogus_pkg:base"
|
||||
# ``ep.load()`` returns a module (no ``name`` attribute), as a real
|
||||
# ``name = pkg:submodule`` entry point would.
|
||||
malformed_ep.load.return_value = types.ModuleType("bogus_pkg.base")
|
||||
|
||||
named_but_invalid_ep = mocker.MagicMock()
|
||||
named_but_invalid_ep.name = "named_bogus"
|
||||
named_but_invalid_ep.value = "bogus_pkg:NamedButInvalidDialect"
|
||||
named_but_invalid_ep.load.return_value = type(
|
||||
"NamedButInvalidDialect",
|
||||
(),
|
||||
{"name": "bogus", "driver": "bogus"},
|
||||
)
|
||||
|
||||
def entry_points(group: str) -> list[object]:
|
||||
return (
|
||||
[malformed_ep, named_but_invalid_ep]
|
||||
if group == "sqlalchemy.dialects"
|
||||
else []
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
side_effect=entry_points,
|
||||
)
|
||||
warning = mocker.patch("superset.db_engine_specs.logger.warning")
|
||||
|
||||
# Must not raise (previously ``AttributeError`` on ``dialect.name``).
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert isinstance(available, dict)
|
||||
# The malformed entry point is skipped with a warning that identifies it.
|
||||
assert any("bogus" in str(call) for call in warning.call_args_list)
|
||||
assert any("named_bogus" in str(call) for call in warning.call_args_list)
|
||||
|
||||
|
||||
def test_get_available_engine_specs_keeps_valid_third_party_dialect(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A valid SQLAlchemy 2.0-style dialect is included without calling dbapi()."""
|
||||
import sqlalchemy.dialects
|
||||
|
||||
from superset.db_engine_specs.sqlite import SqliteEngineSpec
|
||||
|
||||
class ValidDialect(DefaultDialect):
|
||||
name = "sqlite"
|
||||
driver = "valid_driver"
|
||||
|
||||
mocker.patch.object(sqlalchemy.dialects, "__all__", [])
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([SqliteEngineSpec]),
|
||||
)
|
||||
entry_point = mocker.MagicMock()
|
||||
entry_point.load.return_value = ValidDialect
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
return_value=[entry_point],
|
||||
)
|
||||
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert available[SqliteEngineSpec] == {"valid_driver"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"app",
|
||||
[{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}],
|
||||
|
||||
@@ -78,6 +78,32 @@ def test_ephemeral_get_delegates_to_dao(
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.api.ExtensionEphemeralDAO")
|
||||
@patch("superset.extensions.storage.utils.get_extensions")
|
||||
def test_ephemeral_get_response_is_marked_no_store(
|
||||
mock_get_ext: MagicMock, mock_dao: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""Stored values are scoped to the requesting user, so responses built via
|
||||
`response()` must never be cached (e.g. by a shared/CDN cache)."""
|
||||
mock_get_ext.return_value = {"acme.dashboard": MagicMock()}
|
||||
Babel(app)
|
||||
app.appbuilder = MagicMock()
|
||||
app.appbuilder.sm.is_item_public.return_value = True
|
||||
mock_dao.get_raw.return_value = (get_codec("json").encode({"data": 42}), "json")
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/v1/extensions/acme/dashboard/storage/ephemeral/my-key"
|
||||
):
|
||||
g.user = MagicMock(id=7)
|
||||
|
||||
body, status_code = ExtensionStorageRestApi().get_ephemeral(
|
||||
"acme", "dashboard", "my-key"
|
||||
)
|
||||
|
||||
assert status_code == 200
|
||||
assert body.cache_control.no_store is True
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.api.ExtensionEphemeralDAO")
|
||||
@patch("superset.extensions.storage.utils.get_extensions")
|
||||
def test_ephemeral_get_returns_none_when_entry_missing(
|
||||
|
||||
@@ -103,6 +103,17 @@ def test_unrelated_path_is_not_intercepted() -> None:
|
||||
assert headers == upstream
|
||||
|
||||
|
||||
def test_storage_endpoints_are_not_intercepted() -> None:
|
||||
"""Per-user storage responses must keep Vary: Cookie for shared caches."""
|
||||
upstream = [("Vary", "Accept-Encoding, Cookie")]
|
||||
for path in (
|
||||
"/api/v1/extensions/acme/my-ext/storage/ephemeral/some-key",
|
||||
"/api/v1/extensions/acme/my-ext/storage/persistent/some-key",
|
||||
):
|
||||
headers = call_middleware(path, upstream)
|
||||
assert headers == upstream
|
||||
|
||||
|
||||
# --- Vary stripping logic ---
|
||||
|
||||
|
||||
|
||||
@@ -1096,6 +1096,34 @@ def test_metric_macro_with_dataset_id(mocker: MockerFixture) -> None:
|
||||
mock_get_form_data.assert_not_called()
|
||||
|
||||
|
||||
def test_metric_macro_guest_user_dataset_out_of_scope(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that ``metric_macro`` denies a guest user a dataset that is not
|
||||
reachable through any dashboard their guest token grants.
|
||||
"""
|
||||
mocker.patch("superset.security_manager.is_guest_user", return_value=True)
|
||||
guest_user = mocker.MagicMock()
|
||||
guest_user.guest_token = {}
|
||||
mocker.patch(
|
||||
"superset.security_manager.get_current_guest_user_if_guest",
|
||||
return_value=guest_user,
|
||||
)
|
||||
DatasetDAO = mocker.patch("superset.daos.dataset.DatasetDAO") # noqa: N806
|
||||
DatasetDAO.find_by_id.return_value = SqlaTable(
|
||||
id=1,
|
||||
table_name="test_dataset",
|
||||
metrics=[
|
||||
SqlMetric(metric_name="count", expression="COUNT(*)"),
|
||||
],
|
||||
database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
|
||||
schema="my_schema",
|
||||
sql=None,
|
||||
)
|
||||
env = SandboxedEnvironment(undefined=DebugUndefined)
|
||||
with pytest.raises(DatasetNotFoundError):
|
||||
metric_macro(env, {}, "count", 1)
|
||||
|
||||
|
||||
def test_metric_macro_recursive(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test the ``metric_macro`` when the definition is recursive.
|
||||
@@ -1732,6 +1760,13 @@ def test_metric_macro_embedded_user_skips_base_filter(mocker: MockerFixture) ->
|
||||
mock_is_guest_user = mocker.patch("superset.security_manager.is_guest_user")
|
||||
mock_is_guest_user.return_value = True
|
||||
|
||||
# Dashboard-level guest scope is asserted separately; here the dataset is
|
||||
# in scope so the test can focus on the base-filter bypass.
|
||||
mocker.patch(
|
||||
"superset.jinja_context.guest_user_can_access_dataset",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
DatasetDAO = mocker.patch("superset.daos.dataset.DatasetDAO") # noqa: N806
|
||||
DatasetDAO.find_by_id.return_value = SqlaTable(
|
||||
table_name="test_dataset",
|
||||
|
||||
@@ -66,7 +66,7 @@ def mock_auth():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def allow_data_model_metadata():
|
||||
def allow_data_model_metadata(): # noqa: PT004
|
||||
"""Keep the standalone get_schema suite in the unrestricted default path."""
|
||||
with patch.object(
|
||||
get_schema_module,
|
||||
@@ -606,3 +606,40 @@ class TestGetSchemaPermissionMap:
|
||||
factories = set(get_schema_module._SCHEMA_CORE_FACTORIES.keys())
|
||||
perms = set(get_schema_module._MODEL_TYPE_CLASS_PERMISSION.keys())
|
||||
assert factories == perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_scope_is_enforced(self, app, mcp_server):
|
||||
"""RBAC access alone cannot bypass a scoped token's resource limit."""
|
||||
with (
|
||||
patch.dict(app.config, {"MCP_RBAC_ENABLED": True}),
|
||||
patch("superset.security_manager.can_access", return_value=True),
|
||||
patch.object(
|
||||
get_schema_module, "_token_scope_allows", return_value=False
|
||||
) as scope_allows,
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await client.call_tool(
|
||||
"get_schema", {"request": {"model_type": "chart"}}
|
||||
)
|
||||
|
||||
scope_allows.assert_called_once_with("read", "Chart")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_scope_is_enforced_when_rbac_disabled(self, app, mcp_server):
|
||||
"""The RBAC feature flag does not disable credential scopes."""
|
||||
with (
|
||||
patch.dict(app.config, {"MCP_RBAC_ENABLED": False}),
|
||||
patch("superset.security_manager.can_access") as can_access,
|
||||
patch.object(
|
||||
get_schema_module, "_token_scope_allows", return_value=False
|
||||
) as scope_allows,
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await client.call_tool(
|
||||
"get_schema", {"request": {"model_type": "chart"}}
|
||||
)
|
||||
|
||||
can_access.assert_not_called()
|
||||
scope_allows.assert_called_once_with("read", "Chart")
|
||||
|
||||
@@ -23,12 +23,14 @@ import pytest
|
||||
from flask import g
|
||||
|
||||
from superset.mcp_service.auth import (
|
||||
_required_resource_scope,
|
||||
check_tool_permission,
|
||||
CLASS_PERMISSION_ATTR,
|
||||
is_tool_visible_to_current_user,
|
||||
MCPPermissionDeniedError,
|
||||
METHOD_PERMISSION_ATTR,
|
||||
PERMISSION_PREFIX,
|
||||
RESOURCE_SCOPE_NAME,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +110,17 @@ def test_check_tool_permission_no_class_permission_allows(app_context) -> None:
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
def test_scoped_token_constrains_permissionless_tool(app_context) -> None:
|
||||
"""Resource-only scopes do not grant permission-less tools."""
|
||||
g.user = MagicMock(username="admin")
|
||||
func = _make_tool_func()
|
||||
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:read"]):
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
def test_check_tool_permission_no_user_denies(app_context) -> None:
|
||||
"""If no g.user, permission check should deny."""
|
||||
g.user = None
|
||||
@@ -170,6 +183,19 @@ def test_check_tool_permission_disabled_via_config(app_context, app) -> None:
|
||||
app.config["MCP_RBAC_ENABLED"] = True
|
||||
|
||||
|
||||
def test_disabled_rbac_still_enforces_token_scopes(app_context, app) -> None:
|
||||
"""Disabling user RBAC does not disable credential restrictions."""
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
app.config["MCP_RBAC_ENABLED"] = False
|
||||
try:
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:chart:write"]):
|
||||
assert check_tool_permission(func) is True
|
||||
finally:
|
||||
app.config["MCP_RBAC_ENABLED"] = True
|
||||
|
||||
|
||||
# -- Permission constants --
|
||||
|
||||
|
||||
@@ -289,6 +315,19 @@ def test_visibility_public_tool_no_class_permission(app_context) -> None:
|
||||
assert is_tool_visible_to_current_user(tool) is True
|
||||
|
||||
|
||||
def test_visibility_hides_permissionless_tool_from_resource_scoped_token(
|
||||
app_context,
|
||||
) -> None:
|
||||
"""Permission-less tools require a flat scope in tools/list too."""
|
||||
g.user = MagicMock(username="viewer")
|
||||
tool = _make_mock_tool(fn=_make_tool_func())
|
||||
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert is_tool_visible_to_current_user(tool) is False
|
||||
with _patch_token_scopes(["superset:read"]):
|
||||
assert is_tool_visible_to_current_user(tool) is True
|
||||
|
||||
|
||||
def test_visibility_allowed_tool(app_context) -> None:
|
||||
"""Tools where security_manager grants access are visible."""
|
||||
g.user = MagicMock(username="admin")
|
||||
@@ -431,6 +470,23 @@ def test_scope_falls_back_to_rbac_when_no_jwt_context(app_context) -> None:
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_scope_context_error_fails_closed(app_context) -> None:
|
||||
"""An unexpected token lookup failure cannot erase token restrictions."""
|
||||
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(
|
||||
"fastmcp.server.dependencies.get_access_token",
|
||||
side_effect=TypeError("invalid token context"),
|
||||
),
|
||||
):
|
||||
assert check_tool_permission(func) is False
|
||||
|
||||
|
||||
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")
|
||||
@@ -447,7 +503,9 @@ def test_scope_read_denied_when_token_lacks_read_scope(app_context) -> None:
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_scope_denies_unmapped_method_for_scoped_token(app_context) -> None:
|
||||
def test_scope_denies_unmapped_method_for_scoped_token(
|
||||
app_context, caplog: pytest.LogCaptureFixture
|
||||
) -> 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."""
|
||||
@@ -463,6 +521,8 @@ def test_scope_denies_unmapped_method_for_scoped_token(app_context) -> None:
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
assert "unmapped method permission 'some_custom_perm'" in caplog.text
|
||||
assert "required scope 'None'" not in caplog.text
|
||||
|
||||
|
||||
def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
@@ -480,6 +540,103 @@ def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
# -- Per-resource scopes (superset:<resource>:<action>) --
|
||||
|
||||
|
||||
def test_required_resource_scope_special_names() -> None:
|
||||
"""The explicit resource map handles names a naive lower() would break:
|
||||
'Row Level Security' (spaces) and 'ReportSchedule'/'SQLLab' (misnames)."""
|
||||
assert _required_resource_scope("Row Level Security", "read") == "superset:rls:read"
|
||||
assert _required_resource_scope("ReportSchedule", "write") == (
|
||||
"superset:report:write"
|
||||
)
|
||||
assert _required_resource_scope("SQLLab", "execute_sql_query") == (
|
||||
"superset:sqllab:write"
|
||||
)
|
||||
assert _required_resource_scope("Chart", "update") == "superset:chart:write"
|
||||
|
||||
|
||||
def test_required_resource_scope_unmapped_returns_none() -> None:
|
||||
"""An unmapped resource or method yields None (no per-resource scope),
|
||||
which callers must NOT treat as a grant."""
|
||||
assert _required_resource_scope("NotAResource", "read") is None
|
||||
assert _required_resource_scope("Chart", "not_a_method") is None
|
||||
|
||||
|
||||
def test_resource_scope_name_covers_all_tool_resource_classes() -> None:
|
||||
"""RESOURCE_SCOPE_NAME must cover every class_permission_name declared by
|
||||
MCP tools. If a new resource class is added, add it to the map."""
|
||||
assert set(RESOURCE_SCOPE_NAME.keys()) == {
|
||||
"Annotation",
|
||||
"Chart",
|
||||
"Dashboard",
|
||||
"Database",
|
||||
"Dataset",
|
||||
"Explore",
|
||||
"Query",
|
||||
"ReportSchedule",
|
||||
"Role",
|
||||
"Row Level Security",
|
||||
"SavedQuery",
|
||||
"SQLLab",
|
||||
"Tag",
|
||||
"Task",
|
||||
"Theme",
|
||||
"User",
|
||||
}
|
||||
|
||||
|
||||
def test_per_resource_scope_grants_matching_tool(app_context) -> None:
|
||||
"""A token scoped ONLY to superset:chart:write (no flat superset:write)
|
||||
still grants a Chart/write tool via the per-resource grant path."""
|
||||
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:chart:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_per_resource_scope_does_not_leak_across_resources(app_context) -> None:
|
||||
"""A token scoped to superset:chart:write does NOT grant a Dashboard/write
|
||||
tool (resource isolation)."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Dashboard", 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:chart:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_per_resource_scope_enforces_action(app_context) -> None:
|
||||
"""A token scoped to superset:chart:read does NOT grant a Chart/write tool
|
||||
(action still enforced within the resource)."""
|
||||
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:chart:read"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User/Role tools must request a permission FAB actually registers.
|
||||
#
|
||||
|
||||
@@ -233,13 +233,22 @@ async def test_api_key_passthrough_propagates_required_scopes() -> None:
|
||||
# -- Transport-layer DB validation (app configured) --
|
||||
|
||||
|
||||
def _make_app_with_api_key(username: str | None) -> MagicMock:
|
||||
"""Return a mock Flask app whose SecurityManager validates to ``username``."""
|
||||
def _make_app_with_api_key(
|
||||
username: str | None, scopes: str | None = None
|
||||
) -> MagicMock:
|
||||
"""Return a mock Flask app whose SecurityManager validates to ``username``.
|
||||
|
||||
``scopes`` is what ``get_api_key_scopes`` returns (FAB stores scopes as a
|
||||
comma-separated string, or None). It must be configured explicitly — an
|
||||
unconfigured MagicMock return value would raise on ``.split(",")`` inside
|
||||
the verifier's broad except-block and silently read as a rejected key.
|
||||
"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = username
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.validate_api_key = MagicMock(return_value=mock_user if username else None)
|
||||
mock_sm.get_api_key_scopes = MagicMock(return_value=scopes)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.app_context.return_value.__enter__ = MagicMock(return_value=None)
|
||||
@@ -264,6 +273,41 @@ async def test_transport_validation_valid_key_returns_access_token() -> None:
|
||||
assert result.claims.get(API_KEY_VALIDATED_USERNAME_CLAIM) == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_uses_keys_own_scopes() -> None:
|
||||
"""A key with its own ApiKey.scopes carries them on the AccessToken,
|
||||
parsed from FAB's comma-separated storage format."""
|
||||
mock_app = _make_app_with_api_key(
|
||||
"alice", scopes="superset:dashboard:read, superset:chart:read"
|
||||
)
|
||||
verifier = CompositeTokenVerifier(
|
||||
jwt_verifier=None, api_key_prefixes=["sst_"], app=mock_app
|
||||
)
|
||||
|
||||
result = await verifier.verify_token("sst_valid_key")
|
||||
|
||||
assert result is not None
|
||||
assert result.scopes == ["superset:dashboard:read", "superset:chart:read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_no_key_scopes_remains_unscoped() -> None:
|
||||
"""A key without scopes remains unscoped despite global JWT requirements."""
|
||||
mock_app = _make_app_with_api_key("alice", scopes=None)
|
||||
jwt_verifier = MagicMock()
|
||||
jwt_verifier.required_scopes = ["superset:read"]
|
||||
jwt_verifier.verify_token = AsyncMock()
|
||||
|
||||
verifier = CompositeTokenVerifier(
|
||||
jwt_verifier=jwt_verifier, api_key_prefixes=["sst_"], app=mock_app
|
||||
)
|
||||
|
||||
result = await verifier.verify_token("sst_valid_key")
|
||||
|
||||
assert result is not None
|
||||
assert result.scopes == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_invalid_key_returns_none() -> None:
|
||||
"""An invalid API key is rejected at transport (returns None → HTTP 401)."""
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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 pathlib import Path
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
def test_documented_dashboard_list_schema_matches_public_contract() -> None:
|
||||
"""The published dashboard list contract must use generated relationships."""
|
||||
spec_path = (
|
||||
Path(__file__).parents[3] / "docs" / "static" / "resources" / "openapi.json"
|
||||
)
|
||||
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
||||
schemas = spec["components"]["schemas"]
|
||||
schema_name = "DashboardRestApi.get_list"
|
||||
properties = schemas[schema_name]["properties"]
|
||||
|
||||
result_items = spec["paths"]["/api/v1/dashboard/"]["get"]["responses"]["200"][
|
||||
"content"
|
||||
]["application/json"]["schema"]["properties"]["result"]["items"]
|
||||
assert result_items == {"$ref": f"#/components/schemas/{schema_name}"}
|
||||
|
||||
assert "description" in properties
|
||||
assert {"owners", "roles", "thumbnail_url"}.isdisjoint(properties)
|
||||
|
||||
relationship_refs = {
|
||||
"editors": "DashboardRestApi.get_list.Subject",
|
||||
"tags": "DashboardRestApi.get_list.Tag",
|
||||
"viewers": "DashboardRestApi.get_list.Subject1",
|
||||
}
|
||||
for field_name, component_name in relationship_refs.items():
|
||||
assert properties[field_name] == {
|
||||
"items": {"$ref": f"#/components/schemas/{component_name}"},
|
||||
"type": "array",
|
||||
}
|
||||
assert component_name in schemas
|
||||
|
||||
assert f"{schema_name}.Role" not in schemas
|
||||
assert f"{schema_name}.User2" not in schemas
|
||||
@@ -224,6 +224,69 @@ def test_raise_for_access_guest_user_ok_subset(
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
|
||||
def test_raise_for_access_guest_user_deck_multi_child_requires_child_datasource(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""
|
||||
The deck.gl multi-layer child leg must bind the requested datasource to
|
||||
the child chart: a valid parent/child pair does not authorize querying
|
||||
an arbitrary dataset.
|
||||
"""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
||||
mocker.patch.object(sm, "can_access", return_value=False)
|
||||
mocker.patch.object(sm, "can_access_schema", return_value=False)
|
||||
mocker.patch.object(sm, "is_editor", return_value=False)
|
||||
mocker.patch.object(sm, "can_access_dashboard", return_value=True)
|
||||
mocker.patch.object(sm, "get_current_guest_user_if_guest", return_value=None)
|
||||
mocker.patch(
|
||||
"superset.is_feature_enabled",
|
||||
side_effect=lambda feature: feature == "EMBEDDED_SUPERSET",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.manager.query_context_modified",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
child_datasource = mocker.MagicMock()
|
||||
other_datasource = mocker.MagicMock()
|
||||
|
||||
parent_slc = mocker.MagicMock()
|
||||
parent_slc.params = json.dumps({"viz_type": "deck_multi", "deck_slices": [42]})
|
||||
child_slc = mocker.MagicMock()
|
||||
child_slc.datasource = child_datasource
|
||||
|
||||
dashboard = mocker.MagicMock()
|
||||
dashboard.slices = [parent_slc]
|
||||
|
||||
query_mock = mocker.patch.object(sm.session, "query")
|
||||
query_mock.return_value.filter.return_value.one_or_none.side_effect = [
|
||||
dashboard,
|
||||
parent_slc,
|
||||
child_slc,
|
||||
dashboard,
|
||||
parent_slc,
|
||||
child_slc,
|
||||
]
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
query_context.form_data = {
|
||||
"dashboardId": 10,
|
||||
"slice_id": 42,
|
||||
"parent_slice_id": 41,
|
||||
}
|
||||
|
||||
# Requesting the child's own datasource is allowed.
|
||||
query_context.datasource = child_datasource
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
# The same chart context with any other datasource is rejected.
|
||||
query_context.datasource = other_datasource
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
|
||||
def test_raise_for_access_guest_user_tampered_id(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
@@ -1542,6 +1605,32 @@ def test_query_context_modified_native_filter_arbitrary_saved_metric_blocked(
|
||||
assert query_context_modified(qc)
|
||||
|
||||
|
||||
def test_query_context_modified_native_filter_series_limit_terms_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A series-limit metric or series column beyond the target is modified."""
|
||||
query = SimpleNamespace(
|
||||
columns=["region"],
|
||||
metrics=[],
|
||||
groupby=[],
|
||||
series_columns=["region"],
|
||||
series_limit=5,
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {"column_name": "salary"},
|
||||
"aggregate": "MAX",
|
||||
},
|
||||
)
|
||||
qc = _native_filter_ctx(mocker, [query])
|
||||
assert query_context_modified(qc)
|
||||
|
||||
query = SimpleNamespace(
|
||||
columns=["region"], metrics=[], groupby=[], series_columns=["ssn"]
|
||||
)
|
||||
qc = _native_filter_ctx(mocker, [query])
|
||||
assert query_context_modified(qc)
|
||||
|
||||
|
||||
def test_query_context_modified_native_filter_orderby_arbitrary_column_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for API key scope validation in SupersetSecurityManager.
|
||||
|
||||
Covers the "intersection, never broader" rule: a user must not be able to
|
||||
mint an API key scoped beyond what their own RBAC already permits.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.extensions import appbuilder
|
||||
from superset.security.api_key_scopes import (
|
||||
RESOURCE_SCOPE_ACTIONS,
|
||||
RESOURCE_SCOPE_CLASS,
|
||||
)
|
||||
from superset.security.manager import SupersetSecurityManager
|
||||
|
||||
|
||||
def _make_user(*role_names: str) -> MagicMock:
|
||||
"""Build a mock user whose roles carry the given names."""
|
||||
user = MagicMock()
|
||||
roles = []
|
||||
for role_name in role_names:
|
||||
role = MagicMock()
|
||||
role.name = role_name
|
||||
roles.append(role)
|
||||
user.roles = roles
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sm(app_context: None) -> SupersetSecurityManager:
|
||||
return SupersetSecurityManager(appbuilder)
|
||||
|
||||
|
||||
def test_frontend_scope_catalog_matches_backend_contract() -> None:
|
||||
"""Keep the UI picker aligned with the canonical enforcement vocabulary."""
|
||||
frontend_catalog = (
|
||||
Path(__file__).parents[3]
|
||||
/ "superset-frontend/src/features/apiKeys/apiKeyScopes.ts"
|
||||
).read_text()
|
||||
resources_source = re.search(
|
||||
r"const API_KEY_SCOPE_RESOURCES = \[(.*?)\] as const;",
|
||||
frontend_catalog,
|
||||
re.DOTALL,
|
||||
)
|
||||
actions_source = re.search(
|
||||
r"const API_KEY_SCOPE_ACTIONS = \[(.*?)\] as const;",
|
||||
frontend_catalog,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
assert resources_source is not None
|
||||
assert actions_source is not None
|
||||
assert set(re.findall(r"'([^']+)'", resources_source.group(1))) == set(
|
||||
RESOURCE_SCOPE_CLASS
|
||||
)
|
||||
assert set(re.findall(r"'([^']+)'", actions_source.group(1))) == set(
|
||||
RESOURCE_SCOPE_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
def test_no_scopes_is_a_noop(sm: SupersetSecurityManager) -> None:
|
||||
"""No scopes requested: nothing to validate, no RBAC lookups."""
|
||||
sm._has_view_access = MagicMock()
|
||||
sm._validate_requested_api_key_scopes(_make_user("Gamma"), None)
|
||||
sm._validate_requested_api_key_scopes(_make_user("Gamma"), "")
|
||||
sm._has_view_access.assert_not_called()
|
||||
|
||||
|
||||
def test_per_resource_scope_allowed_when_user_has_permission(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""A per-resource scope the user's RBAC covers is allowed, and is checked
|
||||
against the matching can_<method> grant."""
|
||||
sm._has_view_access = MagicMock(return_value=True)
|
||||
user = _make_user("Gamma")
|
||||
sm._validate_requested_api_key_scopes(user, "superset:dashboard:read")
|
||||
sm._has_view_access.assert_called_once_with(user, "can_read", "Dashboard")
|
||||
|
||||
|
||||
def test_per_resource_scope_rejected_when_user_lacks_permission(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""A per-resource scope beyond the user's RBAC is rejected."""
|
||||
sm._has_view_access = MagicMock(return_value=False)
|
||||
with pytest.raises(ValueError, match="exceeds the issuing user's own"):
|
||||
sm._validate_requested_api_key_scopes(
|
||||
_make_user("Gamma"), "superset:dashboard:write"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scope", "registered_permission"),
|
||||
[
|
||||
("superset:user:read", "can_get"),
|
||||
("superset:role:read", "can_get"),
|
||||
("superset:sqllab:write", "can_execute_sql_query"),
|
||||
],
|
||||
)
|
||||
def test_scope_issuance_uses_runtime_method_mapping(
|
||||
sm: SupersetSecurityManager, scope: str, registered_permission: str
|
||||
) -> None:
|
||||
"""Issuance accepts the FAB method permission used by runtime tools."""
|
||||
user = _make_user("Gamma")
|
||||
sm._has_view_access = MagicMock(
|
||||
side_effect=lambda _user, permission, _view: permission == registered_permission
|
||||
)
|
||||
|
||||
sm._validate_requested_api_key_scopes(user, scope)
|
||||
|
||||
assert any(
|
||||
call.args[1] == registered_permission
|
||||
for call in sm._has_view_access.call_args_list
|
||||
)
|
||||
|
||||
|
||||
def test_custom_admin_role_can_issue_flat_scope(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""Flat-scope issuance honors AUTH_ROLE_ADMIN rather than a fixed name."""
|
||||
with patch("superset.security.manager.get_conf") as get_conf:
|
||||
get_conf.return_value = {"AUTH_ROLE_ADMIN": "PlatformAdmin"}
|
||||
sm._validate_requested_api_key_scopes(
|
||||
_make_user("PlatformAdmin"), "superset:write"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["delete", "update", "garbage"])
|
||||
def test_unrecognized_actions_are_rejected(
|
||||
sm: SupersetSecurityManager, action: str
|
||||
) -> None:
|
||||
"""Actions that runtime enforcement cannot consume are rejected."""
|
||||
sm._has_view_access = MagicMock()
|
||||
with pytest.raises(ValueError, match="unrecognized action"):
|
||||
sm._validate_requested_api_key_scopes(
|
||||
_make_user("Gamma"), f"superset:chart:{action}"
|
||||
)
|
||||
sm._has_view_access.assert_not_called()
|
||||
|
||||
|
||||
def test_unrecognized_resource_slug_rejected_without_rbac_lookup(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""An unknown resource slug is rejected outright (fail closed) and never
|
||||
consults RBAC."""
|
||||
sm._has_view_access = MagicMock()
|
||||
with pytest.raises(ValueError, match="unrecognized resource"):
|
||||
sm._validate_requested_api_key_scopes(
|
||||
_make_user("Admin"), "superset:notathing:read"
|
||||
)
|
||||
sm._has_view_access.assert_not_called()
|
||||
|
||||
|
||||
def test_flat_scope_allowed_for_admin(sm: SupersetSecurityManager) -> None:
|
||||
"""A flat scope (superset:write) may be self-issued by an Admin, with no
|
||||
per-resource RBAC lookups."""
|
||||
sm._has_view_access = MagicMock()
|
||||
sm._validate_requested_api_key_scopes(_make_user("Admin"), "superset:write")
|
||||
sm._has_view_access.assert_not_called()
|
||||
|
||||
|
||||
def test_unrecognized_flat_scope_rejected_for_admin(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""Admins cannot mint undefined flat scopes."""
|
||||
sm._has_view_access = MagicMock()
|
||||
with pytest.raises(ValueError, match="not a recognized"):
|
||||
sm._validate_requested_api_key_scopes(_make_user("Admin"), "superset:garbage")
|
||||
sm._has_view_access.assert_not_called()
|
||||
|
||||
|
||||
def test_flat_scope_rejected_for_non_admin(sm: SupersetSecurityManager) -> None:
|
||||
"""A flat scope grants a method across every resource; non-Admins cannot
|
||||
self-issue it."""
|
||||
sm._has_view_access = MagicMock()
|
||||
with pytest.raises(ValueError, match="requires Admin"):
|
||||
sm._validate_requested_api_key_scopes(_make_user("Gamma"), "superset:write")
|
||||
|
||||
|
||||
def test_any_failing_scope_rejects_the_whole_request(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""With multiple comma-separated scopes, one failure rejects the request
|
||||
even when other scopes are individually allowed."""
|
||||
sm._has_view_access = MagicMock(
|
||||
side_effect=lambda user, perm, view: view == "Chart"
|
||||
)
|
||||
with pytest.raises(ValueError, match="exceeds the issuing user's own"):
|
||||
sm._validate_requested_api_key_scopes(
|
||||
_make_user("Gamma"),
|
||||
"superset:chart:read, superset:dashboard:write",
|
||||
)
|
||||
|
||||
|
||||
def test_create_api_key_rejects_before_delegating_to_fab(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""create_api_key validates scopes BEFORE calling FAB's implementation:
|
||||
a rejected request never reaches FAB."""
|
||||
sm._has_view_access = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"flask_appbuilder.security.sqla.manager.SecurityManager.create_api_key"
|
||||
) as fab_create:
|
||||
with pytest.raises(ValueError, match="exceeds the issuing user's own"):
|
||||
sm.create_api_key(
|
||||
user=_make_user("Gamma"),
|
||||
name="my key",
|
||||
scopes="superset:dashboard:write",
|
||||
)
|
||||
fab_create.assert_not_called()
|
||||
|
||||
|
||||
def test_create_api_key_delegates_to_fab_on_success(
|
||||
sm: SupersetSecurityManager,
|
||||
) -> None:
|
||||
"""A validated request is delegated to FAB's create_api_key unchanged."""
|
||||
sm._has_view_access = MagicMock(return_value=True)
|
||||
user = _make_user("Gamma")
|
||||
with patch(
|
||||
"flask_appbuilder.security.sqla.manager.SecurityManager.create_api_key",
|
||||
return_value={"key": "sst_secret"},
|
||||
) as fab_create:
|
||||
result = sm.create_api_key(
|
||||
user=user,
|
||||
name="my key",
|
||||
scopes="superset:dashboard:read",
|
||||
)
|
||||
fab_create.assert_called_once_with(
|
||||
user=user, name="my key", scopes="superset:dashboard:read", expires_on=None
|
||||
)
|
||||
assert result == {"key": "sst_secret"}
|
||||
@@ -0,0 +1,276 @@
|
||||
# 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.
|
||||
"""Password-change paths and the session-invalidation epoch.
|
||||
|
||||
``UserAttribute.sessions_invalidated_at`` (see
|
||||
``superset.security.session_invalidation``) is the mechanism that forces
|
||||
outstanding sessions to log out. Originally it was stamped exclusively by the
|
||||
``after_update`` listener that fires when an account's ``active`` flag flips
|
||||
to ``False``; these tests now cover the additional password-change paths --
|
||||
self-service reset, admin-initiated reset, and the ``PUT /api/v1/me/``
|
||||
self-service update -- which also stamp that epoch, so a session authenticated
|
||||
before a password change stops working after it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.daos.user import UserDAO
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
from superset.security.manager import SupersetUserApi
|
||||
from superset.views.users.api import CurrentUserRestApi
|
||||
from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401
|
||||
|
||||
|
||||
def _invalidated_at(user_id: int):
|
||||
attr = db.session.query(UserAttribute).filter_by(user_id=user_id).one_or_none()
|
||||
return attr.sessions_invalidated_at if attr else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_admins() -> Iterator[tuple[User, User]]:
|
||||
"""Two admin-role users for the reset_password tests below.
|
||||
|
||||
``SupersetSecurityManager.reset_password`` -> FAB's ``update_user``
|
||||
hard-commits the session (``commit=True`` by default), so the rollback
|
||||
the shared ``after_each``/``admin_user`` fixtures rely on can't undo it.
|
||||
This fixture creates its own users and deletes them again on teardown so
|
||||
a committed reset doesn't leak rows into later tests.
|
||||
"""
|
||||
role = db.session.query(security_manager.role_model).filter_by(name="Admin").one()
|
||||
target = User(
|
||||
first_name="Target",
|
||||
last_name="User",
|
||||
email="session_invalidation_target@example.org",
|
||||
username="session_invalidation_target",
|
||||
roles=[role],
|
||||
)
|
||||
actor = User(
|
||||
first_name="Acting",
|
||||
last_name="Admin",
|
||||
email="session_invalidation_actor@example.org",
|
||||
username="session_invalidation_actor",
|
||||
roles=[role],
|
||||
)
|
||||
db.session.add_all([target, actor])
|
||||
db.session.commit()
|
||||
|
||||
yield target, actor
|
||||
|
||||
db.session.query(UserAttribute).filter(
|
||||
UserAttribute.user_id.in_([target.id, actor.id])
|
||||
).delete(synchronize_session=False)
|
||||
db.session.query(User).filter(User.id.in_([target.id, actor.id])).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_self_service_password_reset_invalidates_other_sessions(
|
||||
two_admins: tuple[User, User],
|
||||
) -> None:
|
||||
"""``SupersetSecurityManager.reset_password`` used for a self-service
|
||||
reset (acting user resets their own password) stamps the session epoch,
|
||||
so any other outstanding session for the account stops working after the
|
||||
password changes.
|
||||
"""
|
||||
target, _actor = two_admins
|
||||
|
||||
with patch("superset.security.manager.g") as mock_g:
|
||||
mock_g.user = target
|
||||
security_manager.reset_password(target.id, "BrandNewPassw0rd!")
|
||||
|
||||
assert _invalidated_at(target.id) is not None
|
||||
|
||||
|
||||
def test_admin_password_reset_invalidates_target_sessions(
|
||||
two_admins: tuple[User, User],
|
||||
) -> None:
|
||||
"""An admin-initiated reset of *another* user's password also stamps the
|
||||
epoch, so the target's outstanding sessions stop working -- this is the
|
||||
closest existing action to an explicit "terminate that user's sessions",
|
||||
short of disabling the account.
|
||||
"""
|
||||
target, actor = two_admins
|
||||
|
||||
with patch("superset.security.manager.g") as mock_g:
|
||||
mock_g.user = actor # differs from target: an admin-initiated reset
|
||||
security_manager.reset_password(target.id, "TemporaryPassw0rd!")
|
||||
|
||||
assert _invalidated_at(target.id) is not None
|
||||
|
||||
|
||||
def test_update_me_password_change_invalidates_other_sessions(
|
||||
admin_user: User, # noqa: F811
|
||||
after_each: None, # noqa: F811
|
||||
) -> None:
|
||||
"""The ``PUT /api/v1/me/`` self-service password change (``pre_update`` +
|
||||
``UserDAO.update`` in ``CurrentUserRestApi.update_me``) also stamps the
|
||||
session-invalidation epoch. ``admin_user`` starts with no password set, so
|
||||
no ``current_password`` proof is required for this change to go through.
|
||||
"""
|
||||
api = CurrentUserRestApi()
|
||||
data = {"password": "BrandNewPassw0rd!"}
|
||||
|
||||
with patch("superset.views.users.api.g") as mock_g:
|
||||
mock_g.user = admin_user
|
||||
api.pre_update(admin_user, data)
|
||||
UserDAO.update(item=admin_user, attributes=data)
|
||||
db.session.flush()
|
||||
|
||||
assert _invalidated_at(admin_user.id) is not None
|
||||
|
||||
|
||||
def test_admin_edit_user_password_via_put_invalidates_target_sessions(
|
||||
after_each: None, # noqa: F811
|
||||
) -> None:
|
||||
"""An admin editing another user's password via ``PUT
|
||||
/api/v1/security/users/<pk>`` (``SupersetUserApi.pre_update``, which FAB's
|
||||
``UserApi.put`` calls before its own commit) must also stamp the target's
|
||||
session-invalidation epoch, the same as the self-service ``/me/`` path and
|
||||
the two password-reset views -- otherwise this admin path is the one way
|
||||
to change a user's password that leaves their other sessions alive.
|
||||
"""
|
||||
role = db.session.query(security_manager.role_model).filter_by(name="Admin").one()
|
||||
user = User(
|
||||
first_name="Target",
|
||||
last_name="User",
|
||||
email="admin_edit_password_target@example.org",
|
||||
username="admin_edit_password_target",
|
||||
roles=[role],
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
api = SupersetUserApi()
|
||||
api.datamodel = SQLAInterface(User, db.session)
|
||||
api.appbuilder = SimpleNamespace(
|
||||
sm=SimpleNamespace(current_user=SimpleNamespace(id=1))
|
||||
)
|
||||
|
||||
api.pre_update(user, {"password": "AdminSetPassw0rd!"})
|
||||
|
||||
assert _invalidated_at(user.id) is not None
|
||||
|
||||
db.session.query(UserAttribute).filter_by(user_id=user.id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.session.query(User).filter_by(id=user.id).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_admin_edit_user_without_password_change_does_not_invalidate_sessions(
|
||||
after_each: None, # noqa: F811
|
||||
) -> None:
|
||||
"""Editing a user through the same endpoint *without* touching the
|
||||
password (e.g. renaming them) must not stamp the epoch -- only an actual
|
||||
password change should force other sessions to log out.
|
||||
"""
|
||||
role = db.session.query(security_manager.role_model).filter_by(name="Admin").one()
|
||||
user = User(
|
||||
first_name="Target",
|
||||
last_name="User",
|
||||
email="admin_edit_no_password_target@example.org",
|
||||
username="admin_edit_no_password_target",
|
||||
roles=[role],
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
api = SupersetUserApi()
|
||||
api.datamodel = SQLAInterface(User, db.session)
|
||||
api.appbuilder = SimpleNamespace(
|
||||
sm=SimpleNamespace(current_user=SimpleNamespace(id=1))
|
||||
)
|
||||
|
||||
api.pre_update(user, {"first_name": "Renamed"})
|
||||
|
||||
assert _invalidated_at(user.id) is None
|
||||
|
||||
db.session.query(User).filter_by(id=user.id).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _make_api_for_target(user: User) -> SupersetUserApi:
|
||||
"""A ``SupersetUserApi`` instance wired to a fake ``datamodel`` that
|
||||
resolves any pk lookup to ``user`` -- enough to exercise
|
||||
``terminate_sessions`` without going through HTTP/auth plumbing, mirroring
|
||||
the pattern used in ``test_superset_user_api_subject_sync.py``.
|
||||
"""
|
||||
api = SupersetUserApi()
|
||||
api.datamodel = SimpleNamespace(
|
||||
session=db.session,
|
||||
obj=User,
|
||||
get=lambda pk, base_filters=None: user,
|
||||
)
|
||||
api._base_filters = None
|
||||
return api
|
||||
|
||||
|
||||
def test_terminate_sessions_action_stamps_target_epoch_without_disabling_account(
|
||||
after_each: None, # noqa: F811
|
||||
) -> None:
|
||||
"""``SupersetUserApi.terminate_sessions`` -- the direct, explicit
|
||||
"terminate this user's sessions" admin action -- stamps the epoch for the
|
||||
target user without flipping ``active`` or otherwise touching the account,
|
||||
unlike the only other action that has this effect (disabling the user).
|
||||
"""
|
||||
role = db.session.query(security_manager.role_model).filter_by(name="Admin").one()
|
||||
user = User(
|
||||
first_name="Target",
|
||||
last_name="User",
|
||||
email="terminate_sessions_target@example.org",
|
||||
username="terminate_sessions_target",
|
||||
roles=[role],
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
with patch.object(security_manager, "has_access", return_value=True):
|
||||
response = _make_api_for_target(user).terminate_sessions(user.id)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _invalidated_at(user.id) is not None
|
||||
assert user.active
|
||||
|
||||
|
||||
def test_terminate_sessions_action_404s_for_unknown_user(
|
||||
after_each: None, # noqa: F811
|
||||
) -> None:
|
||||
"""A pk that doesn't resolve to a user (or is filtered out by
|
||||
``base_filters``) 404s rather than stamping anything.
|
||||
"""
|
||||
api = SupersetUserApi()
|
||||
api.datamodel = SimpleNamespace(
|
||||
session=db.session,
|
||||
obj=User,
|
||||
get=lambda pk, base_filters=None: None,
|
||||
)
|
||||
api._base_filters = None
|
||||
|
||||
with patch.object(security_manager, "has_access", return_value=True):
|
||||
response = api.terminate_sessions(999999)
|
||||
|
||||
assert response.status_code == 404
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user