mirror of
https://github.com/apache/superset.git
synced 2026-08-17 21:51:25 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea29d10839 | ||
|
|
01b00e6c33 | ||
|
|
e0a2e934c3 | ||
|
|
300ed1db84 | ||
|
|
9747222451 | ||
|
|
3d02f373d6 | ||
|
|
8f304064f7 | ||
|
|
f9f1aba40a | ||
|
|
9237b3d96d | ||
|
|
a34e10d18c | ||
|
|
2f996ad84f | ||
|
|
320fb9ef98 | ||
|
|
2d88c198dc | ||
|
|
048cfae595 | ||
|
|
92728169de | ||
|
|
84c371d56e | ||
|
|
a4c47359e6 | ||
|
|
7c0c5283c3 | ||
|
|
9c5bde9491 | ||
|
|
3b99e092d0 | ||
|
|
acf39e3ef0 | ||
|
|
8523ea4d0a | ||
|
|
9bc3173e3a | ||
|
|
a6db0d1cde | ||
|
|
aec567f7d6 | ||
|
|
bacaf08a22 |
+1
-1
@@ -19,7 +19,7 @@
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas
|
||||
/docs/developer_docs/extensions/ @michael-s-molina @villebro @rusackas
|
||||
/superset-extensions-cli/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset-frontend/src/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
|
||||
@@ -167,7 +167,7 @@ The Developer Portal auto-generates MDX documentation from Storybook stories. **
|
||||
### Generator Location
|
||||
- Script: `docs/scripts/generate-superset-components.mjs`
|
||||
- Wrapper: `docs/src/components/StorybookWrapper.jsx`
|
||||
- Output: `docs/developer_portal/components/`
|
||||
- Output: `docs/developer_docs/components/`
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
|
||||
+1
-1
@@ -35,4 +35,4 @@ The Developer Portal includes comprehensive guides for:
|
||||
- [Code Review Process](https://superset.apache.org/developer_portal/contributing/code-review)
|
||||
- [Development How-tos](https://superset.apache.org/developer_portal/contributing/howtos)
|
||||
|
||||
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_portal).
|
||||
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_docs).
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: Dashboard Performance
|
||||
hide_title: true
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Dashboard Performance
|
||||
|
||||
A dashboard's perceived speed is determined by three independent things: how
|
||||
many charts have to render, how many queries the backend can execute
|
||||
concurrently, and how quickly the underlying data warehouse can return
|
||||
results. Superset gives you levers for the first two; the third belongs to
|
||||
your warehouse. This page covers the dashboard-side levers and the practical
|
||||
guidance around them.
|
||||
|
||||
## Is there a maximum chart count per dashboard?
|
||||
|
||||
**No hard limit is enforced** — Superset has no configuration key that
|
||||
caps the number of charts on a dashboard. In practice, dashboards behave
|
||||
well up to a few dozen charts. Beyond that, you'll typically feel friction
|
||||
on the initial load and during cross-filter / time-range updates, even with
|
||||
the lazy-loading optimizations described below.
|
||||
|
||||
Rough thresholds to keep in mind:
|
||||
|
||||
- **Under ~25 charts**: usually no perceptible problem.
|
||||
- **25–50 charts**: still fine, but you start to want tabs to break the
|
||||
page into chunks the user actually looks at.
|
||||
- **Over ~50 charts**: split into multiple dashboards or use tabs
|
||||
aggressively. The bottleneck is rarely Superset itself — it's the
|
||||
warehouse executing dozens of queries in parallel and the browser
|
||||
rendering dozens of chart frames.
|
||||
|
||||
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
|
||||
charts hitting a fast cache behaves very differently from a dashboard of
|
||||
20 heavy aggregations against a cold warehouse.
|
||||
|
||||
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
|
||||
|
||||
Superset's dashboard layout is virtualized at the row level. Charts that
|
||||
are far below the user's current scroll position render a placeholder
|
||||
instead of their visualization until the user scrolls them into view, and
|
||||
go back to a placeholder if scrolled well past. The chart component itself
|
||||
stays mounted throughout — only the visualization is swapped for a
|
||||
placeholder — so this alone does **not** reduce backend query load; see
|
||||
[Deferred data fetch](#deferred-data-fetch--dashboard_virtualization_defer_data)
|
||||
below for that. This is on by default.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
|
||||
|
||||
The flag is `stable` and marked for path-to-deprecation — meaning the
|
||||
behavior will eventually be non-optional, but the flag still exists so
|
||||
operators can disable it if a specific layout misbehaves.
|
||||
|
||||
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
|
||||
|
||||
- A chart's visualization is rendered when its row scrolls within **1
|
||||
viewport height** of the visible area.
|
||||
- A chart's visualization is swapped back for a placeholder when its row
|
||||
scrolls more than **4 viewport heights** away from the visible area.
|
||||
- Tabs that aren't currently selected don't render their content at all
|
||||
(see below).
|
||||
- The placeholder-swap-back is skipped in **embedded** mode (so an
|
||||
embedded dashboard keeps its charts rendered once they've been seen,
|
||||
which avoids re-rendering on scroll-up). Both halves are skipped for
|
||||
**headless / bot** rendering (so screenshot / report jobs load every
|
||||
chart).
|
||||
|
||||
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
|
||||
By default, `DASHBOARD_VIRTUALIZATION` only controls whether a chart's
|
||||
*visualization* is rendered — the chart component still mounts and issues
|
||||
its data request immediately, regardless of scroll position.
|
||||
`DASHBOARD_VIRTUALIZATION_DEFER_DATA` is a supplementary flag that skips
|
||||
the data request itself for charts that aren't currently in view, useful
|
||||
for backends where opening a connection or compiling a query is expensive
|
||||
even if the result would be thrown away. It only has an effect when
|
||||
`DASHBOARD_VIRTUALIZATION` is also enabled — with virtualization off,
|
||||
every chart is treated as in view, so there's nothing left to defer.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
|
||||
|
||||
Enable this if you see warehouse load spike on dashboard *open* even
|
||||
though most charts are off-screen.
|
||||
|
||||
## Per-tab lazy loading
|
||||
|
||||
**This is on by default and has no flag.** A tab's content is not rendered
|
||||
until the user activates that tab, so charts inside an unselected tab do
|
||||
not fetch data on dashboard open. When the user clicks the tab, that
|
||||
tab's charts mount and fetch in the normal way.
|
||||
|
||||
Practically: tabs are the single most effective tool for a large
|
||||
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
|
||||
open into "load ~15 charts," and the remaining ones lazy-load only if the
|
||||
user goes looking.
|
||||
|
||||
## Is there a switch to cap concurrent chart queries?
|
||||
|
||||
**No.** Superset does not implement a frontend-side concurrent-request
|
||||
limiter. Each chart issues its own data request when it mounts, and the
|
||||
browser handles parallelism — typically ~6 in-flight requests per origin
|
||||
under HTTP/1.1, though HTTP/2 or HTTP/3 (if your deployment terminates
|
||||
TLS that way) can multiplex considerably more over a single connection.
|
||||
Backend throughput is bounded by your
|
||||
Gunicorn worker count for synchronous query execution, or by your Celery
|
||||
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
|
||||
|
||||
If you need to throttle warehouse load, the right place is:
|
||||
|
||||
1. The warehouse itself (connection pool / concurrency limits).
|
||||
2. Superset's Celery configuration (smaller worker pool when async
|
||||
queries are on).
|
||||
3. Splitting heavy charts across tabs or separate dashboards (each
|
||||
dashboard load only fetches what's visible).
|
||||
|
||||
## Splitting strategies
|
||||
|
||||
When a dashboard outgrows comfortable performance, the options in order
|
||||
of effort:
|
||||
|
||||
**1. Move sections into tabs.** Same dashboard, but only the active tab's
|
||||
charts fetch. This is the cheapest change and often the only one needed.
|
||||
|
||||
**2. Cache aggressively.** A Redis cache backend (see
|
||||
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
|
||||
rather than re-hitting the warehouse. This is especially impactful for
|
||||
dashboards opened by many users in close succession.
|
||||
|
||||
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
|
||||
via Celery decouples query duration from request lifetime, so a slow
|
||||
chart doesn't block the page. The user sees other charts come in as
|
||||
their queries complete.
|
||||
|
||||
**4. Split into multiple dashboards.** Group related charts into purpose-
|
||||
specific dashboards rather than one mega-dashboard. Link them from a
|
||||
landing dashboard or a navigation menu.
|
||||
|
||||
**5. Pre-aggregate at the warehouse level.** If the same expensive
|
||||
aggregation appears across many charts, materialize it as a view or
|
||||
scheduled table in the warehouse so each chart query is a cheap lookup.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The feature flags above are set in `superset_config.py`, e.g.:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"DASHBOARD_VIRTUALIZATION": True,
|
||||
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
|
||||
}
|
||||
```
|
||||
|
||||
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
|
||||
flags and their lifecycle stages.
|
||||
- Server-side screenshot jobs (alerts, scheduled reports, thumbnails)
|
||||
render the dashboard in a headless, webdriver-controlled browser, which
|
||||
intentionally bypasses row virtualization so the rendered artifact
|
||||
includes every chart, not just the ones above the fold. User-triggered
|
||||
"download as image/PDF" is different: it captures whatever's currently
|
||||
rendered in the user's own browser, so it's still subject to
|
||||
virtualization like any other page view. Metadata/YAML dashboard export
|
||||
doesn't render the frontend at all, so virtualization doesn't apply to
|
||||
it either.
|
||||
@@ -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
|
||||
|
||||
@@ -519,6 +519,30 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install
|
||||
|
||||
For those interested, you may also try out [avn](https://github.com/nvm-sh/nvm#deeper-shell-integration) to automatically switch to the node version that is required to run Superset frontend.
|
||||
|
||||
##### zstd
|
||||
|
||||
`npm run dev-server` proxies requests to your local Superset server and rewrites the HTML it returns, so it has to decompress responses sent with `Content-Encoding: zstd`. It does that with [`simple-zstd`](https://www.npmjs.com/package/simple-zstd), which wraps the system `zstd` binary instead of bundling one. That binary has to be on your `PATH`:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install zstd
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install zstd
|
||||
|
||||
# Windows
|
||||
choco install zstd
|
||||
```
|
||||
|
||||
`simple-zstd` looks for the binary when it is first imported, not when a response is decompressed, so a missing `zstd` stops the dev server at startup with:
|
||||
|
||||
```
|
||||
Error: Can not access zstd! Is it installed?
|
||||
at Object.<anonymous> (.../node_modules/simple-zstd/dist/src/index.js:102:11)
|
||||
```
|
||||
|
||||
The message names the dependency, but it surfaces from inside `webpack.proxy-config.js` while the webpack config is loading, which reads like a build-tooling failure rather than a missing system package.
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
Install third-party dependencies listed in `package.json` via:
|
||||
|
||||
@@ -198,7 +198,7 @@ Each component should come with its dedicated storybook file.
|
||||
|
||||
**One component per story:** Each storybook file should only contain one component unless substantially different variants are required
|
||||
|
||||
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/src/components/Select/Select.stories.tsx` as an example.
|
||||
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/packages/superset-ui-core/src/components/Select/Select.stories.tsx` as an example.
|
||||
|
||||
**Isolated state:** The storybook should show how the component works in an isolated state and with as few dependencies as possible
|
||||
|
||||
|
||||
+4
-4
@@ -58,12 +58,12 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.6",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.6",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
+26
-26
@@ -4095,23 +4095,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.6.tgz#445d4e0992a0862a22bffcea321ee2cb034846b5"
|
||||
integrity sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==
|
||||
"@storybook/addon-docs@^10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
|
||||
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.6"
|
||||
"@storybook/csf-plugin" "10.5.7"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.6"
|
||||
"@storybook/react-dom-shim" "10.5.7"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz#9fca28f5fd7d545a32638bb4f08902f6887072b2"
|
||||
integrity sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==
|
||||
"@storybook/csf-plugin@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
|
||||
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4125,10 +4125,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.6":
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz#3685605c9dd27298fada7fef264801b2e7d62cbb"
|
||||
integrity sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==
|
||||
"@storybook/react-dom-shim@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
|
||||
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -6164,10 +6164,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.3:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.3.tgz#3c7d2ec4a20be116f72b7fbbdb4497d65ffd6dae"
|
||||
integrity sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==
|
||||
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==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6745,10 +6745,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.30001806:
|
||||
version "1.0.30001806"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz#1bc8e502b723fa393455dfbedd5ccec0c29bb74e"
|
||||
integrity sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==
|
||||
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==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -14765,10 +14765,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.6:
|
||||
version "10.5.6"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.6.tgz#c91f22f617f3718dd06c58c87b46ff66f4ce7bf5"
|
||||
integrity sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==
|
||||
storybook@^10.5.7:
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
|
||||
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
|
||||
Generated
+94
-85
@@ -81,7 +81,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -116,7 +116,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"query-string": "9.4.1",
|
||||
"query-string": "9.5.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
@@ -180,9 +180,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.6",
|
||||
"@storybook/addon-links": "10.5.6",
|
||||
"@storybook/react-webpack5": "10.5.6",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -235,7 +235,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.6",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -266,13 +266,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.9",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -10741,16 +10741,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.6.tgz",
|
||||
"integrity": "sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
|
||||
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.6",
|
||||
"@storybook/csf-plugin": "10.5.7",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.6",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -10761,7 +10761,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10770,9 +10770,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz",
|
||||
"integrity": "sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
|
||||
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10785,7 +10785,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10805,9 +10805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
|
||||
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10819,7 +10819,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10831,9 +10831,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.6.tgz",
|
||||
"integrity": "sha512-pw+OS/wUZ4ijdVGOsE5QOt59+C2i4fwtFBs2ircB7KMlwEE7gslovZEjnrz8bbaznvmoLoYGWYZxXcLi+bYmzg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
|
||||
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10846,7 +10846,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10940,15 +10940,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.6.tgz",
|
||||
"integrity": "sha512-UdsC+IrZHBAtEvvDkfCPhg5sy5jnJAHT4RS3I8wNHVJ+93gaUrZLElSIV+w5UB1u/yghoqmQm7/LfpPpZrjPZQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.6",
|
||||
"@storybook/preset-react-webpack": "10.5.6",
|
||||
"@storybook/react": "10.5.6"
|
||||
"@storybook/builder-webpack5": "10.5.7",
|
||||
"@storybook/preset-react-webpack": "10.5.7",
|
||||
"@storybook/react": "10.5.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10957,7 +10957,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10967,13 +10967,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.6.tgz",
|
||||
"integrity": "sha512-uWo/MzNC6HXMEpy8QQfbeYh1j6aOC6Ly0sAR6RE0LPvpyGEWC0VaVOBERIJJBjeuPuonDBVKfEjh1iUvZhJopg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.6",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -10995,7 +10995,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11004,9 +11004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11017,17 +11017,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-QPUl2t+0VIp1Wy7JfqvV8cI1NrULUt+XFMKdIaNp39TuyMn3El4txvmxQWKhcYvnSOEzQ2SGNDqSWcJISwmeAA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.6",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -11044,7 +11044,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11053,9 +11053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
|
||||
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11066,18 +11066,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.6.tgz",
|
||||
"integrity": "sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
|
||||
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.6",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -11090,7 +11090,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -11106,9 +11106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
|
||||
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11120,7 +11120,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -15214,9 +15214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.3.tgz",
|
||||
"integrity": "sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==",
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.4.tgz",
|
||||
"integrity": "sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
@@ -18913,9 +18913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/decode-uri-component": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
|
||||
"integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz",
|
||||
"integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
@@ -20522,9 +20522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz",
|
||||
"integrity": "sha512-uOXhNkIH+iTdyViSmWnCrwtapasL57M3nq5yfST1H7y9djRLyuAIfNcf9cPBedc2G1oqI8jn3up/VHdN3y3Btw==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20533,7 +20533,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.6"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -28748,9 +28748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.0.tgz",
|
||||
"integrity": "sha512-WEbvl2ju0MUZ+R83HeCosmJBTyYdhmFcajeQ7kwLyJ0EHUw9YG/k2QLcMmAQ8sXZpkWq1BbmfjT5lh/oInOnCw==",
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
|
||||
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -33668,12 +33668,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.1.tgz",
|
||||
"integrity": "sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==",
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.0.tgz",
|
||||
"integrity": "sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.4.1",
|
||||
"decode-uri-component": "^0.5.0",
|
||||
"filter-obj": "^5.1.0",
|
||||
"split-on-first": "^3.0.0"
|
||||
},
|
||||
@@ -37991,9 +37991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.6.tgz",
|
||||
"integrity": "sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -39966,9 +39966,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.9",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
|
||||
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
|
||||
"version": "4.23.10",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz",
|
||||
"integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43353,6 +43353,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -43936,7 +43945,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"antd": "^6.5.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -185,7 +185,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -193,7 +193,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"query-string": "9.4.1",
|
||||
"query-string": "9.5.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
@@ -257,9 +257,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.6",
|
||||
"@storybook/addon-links": "10.5.6",
|
||||
"@storybook/react-webpack5": "10.5.6",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.6",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,13 +343,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.6",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.9",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Reduces a webpack `--json` stats file down to the handful of headline
|
||||
// numbers worth tracking over time, in the flat array format
|
||||
// benchmark-action/github-action-benchmark expects for its
|
||||
// "customSmallerIsBetter" tool. The full stats file also includes a
|
||||
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
|
||||
// useful for this purpose, so we only ever read `entrypoints`.
|
||||
//
|
||||
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
|
||||
// `preamble`, `theme`, and `service-worker` are small, low-variance
|
||||
// infrastructure chunks, not where bundle bloat actually shows up.
|
||||
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
|
||||
|
||||
function entrypointSizeByExt(entrypoint, ext) {
|
||||
return (entrypoint.assets || [])
|
||||
.filter(asset => asset.name.endsWith(ext))
|
||||
.reduce((total, asset) => total + asset.size, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const statsPath = process.argv[2];
|
||||
if (!statsPath) {
|
||||
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
||||
const { entrypoints } = stats;
|
||||
if (!entrypoints) {
|
||||
console.error(
|
||||
'stats.json has no `entrypoints` key -- was it built with ' +
|
||||
'`BUNDLE_SIZE_STATS=true` set? Without it, webpack.config.js uses ' +
|
||||
'`stats: "minimal"`, which omits `entrypoints`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
TRACKED_ENTRYPOINTS.forEach(name => {
|
||||
const entrypoint = entrypoints[name];
|
||||
if (!entrypoint) {
|
||||
console.error(`stats.json is missing the "${name}" entrypoint`);
|
||||
process.exit(1);
|
||||
}
|
||||
results.push({
|
||||
name: `${name} entrypoint (JS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.js'),
|
||||
});
|
||||
results.push({
|
||||
name: `${name} entrypoint (CSS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.css'),
|
||||
});
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };
|
||||
@@ -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')
|
||||
}
|
||||
/>
|
||||
</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.',
|
||||
);
|
||||
@@ -66,6 +66,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:
|
||||
@@ -115,19 +120,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.
|
||||
|
||||
@@ -143,8 +153,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
|
||||
@@ -156,12 +171,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:
|
||||
@@ -179,7 +203,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):
|
||||
@@ -223,12 +255,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__,
|
||||
@@ -237,7 +277,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__,
|
||||
)
|
||||
|
||||
@@ -355,8 +395,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:
|
||||
@@ -369,7 +414,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
|
||||
@@ -383,9 +427,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(
|
||||
@@ -400,7 +452,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,
|
||||
@@ -463,7 +517,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,
|
||||
@@ -476,10 +530,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}"
|
||||
@@ -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
|
||||
@@ -4926,6 +4927,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"}
|
||||
Reference in New Issue
Block a user