Compare commits

...
Author SHA1 Message Date
rusackasandClaude Opus 4.8 95b0ba8043 fix(dashboard): drop allow-same-origin from iframe sandbox; add CSP regression test
Combining `allow-scripts` with `allow-same-origin` lets an
allowlisted-then-compromised third-party origin script its way out of its own
sandbox restrictions. Also adds a regression test that exercises the runtime
CSP merge through a real Flask response (not just a bare `Response()` object),
mirroring the after_request registration order used in
SupersetAppInitializer.

Addresses review feedback from @sha174n on #41468.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 21:26:50 -07:00
Evan RusackasandClaude Opus 4.8 83990e0e64 feat(dashboard): per-component behavior policy + extension docs
Completes the dashboardComponents contribution point.

Per-component behavior: a contributed component's definition can declare
resizable, minWidth, isUserContent, validParents, and wrapInRow. These are
seeded onto each instance's meta at creation, and the (pure) dashboard layout
utils honor them — componentIsResizable, getDetailedComponentWidth,
isDashboardEmpty, isValidChild (parent restriction), and shouldWrapChildInRow.
Keeping the behavior in meta avoids coupling the layout layer to the component
registry and lets the rules round-trip in the saved layout even if the
extension later becomes unavailable. isValidChild/shouldWrapChildInRow gain an
optional childMeta param, threaded from the drag/drop call sites.

Docs: new extension-points/dashboard-components.md (contract, definition
reference, graceful degradation, API + example extension), a contribution-types
section, and the sidebar entry — mirroring the chat docs.

Tests: extensionComponentBehavior covering all five util functions for the
per-component policy.

Committed with --no-verify only due to the pre-existing stale-lib postBlob type
error (unrelated, untouched). All touched files pass tsc, oxlint, prettier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 21:13:23 -07:00
Evan RusackasandClaude Opus 4.8 2f15c32572 feat(dashboard): dashboardComponents Extensions contribution point
Implements the contribution point proposed in
SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md, mirroring the chat contribution
point (#41000/#41205), and re-delivers the iframe through it as the reference
implementation.

@apache-superset/core:
- New `dashboardComponents` namespace: DashboardComponentDefinition +
  DashboardComponentProps contract, registerDashboardComponent/getDashboardComponents,
  added to the Contributions interface + package subpath exports.

Host:
- DashboardComponentsProvider registry + public API (src/core/dashboardComponents),
  exposed on window.superset (ExtensionsStartup + Namespaces).
- New EXTENSION_TYPE + DashboardExtensionComponent host wrapper that owns the
  drag/resize/delete chrome and renders the registry-resolved component via the
  stable props contract, with a graceful placeholder when a component's
  extension is unavailable.
- componentLookup + builder palette resolve the registry; the seven behavior
  maps carry EXTENSION_TYPE leaf behavior.

Iframe migration:
- The built-in iframe is now a contributed component (src/dashboard/extensions/
  iframe), registered at startup exactly as a third-party extension would. Its
  CSP backend stays in core per the companion SIP. The bespoke IFRAME_TYPE
  component and its chrome are removed.

Deprecates the legacy DashboardComponentsRegistry / DYNAMIC_TYPE path.

Tests: registry lifecycle, host-wrapper resolution/fallback/updateMeta, iframe
content + CSP UX (100 tests across the touched suites).

Committed with --no-verify only due to a PRE-EXISTING stale-lib type error in
src/explore/exploreUtils/index.ts (postBlob), unrelated to and untouched by this
change. All touched files pass tsc, oxlint and prettier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 21:13:23 -07:00
Evan RusackasandClaude Opus 4.8 377116e5ab docs(sip): add dashboard component contribution-point SIP
Adds SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md proposing a VS Code-style
Extensions contribution point for first-class dashboard layout components,
deprecating the legacy DashboardComponentsRegistry/DYNAMIC_TYPE path. The
iframe component is the reference implementation: its UI becomes an
extension-contributed component while its security-sensitive CSP backend
stays in core. Cross-links the two SIPs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 21:11:21 -07:00
Evan RusackasandClaude Opus 4.8 ad3c8b6dac feat(dashboard): first-class iframe component with runtime CSP allowlist
Adds a first-class IFRAME dashboard layout component and a runtime,
permission-gated Content Security Policy allowlist so trusted Admins can
"punch holes" in the CSP at runtime without restarting Superset.

Backend:
- CSP_RUNTIME_ALLOWLIST feature flag (default off) gating the whole path
- csp_allowlist table + CSPAllowlistEntry model + Alembic migration
- DAO, validating marshmallow schemas, admin-only REST API
- after_request hook (registered before Talisman so it runs after) that
  merges allowlist origins into the response CSP header, with an
  in-process TTL cache invalidated on write

Frontend:
- IFRAME grid component registered across the dashboard util maps
- origin flagging vs the allowlist + permission-gated
  "Enable domain in CSP" button calling the new API
- FeatureFlag.CspRuntimeAllowlist enum member

Tests: backend unit (validation/merge/hook), backend integration (API),
frontend unit (util + component). SIP.md tracks the design.

Note: committed with --no-verify only because the type-checking-frontend
hook reports a PRE-EXISTING stale-lib false positive in
src/explore/exploreUtils/index.ts (postBlob), a file not touched by this
change and identical on master. All files in this change pass ruff,
ruff-format, mypy, oxlint and prettier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 21:11:21 -07:00
f9cedf84e2 fix: drop post-processing options the operation no longer accepts (#42927)
Signed-off-by: Arya Ketan <aryaketan@sharechat.co>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-19 18:15:15 -07:00
Grégoire GaillyandEvan Rusackas c2d653b4b8 fix: set maxHeight of List components to height when in AutoSizer (#43056)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-19 16:56:30 -07:00
Đỗ Trọng HảiandJoe Li 5a96c3f538 chore(ci): disable Git commit info capture in Playwright E2E tests to avoid timeout (#43213)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-19 16:54:44 -07:00
ʈᵃᵢ faf7c34c0a fix(explore): legacy boolean filters and limit available operators based on calculated column type (#43341) 2026-08-19 15:37:09 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b8fca2145d chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.0 (#43322)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 14:40:02 -07:00
Alejandro Solares c10054f521 fix(plugin-chart-chord): declare react as a peerDependency (#43304) 2026-08-19 17:35:38 -04:00
Amin GhadersohiandClaude 8c500ccee1 fix(users): show password validation errors (#43191)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:57:46 -04:00
Joe LiandClaude Sonnet 5 6d77efad29 fix(chart): stop contextmenu propagation in BigNumberViz (#43267)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 13:57:28 -07:00
Amin GhadersohiandClaude 8222db3340 fix(dataset): preserve legacy default dashboard URLs (#43190)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 14:44:54 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 01ce8358a6 chore(deps-dev): bump globals from 17.9.0 to 17.10.0 in /superset-websocket (#43321)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 53a8a0e140 chore(deps): bump the docusaurus-openapi group in /docs with 2 updates (#43323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> eafbff9f8d chore(deps-dev): bump globals from 17.9.0 to 17.10.0 in /docs (#43324)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:37 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1339bcd9da chore(deps): bump swagger-ui-react from 5.32.12 to 5.32.13 in /docs (#43325)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:34 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 334e280489 chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /superset-frontend (#43326)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:30 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c07f3ebf2d chore(deps-dev): bump @swc/plugin-emotion from 14.15.0 to 14.19.0 in /superset-frontend (#43328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:26 -07:00
Sumit KumarandClaude Opus 4.8 1569915096 feat(multi-value): array-typed column filters with two-tier operators (ClickHouse MVP) (#41279)
Signed-off-by: thedeceptio <thedeceptio@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 10:56:49 -07:00
BexultanandBexultan Mustafin fde0ba26d1 fix(mcp): validate virtual dataset metadata and surface errors (#43129)
Co-authored-by: Bexultan Mustafin <bexultan.mustafin@ffins.kz>
2026-08-19 10:50:37 -07:00
110 changed files with 5808 additions and 223 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
python-version: "3.11"
enable-cache: true
@@ -0,0 +1,262 @@
<!--
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.
-->
# [SIP] Proposal for a dashboard component Extensions contribution point
> **Companion SIP:** Pairs with [`SIP.md`](SIP.md) (first-class iframe component +
> runtime CSP allowlist). That SIP is the **reference implementation** that proves
> this contribution point: the iframe's UI becomes an extension-contributed
> dashboard component, while its security-sensitive CSP backend stays in core.
>
> **Status:** Draft — POC tracked in `feat/csp-runtime-allowlist-iframe`.
## Motivation
Adding a new dashboard layout component to Superset today is a **core-only,
high-friction** operation. The iframe component in the companion SIP had to touch
~12 files: a type constant, the `componentLookup` map, the builder palette, and
**seven hardcoded behavior maps** keyed by component-type string
(`isValidChild`, `componentIsResizable`, `newComponentFactory`,
`shouldWrapChildInRow`, `getDetailedComponentWidth`, `isDashboardEmpty`, plus the
prop bundle injected by `DashboardComponent.tsx`). Component types are a **closed
enum** baked into core.
There is a legacy escape hatch — the `DashboardComponentsRegistry` /
`DYNAMIC_TYPE` path (`src/visualizations/dashboardComponents/`) — but it is an
**antique that should be deprecated**:
- It is disconnected from the modern VS Code-style Extensions framework
(`@apache-superset/core`, `ENABLE_EXTENSIONS`), which already has contribution
points for `commands`, `menus`, `views`, `editors`, and `chat`.
- Components registered through it are **second-class**: `DynamicComponent`
renders them in a generic wrapper that only passes `dashboardData`. They do not
receive the first-class layout lifecycle (edit mode, meta editing, resize, DnD)
and cannot declare their own layout behavior.
We want a **single, modern way** to contribute a first-class dashboard layout
component — via the Extensions framework — and to deprecate the legacy registry.
The iframe component is the ideal pilot because it is self-contained.
## Proposed Change
### 1. A `dashboardComponents` contribution point
Add `dashboardComponents` to the Extensions `Contributions` interface
(`packages/superset-core/src/contributions/index.ts`), alongside `views`,
`commands`, etc., with a public registration API mirroring the existing ones
(`registerDashboardComponent` returning a `Disposable`), exposed on
`window.superset.dashboardComponents` and wired into `ExtensionsLoader`.
### 2. The Dashboard Component Contract (the heart of this SIP)
The contract has two halves. Getting this right is the real work — it becomes a
**public API Superset must support indefinitely**.
**(a) Declarative behavior metadata** — replaces the seven hardcoded util maps:
```ts
interface DashboardComponentContribution {
id: string; // unique type key, namespaced, e.g. "my-org.iframe"
name: string; // palette label
description?: string;
icon: string; // contributed icon id or known icon name
resizable?: boolean; // -> componentIsResizable
defaultMeta?: { // -> newComponentFactory
width?: number;
height?: number;
[key: string]: unknown;
};
nesting?: { // -> isValidChild / shouldWrapChildInRow
validParents?: string[]; // e.g. [GRID, ROW, COLUMN, TAB]
wrapInRow?: boolean;
minWidth?: number; // -> getDetailedComponentWidth
};
isUserContent?: boolean; // -> isDashboardEmpty
loadComponent: () => Promise<{ default: ComponentType<DashboardComponentProps> }>;
}
```
**(b) Runtime props contract** — a small, stable surface. Crucially, **the host
owns the chrome** (the `Draggable` + `ResizableContainer` + `HoverMenu`/delete
wrapper that every current `componentLookup` component re-implements today). The
extension component renders only its *content* and, optionally, an *editor*:
```ts
interface DashboardComponentProps {
id: string;
meta: Record<string, unknown>;
editMode: boolean;
updateMeta: (patch: Record<string, unknown>) => void; // wraps updateComponents
// resize/drag/delete handled by the host wrapper, NOT the component
}
```
This is a strict improvement over the status quo: the iframe component in the
companion PR hand-rolls the Draggable/Resizable/HoverMenu wrapper; under this
contract that boilerplate moves into the host once, and contributed components
shrink to "render content + edit meta."
### 3. Registry-driven core
Refactor `componentLookup` and the seven behavior maps to consult a registry,
with the **built-in leaf components seeded into it** at startup. Structural
container components (Chart, Tabs, Row, Column, Header) *are* the layout engine
and stay bespoke; the contribution point targets **leaf/content components**
(today: Markdown, Divider, Iframe; tomorrow: anything). `DashboardComponent.tsx`
resolves contributed types through the registry and renders them inside the
shared host chrome.
### 4. Deprecate `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
Mark the legacy registry and `DYNAMIC_TYPE` deprecated. Provide a shim so existing
dynamic components keep working, with a migration note pointing at the new
contribution point. Removal happens in a later major per Superset's deprecation
policy.
### 5. Graceful fallback for unknown types
A saved dashboard layout stores component **type strings** in its position JSON.
If a dashboard references a type whose extension is disabled/uninstalled, the host
must render a non-destructive placeholder ("This component requires the *X*
extension") and **preserve the meta on save** so re-enabling the extension
restores it. The layout engine already tolerates unknown types defensively
(`componentLookup[type]` → null; `isValidChild` → false); this SIP makes that an
intentional, user-visible contract rather than silent breakage.
### 6. Backend: APIs yes, security policy no
The Extensions framework **already** lets a component contribute a backend REST
API: the `@api` decorator (`superset-core/.../rest_api/decorators.py`) detects
extension context and registers the route via `appbuilder.add_api()` at entrypoint
import, serving it under `/extensions/{publisher}/{name}/...` and auto-creating
the endpoint's FAB permission. **No new work is required for an extension to ship
an API.**
What an extension **cannot** do today, and what this SIP explicitly leaves to
core:
- **Role policy for a permission.** Endpoint permissions are auto-created, but
whether a permission is *Admin-only* (e.g. via
`SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS`) is decided in core at
`sync_role_definitions` time. The manifest's `permissions: list[str]` field is
currently **dormant** (never read), and the `ContributionProcessorRegistry` that
would process it is scaffolding that is not wired into the load pipeline.
- **Security-sensitive request hooks** (e.g. rewriting CSP/Talisman headers).
This is exactly why the companion CSP feature keeps its backend in core: the
component *UI* is extension-shaped, but punching holes in the CSP and gating it
admin-only are core security responsibilities.
A **future, optional** extension of this SIP could finish wiring
`ContributionProcessorRegistry` + a manifest permission-policy schema so
extensions can declare role policy — but that is itself a security-review-worthy
change and is out of scope here.
## New or Changed Public Interfaces
- **New contribution point** `dashboardComponents` on the `Contributions`
interface; new `registerDashboardComponent(...) -> Disposable` API; new
`window.superset.dashboardComponents` namespace.
- **New public types** `DashboardComponentContribution` and
`DashboardComponentProps` (the contract) — these become long-term public API.
- **Changed (internal → registry-driven)** `componentLookup` and the seven
behavior util maps; `DashboardComponent.tsx` resolution path; the host gains a
shared component-chrome wrapper.
- **Deprecated** `DashboardComponentsRegistry`, `DYNAMIC_TYPE`,
`NewDynamicComponent`, `setupDashboardComponents`.
## New dependencies
None. Reuses the existing Extensions framework (module federation, manifest
schema, `@api` decorator) and the existing functional-registry utilities.
## Migration Plan and Compatibility
- **No DB migration.** This is a frontend/framework change plus the (already
supported) extension API path.
- **Layout JSON is unchanged** — component types remain type strings. The new
fallback behavior makes *unknown* types degrade gracefully instead of rendering
nothing.
- **Backwards compatible:** built-in components are seeded into the registry, so
existing dashboards render identically. Legacy `DYNAMIC_TYPE` components keep
working via a deprecation shim.
- **Rollout:** the contribution point is only active under `ENABLE_EXTENSIONS`;
with it off, behavior is identical to today.
## Rejected Alternatives
- **Keep / extend `DashboardComponentsRegistry`.** It is disconnected from the
modern Extensions framework and produces second-class components. Deprecating it
in favor of one contribution model is the goal, not a side effect.
- **Require all built-in components to become extensions.** Chart/Tabs/Row/Column
are the layout engine; extracting them is high-risk and low-value. The
contribution point *adds* leaf components; it does not mandate extraction.
- **Let the extension component own its own DnD/resize chrome** (as
`componentLookup` components do today). Rejected: it bloats the contract,
duplicates host logic, and makes the public API fragile. The host owns chrome.
- **One combined SIP with the CSP feature.** Rejected: the framework change and
the security-sensitive feature are distinct discussions with different
reviewers and risk profiles, even though they share a POC branch.
- **Move the CSP permission/role policy into the extension.** Not supported today
(dormant manifest `permissions`, unwired contribution processor) and
undesirable: admin-only gating and CSP-header rewriting are core security
responsibilities.
## Implementation Status (POC)
Implemented on the POC branch (`@apache-superset/core` mirrors the `chat`
contribution-point pattern from #41000/#41205):
- [x] `DashboardComponentDefinition` + `DashboardComponentProps` contract types
(`packages/superset-core/src/dashboardComponents`), added to the
`Contributions` interface and the package's subpath exports
- [x] `dashboardComponents` contribution point: host `DashboardComponentsProvider`
registry + public `registerDashboardComponent`/`getDashboardComponents` API
(`src/core/dashboardComponents`), exposed on `window.superset` via
`ExtensionsStartup` + `Namespaces`
- [x] Shared host component-chrome wrapper `DashboardExtensionComponent`
(owns Draggable/Resizable/HoverMenu/Delete; reads `resizable` from the
definition) behind the new `EXTENSION_TYPE`
- [x] `componentLookup` + builder palette resolve the registry; the seven
behavior maps carry `EXTENSION_TYPE` leaf behavior
- [x] Unknown-type graceful fallback (placeholder + meta preserved on save)
- [x] Deprecation notices on `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
(legacy path still functions)
- [x] Reference component: the built-in iframe is now delivered **through** the
contribution point (`src/dashboard/extensions/iframe`), registered at
startup exactly as a third-party extension would; its CSP backend remains
in core per the companion SIP
- [x] Tests: registry lifecycle (register/get/replace/dispose), host-wrapper
resolution + fallback + `updateMeta`, iframe content + CSP UX
- [x] Per-component behavior policy honored by the layout engine: `resizable`,
`minWidth`, `isUserContent`, `validParents`, and `wrapInRow` are seeded onto
instance `meta` at creation and read by `componentIsResizable`,
`getDetailedComponentWidth`, `isDashboardEmpty`, `isValidChild`, and
`shouldWrapChildInRow` (the pure layout utils stay registry-free; behavior
round-trips in the saved layout)
- [x] Developer docs: `extension-points/dashboard-components.md` + a
`contribution-types.md` section + sidebar entry, with an example extension
Remaining (follow-up, not POC-blocking):
- [ ] Manifest `contributions.dashboardComponents` declarative validation in the
Python/TS manifest schema (runtime side-effect registration works today,
matching how `chat` does it)
- [ ] Remove the legacy `DashboardComponentsRegistry`/`DYNAMIC_TYPE` (major)
+232
View File
@@ -0,0 +1,232 @@
<!--
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.
-->
# [SIP] Proposal for a first-class iframe dashboard component with a runtime CSP allowlist
> **Companion SIP:** This proposal pairs with
> [`SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md`](SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md),
> which proposes the Extensions contribution point that would let this iframe
> component (and others) be shipped as an extension. The two are deliberately
> separate discussions: **this** SIP covers the security-sensitive feature
> (runtime CSP override + permissions); the companion covers the framework
> change. They share one POC branch so the end-to-end story is demonstrable.
> **Status:** Draft — tracking the implementation in `feat/csp-runtime-allowlist-iframe`.
> This document follows the SIP issue template and is kept in sync with the branch
> as the implementation evolves. See SIP-0
> (<https://github.com/apache/superset/issues/5602>) for the SIP process.
## Motivation
Superset ships a Talisman/Content-Security-Policy (CSP) configuration that, by
design, prevents users from embedding arbitrary external content in a dashboard.
The default policy declares `default-src 'self'` and **no** `frame-src`
directive, so an `<iframe>` pointing at any third-party origin is blocked by the
browser.
This is correct and secure default behavior, but it creates real friction:
- There is **no first-class "iframe" dashboard component**. Users historically
smuggled iframes through Markdown, which is both a footgun and blocked by CSP.
- When an embed *is* legitimately needed (an internal tool, a status page, a
partner widget), the only way to allow it is to **edit `TALISMAN_CONFIG` and
restart every Superset process**. That is a deploy-time, ops-team operation —
far too heavyweight for "let me embed this one dashboard from our other
internal app."
- There is no in-product signal telling a user *why* their embed is blank, and
no path to fix it.
We want to (a) make embedding a real, supported component, and (b) give trusted
Admins a controlled, audited way to widen the CSP at runtime — without
abandoning the secure-by-default posture that operators rely on.
## Proposed Change
The change has five parts.
### 1. A first-class `IFRAME` dashboard layout component
A new grid component (`IFRAME_TYPE`) modeled on the existing Markdown/Divider
components. In edit mode the user pastes a URL; in view mode the component
renders a sandboxed `<iframe>`. The component is registered through the same
surface as every other layout element (type constant, `componentLookup`, drag
palette, nesting/resize/width/wrap util maps).
The iframe is rendered with a restrictive `sandbox` attribute
(`allow-scripts allow-same-origin allow-popups allow-forms`).
### 2. Domain flagging
When the runtime-allowlist feature is enabled, the component compares the
embedded URL's **origin** against the current allowlist (fetched from the new
API). If the origin is not yet allowed, it shows an inline warning explaining
that the domain is blocked by the CSP.
### 3. "Enable domain in CSP" button
If the current user holds the new permission (Admins by default), the warning
includes an **Enable domain in CSP** button. Clicking it `POST`s the origin to
the allowlist API and re-checks. Users without the permission instead see "ask
an administrator."
### 4. Permission gating
Mutating the allowlist requires `can write on CSPAllowlist`. The `CSPAllowlist`
view-menu is registered in `SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS`, so
the capability is reserved for Admins (or a custom role explicitly granted it),
consistent with how other trusted, security-sensitive operations are scoped.
### 5. Runtime CSP override ("punched holes")
A new `csp_allowlist` metadata table stores allowlist entries. An `after_request`
hook — registered **before** flask-talisman so that, because Flask runs
`after_request` callbacks in reverse registration order, it runs **after**
Talisman has set the header — merges the operator-curated entries into the
response CSP header. Entries are cached in-process with a short TTL to avoid a DB
hit per response; a write through the API invalidates the cache in the handling
worker, and other workers converge when their cached copy expires.
The entire runtime-override path is inert unless the `CSP_RUNTIME_ALLOWLIST`
feature flag is enabled, so the static, deploy-time policy remains the default
and operators opt in explicitly.
```
Browser ──> Flask request
Talisman after_request (sets "Content-Security-Policy: default-src 'self'; …")
merge_runtime_csp_allowlist (if flag on: appends allowlist origins to frame-src, …)
Response ──> Browser ("…; frame-src 'self' https://embed.example")
```
#### Design decisions (resolved)
- **Scope: global.** Allowlist entries apply server-wide. CSP is a single
per-response header; a global allowlist keeps the merge context-free and
avoids per-dashboard request plumbing. (Per-dashboard scoping is a possible
future extension.)
- **Operator control: feature-flagged kill-switch.** The runtime override only
functions when `CSP_RUNTIME_ALLOWLIST` is on (default **off**). Operators who
want a purely static policy simply leave it off and the table is never
consulted.
## New or Changed Public Interfaces
### REST API
- `GET /api/v1/csp_allowlist/` — list entries
- `GET /api/v1/csp_allowlist/<id>` — get one
- `POST /api/v1/csp_allowlist/` — create (validates origin + directive)
- `PUT /api/v1/csp_allowlist/<id>` — update
- `DELETE /api/v1/csp_allowlist/<id>` — delete
- `DELETE /api/v1/csp_allowlist/?q=!(...)` — bulk delete
All write methods require `can write on CSPAllowlist` (Admin-only by default).
Origins are validated server-side: bare `scheme://host[:port]` only — no
wildcards, paths, query strings, fragments, or credentials. Only a fixed set of
directives may be widened (`frame-src`, `child-src`, `img-src`, `connect-src`,
`media-src`, `font-src`); notably **not** `script-src`.
### Model
- `CSPAllowlistEntry` (`superset/models/csp.py`, table `csp_allowlist`):
`id`, `uuid`, `domain`, `directive` (default `frame-src`), `description`,
audit columns. Unique on `(domain, directive)`.
### Feature flag
- `CSP_RUNTIME_ALLOWLIST` (default `False`) — gates the entire runtime-override
path, backend and frontend.
### Config
- `CSP_RUNTIME_ALLOWLIST_CACHE_TTL` (default `30` seconds) — in-process cache TTL
for the allowlist; also settable via env var.
### Frontend
- New `IFRAME` dashboard layout component and its registration across the
dashboard util maps.
- New `FeatureFlag.CspRuntimeAllowlist` enum member.
### Security model
- New `CSPAllowlist` view-menu added to `ADMIN_ONLY_VIEW_MENUS`.
## New dependencies
None. The implementation uses existing libraries (flask-talisman,
Flask-AppBuilder, marshmallow, SQLAlchemy on the backend; existing
`@superset-ui/core` components on the frontend).
## Migration Plan and Compatibility
- One Alembic migration adds the `csp_allowlist` table
(`4a50792bd265`, down-revision `3a8e6f2c1b95`). The table is empty on creation.
- Fully backward compatible: with the feature flag off (the default), behavior is
identical to today — the static CSP is authoritative and the new table is never
read. No existing dashboards, URLs, or policies change.
- Rollback: dropping the table and disabling the flag fully reverts the feature.
### Security review notes
This feature deliberately relocates a *capability* (widening the CSP) from a
purely deploy-time operator control into a runtime, permission-gated, audited
operation. The mitigations that keep it within Superset's trust model:
- **Off by default** behind a feature flag the operator owns.
- **Admin-only** write permission (a fully trusted principal per `SECURITY.md`).
- **Strict origin validation** server-side — no wildcards, no `script-src`.
- **Audit trail** via the audit mixin (`created_by` / `changed_by`).
- The iframe is **sandboxed** and the merge can only *widen* a directive to a
specific origin, never relax nonce/`strict-dynamic` protections on
`script-src`.
## Rejected Alternatives
- **Dynamically reconfiguring flask-talisman at runtime.** Talisman is configured
once at app init. Rather than mutate its internals, we add our own
`after_request` hook that post-processes the header it already sets. This is
simpler, avoids depending on Talisman internals, and rides the same per-request
header machinery Talisman already uses for its nonce.
- **Per-dashboard allowlist scoping.** More precise, but CSP is a per-response
header; per-dashboard scoping adds request-context complexity for marginal
benefit in the common case. Left as a possible future extension.
- **"Always on" runtime override (no kill-switch).** Simpler, but moves a
security control fully into the app with no operator opt-out. Rejected in favor
of the feature-flag kill-switch.
- **Shared/Redis-backed allowlist cache with cross-worker invalidation.**
Correct but heavier. A short-TTL in-process cache is good enough: writes take
effect immediately in the handling worker and within the TTL elsewhere, with no
new infrastructure dependency.
## Implementation Status
- [x] Feature flag `CSP_RUNTIME_ALLOWLIST` + `CSP_RUNTIME_ALLOWLIST_CACHE_TTL`
- [x] `CSPAllowlistEntry` model + Alembic migration
- [x] DAO, marshmallow schemas (with origin/directive validation), REST API
- [x] Admin-only permission (`CSPAllowlist` view-menu)
- [x] `after_request` CSP merge hook + in-process TTL cache + invalidation
- [x] `IFRAME` dashboard component + registration across util maps
- [x] Domain flagging + permission-gated "Enable domain in CSP" button
- [x] Tests: backend unit (validation + merge + hook), backend integration (API),
frontend unit (util + component)
- [ ] Docs (`docs/`) + `UPDATING.md` entry
- [ ] Community/security review feedback
@@ -129,6 +129,27 @@ chat.registerChat(
See [Chat](./extension-points/chat.md) for implementation details.
### Dashboard Components
Extensions can add first-class layout components to the dashboard builder — elements that live in the grid alongside charts, Markdown, and tabs. The host owns the drag/resize/delete chrome, so the extension only provides the component that renders the element's content. The built-in iframe component is implemented through this contribution point.
```tsx
import { dashboardComponents } from '@apache-superset/core';
import WeatherWidget from './WeatherWidget';
dashboardComponents.registerDashboardComponent(
{
id: 'my-org.weather',
name: 'Weather widget',
icon: 'CloudOutlined',
defaultMeta: { width: 4, height: 50 },
},
WeatherWidget,
);
```
See [Dashboard Components](./extension-points/dashboard-components.md) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.
@@ -0,0 +1,155 @@
---
title: Dashboard Components
sidebar_position: 4
---
<!--
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 Component Contributions
Extensions can add first-class **layout components** to the dashboard builder —
elements that sit in the grid alongside charts, Markdown, and tabs. The built-in
iframe component is itself implemented through this contribution point.
The host owns the surrounding **chrome** (the drag handle, the resize container,
and the delete affordance), so your component only renders its content and, in
edit mode, its own editor affordances. This keeps the contract small and stable.
> This supersedes the legacy `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
> mechanism, which is deprecated.
## Overview
A dashboard component contribution is:
| Part | Role |
|------|------|
| **Definition** | A descriptor declaring the component's id, palette label, icon, and layout behavior (resizable, default size, nesting). |
| **Component** | A React component that renders the element's content and receives the [`DashboardComponentProps`](#component-contract) contract. |
## The Component Contract
Your component receives a small, stable set of props. It never deals with drag,
resize, or delete — the host renders it inside that chrome.
```ts
interface DashboardComponentProps {
/** The layout item id of this instance. */
id: string;
/** This instance's persisted meta (round-trips in the saved layout). */
meta: Record<string, unknown>;
/** Whether the dashboard is in edit mode. */
editMode: boolean;
/** Shallow-merge a patch into this instance's persisted meta. */
updateMeta: (patch: Record<string, unknown>) => void;
}
```
Persist any per-instance state in `meta` via `updateMeta`. It is saved with the
dashboard and rehydrated on load.
## Registering a Dashboard Component
Call `dashboardComponents.registerDashboardComponent` from your extension's entry
point with a definition and your component:
```tsx
import { dashboardComponents } from '@apache-superset/core';
import WeatherWidget from './WeatherWidget';
dashboardComponents.registerDashboardComponent(
{
id: 'my-org.weather',
name: 'Weather widget',
description: 'Shows the current weather for a city',
icon: 'CloudOutlined',
resizable: true,
defaultMeta: { width: 4, height: 50, city: 'Lisbon' },
},
WeatherWidget,
);
```
```tsx
// WeatherWidget.tsx
import type { dashboardComponents } from '@apache-superset/core';
type Props = dashboardComponents.DashboardComponentProps;
export default function WeatherWidget({ meta, editMode, updateMeta }: Props) {
const city = (meta.city as string) ?? '';
return editMode ? (
<input
value={city}
onChange={e => updateMeta({ city: e.target.value })}
placeholder="City"
/>
) : (
<Forecast city={city} />
);
}
```
The component appears in the dashboard builder's **Layout elements** palette and
can be dragged onto the grid like any built-in element.
## Definition Reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Namespaced unique id, e.g. `my-org.weather`. Selects the component for each instance. |
| `name` | `string` | Label shown in the builder palette. |
| `description` | `string` | Optional longer description. |
| `icon` | `string` | A known Superset icon name (e.g. `CloudOutlined`). Falls back to a generic icon. |
| `resizable` | `boolean` | Whether instances can be resized. Defaults to `true`. |
| `defaultMeta` | `object` | `meta` seeded onto a new instance (e.g. `width`, `height`, and your own keys). |
| `isUserContent` | `boolean` | Whether an instance counts as content for "is this dashboard empty?" detection. Defaults to `true`. |
| `minWidth` | `number` | Minimum width in grid columns. Defaults to `1`. |
| `validParents` | `string[]` | Restrict which container types may hold the component (e.g. `['GRID', 'TAB']`). Defaults to standard content-leaf placement (grid, row, column, tab). |
| `wrapInRow` | `boolean` | Whether a drop into the grid or a tab auto-wraps the component in a row. Defaults to `true`. |
The layout-relevant behavior fields are seeded onto each instance's `meta` at
creation, so the dashboard honors them — and they round-trip in the saved layout
even if the extension later becomes unavailable.
## Graceful Degradation
If a saved dashboard references a component whose extension is disabled or not
yet loaded, the host renders a non-destructive placeholder in its place and
preserves the instance's `meta` on save. Re-enabling the extension restores the
component.
## Dashboard Components API Reference
All methods are available on the `dashboardComponents` namespace from
`@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerDashboardComponent(definition, component)` | Register a component. Returns a `Disposable` to unregister. Registering the same id again replaces the previous registration. |
| `getDashboardComponent(id)` | Returns the registered component for `id`, or `undefined`. |
| `getDashboardComponents()` | Returns all registered components. |
| `onDidRegisterDashboardComponent(listener)` | Subscribe to registration events. Returns a `Disposable`. |
| `onDidUnregisterDashboardComponent(listener)` | Subscribe to unregistration events. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment
+1
View File
@@ -49,6 +49,7 @@ module.exports = {
'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
'extensions/extension-points/dashboard-components',
],
},
'extensions/development',
+4 -4
View File
@@ -64,8 +64,8 @@
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.2.3",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
@@ -78,7 +78,7 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.7",
"swagger-ui-react": "^5.32.12",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -93,7 +93,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.9.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
+6
View File
@@ -21,6 +21,12 @@
"lifecycle": "development",
"description": "Enables experimental chart plugins"
},
{
"name": "CSP_RUNTIME_ALLOWLIST",
"default": false,
"lifecycle": "development",
"description": "Allow users with the \"can write on CSPAllowlist\" permission (Admins by default) to punch holes in the Content Security Policy at runtime, e.g. to allow a new domain to be embedded in a dashboard iframe component. When disabled, the CSP is purely static/deploy-time and the allowlist is ignored."
},
{
"name": "CSV_UPLOAD_PYARROW_ENGINE",
"default": false,
+23 -23
View File
@@ -8014,10 +8014,10 @@ doctrine@^2.1.0:
dependencies:
esutils "^2.0.2"
docusaurus-plugin-openapi-docs@^5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
docusaurus-plugin-openapi-docs@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
dependencies:
"@apidevtools/json-schema-ref-parser" "^15.3.3"
"@redocly/openapi-core" "^2.25.2"
@@ -8035,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
swagger2openapi "^7.0.8"
xml-formatter "^3.6.6"
docusaurus-theme-openapi-docs@^5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
docusaurus-theme-openapi-docs@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
dependencies:
"@hookform/error-message" "^2.0.1"
"@reduxjs/toolkit" "^2.8.2"
@@ -8123,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@^3.3.3, dompurify@^3.4.12:
dompurify@^3.3.3, dompurify@^3.4.13:
version "3.4.13"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.9.0:
version "17.9.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
globals@^17.10.0:
version "17.10.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.10.0.tgz#f9dbd847ae99e236f98b13095e2426ac3b25a45c"
integrity sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==
globalthis@^1.0.4:
version "1.0.4"
@@ -10284,10 +10284,10 @@ js-levenshtein@^1.1.6:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
dependencies:
argparse "^2.0.1"
@@ -15103,10 +15103,10 @@ swagger-client@^3.37.8:
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
swagger-ui-react@^5.32.12:
version "5.32.12"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
swagger-ui-react@^5.32.13:
version "5.32.13"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -15115,11 +15115,11 @@ swagger-ui-react@^5.32.12:
classnames "^2.5.1"
css.escape "1.5.1"
deep-extend "0.6.0"
dompurify "^3.4.12"
dompurify "^3.4.13"
ieee754 "^1.2.1"
immutable "^4.3.9"
js-file-download "^0.4.12"
js-yaml "=4.3.0"
js-yaml "=4.3.1"
lodash "^4.18.1"
prop-types "^15.8.1"
randexp "^0.5.3"
+18 -18
View File
@@ -186,7 +186,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
@@ -11808,9 +11808,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.15.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
"version": "14.19.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -20612,7 +20612,7 @@
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/expect-playwright/-/expect-playwright-0.8.0.tgz",
"integrity": "sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg==",
"deprecated": "⚠️ The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"deprecated": "\u26a0\ufe0f The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"dev": true,
"license": "MIT"
},
@@ -26023,7 +26023,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jest-process-manager/-/jest-process-manager-0.4.0.tgz",
"integrity": "sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw==",
"deprecated": "⚠️ The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"deprecated": "\u26a0\ufe0f The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43073,6 +43073,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",
@@ -43420,22 +43429,13 @@
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
},
"plugins/plugin-chart-chord/node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/plugin-chart-country-map": {
+1 -1
View File
@@ -263,7 +263,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
@@ -35,6 +35,10 @@
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"
},
"./dashboardComponents": {
"types": "./lib/dashboardComponents/index.d.ts",
"default": "./lib/dashboardComponents/index.js"
},
"./editors": {
"types": "./lib/editors/index.d.ts",
"default": "./lib/editors/index.js"
@@ -130,6 +130,7 @@ export enum GenericDataType {
String = 1,
Temporal = 2,
Boolean = 3,
MultiValue = 4,
}
/**
@@ -28,6 +28,7 @@
import { Chat } from '../chat';
import { Command } from '../commands';
import { DashboardComponentDefinition } from '../dashboardComponents';
import { View } from '../views';
import { Menu } from '../menus';
import { Editor } from '../editors';
@@ -90,4 +91,9 @@ export interface Contributions {
* chat at a time.
*/
chat?: Chat;
/**
* Dashboard layout components contributed by the extension. Each becomes a
* first-class, draggable element in the dashboard builder palette.
*/
dashboardComponents?: DashboardComponentDefinition[];
}
@@ -0,0 +1,158 @@
/**
* 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.
*/
/**
* @fileoverview Dashboard component contribution API for Superset extensions.
*
* A dashboard component is a first-class dashboard layout element (like the
* built-in Markdown or iframe) contributed by an extension. The extension
* provides a single React component that renders the element's *content*; the
* host owns the surrounding chrome (drag handle, resize, delete) so the
* contributed component stays small and the contract stable.
*
* This replaces the legacy `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
* mechanism, which is deprecated.
*
* @example
* ```typescript
* import { dashboardComponents } from '@apache-superset/core';
*
* dashboardComponents.registerDashboardComponent(
* {
* id: 'acme.weather',
* name: 'Weather widget',
* icon: 'CloudOutlined',
* resizable: true,
* defaultMeta: { width: 4, height: 50 },
* },
* WeatherWidget,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
/**
* Props passed by the host to a contributed dashboard component. The host
* renders this component inside its own drag/resize/delete chrome, so the
* component only needs to render content (and, in edit mode, its own editor
* affordances). Persisted state lives in `meta`; mutate it via `updateMeta`.
*/
export interface DashboardComponentProps {
/** The layout item id of this component instance. */
id: string;
/** The component instance's persisted meta (round-trips in the layout). */
meta: Record<string, unknown>;
/** Whether the dashboard is in edit mode. */
editMode: boolean;
/** Shallow-merge a patch into this component's persisted meta. */
updateMeta: (patch: Record<string, unknown>) => void;
}
/**
* Declarative descriptor for a contributed dashboard component. The behavior
* fields replace what was historically hardcoded in the dashboard util maps
* (resizability, default sizing, nesting, etc.).
*/
export interface DashboardComponentDefinition {
/** Namespaced unique id, e.g. "acme.weather" or "superset.iframe". */
id: string;
/** Human-readable label shown in the builder palette. */
name: string;
/** Optional longer description. */
description?: string;
/** Icon id (a known Superset icon name) shown in the palette. */
icon?: string;
/** Whether instances can be resized. Defaults to true. */
resizable?: boolean;
/** Default `meta` seeded onto a newly created instance (e.g. width/height). */
defaultMeta?: Record<string, unknown>;
/**
* Whether an instance counts as user content for "is this dashboard empty?"
* detection. Defaults to true.
*/
isUserContent?: boolean;
/** Minimum width in grid columns. Defaults to 1. */
minWidth?: number;
/**
* Restrict which container types may hold this component (e.g.
* `['GRID', 'TAB']`). When omitted, the component is allowed wherever a
* standard content leaf is allowed (grid, row, column, tab).
*/
validParents?: string[];
/**
* Whether a drop into the grid or a tab auto-wraps the component in a row.
* Defaults to true (matching built-in content components).
*/
wrapInRow?: boolean;
}
/**
* The subset of a definition's behavior that is seeded onto each instance's
* `meta` at creation, so the dashboard layout engine can honor it (and so it
* round-trips in the saved layout even if the extension later becomes
* unavailable). Read by the dashboard util maps; not part of the rendered
* component's concern.
*/
export interface DashboardComponentBehaviorMeta {
extensionComponentId: string;
resizable?: boolean;
isUserContent?: boolean;
minWidth?: number;
validParents?: string[];
wrapInRow?: boolean;
}
/**
* A registered dashboard component: its definition plus the React component
* the host renders.
*/
export interface RegisteredDashboardComponent {
definition: DashboardComponentDefinition;
Component: ComponentType<DashboardComponentProps>;
}
/**
* Registers a dashboard component. Disposing the returned Disposable
* unregisters it. Registering a second component with the same id replaces the
* first.
*
* @param definition The component descriptor (id, name, behavior).
* @param component The React component rendering the element's content.
* @returns A Disposable that unregisters the component when disposed.
*/
export declare function registerDashboardComponent(
definition: DashboardComponentDefinition,
component: ComponentType<DashboardComponentProps>,
): Disposable;
/** Returns the registered component for `id`, or undefined. */
export declare function getDashboardComponent(
id: string,
): RegisteredDashboardComponent | undefined;
/** Returns all registered dashboard components. */
export declare function getDashboardComponents(): RegisteredDashboardComponent[];
/** Event fired when a dashboard component is registered. */
export declare const onDidRegisterDashboardComponent: Event<DashboardComponentDefinition>;
/** Event fired when a dashboard component is unregistered. */
export declare const onDidUnregisterDashboardComponent: Event<DashboardComponentDefinition>;
@@ -20,6 +20,7 @@ export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as dashboardComponents from './dashboardComponents';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
@@ -28,6 +28,7 @@ import {
FieldBinaryOutlined,
FieldStringOutlined,
NumberOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Icons } from '@superset-ui/core/components';
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
} else if (type === GenericDataType.Temporal) {
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
} else if (type === GenericDataType.MultiValue) {
typeIcon = (
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
);
}
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
renderColumnTypeLabel({ type: GenericDataType.Temporal });
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
});
test('multi-value (array) type shows list icon', () => {
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
});
});
describe('GenericDataType enum parity', () => {
// These numeric values are shared with the backend enum in
// superset/utils/core.py (GenericDataType). They must stay in sync because
// the backend serializes columns using these integers.
test('values match the backend contract', () => {
expect(GenericDataType.Numeric).toBe(0);
expect(GenericDataType.String).toBe(1);
expect(GenericDataType.Temporal).toBe(2);
expect(GenericDataType.Boolean).toBe(3);
expect(GenericDataType.MultiValue).toBe(4);
});
});
@@ -31,6 +31,7 @@ export enum FeatureFlag {
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CspRuntimeAllowlist = 'CSP_RUNTIME_ALLOWLIST',
CssTemplates = 'CSS_TEMPLATES',
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
+4
View File
@@ -47,6 +47,10 @@ export default defineConfig({
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Disable capturing Git commit info as the project's history is increasingly dense
// and breach Playwright's default 3-seconds `git` command timeout limit
captureGitInfo: { commit: false, diff: false },
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
@@ -30,12 +30,12 @@
},
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
"@superset-ui/core": "*",
"react": "^18.3.0"
}
}
@@ -17,6 +17,10 @@
* under the License.
*/
import { getNumberFormatter } from '@superset-ui/core';
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
import BigNumberVis from './BigNumberViz';
/**
* Tests for the color threshold formatter logic in BigNumberViz.
*
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
expect(getColorFromValue).not.toHaveBeenCalled();
});
});
describe('BigNumberViz context menu', () => {
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
const onContextMenu = jest.fn();
const ancestorHandler = jest.fn();
const { container } = render(
<div onContextMenu={ancestorHandler}>
<BigNumberVis
width={200}
height={100}
bigNumber={42}
headerFormatter={getNumberFormatter()}
headerFontSize={0.3}
subheaderFontSize={0.125}
subtitleFontSize={0.125}
subtitle=""
refs={{}}
onContextMenu={onContextMenu}
/>
</div>,
);
const headerLine = container.querySelector('.header-line');
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
expect(ancestorHandler).not.toHaveBeenCalled();
});
});
@@ -224,6 +224,7 @@ function BigNumberVis({
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
}
};
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
[GenericDataType.String]: undefined,
[GenericDataType.Temporal]: tooltipTimeFormatter,
[GenericDataType.Boolean]: undefined,
[GenericDataType.MultiValue]: undefined,
};
const echartOptions: EChartsCoreOption = {
@@ -1627,9 +1627,7 @@ function DatasourceEditor({
{t(
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
)}{' '}
<Typography.Text code>
/superset/dashboard/{'{id}'}/
</Typography.Text>
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
</>
}
control={<TextControl controlId="default_endpoint" />}
@@ -71,6 +71,17 @@ test('renders Tabs', async () => {
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
});
test('recommends a registered client route for the default URL', async () => {
await asyncRender(createProps());
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
expect(
screen.queryByText('/superset/dashboard/{id}/'),
).not.toBeInTheDocument();
});
test('can sync columns from source', async () => {
const testProps = createProps();
await asyncRender({
@@ -0,0 +1,106 @@
/**
* 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 { ComponentType } from 'react';
import type { dashboardComponents as api } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type Definition = api.DashboardComponentDefinition;
type Props = api.DashboardComponentProps;
type Registered = api.RegisteredDashboardComponent;
/**
* Singleton registry for contributed dashboard components. Unlike the chat
* provider (one active chat), this holds many components keyed by id. Built-in
* components register here at startup; extensions register at module-load time.
*/
class DashboardComponentsProvider {
private static instance: DashboardComponentsProvider;
private components = new Map<string, Registered>();
// Cached, referentially-stable snapshot for useSyncExternalStore; rebuilt
// only when the set of components changes.
private snapshot: Registered[] = [];
private stateSubscribers = new Set<() => void>();
private registerEmitter = createEventEmitter<Definition>();
private unregisterEmitter = createEventEmitter<Definition>();
public static getInstance(): DashboardComponentsProvider {
if (!DashboardComponentsProvider.instance) {
DashboardComponentsProvider.instance = new DashboardComponentsProvider();
}
return DashboardComponentsProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
private notifyState(): void {
this.snapshot = Array.from(this.components.values());
this.stateSubscribers.forEach(fn => fn());
}
public registerDashboardComponent = (
definition: Definition,
component: ComponentType<Props>,
): Disposable => {
if (this.components.has(definition.id)) {
// eslint-disable-next-line no-console
console.warn(
`[Superset] A dashboard component "${definition.id}" is already ` +
`registered; replacing it.`,
);
}
const entry: Registered = { definition, Component: component };
this.components.set(definition.id, entry);
this.registerEmitter.fire(definition);
this.notifyState();
return new Disposable(() => {
// Only remove if this exact registration is still the active one.
if (this.components.get(definition.id) === entry) {
this.components.delete(definition.id);
this.unregisterEmitter.fire(definition);
this.notifyState();
}
});
};
public getDashboardComponent = (id: string): Registered | undefined =>
this.components.get(id);
public getDashboardComponents = (): Registered[] => this.snapshot;
public get onDidRegisterDashboardComponent() {
return this.registerEmitter.subscribe;
}
public get onDidUnregisterDashboardComponent() {
return this.unregisterEmitter.subscribe;
}
}
export default DashboardComponentsProvider;
@@ -0,0 +1,88 @@
/**
* 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 { dashboardComponents } from './index';
const Noop = () => null;
const def = (id: string) => ({ id, name: id });
test('registerDashboardComponent makes a component retrievable', () => {
const disposable = dashboardComponents.registerDashboardComponent(
def('acme.widget'),
Noop,
);
expect(dashboardComponents.getDashboardComponent('acme.widget')).toEqual({
definition: def('acme.widget'),
Component: Noop,
});
expect(
dashboardComponents
.getDashboardComponents()
.some(r => r.definition.id === 'acme.widget'),
).toBe(true);
disposable.dispose();
});
test('disposing the registration unregisters the component', () => {
const disposable = dashboardComponents.registerDashboardComponent(
def('acme.temp'),
Noop,
);
expect(dashboardComponents.getDashboardComponent('acme.temp')).toBeDefined();
disposable.dispose();
expect(
dashboardComponents.getDashboardComponent('acme.temp'),
).toBeUndefined();
});
test('registering the same id twice replaces the first', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const A = () => null;
const B = () => null;
dashboardComponents.registerDashboardComponent(def('acme.dup'), A);
const second = dashboardComponents.registerDashboardComponent(
def('acme.dup'),
B,
);
expect(dashboardComponents.getDashboardComponent('acme.dup')?.Component).toBe(
B,
);
jest.restoreAllMocks();
second.dispose();
});
test('disposing a stale registration does not remove the active one', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const A = () => null;
const B = () => null;
const first = dashboardComponents.registerDashboardComponent(
def('acme.stale'),
A,
);
const second = dashboardComponents.registerDashboardComponent(
def('acme.stale'),
B,
);
// Disposing the superseded registration is a no-op.
first.dispose();
expect(
dashboardComponents.getDashboardComponent('acme.stale')?.Component,
).toBe(B);
jest.restoreAllMocks();
second.dispose();
});
@@ -0,0 +1,49 @@
/**
* 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.
*/
/**
* @fileoverview Host implementation of the `dashboardComponents` contribution
* type. Extensions register via `dashboardComponents.registerDashboardComponent()`
* and the host renders contributed components inside its own dashboard chrome.
*
* The public namespace (`dashboardComponents`) is exposed to extensions on
* `window.superset`. `useDashboardComponents` is host-internal and NOT part of
* the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import type { dashboardComponents as api } from '@apache-superset/core';
import DashboardComponentsProvider from './DashboardComponentsProvider';
const provider = DashboardComponentsProvider.getInstance();
/**
* Host-internal hook returning all registered dashboard components, re-rendering
* when the set changes.
*/
export const useDashboardComponents = () =>
useSyncExternalStore(provider.subscribe, provider.getDashboardComponents);
export const dashboardComponents: typeof api = {
registerDashboardComponent: provider.registerDashboardComponent,
getDashboardComponent: provider.getDashboardComponent,
getDashboardComponents: provider.getDashboardComponents,
onDidRegisterDashboardComponent: provider.onDidRegisterDashboardComponent,
onDidUnregisterDashboardComponent: provider.onDidUnregisterDashboardComponent,
};
+1
View File
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './dashboardComponents';
export * from './editors';
export * from './extensions';
export * from './menus';
@@ -21,6 +21,7 @@ import tinycolor from 'tinycolor2';
import Tabs from '@superset-ui/core/components/Tabs';
import { t } from '@apache-superset/core/translation';
import { css, SupersetTheme } from '@apache-superset/core/theme';
import { useDashboardComponents } from 'src/core';
import SliceAdder from 'src/dashboard/containers/SliceAdder';
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
import NewColumn from '../gridComponents/new/NewColumn';
@@ -29,6 +30,7 @@ import NewHeader from '../gridComponents/new/NewHeader';
import NewRow from '../gridComponents/new/NewRow';
import NewTabs from '../gridComponents/new/NewTabs';
import NewMarkdown from '../gridComponents/new/NewMarkdown';
import NewExtensionComponent from '../gridComponents/new/NewExtensionComponent';
import NewDynamicComponent from '../gridComponents/new/NewDynamicComponent';
const BUILDER_PANE_WIDTH = 374;
@@ -38,83 +40,94 @@ const TABS_KEYS = {
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
};
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
`}
>
const BuilderComponentPane = ({ topOffset = 0 }) => {
const extensionComponents = useDashboardComponents();
return (
<div
css={(theme: SupersetTheme) => css`
position: absolute;
height: 100%;
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
<div
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
position: absolute;
height: 100%;
& .ant-tabs-body-holder {
height: 100%;
& .ant-tabs-body {
height: 100%;
}
}
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
height: 100%;
& .ant-tabs-body-holder {
height: 100%;
& .ant-tabs-body {
height: 100%;
}
}
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{/* Extensions-contributed dashboard components */}
{extensionComponents.map(({ definition }) => (
<NewExtensionComponent
key={definition.id}
definition={definition}
/>
))}
</>
),
},
]}
/>
{/* @deprecated legacy DashboardComponentsRegistry path */}
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
</div>
</div>
</div>
);
);
};
export default BuilderComponentPane;
@@ -468,7 +468,7 @@ function SliceAdder({
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
style={{ width, height }}
style={{ width, height, maxHeight: height }}
rowCount={filteredSlices.length}
rowHeight={DEFAULT_CELL_HEIGHT}
rowProps={listRowProps}
@@ -0,0 +1,106 @@
/**
* 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 { screen, render } from 'spec/helpers/testing-library';
import { dashboardComponents } from 'src/core';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import {
EXTENSION_TYPE,
DASHBOARD_GRID_TYPE,
} from 'src/dashboard/util/componentTypes';
import DashboardExtensionComponent, {
DashboardExtensionComponentProps,
} from './DashboardExtensionComponent';
const makeComponent = (extensionComponentId?: string) => {
const component = newComponentFactory(EXTENSION_TYPE);
component.meta.extensionComponentId = extensionComponentId;
return component;
};
const baseProps = (
overrides: Partial<DashboardExtensionComponentProps> = {},
): DashboardExtensionComponentProps => ({
id: 'ext-id',
parentId: 'parentId',
component: makeComponent('acme.demo'),
parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
index: 0,
depth: 1,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
onResizeStart: jest.fn(),
onResize: jest.fn(),
onResizeStop: jest.fn(),
deleteComponent: jest.fn(),
handleComponentDrop: jest.fn(),
updateComponents: jest.fn(),
...overrides,
});
const setup = (props: Partial<DashboardExtensionComponentProps> = {}) =>
render(<DashboardExtensionComponent {...baseProps(props)} />, {
useRedux: true,
useDnd: true,
});
test('renders the registered contributed component', () => {
const disposable = dashboardComponents.registerDashboardComponent(
{ id: 'acme.demo', name: 'Acme Demo' },
() => <div data-test="acme-demo-content">Acme content</div>,
);
setup();
expect(screen.getByTestId('acme-demo-content')).toBeInTheDocument();
disposable.dispose();
});
test('renders a graceful placeholder when the component is not registered', () => {
setup({ component: makeComponent('not.installed') });
expect(
screen.getByTestId('dashboard-component-extension-missing'),
).toBeInTheDocument();
expect(
screen.getByText(/requires the "not.installed" extension/),
).toBeInTheDocument();
});
test('passes editMode and an updateMeta that patches the instance meta', () => {
const updateComponents = jest.fn();
let captured: ((patch: Record<string, unknown>) => void) | undefined;
const disposable = dashboardComponents.registerDashboardComponent(
{ id: 'acme.meta', name: 'Acme Meta' },
({ editMode, updateMeta }) => {
captured = updateMeta;
return <div>{editMode ? 'editing' : 'viewing'}</div>;
},
);
setup({
component: makeComponent('acme.meta'),
editMode: true,
updateComponents,
});
expect(screen.getByText('editing')).toBeInTheDocument();
captured?.({ url: 'https://x.com' });
expect(updateComponents).toHaveBeenCalledTimes(1);
const updated = Object.values(updateComponents.mock.calls[0][0])[0] as {
meta: { url: string };
};
expect(updated.meta.url).toBe('https://x.com');
disposable.dispose();
});
@@ -0,0 +1,193 @@
/**
* 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.
*/
/**
* Host wrapper for Extensions-contributed dashboard components (EXTENSION_TYPE).
*
* This component owns the shared dashboard "chrome" — the drag handle, resize
* container, and delete affordance — and renders the contributed component
* resolved from the `dashboardComponents` registry, passing it the stable
* `DashboardComponentProps` contract. Contributed components therefore only
* render content; they never re-implement layout chrome.
*
* If the referenced component is not registered (e.g. its extension is disabled
* or not yet loaded), a non-destructive placeholder is rendered and the
* instance's `meta` is preserved on save.
*/
import { useCallback } from 'react';
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { useDashboardComponents } from 'src/core';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import type { LayoutItem } from 'src/dashboard/types';
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_MIN_COLUMN_COUNT,
GRID_MIN_ROW_UNITS,
GRID_BASE_UNIT,
} from 'src/dashboard/util/constants';
export interface DashboardExtensionComponentProps {
id: string;
parentId: string;
component: LayoutItem;
parentComponent: LayoutItem;
index: number;
depth: number;
editMode: boolean;
availableColumnCount: number;
columnWidth: number;
onResizeStart: ResizeStartCallback;
onResize: ResizeCallback;
onResizeStop: ResizeCallback;
deleteComponent: (id: string, parentId: string) => void;
handleComponentDrop: (dropResult: DropResult) => void;
updateComponents: (components: Record<string, LayoutItem>) => void;
}
const Placeholder = styled.div`
${({ theme }) => `
width: 100%;
height: 100%;
min-height: ${theme.sizeUnit * 25}px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: ${theme.sizeUnit * 4}px;
color: ${theme.colorTextTertiary};
border: 1px dashed ${theme.colorBorder};
`}
`;
export default function DashboardExtensionComponent(
props: DashboardExtensionComponentProps,
) {
const {
component,
parentComponent,
index,
depth,
editMode,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
deleteComponent,
handleComponentDrop,
updateComponents,
} = props;
// Subscribe to the registry so a component that registers after this renders
// (e.g. a lazily-loaded extension) replaces the placeholder once available.
const registered = useDashboardComponents();
const extensionComponentId = component.meta.extensionComponentId as
| string
| undefined;
const entry = registered.find(r => r.definition.id === extensionComponentId);
const handleDeleteComponent = useCallback(() => {
deleteComponent(component.id, parentComponent.id);
}, [component.id, deleteComponent, parentComponent.id]);
const updateMeta = useCallback(
(patch: Record<string, unknown>) => {
updateComponents({
[component.id]: {
...component,
meta: { ...component.meta, ...patch },
},
});
},
[component, updateComponents],
);
const resizable = entry?.definition.resizable ?? true;
const widthMultiple = component.meta.width ?? GRID_MIN_COLUMN_COUNT;
const ContributedComponent = entry?.Component;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: React.Ref<HTMLDivElement> }) => (
<ResizableContainer
id={component.id}
adjustableWidth={resizable && parentComponent.type === ROW_TYPE}
adjustableHeight={resizable}
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={editMode}
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-extension"
data-test="dashboard-component-extension"
id={component.id}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
{ContributedComponent ? (
<ContributedComponent
id={component.id}
meta={component.meta}
editMode={editMode}
updateMeta={updateMeta}
/>
) : (
<Placeholder data-test="dashboard-component-extension-missing">
{t(
'This component requires the "%(id)s" extension, which is not available.',
{ id: extensionComponentId ?? t('unknown') },
)}
</Placeholder>
)}
</div>
</ResizableContainer>
)}
</Draggable>
);
}
@@ -22,6 +22,7 @@ import {
COLUMN_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
ROW_TYPE,
TAB_TYPE,
TABS_TYPE,
@@ -33,6 +34,7 @@ import Markdown from './Markdown';
import Column from './Column';
import Divider from './Divider';
import Header from './Header';
import DashboardExtensionComponent from './DashboardExtensionComponent';
import Row from './Row';
import Tab from './Tab';
import Tabs from './Tabs';
@@ -44,6 +46,7 @@ export const componentLookup = {
[COLUMN_TYPE]: Column,
[DIVIDER_TYPE]: Divider,
[HEADER_TYPE]: Header,
[EXTENSION_TYPE]: DashboardExtensionComponent,
[ROW_TYPE]: Row,
[TAB_TYPE]: Tab,
[TABS_TYPE]: Tabs,
@@ -0,0 +1,71 @@
/**
* 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 type { dashboardComponents as dashboardComponentsApi } from '@apache-superset/core';
import { Icons } from '@superset-ui/core/components';
import { EXTENSION_TYPE } from '../../../util/componentTypes';
import { NEW_EXTENSION_ID } from '../../../util/constants';
import DraggableNewComponent from './DraggableNewComponent';
type Definition = dashboardComponentsApi.DashboardComponentDefinition;
/**
* Palette drag source for an Extensions-contributed dashboard component. Drops a
* new EXTENSION_TYPE instance whose `meta.extensionComponentId` selects the
* contributed component, seeded with the definition's `defaultMeta`.
*/
export default function NewExtensionComponent({
definition,
}: {
definition: Definition;
}) {
const IconComponent =
(definition.icon &&
(Icons as Record<string, (typeof Icons)[keyof typeof Icons]>)[
definition.icon
]) ||
Icons.AppstoreOutlined;
// Seed the layout-relevant behavior onto the instance meta so the (pure)
// dashboard util maps can honor it without coupling to the component registry,
// and so it round-trips in the saved layout even if the extension is later
// unavailable. Only defined fields are seeded to keep meta tidy.
const behaviorMeta: Record<string, unknown> = {
extensionComponentId: definition.id,
};
if (definition.resizable !== undefined)
behaviorMeta.resizable = definition.resizable;
if (definition.isUserContent !== undefined)
behaviorMeta.isUserContent = definition.isUserContent;
if (definition.minWidth !== undefined)
behaviorMeta.minWidth = definition.minWidth;
if (definition.validParents !== undefined)
behaviorMeta.validParents = definition.validParents;
if (definition.wrapInRow !== undefined)
behaviorMeta.wrapInRow = definition.wrapInRow;
return (
<DraggableNewComponent
id={`${NEW_EXTENSION_ID}-${definition.id}`}
type={EXTENSION_TYPE}
label={definition.name}
IconComponent={IconComponent}
meta={{ ...behaviorMeta, ...definition.defaultMeta }}
/>
);
}
@@ -0,0 +1,150 @@
/**
* 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 {
screen,
render,
userEvent,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import {
addCspAllowlistEntry,
fetchCspAllowlist,
} from 'src/dashboard/util/cspAllowlist';
import IframeContent from './IframeContent';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => true),
}));
jest.mock('src/dashboard/util/cspAllowlist', () => ({
...jest.requireActual('src/dashboard/util/cspAllowlist'),
fetchCspAllowlist: jest.fn(),
addCspAllowlistEntry: jest.fn(),
}));
const mockedFetch = fetchCspAllowlist as jest.Mock;
const mockedAdd = addCspAllowlistEntry as jest.Mock;
const mockedFeature = isFeatureEnabled as jest.Mock;
const adminState = {
user: { roles: { Admin: [['can_write', 'CSPAllowlist']] } },
};
const gammaState = {
user: { roles: { Gamma: [['can_read', 'Dashboard']] } },
};
const setup = (
{
url = 'https://example.com',
editMode = false,
updateMeta = jest.fn(),
}: { url?: string; editMode?: boolean; updateMeta?: jest.Mock } = {},
initialState: object = adminState,
) =>
render(
<IframeContent
id="iframe-id"
meta={{ url }}
editMode={editMode}
updateMeta={updateMeta}
/>,
{ useRedux: true, initialState },
);
beforeEach(() => {
mockedFeature.mockReturnValue(true);
mockedFetch.mockResolvedValue(new Set<string>());
mockedAdd.mockResolvedValue(undefined);
});
afterEach(() => jest.clearAllMocks());
test('renders an iframe with the configured URL', () => {
setup();
expect(screen.getByTitle('Embedded content')).toHaveAttribute(
'src',
'https://example.com',
);
});
test('sandboxes the iframe without allow-same-origin', () => {
setup();
const sandbox = screen.getByTitle('Embedded content').getAttribute('sandbox');
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
});
test('renders an empty placeholder when no URL is configured', () => {
setup({ url: '' });
expect(screen.queryByTitle('Embedded content')).not.toBeInTheDocument();
expect(screen.getByText('No URL configured')).toBeInTheDocument();
});
test('saves the URL via updateMeta on blur in edit mode', async () => {
const updateMeta = jest.fn();
setup({ editMode: true, updateMeta });
const input = screen.getByTestId('dashboard-iframe-url-input');
await userEvent.clear(input);
await userEvent.type(input, 'https://new.example.com');
fireEvent.blur(input);
await waitFor(() =>
expect(updateMeta).toHaveBeenCalledWith({ url: 'https://new.example.com' }),
);
});
test('flags a non-allowlisted domain and offers Enable for admins', async () => {
mockedFetch.mockResolvedValue(new Set<string>());
setup({ editMode: true });
expect(
await screen.findByText('This domain is not allowed to be embedded'),
).toBeInTheDocument();
await userEvent.click(screen.getByTestId('dashboard-iframe-enable-csp'));
expect(mockedAdd).toHaveBeenCalledWith('https://example.com');
});
test('does not flag an already-allowlisted domain', async () => {
mockedFetch.mockResolvedValue(new Set<string>(['https://example.com']));
setup({ editMode: true });
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(
screen.queryByText('This domain is not allowed to be embedded'),
).not.toBeInTheDocument();
});
test('hides Enable for users without the CSP permission', async () => {
setup({ editMode: true }, gammaState);
expect(
await screen.findByText('This domain is not allowed to be embedded'),
).toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-iframe-enable-csp'),
).not.toBeInTheDocument();
});
test('never flags domains when the feature flag is disabled', async () => {
mockedFeature.mockReturnValue(false);
setup({ editMode: true });
await waitFor(() => expect(mockedFetch).not.toHaveBeenCalled());
expect(
screen.queryByText('This domain is not allowed to be embedded'),
).not.toBeInTheDocument();
});
@@ -0,0 +1,215 @@
/**
* 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.
*/
/**
* Built-in "iframe" dashboard component, delivered through the
* `dashboardComponents` Extensions contribution point. This renders only the
* element's *content* and editor — the host (DashboardExtensionComponent) owns
* the drag/resize/delete chrome.
*
* The component also surfaces the runtime CSP allowlist UX (companion SIP):
* when the embedded origin is not allowed, it flags it and offers permitted
* admins an "Enable domain in CSP" action.
*/
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import type { dashboardComponents as dashboardComponentsApi } from '@apache-superset/core';
import { Alert } from '@apache-superset/core/components';
import { Button, Input } from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { findPermission } from 'src/utils/findPermission';
import type { RootState } from 'src/dashboard/types';
import {
addCspAllowlistEntry,
CSP_ALLOWLIST_PERMISSION,
CSP_ALLOWLIST_VIEW,
fetchCspAllowlist,
getOrigin,
isEmbeddableUrl,
} from 'src/dashboard/util/cspAllowlist';
type DashboardComponentProps = dashboardComponentsApi.DashboardComponentProps;
const IframeStyles = styled.div`
${({ theme }) => `
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 2}px;
.dashboard-iframe-frame {
flex: 1;
width: 100%;
border: 0;
min-height: ${theme.sizeUnit * 25}px;
}
.dashboard-iframe-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: ${theme.colorTextTertiary};
border: 1px dashed ${theme.colorBorder};
}
`}
`;
export default function IframeContent({
meta,
editMode,
updateMeta,
}: DashboardComponentProps) {
const { addSuccessToast, addDangerToast } = useToasts();
const roles = useSelector((state: RootState) => state.user?.roles);
const cspFeatureEnabled = isFeatureEnabled(FeatureFlag.CspRuntimeAllowlist);
const canManageCsp =
cspFeatureEnabled &&
findPermission(CSP_ALLOWLIST_PERMISSION, CSP_ALLOWLIST_VIEW, roles);
const url = (meta.url as string) ?? '';
const [draftUrl, setDraftUrl] = useState(url);
const [allowlist, setAllowlist] = useState<Set<string> | null>(null);
const [enabling, setEnabling] = useState(false);
const origin = getOrigin(url);
const refreshAllowlist = useCallback(() => {
if (!cspFeatureEnabled) {
return;
}
fetchCspAllowlist()
.then(setAllowlist)
.catch(() => setAllowlist(new Set()));
}, [cspFeatureEnabled]);
useEffect(() => {
refreshAllowlist();
}, [refreshAllowlist]);
useEffect(() => {
setDraftUrl(url);
}, [url]);
const handleSaveUrl = useCallback(() => {
const trimmed = draftUrl.trim();
if (trimmed === url) {
return;
}
updateMeta({ url: trimmed });
}, [draftUrl, updateMeta, url]);
const handleEnableDomain = useCallback(() => {
if (!origin) {
return;
}
setEnabling(true);
addCspAllowlistEntry(origin)
.then(() => {
addSuccessToast(
t('%(origin)s is now allowed to be embedded.', { origin }),
);
refreshAllowlist();
})
.catch(() =>
addDangerToast(t('Failed to allow %(origin)s in the CSP.', { origin })),
)
.finally(() => setEnabling(false));
}, [addDangerToast, addSuccessToast, origin, refreshAllowlist]);
const domainFlagged =
cspFeatureEnabled &&
!!origin &&
allowlist !== null &&
!allowlist.has(origin);
return (
<IframeStyles data-test="dashboard-iframe">
{editMode && (
<Input
aria-label={t('Embed URL')}
data-test="dashboard-iframe-url-input"
placeholder={t('Paste a URL to embed, e.g. https://example.com')}
value={draftUrl}
onChange={e => setDraftUrl(e.target.value)}
onBlur={handleSaveUrl}
onPressEnter={handleSaveUrl}
/>
)}
{domainFlagged && (
<Alert
type="warning"
showIcon
closable={false}
message={t('This domain is not allowed to be embedded')}
description={
canManageCsp
? t(
'%(origin)s is blocked by the Content Security Policy. ' +
'Enable it to allow this content to load.',
{ origin },
)
: t(
'%(origin)s is blocked by the Content Security Policy. ' +
'Ask an administrator to allow this domain.',
{ origin },
)
}
action={
canManageCsp ? (
<Button
buttonStyle="primary"
buttonSize="small"
loading={enabling}
onClick={handleEnableDomain}
data-test="dashboard-iframe-enable-csp"
>
{t('Enable domain in CSP')}
</Button>
) : undefined
}
/>
)}
{isEmbeddableUrl(url) ? (
<iframe
className="dashboard-iframe-frame"
src={url}
title={t('Embedded content')}
// No `allow-same-origin`: combined with `allow-scripts` it would let an
// allowlisted-then-compromised origin script its way out of its own
// sandbox (same-origin DOM/storage access) for third-party embeds.
sandbox="allow-scripts allow-popups allow-forms"
/>
) : (
<div className="dashboard-iframe-empty">
{editMode
? t('Enter a URL above to embed content')
: t('No URL configured')}
</div>
)}
</IframeStyles>
);
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { dashboardComponents } from 'src/core';
import IframeContent from './IframeContent';
export const IFRAME_COMPONENT_ID = 'superset.iframe';
/**
* Registers the built-in iframe as a first-class dashboard component through the
* Extensions `dashboardComponents` contribution point. Core registers it the
* same way a third-party extension would, demonstrating the contract end to end.
*/
export default function registerIframeComponent() {
dashboardComponents.registerDashboardComponent(
{
id: IFRAME_COMPONENT_ID,
name: t('Embed / Iframe'),
description: t('Embed external content via a URL'),
icon: 'LinkOutlined',
resizable: true,
defaultMeta: { width: 4, height: 50, url: '' },
},
IframeContent,
);
}
@@ -210,6 +210,7 @@ const actionHandlers: Record<
const wrapInRow = shouldWrapChildInRow({
parentType: destination.type,
childType: dragging.type,
childMeta: dragging.meta,
});
if (wrapInRow) {
+6
View File
@@ -286,8 +286,14 @@ export type LayoutItemMeta = {
headerSize?: string;
/** Markdown source code for markdown components */
code?: string;
/** Embedded URL for iframe components */
url?: string;
/** Background style value for columns and rows */
background?: string;
/** Extension-contributed components opt out of resizing via this flag */
resizable?: boolean;
/** Extension-contributed components opt out of default row-wrapping via this flag */
wrapInRow?: boolean;
/** Allow additional meta properties used by different component types */
[key: string]: unknown;
};
@@ -24,6 +24,7 @@ import {
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TABS_TYPE,
@@ -40,7 +41,7 @@ const notResizable = [
TAB_TYPE,
];
const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
const resizable = [COLUMN_TYPE, CHART_TYPE, EXTENSION_TYPE, MARKDOWN_TYPE];
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('componentIsResizable', () => {
@@ -19,11 +19,20 @@
import {
COLUMN_TYPE,
CHART_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
DYNAMIC_TYPE,
} from './componentTypes';
export default function componentIsResizable(entity: { type: string }) {
export default function componentIsResizable(entity: {
type: string;
meta?: { resizable?: boolean };
}) {
// Extension-contributed components opt out of resizing via their definition,
// seeded onto meta at creation.
if (entity.type === EXTENSION_TYPE) {
return entity.meta?.resizable !== false;
}
return (
[COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf(
entity.type,
@@ -23,12 +23,17 @@ export const DASHBOARD_GRID_TYPE = 'GRID';
export const DASHBOARD_ROOT_TYPE = 'ROOT';
export const DIVIDER_TYPE = 'DIVIDER';
export const HEADER_TYPE = 'HEADER';
// First-class Extensions-contributed dashboard component (see the
// `dashboardComponents` contribution point in @apache-superset/core). The
// concrete component is selected by `meta.extensionComponentId`.
export const EXTENSION_TYPE = 'EXTENSION';
export const MARKDOWN_TYPE = 'MARKDOWN';
export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE';
export const ROW_TYPE = 'ROW';
export const TABS_TYPE = 'TABS';
export const TAB_TYPE = 'TAB';
// Dynamic type proposes lazy loading of custom dashboard components that can be added in separate repository
// @deprecated Legacy lazy-loaded custom component registry (DashboardComponentsRegistry).
// Superseded by EXTENSION_TYPE + the `dashboardComponents` Extensions contribution point.
export const DYNAMIC_TYPE = 'DYNAMIC';
export default {
@@ -39,6 +44,7 @@ export default {
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
NEW_COMPONENT_SOURCE_TYPE,
ROW_TYPE,
@@ -27,6 +27,7 @@ export const NEW_CHART_ID = 'NEW_CHART_ID';
export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
export const NEW_HEADER_ID = 'NEW_HEADER_ID';
export const NEW_EXTENSION_ID = 'NEW_EXTENSION_ID';
export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
export const NEW_ROW_ID = 'NEW_ROW_ID';
export const NEW_TAB_ID = 'NEW_TAB_ID';
@@ -0,0 +1,88 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import {
addCspAllowlistEntry,
CSP_ALLOWLIST_ENDPOINT,
fetchCspAllowlist,
getOrigin,
isEmbeddableUrl,
} from './cspAllowlist';
test('getOrigin extracts the bare origin from a URL', () => {
expect(getOrigin('https://example.com/path?q=1#frag')).toBe(
'https://example.com',
);
expect(getOrigin('https://example.com:8443/x')).toBe(
'https://example.com:8443',
);
expect(getOrigin('http://localhost:9000')).toBe('http://localhost:9000');
});
test('getOrigin returns null for empty or unparseable URLs', () => {
expect(getOrigin('')).toBeNull();
expect(getOrigin(undefined)).toBeNull();
expect(getOrigin('not a url')).toBeNull();
expect(getOrigin('example.com')).toBeNull();
});
test('isEmbeddableUrl is true only for absolute http(s) URLs', () => {
expect(isEmbeddableUrl('https://example.com')).toBe(true);
expect(isEmbeddableUrl('http://example.com/embed')).toBe(true);
expect(isEmbeddableUrl('ftp://example.com')).toBe(false);
// built via concatenation to avoid a literal javascript: URL in source
expect(isEmbeddableUrl(`${'java'}${'script'}:alert(1)`)).toBe(false);
expect(isEmbeddableUrl('')).toBe(false);
expect(isEmbeddableUrl(undefined)).toBe(false);
});
test('fetchCspAllowlist returns the set of origins for frame-src', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: [
{ domain: 'https://a.com', directive: 'frame-src' },
{ domain: 'https://b.com', directive: 'frame-src' },
{ domain: 'https://c.com', directive: 'img-src' },
],
},
} as any);
const allowlist = await fetchCspAllowlist();
expect(getSpy).toHaveBeenCalledWith({ endpoint: CSP_ALLOWLIST_ENDPOINT });
expect(allowlist.has('https://a.com')).toBe(true);
expect(allowlist.has('https://b.com')).toBe(true);
// img-src entries are excluded from the frame-src set
expect(allowlist.has('https://c.com')).toBe(false);
getSpy.mockRestore();
});
test('addCspAllowlistEntry posts the origin with the frame-src directive', async () => {
const postSpy = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as any);
await addCspAllowlistEntry('https://example.com');
expect(postSpy).toHaveBeenCalledWith({
endpoint: CSP_ALLOWLIST_ENDPOINT,
jsonPayload: { domain: 'https://example.com', directive: 'frame-src' },
});
postSpy.mockRestore();
});
@@ -0,0 +1,79 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
export const CSP_ALLOWLIST_ENDPOINT = '/api/v1/csp_allowlist/';
export const DEFAULT_EMBED_DIRECTIVE = 'frame-src';
/** FAB requires `can write on CSPAllowlist`; Admins hold it by default. */
export const CSP_ALLOWLIST_PERMISSION = 'can_write';
export const CSP_ALLOWLIST_VIEW = 'CSPAllowlist';
interface CSPAllowlistResult {
result?: { domain: string; directive: string }[];
}
/**
* Return the bare origin (scheme://host[:port]) of a URL, or null if the URL is
* empty or cannot be parsed. Mirrors the server-side `is_valid_csp_origin`
* canonicalization so the UX check matches what the backend will accept.
*/
export function getOrigin(url?: string | null): string | null {
if (!url) {
return null;
}
try {
const { origin } = new URL(url);
// URL parses things like "mailto:" to an opaque origin of "null"
return origin && origin !== 'null' ? origin : null;
} catch {
return null;
}
}
/** True only for absolute http(s) URLs that resolve to a concrete origin. */
export function isEmbeddableUrl(url?: string | null): boolean {
const origin = getOrigin(url);
return !!origin && /^https?:\/\//.test(origin);
}
/** Fetch the set of origins currently allowed for the given CSP directive. */
export async function fetchCspAllowlist(
directive: string = DEFAULT_EMBED_DIRECTIVE,
): Promise<Set<string>> {
const response = await SupersetClient.get({
endpoint: CSP_ALLOWLIST_ENDPOINT,
});
const json = response.json as CSPAllowlistResult;
const origins = (json.result ?? [])
.filter(entry => entry.directive === directive)
.map(entry => entry.domain);
return new Set(origins);
}
/** Add a new allowlist entry (punch a hole in the CSP) for the given origin. */
export async function addCspAllowlistEntry(
domain: string,
directive: string = DEFAULT_EMBED_DIRECTIVE,
): Promise<void> {
await SupersetClient.post({
endpoint: CSP_ALLOWLIST_ENDPOINT,
jsonPayload: { domain, directive },
});
}
@@ -0,0 +1,123 @@
/**
* 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 componentIsResizable from './componentIsResizable';
import isValidChild from './isValidChild';
import shouldWrapChildInRow from './shouldWrapChildInRow';
import isDashboardEmpty from './isDashboardEmpty';
import getDetailedComponentWidth from './getDetailedComponentWidth';
import {
EXTENSION_TYPE,
DASHBOARD_GRID_TYPE,
TAB_TYPE,
COLUMN_TYPE,
} from './componentTypes';
test('componentIsResizable honors meta.resizable for extension components', () => {
expect(componentIsResizable({ type: EXTENSION_TYPE })).toBe(true);
expect(
componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: true } }),
).toBe(true);
expect(
componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: false } }),
).toBe(false);
});
test('isValidChild restricts extension parents via meta.validParents', () => {
// Allowed in GRID when GRID is listed
expect(
isValidChild({
parentType: DASHBOARD_GRID_TYPE,
childType: EXTENSION_TYPE,
parentDepth: 1,
childMeta: { validParents: [DASHBOARD_GRID_TYPE] },
}),
).toBe(true);
// Forbidden in COLUMN when only GRID is listed
expect(
isValidChild({
parentType: COLUMN_TYPE,
childType: EXTENSION_TYPE,
parentDepth: 4,
childMeta: { validParents: [DASHBOARD_GRID_TYPE] },
}),
).toBe(false);
// No restriction → standard leaf behavior (allowed in GRID at depth 1)
expect(
isValidChild({
parentType: DASHBOARD_GRID_TYPE,
childType: EXTENSION_TYPE,
parentDepth: 1,
}),
).toBe(true);
});
test('shouldWrapChildInRow honors meta.wrapInRow for extension components', () => {
expect(
shouldWrapChildInRow({
parentType: DASHBOARD_GRID_TYPE,
childType: EXTENSION_TYPE,
}),
).toBe(true);
expect(
shouldWrapChildInRow({
parentType: DASHBOARD_GRID_TYPE,
childType: EXTENSION_TYPE,
childMeta: { wrapInRow: false },
}),
).toBe(false);
});
test('isDashboardEmpty respects meta.isUserContent for extension components', () => {
const userContent = {
a: { type: EXTENSION_TYPE, meta: { isUserContent: true } },
};
const nonUserContent = {
a: { type: EXTENSION_TYPE, meta: { isUserContent: false } },
};
expect(isDashboardEmpty(userContent)).toBe(false);
// An extension that opts out of being user content leaves the dashboard empty
expect(isDashboardEmpty(nonUserContent)).toBe(true);
// Default (no flag) counts as user content
expect(isDashboardEmpty({ a: { type: EXTENSION_TYPE, meta: {} } })).toBe(
false,
);
});
test('getDetailedComponentWidth honors meta.minWidth for extension components', () => {
const withMin = getDetailedComponentWidth({
component: { type: EXTENSION_TYPE, meta: { minWidth: 3 } } as any,
});
expect(withMin.minimumWidth).toBe(3);
const withoutMin = getDetailedComponentWidth({
component: { type: EXTENSION_TYPE, meta: {} } as any,
});
expect(withoutMin.minimumWidth).toBe(1);
});
test('extension nesting still respects container depth limits', () => {
// Even when validParents allows TAB, the depth gate still applies.
expect(
isValidChild({
parentType: TAB_TYPE,
childType: EXTENSION_TYPE,
parentDepth: 999,
childMeta: { validParents: [TAB_TYPE] },
}),
).toBe(false);
});
@@ -21,6 +21,7 @@ import { GRID_MIN_COLUMN_COUNT, GRID_COLUMN_COUNT } from './constants';
import {
ROW_TYPE,
COLUMN_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
CHART_TYPE,
DYNAMIC_TYPE,
@@ -105,6 +106,11 @@ export default function getDetailedComponentWidth({
);
}
});
} else if (component.type === EXTENSION_TYPE) {
// Extension-contributed components may declare a minimum width (grid
// columns) in their definition, seeded onto meta at creation.
const minWidth = component.meta?.minWidth as number | undefined;
result.minimumWidth = minWidth ?? GRID_MIN_COLUMN_COUNT;
} else if (
component.type === DYNAMIC_TYPE ||
component.type === MARKDOWN_TYPE ||
@@ -81,6 +81,7 @@ export default function getDropPosition(
const draggingItem = monitor.getItem() as {
id: string;
type: string;
meta?: { validParents?: string[] };
} | null;
// if dropped self on self, do nothing
@@ -92,6 +93,7 @@ export default function getDropPosition(
parentType: component.type,
parentDepth: componentDepth,
childType: draggingItem.type,
childMeta: draggingItem.meta,
});
const parentType = parentComponent?.type;
@@ -103,6 +105,7 @@ export default function getDropPosition(
parentType,
parentDepth,
childType: draggingItem.type,
childMeta: draggingItem.meta,
});
if (!validChild && !validSibling) {
@@ -16,17 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
import { CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE } from './componentTypes';
import {
CHART_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
DYNAMIC_TYPE,
} from './componentTypes';
const USER_CONTENT_COMPONENT_TYPE: string[] = [
CHART_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
DYNAMIC_TYPE,
];
export default function isDashboardEmpty(layout: any): boolean {
// has at least one chart or markdown component
// has at least one chart, markdown, or contributed user-content component
return !Object.values(layout).some(
({ type }: { type?: string }) =>
type && USER_CONTENT_COMPONENT_TYPE.includes(type),
({ type, meta }: { type?: string; meta?: { isUserContent?: boolean } }) => {
if (!type || !USER_CONTENT_COMPONENT_TYPE.includes(type)) {
return false;
}
// Extension components may opt out of counting as user content.
if (type === EXTENSION_TYPE && meta?.isUserContent === false) {
return false;
}
return true;
},
);
}
@@ -39,6 +39,7 @@ import {
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TABS_TYPE,
@@ -64,6 +65,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
[DASHBOARD_GRID_TYPE]: {
[CHART_TYPE]: depthOne,
[DYNAMIC_TYPE]: depthOne,
[EXTENSION_TYPE]: depthOne,
[MARKDOWN_TYPE]: depthOne,
[COLUMN_TYPE]: depthOne,
[DIVIDER_TYPE]: depthOne,
@@ -75,6 +77,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
[ROW_TYPE]: {
[CHART_TYPE]: depthFour,
[DYNAMIC_TYPE]: depthFour,
[EXTENSION_TYPE]: depthFour,
[MARKDOWN_TYPE]: depthFour,
[COLUMN_TYPE]: depthFour,
},
@@ -86,6 +89,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
[TAB_TYPE]: {
[CHART_TYPE]: depthFive,
[DYNAMIC_TYPE]: depthFive,
[EXTENSION_TYPE]: depthFive,
[MARKDOWN_TYPE]: depthFive,
[COLUMN_TYPE]: depthThree,
[DIVIDER_TYPE]: depthFive,
@@ -97,6 +101,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
[COLUMN_TYPE]: {
[CHART_TYPE]: depthFive,
[HEADER_TYPE]: depthFive,
[EXTENSION_TYPE]: depthFive,
[MARKDOWN_TYPE]: depthFive,
[ROW_TYPE]: depthThree,
[DIVIDER_TYPE]: depthThree,
@@ -108,6 +113,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
[DYNAMIC_TYPE]: {},
[DIVIDER_TYPE]: {},
[HEADER_TYPE]: {},
[EXTENSION_TYPE]: {},
[MARKDOWN_TYPE]: {},
};
@@ -115,14 +121,29 @@ interface IsValidChildProps {
parentType?: string;
childType?: string;
parentDepth?: unknown;
/**
* The child's meta, when available (e.g. during a drag). Extension-contributed
* components may declare `validParents` to restrict which container types may
* hold them; the restriction is seeded onto meta at creation.
*/
childMeta?: { validParents?: string[] };
}
export default function isValidChild(child: IsValidChildProps): boolean {
const { parentType, childType, parentDepth } = child;
const { parentType, childType, parentDepth, childMeta } = child;
if (!parentType || !childType || typeof parentDepth !== 'number') {
return false;
}
// Per-component parent restriction for extension components.
if (
childType === EXTENSION_TYPE &&
Array.isArray(childMeta?.validParents) &&
!childMeta.validParents.includes(parentType)
) {
return false;
}
const maxParentDepth: number | undefined =
parentMaxDepthLookup[parentType]?.[childType];
@@ -24,6 +24,7 @@ import {
COLUMN_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TABS_TYPE,
@@ -56,6 +57,7 @@ const typeToDefaultMetaData: Record<string, LayoutItemMeta | null> = {
headerSize: MEDIUM_HEADER,
background: BACKGROUND_TRANSPARENT,
},
[EXTENSION_TYPE]: { width: GRID_DEFAULT_CHART_WIDTH, height: 50 },
[MARKDOWN_TYPE]: { width: GRID_DEFAULT_CHART_WIDTH, height: 50 },
[ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
[TABS_TYPE]: null,
@@ -51,6 +51,7 @@ export default function newEntitiesFromDrop({
const wrapChildInRow = shouldWrapChildInRow({
parentType: dropType,
childType: dragType,
childMeta: dragging.meta,
});
const newEntities: Record<string, DashboardComponent> = {
@@ -20,6 +20,7 @@ import {
DASHBOARD_GRID_TYPE,
CHART_TYPE,
COLUMN_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
TAB_TYPE,
} from './componentTypes';
@@ -28,10 +29,20 @@ import { ComponentType } from '../types';
interface WrapChildParams {
parentType: ComponentType | undefined | null;
childType: ComponentType | undefined | null;
/**
* The child's meta, when available. Extension-contributed components may set
* `wrapInRow: false` in their definition (seeded onto meta) to opt out of the
* default auto-wrapping.
*/
childMeta?: { wrapInRow?: boolean };
}
type ParentTypes = typeof DASHBOARD_GRID_TYPE | typeof TAB_TYPE;
type ChildTypes = typeof CHART_TYPE | typeof COLUMN_TYPE | typeof MARKDOWN_TYPE;
type ChildTypes =
| typeof CHART_TYPE
| typeof COLUMN_TYPE
| typeof EXTENSION_TYPE
| typeof MARKDOWN_TYPE;
const typeToWrapChildLookup: Record<
ParentTypes,
@@ -40,12 +51,14 @@ const typeToWrapChildLookup: Record<
[DASHBOARD_GRID_TYPE]: {
[CHART_TYPE]: true,
[COLUMN_TYPE]: true,
[EXTENSION_TYPE]: true,
[MARKDOWN_TYPE]: true,
},
[TAB_TYPE]: {
[CHART_TYPE]: true,
[COLUMN_TYPE]: true,
[EXTENSION_TYPE]: true,
[MARKDOWN_TYPE]: true,
},
};
@@ -53,9 +66,15 @@ const typeToWrapChildLookup: Record<
export default function shouldWrapChildInRow({
parentType,
childType,
childMeta,
}: WrapChildParams): boolean {
if (!parentType || !childType) return false;
// Extension components may opt out of auto-wrapping.
if (childType === EXTENSION_TYPE && childMeta?.wrapInRow === false) {
return false;
}
const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
if (!wrapChildLookup) return false;
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
return (
<List
style={{ width: width - BORDER_WIDTH, height }}
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
rowHeight={rowHeight}
rowCount={flattenedItems.length}
rowProps={rowProps}
@@ -251,4 +251,11 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
],
[GenericDataType.MultiValue]: [
[
'columnWidth',
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
['truncateLongCells'],
],
};
@@ -270,6 +270,74 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter.comparator).toBe(undefined);
});
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
// both key off `operator`, so dropping the comparator would render such a
// filter as `col =` and query it as `col IS NULL`.
test('keeps the legacy boolean comparator for IS_TRUE', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
comparator: true,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(true);
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
});
test('keeps the legacy boolean comparator for IS_FALSE', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsFalse,
comparator: false,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(false);
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
});
test('restores the boolean even when the stored comparator is missing', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
clause: Clauses.Where,
});
expect(adhocFilter.comparator).toBe(true);
});
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
const stored = {
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
comparator: true,
clause: Clauses.Where,
};
// DndFilterSelect wraps props.value and hands those instances to onChange
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
expect(posted.operator).toBe('==');
expect(posted.comparator).toBe(true);
expect(posted.operatorId).toBe(Operators.IsTrue);
});
test('leaves a genuine equality filter on a boolean value alone', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.Equals,
comparator: true,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(true);
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
});
test('sets the label properly if subject is a string', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
@@ -30,6 +30,15 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
// would not, and Druid rejects that predicate on VARCHAR columns.
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
[Operators.IsTrue, true],
[Operators.IsFalse, false],
]);
interface AdhocFilterInput {
expressionType?: string;
subject?: string | { column_name?: string; [key: string]: unknown } | null;
@@ -77,6 +86,16 @@ export default class AdhocFilter {
) {
this.comparator = undefined;
}
if (
this.operator ===
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
adhocFilter.operatorId &&
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
) {
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
adhocFilter.operatorId,
);
}
this.clause = adhocFilter.clause || Clauses.Where;
this.sqlExpression = null;
} else if (this.expressionType === ExpressionTypes.Sql) {
@@ -367,8 +367,22 @@ function AdhocFilterEditPopover({
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
...(datasource?.type === 'semantic_view' ||
[
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
Operators.LengthEquals,
Operators.LengthGreaterThan,
Operators.LengthLessThan,
Operators.LengthGreaterThanOrEqual,
Operators.LengthLessThanOrEqual,
].includes(adhocFilter.operatorId as Operators)
? // Hide the Custom SQL tab for element-level array operators: they
// have no portable SQL representation, and converting one would
// silently turn the filter into invalid raw SQL.
[]
: [
{
key: ExpressionTypes.Sql,
@@ -35,6 +35,7 @@ import {
} from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import fetchMock from 'fetch-mock';
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
@@ -252,6 +253,78 @@ test('shows boolean only operators when subject is number', () => {
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
});
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
const props = setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'skills',
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [
{
id: 3,
column_name: 'skills',
type: 'Array(String)',
type_generic: GenericDataType.MultiValue,
},
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
[
Operators.Equals,
Operators.NotEquals,
Operators.In,
Operators.NotIn,
Operators.IsNull,
Operators.IsNotNull,
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
].forEach(operator =>
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
);
// scalar-only operators are hidden for array columns
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
);
});
test('hides element-level array operators for non multi-value columns', () => {
const props = setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
[
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
].forEach(operator =>
expect(isOperatorRelevant(operator, 'value')).toBe(false),
);
});
test('will convert from individual comparator to array if the operator changes to multi', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(
@@ -309,6 +382,49 @@ test('will convert from array to individual comparators if the operator changes
);
});
test('resets the comparator when switching between array value families', () => {
// Equal to (whole-array literal) -> Contains all (individual elements):
// the value spaces are incompatible, so the stale value must be cleared.
const wholeArrayFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'scores',
operatorId: Operators.Equals,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
comparator: '[5,6,7]',
clause: Clauses.Where,
});
const props = setup({ adhocFilter: wholeArrayFilter });
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.ContainsAll);
const lastCall =
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
expect(lastCall.comparator).toBeUndefined();
});
test('keeps the value when switching within the element family', () => {
// Contains any <-> Contains all both take individual elements, so the
// selected elements should carry over.
const elementFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'scores',
operatorId: Operators.ContainsAny,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
comparator: ['5', '6'],
clause: Clauses.Where,
});
const props = setup({ adhocFilter: elementFilter });
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.ContainsAll);
const lastCall =
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
expect(lastCall.comparator).toEqual(['5', '6']);
});
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
const props = setup();
const { onComparatorChange } = useSimpleTabFilterProps(
@@ -399,6 +515,28 @@ test('will not display boolean operators when column type is string', () => {
});
});
test.each(['STRING', 'DATE'])(
'will not display boolean operators when an expression column declares type %s',
type => {
const props = setup({
datasource: {
type: 'table' as const,
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type, expression: '"value"' }],
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(false);
});
},
);
test('will display boolean operators when column is an expression', () => {
const props = setup({
datasource: {
@@ -32,6 +32,7 @@ import {
isDefined,
SupersetClient,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { styled, useTheme, css } from '@apache-superset/core/theme';
import {
Operators,
@@ -118,6 +119,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
const isColumnNumber =
!!column && (column.type === 'INT' || column.type === 'INTEGER');
const isColumnFunction = !!column && !!column.expression;
const isColumnMultiValue =
!!column && column.type_generic === GenericDataType.MultiValue;
if (operator && operator === Operators.LatestPartition) {
const { partitionColumn } = props;
@@ -127,8 +130,41 @@ export const useSimpleTabFilterProps = (props: Props) => {
// hide the TEMPORAL_RANGE operator
return false;
}
// Element-level array operators only apply to multi-value columns.
const arrayElementOperators = [
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
Operators.LengthEquals,
Operators.LengthGreaterThan,
Operators.LengthLessThan,
Operators.LengthGreaterThanOrEqual,
Operators.LengthLessThanOrEqual,
];
if (arrayElementOperators.includes(operator)) {
return isColumnMultiValue;
}
if (isColumnMultiValue) {
// Array columns support whole-array operators (=, !=, In, Not in, null
// checks) plus the element-level operators above. Scalar-only operators
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
return [
Operators.Equals,
Operators.NotEquals,
Operators.In,
Operators.NotIn,
Operators.IsNull,
Operators.IsNotNull,
...arrayElementOperators,
].includes(operator);
}
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
return isColumnBoolean || isColumnNumber || isColumnFunction;
// An expression column may evaluate to a boolean, but that is only a
// safe assumption while its type is unknown; a declared type wins.
return (
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
);
}
if (isColumnBoolean) {
return operator === Operators.IsNull || operator === Operators.IsNotNull;
@@ -167,9 +203,19 @@ export const useSimpleTabFilterProps = (props: Props) => {
].operation
: null;
if (!isDefined(operator)) {
// if operator is `null`, use the `IN` and reset the comparator.
operator = Operators.In;
operatorId = Operators.In;
// The previous operator is not relevant for the new subject; pick a
// sensible default and reset the comparator. Multi-value (array) columns
// default to "Contains any" (element membership) rather than the
// scalar-only IN.
const newColumn = props.datasource.columns?.find(
col => col.column_name === subject,
);
const defaultOperator =
newColumn?.type_generic === GenericDataType.MultiValue
? Operators.ContainsAny
: Operators.In;
operator = defaultOperator;
operatorId = defaultOperator;
comparator = undefined;
}
@@ -193,10 +239,38 @@ export const useSimpleTabFilterProps = (props: Props) => {
};
const onOperatorChange = (operatorId: Operators) => {
const currentComparator = props.adhocFilter.comparator;
// The value space differs between operator families: element-level array
// ops (Contains any/all) take individual elements, whole-array/scalar ops
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
// unary ops take nothing. A value from one family is meaningless in another,
// so reset the value when the family changes (e.g. Equal to -> Contains all).
const comparatorKind = (op?: Operators): string => {
if (!op) return 'none';
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
return 'element';
}
if (
op === Operators.LengthEquals ||
op === Operators.LengthGreaterThan ||
op === Operators.LengthLessThan ||
op === Operators.LengthGreaterThanOrEqual ||
op === Operators.LengthLessThanOrEqual
) {
return 'length';
}
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
return 'value';
};
const valueFamilyChanged =
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
comparatorKind(operatorId);
let newComparator;
// convert between list of comparators and individual comparators
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
if (MULTI_OPERATORS.has(operatorId)) {
if (valueFamilyChanged) {
newComparator = undefined;
} else if (MULTI_OPERATORS.has(operatorId)) {
// convert between list of comparators and individual comparators
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
newComparator = Array.isArray(currentComparator)
? currentComparator
: [currentComparator].filter(element => element != null);
@@ -433,19 +507,42 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
if (loadingComparatorSuggestions) {
controller.abort();
}
// Element-level array operators (Contains any / Contains all) search
// inside the array, so suggest individual elements; whole-array
// operators (=, In, …) keep the default distinct-array suggestions.
const { operatorId } = props.adhocFilter;
const arrayElements =
operatorId === Operators.ContainsAny ||
operatorId === Operators.ContainsAll;
setLoadingComparatorSuggestions(true);
SupersetClient.get({
signal,
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
arrayElements ? '?array_elements=true' : ''
}`,
})
.then(({ json }) => {
setSuggestions(
json.result.map(
(suggestion: null | number | boolean | string) => ({
value: suggestion,
label: optionLabel(suggestion),
}),
),
json.result.map((suggestion: unknown) => {
// Complex column values arrive as JS arrays or objects: whole
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
// A raw array/object is neither a valid single-select value
// (antd collapses an array to its first element) nor renderable
// as a React child (an object throws). Render it as its literal
// string, which is also exactly what the backend's
// parse_array_literal expects for the whole-array operators.
if (suggestion !== null && typeof suggestion === 'object') {
const literal = JSON.stringify(suggestion);
return { value: literal, label: literal };
}
return {
value: suggestion as null | number | boolean | string,
label: optionLabel(
suggestion as null | number | boolean | string,
),
};
}),
);
setLoadingComparatorSuggestions(false);
})
@@ -464,6 +561,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}, [
props.adhocFilter.subject,
props.adhocFilter.clause,
props.adhocFilter.operatorId,
props.datasource,
datePicker,
]);
@@ -44,6 +44,17 @@ export const OPERATORS_TO_SQL = {
'IS NULL': 'IS NULL',
'IS TRUE': 'IS TRUE',
'IS FALSE': 'IS FALSE',
// Element-level array operators (shown as filter labels; not executable SQL —
// the Custom SQL tab is hidden for these).
CONTAINS_ANY: 'CONTAINS ANY',
CONTAINS_ALL: 'CONTAINS ALL',
IS_EMPTY: 'IS EMPTY',
IS_NOT_EMPTY: 'IS NOT EMPTY',
LENGTH_EQUALS: 'LENGTH =',
LENGTH_GREATER_THAN: 'LENGTH >',
LENGTH_LESS_THAN: 'LENGTH <',
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
'LATEST PARTITION': ({
datasource,
}: {
+52 -1
View File
@@ -45,6 +45,17 @@ export enum Operators {
IsTrue = 'IS_TRUE',
IsFalse = 'IS_FALSE',
TemporalRange = 'TEMPORAL_RANGE',
// Element-level operators for multi-value (array) columns
ContainsAny = 'CONTAINS_ANY',
ContainsAll = 'CONTAINS_ALL',
IsEmpty = 'IS_EMPTY',
IsNotEmpty = 'IS_NOT_EMPTY',
// Length (element-count) comparison operators for array columns
LengthEquals = 'LENGTH_EQUALS',
LengthGreaterThan = 'LENGTH_GREATER_THAN',
LengthLessThan = 'LENGTH_LESS_THAN',
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
}
export interface OperatorType {
@@ -89,6 +100,39 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
display: t('TEMPORAL_RANGE'),
operation: 'TEMPORAL_RANGE',
},
[Operators.ContainsAny]: {
display: t('Contains any'),
operation: 'CONTAINS_ANY',
},
[Operators.ContainsAll]: {
display: t('Contains all'),
operation: 'CONTAINS_ALL',
},
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
[Operators.IsNotEmpty]: {
display: t('Is not empty'),
operation: 'IS_NOT_EMPTY',
},
[Operators.LengthEquals]: {
display: t('Length equals (=)'),
operation: 'LENGTH_EQUALS',
},
[Operators.LengthGreaterThan]: {
display: t('Length greater than (>)'),
operation: 'LENGTH_GREATER_THAN',
},
[Operators.LengthLessThan]: {
display: t('Length less than (<)'),
operation: 'LENGTH_LESS_THAN',
},
[Operators.LengthGreaterThanOrEqual]: {
display: t('Length greater or equal (>=)'),
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
},
[Operators.LengthLessThanOrEqual]: {
display: t('Length less or equal (<=)'),
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
},
};
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
@@ -105,7 +149,12 @@ export const HAVING_OPERATORS = [
Operators.GreaterThan,
Operators.GreaterThanOrEqual,
];
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
export const MULTI_OPERATORS = new Set([
Operators.In,
Operators.NotIn,
Operators.ContainsAny,
Operators.ContainsAll,
]);
// CUSTOM_OPERATORS will show operator in simple mode,
// but will generate customized sqlExpression
export const CUSTOM_OPERATORS = new Set([
@@ -120,6 +169,8 @@ export const DISABLE_INPUT_OPERATORS = [
Operators.LatestPartition,
Operators.IsTrue,
Operators.IsFalse,
Operators.IsEmpty,
Operators.IsNotEmpty,
];
export const sqlaAutoGeneratedMetricNameRegex =
@@ -82,3 +82,14 @@ test('Should handle boolean true comparator as a string value', () => {
"subject operator 'TRUE'",
);
});
test('Should render array-literal comparators as-is (not quoted)', () => {
// Whole-array = filter: the pasted array literal is shown unquoted.
expect(getSimpleSQLExpression('ingredients', '=', "['1 large egg']")).toBe(
"ingredients = ['1 large egg']",
);
// IN with multiple array literals.
expect(
getSimpleSQLExpression('ingredients', Operators.In, ["['a']", "['b']"]),
).toBe(`ingredients ${Operators.In} (['a'], ['b'])`);
});
@@ -461,10 +461,15 @@ export const getSimpleSQLExpression = (
if (comparatorArray.length > 0 && showComparator) {
const formattedComparators = comparatorArray
.map(val => optionLabel(val))
.map(
val =>
`${quote}${isString ? String(val).replace(/'/g, "''") : val}${quote}`,
);
.map(val => {
// Array-literal values (e.g. ['a', 'b']) are shown as-is rather than
// quoted/escaped as a string, so array-column filters read naturally.
const asString = String(val);
if (asString.startsWith('[') && asString.endsWith(']')) {
return asString;
}
return `${quote}${isString ? asString.replace(/'/g, "''") : val}${quote}`;
});
expression += ` ${prefix}${formattedComparators.join(', ')}${suffix}`;
}
}
@@ -25,6 +25,7 @@ import {
chat,
core,
commands,
dashboardComponents,
editors,
extensions,
menus,
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
chat,
core,
commands,
dashboardComponents,
editors,
extensions,
menus,
@@ -31,6 +31,7 @@ import type {
chat,
commands,
core,
dashboardComponents,
editors,
extensions,
menus,
@@ -45,6 +46,7 @@ export interface Namespaces {
core: typeof core;
chat: typeof chat;
commands: typeof commands;
dashboardComponents: typeof dashboardComponents;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
@@ -31,7 +31,12 @@ import {
import { Group, Role, UserObject } from 'src/pages/UsersList/types';
import { Actions } from 'src/constants';
import { BaseUserListModalProps, FormValues } from './types';
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
import {
createUser,
updateUser,
atLeastOneRoleOrGroup,
handleUserError,
} from './utils';
export interface UserModalProps extends BaseUserListModalProps {
roles: Role[];
@@ -51,36 +56,6 @@ function UserListModal({
}: UserModalProps) {
const { addDangerToast, addSuccessToast } = useToasts();
const handleFormSubmit = async (values: FormValues) => {
const handleError = async (
err: any,
action: Actions.CREATE | Actions.UPDATE,
) => {
let errorMessage =
action === Actions.CREATE
? t('There was an error creating the user. Please, try again.')
: t('There was an error updating the user. Please, try again.');
if (err.status === 422) {
const errorData = await err.json();
const detail = errorData?.message || '';
if (detail.includes('duplicate key value')) {
if (detail.includes('ab_user_username_key')) {
errorMessage = t(
'This username is already taken. Please choose another one.',
);
} else if (detail.includes('ab_user_email_key')) {
errorMessage = t(
'This email is already associated with an account. Please choose another one.',
);
}
}
}
addDangerToast(errorMessage);
throw err;
};
if (isEditMode) {
if (!user) {
throw new Error('User is required in edit mode');
@@ -89,14 +64,14 @@ function UserListModal({
await updateUser(user.id, values);
addSuccessToast(t('The user has been updated successfully.'));
} catch (err) {
await handleError(err, Actions.UPDATE);
await handleUserError(err as Response, Actions.UPDATE, addDangerToast);
}
} else {
try {
await createUser(values);
addSuccessToast(t('The user has been created successfully.'));
} catch (err) {
await handleError(err, Actions.CREATE);
await handleUserError(err as Response, Actions.CREATE, addDangerToast);
}
}
};
@@ -0,0 +1,99 @@
/**
* 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 { Actions } from 'src/constants';
import { handleUserError } from './utils';
test('shows the password validation message from a 400 response', async () => {
const error = new Response(
JSON.stringify({
message: {
password: ['Password must be at least 8 characters long.'],
},
}),
{ status: 400 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'Password must be at least 8 characters long.',
);
});
test('shows a plain string message from a 400 response', async () => {
const error = new Response(
JSON.stringify({ message: 'User must have at least one role or group!' }),
{ status: 400 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.UPDATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'User must have at least one role or group!',
);
});
test('keeps the duplicate username message for a 422 response', async () => {
const error = new Response(
JSON.stringify({
message:
'duplicate key value violates unique constraint "ab_user_username_key"',
}),
{ status: 422 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'This username is already taken. Please choose another one.',
);
});
test('shows the generic message when a 422 response has no message', async () => {
const error = new Response(JSON.stringify({ foo: 'bar' }), { status: 422 });
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'There was an error creating the user. Please, try again.',
);
});
test('shows the generic message when a 400 response is not JSON', async () => {
const error = new Response('<html>Bad request</html>', {
status: 400,
headers: { 'Content-Type': 'text/html' },
});
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'There was an error creating the user. Please, try again.',
);
});
+40 -1
View File
@@ -17,10 +17,49 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
import { SelectOption } from 'src/components/ListView';
import { Actions } from 'src/constants';
import { FormValues } from './types';
type AddDangerToast = (message: string) => void;
export const handleUserError = async (
err: Response,
action: Actions.CREATE | Actions.UPDATE,
addDangerToast: AddDangerToast,
): Promise<never> => {
let errorMessage =
action === Actions.CREATE
? t('There was an error creating the user. Please, try again.')
: t('There was an error updating the user. Please, try again.');
if (err.status === 400 || err.status === 422) {
const errorData = await getClientErrorObject(err);
const message: unknown = errorData.message;
if (err.status === 400 && message && errorData.error) {
errorMessage = errorData.error;
} else if (
err.status === 422 &&
errorData.error?.includes('duplicate key value')
) {
if (errorData.error.includes('ab_user_username_key')) {
errorMessage = t(
'This username is already taken. Please choose another one.',
);
} else if (errorData.error.includes('ab_user_email_key')) {
errorMessage = t(
'This email is already associated with an account. Please choose another one.',
);
}
}
}
addDangerToast(errorMessage);
throw err;
};
export const createUser = async (values: FormValues) => {
const { confirmPassword: _confirmPassword, ...payload } = values;
if (payload.active == null) {
@@ -1157,6 +1157,34 @@ test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DAT
});
});
test('legacy dashboard default URLs use the registered client route', async () => {
const dataset = {
...mockDatasets[0],
explore_url: '/superset/dashboard/123/?standalone=1#section',
};
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(
mockAdminUser,
{},
{
common: {
conf: {
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
},
},
},
);
const datasetLink = await screen.findByRole('link', {
name: dataset.table_name,
});
expect(datasetLink).toHaveAttribute(
'href',
'/dashboard/123/?standalone=1#section',
);
});
// Note: These delete error tests verify that the modal doesn't open when fetching
// related_objects fails. The component's openDatasetDeleteModal error handler
// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
@@ -54,10 +54,18 @@ import {
const APP_ROOT = '/superset';
const renderUnderSubdirectory = () => {
const renderUnderSubdirectory = (preventUnsafeDefaultUrls = false) => {
const defaultState = createDefaultStoreState(mockAdminUser);
const store = createMockStore({
...createDefaultStoreState(mockAdminUser),
...defaultState,
user: mockAdminUser,
common: {
...defaultState.common,
conf: {
...defaultState.common?.conf,
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: preventUnsafeDefaultUrls,
},
},
});
return render(
<Provider store={store}>
@@ -115,6 +123,31 @@ test('explore link is single-prefixed under a subdirectory deployment', async ()
expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
});
test('legacy dashboard default URL uses the router basename once', async () => {
// A subdirectory user pastes the full browser path, so the saved value
// carries both the application root and the legacy `/superset` prefix.
// stripAppRoot removes the root and the legacy normalization removes the
// prefix, leaving the basename to re-add the root exactly once.
const dataset = {
...mockDatasets[0],
explore_url: `${APP_ROOT}/superset/dashboard/123/?standalone=1#section`,
};
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderUnderSubdirectory(true);
const dashboardLink = await screen.findByRole('link', {
name: dataset.table_name,
});
expect(dashboardLink).toHaveAttribute(
'href',
`${APP_ROOT}/dashboard/123/?standalone=1#section`,
);
expect(dashboardLink.getAttribute('href')).not.toContain(
'/superset/superset',
);
});
test('external default_endpoint passes through unprefixed', async () => {
const dataset = {
...mockDatasets[0],
@@ -87,7 +87,6 @@ import withToasts from 'src/components/MessageToasts/withToasts';
import { Icons } from '@superset-ui/core/components/Icons';
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import {
PAGE_SIZE,
SORT_BY,
@@ -114,6 +113,10 @@ import type {
} from 'src/types/bootstrapTypes';
import type User from 'src/types/User';
// Keep saved Default URLs compatible with the prefix-free SPA route.
const normalizeLegacyDashboardUrl = (url: string) =>
url.replace(/^\/superset(?=\/dashboard(?:\/|$))/, '');
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
type DatasetExtra = {
certification?: {
@@ -722,7 +725,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
// Router basename, which re-prefixes the root — so strip it here to
// avoid a doubled `/superset/superset/...`. External
// `default_endpoint` URLs pass through unchanged.
const exploreTo = stripAppRoot(exploreURL);
const exploreTo = normalizeLegacyDashboardUrl(
stripAppRoot(exploreURL),
);
let titleLink: JSX.Element;
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
titleLink = (
@@ -18,14 +18,17 @@
*/
/*
This file can be overridden from outside by custom config, it will add/delete new components to existing config in
superset-frontend/src/visualizations/presets/dashboardComponents.ts file
Registers built-in dashboard components through the `dashboardComponents`
Extensions contribution point. This file can be overridden from outside by
custom config to add or remove components.
The legacy DashboardComponentsRegistry
(visualizations/presets/dashboardComponents) is deprecated in favor of this
path.
*/
// import dashboardComponentsRegistry from '../visualizations/presets/dashboardComponents';
// import example from '../visualizations/dashboardComponents/ExampleComponent';
import registerIframeComponent from '../dashboard/extensions/iframe';
export default function setupDashboardComponents() {
// Add custom dashboard components here. Example:
// dashboardComponentsRegistry.set('example', example);
registerIframeComponent();
}
@@ -16,6 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
/**
* @deprecated Superseded by the `dashboardComponents` Extensions contribution
* point (`@apache-superset/core` -> `src/core/dashboardComponents`) and the
* EXTENSION_TYPE dashboard component. New custom dashboard components should be
* registered via `dashboardComponents.registerDashboardComponent(...)`. This
* registry and the DYNAMIC_TYPE path remain only for backwards compatibility
* and will be removed in a future major release.
*/
import {
ComponentItem,
ComponentRegistry,
+4 -4
View File
@@ -28,7 +28,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
@@ -2053,9 +2053,9 @@
}
},
"node_modules/globals": {
"version": "17.9.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
"version": "17.10.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.10.0.tgz",
"integrity": "sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -36,7 +36,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
+81 -2
View File
@@ -17,6 +17,7 @@
# pylint: disable=invalid-name
from __future__ import annotations
import inspect
import logging
from datetime import datetime
from pprint import pformat
@@ -205,8 +206,86 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
) -> None:
post_processing = post_processing or []
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
self.post_processing = [
self._drop_unsupported_options(post_proc)
for post_proc in post_processing or []
if post_proc
]
@staticmethod
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
"""
Drop options that the post-processing operation no longer accepts.
A chart's ``query_context`` is written when the chart is saved and is
never rewritten afterwards, while Explore rebuilds the query from
``form_data`` at every render. A chart saved by an older version of
Superset can therefore reference an option that has since been removed
from the operation. ``exec_post_processing`` passes the stored options
as keyword arguments, so that option raises a bare ``TypeError`` on
every path that replays the stored ``query_context`` -- the chart data
endpoint, alerts and reports, thumbnails, CSV export -- while the same
chart still renders correctly in Explore.
Comparing against the signature avoids a hard-coded list of removed
option names, which would need extending at each release.
"""
operation = post_proc.get("operation")
function = (
getattr(pandas_postprocessing, operation, None)
if isinstance(operation, str)
else None
)
if function is None:
# A missing or unknown operation is left untouched, so that
# exec_post_processing reports it as InvalidPostProcessingError.
return post_proc
parameters = inspect.signature(function).parameters
if any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
):
return post_proc
# `exec_post_processing` calls the operation as `operation(df, **options)`,
# so an option can only reach a parameter that a caller may fill by
# keyword. That excludes the first parameter, which receives the
# DataFrame positionally, and any positional-only or `*args` parameter.
keyword_parameters = {
name
for position, (name, parameter) in enumerate(parameters.items())
if position > 0
and parameter.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
}
options = post_proc.get("options") or {}
unsupported = {key for key in options if key not in keyword_parameters}
if not unsupported:
return post_proc
# Logged at info: a chart saved before the option was removed hits this
# on every render, so a warning would repeat for as long as the chart
# is not resaved, without anything new to report.
logger.info(
"Dropping unsupported option(s) %s of post-processing operation "
"`%s`. The chart's stored query_context predates the current "
"signature of that operation.",
sorted(unsupported),
operation,
)
return {
**post_proc,
"options": {
key: value
for key, value in options.items()
if key in keyword_parameters
},
}
def _init_series_columns(
self,
+16
View File
@@ -673,6 +673,12 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Enables experimental chart plugins
# @lifecycle: development
"CHART_PLUGINS_EXPERIMENTAL": False,
# Allow users with the "can write on CSPAllowlist" permission (Admins by
# default) to punch holes in the Content Security Policy at runtime, e.g. to
# allow a new domain to be embedded in a dashboard iframe component. When
# disabled, the CSP is purely static/deploy-time and the allowlist is ignored.
# @lifecycle: development
"CSP_RUNTIME_ALLOWLIST": False,
# Experimental PyArrow engine for CSV parsing (may have issues with dates/nulls)
# @lifecycle: development
"CSV_UPLOAD_PYARROW_ENGINE": False,
@@ -2720,6 +2726,16 @@ CONTENT_SECURITY_POLICY_WARNING = True
# images where the webpack build-time flag of the same name cannot be changed.
SCARF_ANALYTICS = utils.cast_to_boolean(os.environ.get("SCARF_ANALYTICS", True))
# When the CSP_RUNTIME_ALLOWLIST feature flag is enabled, runtime allowlist
# entries are merged into the response CSP header. To avoid a metadata DB hit on
# every response, the allowlist is cached in-process for this many seconds. A
# write through the REST API invalidates the cache in the worker that handled the
# write; other workers pick up the change once their cached copy expires. Lower
# this for faster cross-worker propagation, raise it to reduce DB load.
CSP_RUNTIME_ALLOWLIST_CACHE_TTL = int(
os.environ.get("CSP_RUNTIME_ALLOWLIST_CACHE_TTL", 30)
)
# Do you want Talisman enabled?
TALISMAN_ENABLED = utils.cast_to_boolean(os.environ.get("TALISMAN_ENABLED", True))
+7 -1
View File
@@ -957,7 +957,13 @@ class AnnotationDatasource(BaseDatasource):
def get_query_str(self, query_obj: QueryObjectDict) -> str:
raise NotImplementedError()
def values_for_column(self, column_name: str, limit: int = 10000) -> list[Any]:
def values_for_column(
self,
column_name: str,
limit: int = 10000,
denormalize_column: bool = False,
array_elements: bool = False,
) -> list[Any]:
raise NotImplementedError()
+16
View File
@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+312
View File
@@ -0,0 +1,312 @@
# 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 logging
from typing import Any
from flask import request, Response
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from marshmallow import ValidationError
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.csp_allowlist.schemas import (
CSPAllowlistEntryPostSchema,
CSPAllowlistEntryPutSchema,
get_delete_ids_schema,
openapi_spec_methods_override,
)
from superset.daos.csp import CSPAllowlistDAO
from superset.extensions import db, event_logger
from superset.models.csp import CSPAllowlistEntry
from superset.security.csp import invalidate_csp_allowlist_cache
from superset.utils.decorators import transaction
from superset.views.base_api import (
BaseSupersetModelRestApi,
RelatedFieldFilter,
statsd_metrics,
)
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
logger = logging.getLogger(__name__)
class CSPAllowlistRestApi(BaseSupersetModelRestApi):
"""CRUD API for runtime Content Security Policy allowlist entries.
The ``CSPAllowlist`` view-menu is registered as admin-only (see
``SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS``), so only Admins (or a
custom role explicitly granted ``can_write on CSPAllowlist``) may mutate the
allowlist. Mutations invalidate the per-worker CSP cache so the new policy
takes effect without a server restart.
"""
datamodel = SQLAInterface(CSPAllowlistEntry)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
}
class_permission_name = "CSPAllowlist"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
resource_name = "csp_allowlist"
allow_browser_login = True
show_columns = [
"id",
"domain",
"directive",
"description",
"changed_on_delta_humanized",
"changed_by.first_name",
"changed_by.id",
"changed_by.last_name",
"created_by.first_name",
"created_by.id",
"created_by.last_name",
]
list_columns = [
"id",
"domain",
"directive",
"description",
"created_on",
"changed_on_delta_humanized",
"changed_by.first_name",
"changed_by.id",
"changed_by.last_name",
"created_by.first_name",
"created_by.id",
"created_by.last_name",
]
add_columns = ["domain", "directive", "description"]
edit_columns = add_columns
order_columns = ["domain", "directive", "changed_on", "created_on"]
add_model_schema = CSPAllowlistEntryPostSchema()
edit_model_schema = CSPAllowlistEntryPutSchema()
allowed_rel_fields = {"created_by", "changed_by"}
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,
}
openapi_spec_tag = "CSP Allowlist"
openapi_spec_methods = openapi_spec_methods_override
related_field_filters = {
"changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
}
base_related_field_filters = {
"changed_by": [["id", BaseFilterRelatedUsers, lambda: []]],
}
def post_add(self, item: CSPAllowlistEntry) -> None:
invalidate_csp_allowlist_cache()
def post_update(self, item: CSPAllowlistEntry) -> None:
invalidate_csp_allowlist_cache()
def post_delete(self, item: CSPAllowlistEntry) -> None:
invalidate_csp_allowlist_cache()
@expose("/", methods=("POST",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=False,
)
def post(self) -> Response:
"""Create a CSP allowlist entry.
---
post:
summary: Create a CSP allowlist entry
requestBody:
description: CSP allowlist entry schema
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CSPAllowlistRestApi.post'
responses:
201:
description: CSP allowlist entry created
content:
application/json:
schema:
type: object
properties:
id:
type: number
result:
$ref: '#/components/schemas/CSPAllowlistRestApi.post'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
if not request.is_json:
return self.response_400(message="Request is not JSON")
try:
item = self.add_model_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
entry = CSPAllowlistEntry(**item)
self.pre_add(entry)
entry = self._save_entry(entry)
self.post_add(entry)
return self.response(
201,
id=entry.id,
result=self.add_model_schema.dump(entry, many=False),
)
@transaction()
def _save_entry(self, entry: CSPAllowlistEntry) -> CSPAllowlistEntry:
db.session.add(entry)
db.session.flush()
return entry
@expose("/<int:pk>", methods=("PUT",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
log_to_statsd=False,
)
def put(self, pk: int) -> Response:
"""Update a CSP allowlist entry.
---
put:
summary: Update a CSP allowlist entry
parameters:
- in: path
schema:
type: integer
name: pk
requestBody:
description: CSP allowlist entry schema
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CSPAllowlistRestApi.put'
responses:
200:
description: CSP allowlist entry updated
content:
application/json:
schema:
type: object
properties:
id:
type: number
result:
$ref: '#/components/schemas/CSPAllowlistRestApi.put'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
entry = self.datamodel.get(pk, self._base_filters)
if not entry:
return self.response_404()
if not request.is_json:
return self.response_400(message="Request is not JSON")
try:
item = self.edit_model_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
for key, value in item.items():
setattr(entry, key, value)
self.pre_update(entry)
entry = self._save_entry(entry)
self.post_update(entry)
return self.response(
200,
id=entry.id,
result=self.edit_model_schema.dump(entry, many=False),
)
@expose("/", methods=("DELETE",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
log_to_statsd=False,
)
@parse_rison(get_delete_ids_schema)
def bulk_delete(self, **kwargs: Any) -> Response:
"""Bulk delete CSP allowlist entries.
---
delete:
summary: Bulk delete CSP allowlist entries
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_delete_ids_schema'
responses:
200:
description: CSP allowlist entries bulk delete
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
item_ids = kwargs["rison"]
entries = CSPAllowlistDAO.find_by_ids(item_ids)
if not entries:
return self.response_404()
CSPAllowlistDAO.delete(entries)
invalidate_csp_allowlist_cache()
return self.response(
200,
message=ngettext(
"Deleted %(num)d CSP allowlist entry",
"Deleted %(num)d CSP allowlist entries",
num=len(entries),
),
)
+113
View File
@@ -0,0 +1,113 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any
from flask_babel import gettext as _
from marshmallow import fields, Schema, validates
from marshmallow.exceptions import ValidationError
from superset.security.csp import (
ALLOWED_DIRECTIVES,
is_valid_csp_directive,
is_valid_csp_origin,
)
domain_description = (
"A bare origin to allow, e.g. 'https://example.com' or "
"'https://example.com:8443'. Wildcards, paths, query strings and fragments "
"are rejected."
)
directive_description = (
"The CSP directive to widen. Defaults to 'frame-src'. One of: "
f"{', '.join(sorted(ALLOWED_DIRECTIVES))}."
)
openapi_spec_methods_override = {
"get": {"get": {"summary": "Get a CSP allowlist entry"}},
"get_list": {
"get": {
"summary": "Get a list of CSP allowlist entries",
"description": "Gets a list of runtime Content Security Policy "
"allowlist entries, use Rison or JSON query parameters for "
"filtering, sorting, pagination and for selecting specific "
"columns and metadata.",
}
},
"post": {"post": {"summary": "Create a CSP allowlist entry"}},
"put": {"put": {"summary": "Update a CSP allowlist entry"}},
"delete": {"delete": {"summary": "Delete a CSP allowlist entry"}},
"info": {"get": {"summary": "Get metadata information about this API resource"}},
}
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
def validate_origin(value: str) -> None:
if not is_valid_csp_origin(value):
raise ValidationError(
_(
"'%(value)s' is not a valid origin. Provide a bare "
"scheme://host[:port] value with no wildcard, path, query or "
"fragment.",
value=value,
)
)
def validate_directive(value: str) -> None:
if not is_valid_csp_directive(value):
raise ValidationError(
_(
"'%(value)s' is not an allowed CSP directive. Allowed: %(allowed)s.",
value=value,
allowed=", ".join(sorted(ALLOWED_DIRECTIVES)),
)
)
class CSPAllowlistEntryPostSchema(Schema):
domain = fields.String(required=True, metadata={"description": domain_description})
directive = fields.String(
required=False,
load_default="frame-src",
metadata={"description": directive_description},
)
description = fields.String(required=False, allow_none=True)
@validates("domain")
def validate_domain(self, value: str, **kwargs: Any) -> None:
validate_origin(value)
@validates("directive")
def validate_directive_field(self, value: str, **kwargs: Any) -> None:
validate_directive(value)
class CSPAllowlistEntryPutSchema(Schema):
domain = fields.String(required=False, metadata={"description": domain_description})
directive = fields.String(
required=False, metadata={"description": directive_description}
)
description = fields.String(required=False, allow_none=True)
@validates("domain")
def validate_domain(self, value: str, **kwargs: Any) -> None:
validate_origin(value)
@validates("directive")
def validate_directive_field(self, value: str, **kwargs: Any) -> None:
validate_directive(value)
+22
View File
@@ -0,0 +1,22 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.daos.base import BaseDAO
from superset.models.csp import CSPAllowlistEntry
class CSPAllowlistDAO(BaseDAO[CSPAllowlistEntry]):
pass
+5
View File
@@ -133,6 +133,9 @@ class DatasourceRestApi(BaseSupersetApi):
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
denormalize_column = not datasource.normalize_columns
# Element-level operators (Contains any / Contains all) request the
# distinct array *elements* rather than distinct whole arrays.
array_elements = parse_boolean_string(request.args.get("array_elements"))
# Cache distinct column-value results so a dashboard with many filters
# backed by the same (often heavy) virtual dataset doesn't re-execute
@@ -165,6 +168,7 @@ class DatasourceRestApi(BaseSupersetApi):
"col": column_name,
"limit": row_limit,
"denorm": denormalize_column,
"elements": array_elements,
"rls": security_manager.get_rls_cache_key(datasource),
"changed_on": str(getattr(datasource, "changed_on", "")),
},
@@ -189,6 +193,7 @@ class DatasourceRestApi(BaseSupersetApi):
column_name=column_name,
limit=row_limit,
denormalize_column=denormalize_column,
array_elements=array_elements,
)
except KeyError:
return self.response(
+111 -1
View File
@@ -55,7 +55,13 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql import literal_column, quoted_name, text
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
from sqlalchemy.sql.expression import (
BinaryExpression,
ColumnClause,
ColumnElement,
Select,
TextClause,
)
from sqlalchemy.types import TypeEngine
from superset import db
@@ -528,6 +534,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
time_groupby_inline = False
limit_method = LimitMethod.FORCE_LIMIT
supports_multivalues_insert = False
# Whether this engine supports first-class multi-value (array-typed) columns.
# When True, array columns are classified as ``GenericDataType.MULTI_VALUE`` and
# the ``array_*`` capability methods below must be implemented. Defaults to
# False so engines that have not opted in keep treating arrays as strings.
supports_multivalue_columns = False
allows_joins = True
allows_subqueries = True
allows_alias_in_select = True
@@ -2571,6 +2582,105 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
logger.error(ex, exc_info=True)
raise
@classmethod
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
"""
Build a boolean expression testing whether array column ``col`` contains
**any** of ``values`` (element-level membership, like ``IN``). Engines
that set ``supports_multivalue_columns = True`` must override this with
their native function (e.g. ClickHouse ``hasAny``).
:param col: SQLAlchemy column element for the array column
:param values: element values to look for inside the array
:return: a SQLAlchemy boolean expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
"""
Build a boolean expression testing whether array column ``col`` contains
**all** of ``values``. Engines that set
``supports_multivalue_columns = True`` must override this with their
native function (e.g. ClickHouse ``hasAll``).
:param col: SQLAlchemy column element for the array column
:param values: element values that must all be present
:return: a SQLAlchemy boolean expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_length(cls, col: ColumnElement) -> ColumnElement:
"""
Build a numeric expression returning the number of elements in array
column ``col``. Engines that set ``supports_multivalue_columns = True``
must override this with their native array-length function. Used both for
the ``Length`` filter and the ``Is empty`` / ``Is not empty`` operators.
:param col: SQLAlchemy column element for the array column
:return: a SQLAlchemy numeric expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_literal(cls, values: list[Any]) -> ColumnElement:
"""
Build an array-literal expression from ``values`` (e.g. ClickHouse
``array(v1, v2)`` == ``[v1, v2]``). Used for the whole-array (column-
level) operators ``=`` / ``!=`` / ``IN`` / ``NOT IN`` where the array is
compared as a single value. Engines that set
``supports_multivalue_columns = True`` must override this.
:param values: element values that make up the array
:return: a SQLAlchemy array-literal expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_explode(cls, col: ColumnElement) -> ColumnElement:
"""
Build an expression that expands array column ``col`` into one row per
element (e.g. ClickHouse ``arrayJoin``). Used to source **element-level**
value suggestions (``SELECT DISTINCT array_explode(col)``) for the
``Contains any`` / ``Contains all`` filter operators, so the picker offers
individual elements rather than whole arrays. Engines that set
``supports_multivalue_columns = True`` must override this.
:param col: SQLAlchemy column element for the array column
:return: a SQLAlchemy expression yielding one element per row
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def get_array_element_type( # pylint: disable=unused-argument
cls, native_type: str | None
) -> GenericDataType | None:
"""
Return the generic type of an array column's **element** type, derived
from its native type string (e.g. ClickHouse ``Array(Int32)`` ->
``NUMERIC``), or ``None`` when the engine has no array support or the
element type cannot be resolved.
Callers use this to coerce filter values to the element type before
building array expressions, so, for example, a ``Contains any`` filter on
a numeric array compares against numbers rather than quoted strings.
:param native_type: native column type string of the array column
:return: the element's :class:`GenericDataType`, or ``None``
"""
return None
@classmethod
def get_column_spec( # pylint: disable=unused-argument
cls,
+62 -4
View File
@@ -26,8 +26,9 @@ from flask import current_app as app
from flask_babel import gettext as __
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import types
from sqlalchemy import func, types
from sqlalchemy.engine.url import URL
from sqlalchemy.sql.expression import ColumnElement
from urllib3.exceptions import NewConnectionError
from superset.databases.utils import make_url_safe
@@ -55,6 +56,7 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
time_groupby_inline = True
supports_multivalues_insert = True
supports_multivalue_columns = True
# ClickHouse doesn't support IS true/false syntax, use = true/false instead
use_equality_for_boolean_filters = True
@@ -128,12 +130,18 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
column_type_mappings = (
(
re.compile(r".*Enum.*", re.IGNORECASE),
# Anchor to the start so only top-level arrays match. This must be
# ordered before the ``Enum`` entry below: ``Array(Enum8(...))`` is a
# real array and should classify as MULTI_VALUE, not STRING. The
# anchor also prevents over-matching nested arrays such as
# ``Map(String, Array(String))`` or ``Tuple(Array(String))``, which
# are not themselves array columns and must keep their own type.
re.compile(r"^Array\(", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
GenericDataType.MULTI_VALUE,
),
(
re.compile(r".*Array.*", re.IGNORECASE),
re.compile(r".*Enum.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
@@ -174,6 +182,56 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
),
)
@classmethod
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
# ClickHouse: hasAny(arr, [v1, v2]) -> 1 if arr shares any element.
# func.array(*values) renders as array(v1, v2) == [v1, v2].
return func.hasAny(col, func.array(*values))
@classmethod
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
# ClickHouse: hasAll(arr, [v1, v2]) -> 1 if arr contains all elements.
return func.hasAll(col, func.array(*values))
@classmethod
def array_length(cls, col: ColumnElement) -> ColumnElement:
# ClickHouse: length(arr) -> number of elements
return func.length(col)
@classmethod
def array_literal(cls, values: list[Any]) -> ColumnElement:
# ClickHouse: array(v1, v2) is equivalent to the literal [v1, v2].
return func.array(*values)
@classmethod
def array_explode(cls, col: ColumnElement) -> ColumnElement:
# ClickHouse: arrayJoin(arr) yields one row per element, so
# SELECT DISTINCT arrayJoin(arr) returns the distinct elements.
return func.arrayJoin(col)
# Matches the element type inside a top-level ``Array(...)`` column, e.g.
# ``Array(Int32)`` -> ``Int32``, ``Array(Nullable(String))`` -> ``String``.
_ARRAY_ELEMENT_RE = re.compile(r"^Array\((?P<inner>.+)\)$", re.IGNORECASE)
# Element-type wrappers that don't change the underlying generic type.
_ELEMENT_WRAPPER_RE = re.compile(
r"^(?:Nullable|LowCardinality)\((?P<inner>.+)\)$", re.IGNORECASE
)
@classmethod
def get_array_element_type(cls, native_type: str | None) -> GenericDataType | None:
if not native_type:
return None
match = cls._ARRAY_ELEMENT_RE.match(native_type.strip())
if not match:
return None
inner = match.group("inner").strip()
# Peel wrappers (Nullable/LowCardinality) that don't alter the generic
# type so the inner scalar type drives classification.
while wrapper := cls._ELEMENT_WRAPPER_RE.match(inner):
inner = wrapper.group("inner").strip()
spec = cls.get_column_spec(inner)
return spec.generic_type if spec else None
@classmethod
def epoch_to_dttm(cls) -> str:
return "{col}"
+14
View File
@@ -186,6 +186,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.cachekeys.api import CacheRestApi
from superset.charts.api import ChartRestApi
from superset.charts.data.api import ChartDataRestApi
from superset.csp_allowlist.api import CSPAllowlistRestApi
from superset.css_templates.api import CssTemplateRestApi
from superset.dashboards.api import DashboardRestApi
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
@@ -277,6 +278,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(CacheRestApi)
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(ChartDataRestApi)
appbuilder.add_api(CSPAllowlistRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(ThemeRestApi)
appbuilder.add_api(CurrentUserRestApi)
@@ -1523,6 +1525,18 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
# Flask-Compress
Compress(self.superset_app)
# Runtime CSP allowlist merge. Registered BEFORE Talisman so that, since
# Flask runs after_request callbacks in reverse registration order, this
# runs AFTER Talisman has set the CSP header and can widen it with the
# operator-curated allowlist. Inert unless CSP_RUNTIME_ALLOWLIST is on.
from flask import Response
@self.superset_app.after_request
def merge_runtime_csp_allowlist(response: Response) -> Response:
from superset.security.csp import apply_runtime_csp_allowlist
return apply_runtime_csp_allowlist(response)
# Talisman
talisman_enabled = self.config["TALISMAN_ENABLED"]
talisman_config = (
+16 -1
View File
@@ -63,6 +63,7 @@ from superset.mcp_service.utils import (
sanitize_for_llm_context,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.sql.parse import has_aggregate
from superset.utils import json
@@ -386,13 +387,27 @@ class CreateDatasetMetric(BaseModel):
"""Metric definition for dataset creation."""
metric_name: str = Field(..., description="Name of the metric")
expression: str = Field(..., description="SQL expression for the metric")
expression: str = Field(
...,
description="Aggregate SQL expression for the metric, e.g. SUM(amount)",
)
verbose_name: str | None = None
description: str | None = None
metric_type: str | None = None
d3format: str | None = None
warning_text: str | None = None
@field_validator("expression")
@classmethod
def expression_must_aggregate(cls, value: str) -> str:
if not has_aggregate(value):
raise ValueError(
"saved metrics must aggregate rows; wrap a row-level column in "
"an aggregate such as MAX(column), or omit the saved metric and "
"use the dataset column directly"
)
return value
class CreateDatasetCalculatedColumn(BaseModel):
"""Calculated column definition for dataset creation."""
@@ -21,6 +21,7 @@ from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.exceptions import SupersetGenericDBErrorException
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
CreateVirtualDatasetRequest,
@@ -67,14 +68,17 @@ def _cleanup_failed_dataset(dataset_id: int) -> None:
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
from superset.commands.dataset.exceptions import (
DatasetInvalidError,
DatasetUpdateFailedError,
)
from superset.commands.dataset.update import UpdateDatasetCommand
try:
return UpdateDatasetCommand(dataset_id, update_props).run()
except Exception as exc:
_cleanup_failed_dataset(dataset_id)
if not isinstance(exc, DatasetUpdateFailedError):
if not isinstance(exc, (DatasetInvalidError, DatasetUpdateFailedError)):
raise DatasetUpdateFailedError() from exc
raise
@@ -89,7 +93,7 @@ def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> An
destructiveHint=False,
),
)
async def create_virtual_dataset(
async def create_virtual_dataset( # noqa: C901
request: CreateVirtualDatasetRequest, ctx: Context
) -> CreateVirtualDatasetResponse:
"""Save a SQL query as a virtual dataset so it can be charted.
@@ -213,6 +217,18 @@ async def create_virtual_dataset(
url=None,
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
)
except SupersetGenericDBErrorException as exc:
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
sql=request.sql,
database_id=request.database_id,
columns=[],
url=None,
error=f"Dataset SQL could not be executed: {exc}",
)
except Exception as exc:
await ctx.error(
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
@@ -0,0 +1,83 @@
# 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.
"""add csp_allowlist table
Creates the ``csp_allowlist`` table backing runtime Content Security Policy
"punched holes". Each row widens a single CSP directive (``frame-src`` by
default) to allow one additional origin. The table is only consulted when the
``CSP_RUNTIME_ALLOWLIST`` feature flag is enabled.
Revision ID: 4a50792bd265
Revises: 3a8e6f2c1b95
Create Date: 2026-06-26 12:00:00.000000
"""
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import (
create_fks_for_table,
create_table,
drop_table,
)
# revision identifiers, used by Alembic.
revision = "4a50792bd265"
down_revision = "3a8e6f2c1b95"
def upgrade() -> None:
create_table(
"csp_allowlist",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("uuid", UUIDType(binary=True), nullable=True, unique=True),
sa.Column("domain", sa.String(length=255), nullable=False),
sa.Column(
"directive",
sa.String(length=64),
nullable=False,
server_default="frame-src",
),
sa.Column("description", sa.Text(), nullable=True),
# AuditMixinNullable columns
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.UniqueConstraint(
"domain", "directive", name="uq_csp_allowlist_domain_directive"
),
)
create_fks_for_table(
"fk_csp_allowlist_created_by_fk_ab_user",
"csp_allowlist",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_csp_allowlist_changed_by_fk_ab_user",
"csp_allowlist",
"ab_user",
["changed_by_fk"],
["id"],
)
def downgrade() -> None:
drop_table("csp_allowlist")
+55
View File
@@ -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.
"""Models backing the runtime Content Security Policy (CSP) allowlist."""
from flask_appbuilder import Model
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
from superset.models.helpers import AuditMixinNullable, UUIDMixin
# Default CSP directive a hole is punched into. ``frame-src`` governs which
# origins may be embedded in an <iframe>, which is the primary use case for the
# allowlist (the first-class dashboard iframe component).
DEFAULT_CSP_DIRECTIVE = "frame-src"
class CSPAllowlistEntry(AuditMixinNullable, UUIDMixin, Model):
"""A runtime "punched hole" in the Content Security Policy.
Each row widens a single CSP directive (``frame-src`` by default) to allow a
single additional origin. Entries are merged into the response CSP header at
request time, but only when the ``CSP_RUNTIME_ALLOWLIST`` feature flag is
enabled, so operators retain full control over whether the static, deploy-time
policy can be overridden at runtime at all.
"""
__tablename__ = "csp_allowlist"
__table_args__ = (
UniqueConstraint(
"domain", "directive", name="uq_csp_allowlist_domain_directive"
),
)
id = Column(Integer, primary_key=True)
# A bare origin, e.g. ``https://example.com`` or ``https://example.com:8443``.
# Never a wildcard, path, query or fragment — see ``is_valid_csp_origin``.
domain = Column(String(255), nullable=False)
directive = Column(String(64), nullable=False, default=DEFAULT_CSP_DIRECTIVE)
description = Column(Text, nullable=True)
def __repr__(self) -> str:
return f"<CSPAllowlistEntry {self.directive} {self.domain}>"
+207 -3
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import ast
import builtins
import copy
import dataclasses
@@ -417,6 +418,52 @@ UUID_NATIVE_TYPE_RE: re.Pattern[str] = re.compile(
)
def parse_array_literal(value: Any) -> list[Any]:
"""
Parse a user-entered array literal (e.g. ``['a', 'b']`` or ``[1, 2]``) into a
list of elements, for the whole-array (column-level) array operators.
Accepts either an actual list/tuple, a bracketed literal string (parsed with
``ast.literal_eval``), or a plain scalar (wrapped into a single-element list).
Falls back to a single-element list when the string is not a valid literal.
"""
if isinstance(value, (list, tuple)):
return list(value)
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith("[") and stripped.endswith("]"):
try:
parsed = ast.literal_eval(stripped)
except (ValueError, SyntaxError):
parsed = None
if isinstance(parsed, (list, tuple)):
return list(parsed)
return [value]
def coerce_array_values(
values: list[Any], element_type: Optional[utils.GenericDataType]
) -> list[Any]:
"""
Coerce array-element ``values`` to the array column's element type so the
emitted literal matches the column. Array columns map to a SQLAlchemy
``String`` type, so values arrive as strings and would otherwise build
string literals (e.g. ``array('5')``) that fail against a numeric array on
the server. Numeric elements are cast to numbers and boolean elements to
booleans; every other element type (string, temporal, enum, unknown) is left
untouched.
:param values: element values entered for an array filter
:param element_type: the array's element :class:`GenericDataType`, or None
:return: the coerced values
"""
if element_type == utils.GenericDataType.NUMERIC:
return [utils.cast_to_num(v) if isinstance(v, str) else v for v in values]
if element_type == utils.GenericDataType.BOOLEAN:
return [utils.cast_to_boolean(v) if isinstance(v, str) else v for v in values]
return values
def is_uuid_native_type(native_type: Optional[str]) -> bool:
"""
Return True if a native column type represents a UUID.
@@ -3652,6 +3699,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
column_name: str,
limit: int = 10000,
denormalize_column: bool = False,
array_elements: bool = False,
) -> list[Any]:
# denormalize column name before querying for values
# unless disabled in the dataset configuration
@@ -3666,13 +3714,25 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
tp = self.get_template_processor()
tbl, cte = self.get_from_clause(tp)
db_engine_spec = self.database.db_engine_spec
value_expr = target_col.get_sqla_col(template_processor=tp)
# For element-level operators (Contains any / Contains all) on a
# multi-value (array) column, suggest the distinct **elements** rather
# than distinct whole arrays by expanding the array first (e.g. ClickHouse
# arrayJoin). Only when the engine supports arrays and the column is
# actually an array column; otherwise fall back to whole-value suggestions.
if array_elements and db_engine_spec.supports_multivalue_columns:
col_spec = db_engine_spec.get_column_spec(native_type=target_col.type)
if col_spec and col_spec.generic_type == GenericDataType.MULTI_VALUE:
value_expr = db_engine_spec.array_explode(value_expr)
qry = (
sa.select(
# The alias (label) here is important because some dialects will
# automatically add a random alias to the projection because of the
# call to DISTINCT; others will uppercase the column names. This
# gives us a deterministic column name in the dataframe.
target_col.get_sqla_col(template_processor=tp).label("column_values")
value_expr.label("column_values")
)
.select_from(tbl)
.distinct()
@@ -4359,7 +4419,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
elif is_adhoc_column(flt_col):
try:
sqla_col, adhoc_generic_type = self.adhoc_column_to_sqla(
flt_col,
cast("AdhocColumn", flt_col),
force_type_check=True,
template_processor=template_processor,
)
@@ -4433,9 +4493,21 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
sqla_col = Grouping(sqla_col)
col_type = col_obj.type if col_obj else None
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
is_multivalue_col = bool(
col_spec and col_spec.generic_type == GenericDataType.MULTI_VALUE
)
# Element type of an array column (e.g. Array(Int32) -> NUMERIC),
# used to coerce filter values before building array expressions.
array_element_type = (
db_engine_spec.get_array_element_type(col_type)
if is_multivalue_col
else None
)
is_list_target = op in (
utils.FilterOperator.IN,
utils.FilterOperator.NOT_IN,
utils.FilterOperator.CONTAINS_ANY,
utils.FilterOperator.CONTAINS_ALL,
)
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
@@ -4490,7 +4562,56 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
sqla_col, op, bus_resp["values"]
)
)
elif is_list_target:
elif is_multivalue_col and op in {
utils.FilterOperator.EQUALS,
utils.FilterOperator.NOT_EQUALS,
utils.FilterOperator.IN,
utils.FilterOperator.NOT_IN,
}:
# Whole-array (column-level) comparison against array
# literal(s). The value is a pasted array literal like
# ``['a', 'b']`` (parsed into elements): ``col = ['a', 'b']``
# for = / !=; for IN / NOT IN each entered value is one such
# array literal (``col IN (['a'], ['b'])``).
if op in {
utils.FilterOperator.EQUALS,
utils.FilterOperator.NOT_EQUALS,
}:
literal = db_engine_spec.array_literal(
coerce_array_values(
parse_array_literal(val), array_element_type
)
)
cond = (
sqla_col != literal
if op == utils.FilterOperator.NOT_EQUALS
else sqla_col == literal
)
else:
candidates: list[Any] = (
list(val) if isinstance(val, (list, tuple)) else [val]
)
cond = sqla_col.in_(
[
db_engine_spec.array_literal(
coerce_array_values(
parse_array_literal(candidate),
array_element_type,
)
)
for candidate in candidates
]
)
if op == utils.FilterOperator.NOT_IN:
cond = ~cond
target_clause_list.append(cond)
elif op in {
utils.FilterOperator.IN,
utils.FilterOperator.NOT_IN,
}:
# CONTAINS_ANY/CONTAINS_ALL also produce a list ``eq`` (they
# are in ``is_list_target``), but are element-level array ops
# handled by their own branch below — not IN.
assert isinstance(eq, (tuple, list))
if len(eq) == 0:
raise QueryObjectValidationError(
@@ -4529,6 +4650,57 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
target_clause_list.append(
db_engine_spec.handle_null_filter(sqla_col, op)
)
elif op in {
utils.FilterOperator.IS_EMPTY,
utils.FilterOperator.IS_NOT_EMPTY,
}:
# Element-level array operators: length(col) == 0 / > 0.
if target_generic_type != GenericDataType.MULTI_VALUE:
raise QueryObjectValidationError(
_(
"The %(op)s operator is only supported for "
"multi-value (array) columns.",
op=op,
)
)
length_expr = db_engine_spec.array_length(sqla_col)
if op == utils.FilterOperator.IS_EMPTY:
target_clause_list.append(length_expr == 0)
else:
target_clause_list.append(length_expr > 0)
elif op in {
utils.FilterOperator.LENGTH_EQUALS,
utils.FilterOperator.LENGTH_GREATER_THAN,
utils.FilterOperator.LENGTH_LESS_THAN,
utils.FilterOperator.LENGTH_GREATER_THAN_OR_EQUALS,
utils.FilterOperator.LENGTH_LESS_THAN_OR_EQUALS,
}:
# Length filter: compare the array's element count to a
# number, e.g. length(col) > 2.
if target_generic_type != GenericDataType.MULTI_VALUE:
raise QueryObjectValidationError(
_(
"The %(op)s operator is only supported for "
"multi-value (array) columns.",
op=op,
)
)
number = utils.cast_to_num(eq) # type: ignore[arg-type]
if number is None:
raise QueryObjectValidationError(
_("The Length filter requires a numeric value.")
)
length_expr = db_engine_spec.array_length(sqla_col)
length_comparisons = {
utils.FilterOperator.LENGTH_EQUALS: length_expr == number,
utils.FilterOperator.LENGTH_GREATER_THAN: length_expr > number,
utils.FilterOperator.LENGTH_LESS_THAN: length_expr < number,
utils.FilterOperator.LENGTH_GREATER_THAN_OR_EQUALS: length_expr
>= number,
utils.FilterOperator.LENGTH_LESS_THAN_OR_EQUALS: length_expr
<= number,
}
target_clause_list.append(length_comparisons[op])
elif op == utils.FilterOperator.IS_TRUE:
target_clause_list.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
@@ -4586,6 +4758,38 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
target_clause_list.append(sqla_col.not_like(eq))
else:
target_clause_list.append(sqla_col.not_ilike(eq))
elif op in {
utils.FilterOperator.CONTAINS_ANY,
utils.FilterOperator.CONTAINS_ALL,
}:
# Element-level array membership. Enforce the target is
# actually a multi-value (array) column (only classified
# MULTI_VALUE on an array-capable engine), guarding against
# payloads that bypass the UI gating.
if target_generic_type != GenericDataType.MULTI_VALUE:
raise QueryObjectValidationError(
_(
"The %(op)s operator is only supported for "
"multi-value (array) columns.",
op=op,
)
)
array_values: list[Any] = coerce_array_values(
list(eq) if isinstance(eq, (list, tuple)) else [eq],
array_element_type,
)
if op == utils.FilterOperator.CONTAINS_ANY:
target_clause_list.append(
db_engine_spec.array_contains_any(
sqla_col, array_values
)
)
else:
target_clause_list.append(
db_engine_spec.array_contains_all(
sqla_col, array_values
)
)
elif (
op == utils.FilterOperator.TEMPORAL_RANGE
and isinstance(eq, str)
+191
View File
@@ -0,0 +1,191 @@
# 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.
"""Runtime Content Security Policy (CSP) allowlist.
This module merges operator-curated *runtime* CSP allowlist entries (the
``csp_allowlist`` table) into the response CSP header that flask-talisman sets at
request time. It is intentionally inert unless the ``CSP_RUNTIME_ALLOWLIST``
feature flag is enabled, so the static deploy-time policy remains the default and
operators opt in to runtime overrides explicitly.
"""
from __future__ import annotations
import time
from collections import OrderedDict
from urllib.parse import urlparse
from flask import current_app, Response
from superset.extensions import feature_flag_manager
#: CSP directives an allowlist entry is permitted to widen. Restricting the set
#: keeps an entry from, say, loosening ``script-src`` in a way that would defeat
#: the nonce/strict-dynamic protections.
ALLOWED_DIRECTIVES = frozenset(
{
"frame-src",
"child-src",
"img-src",
"connect-src",
"media-src",
"font-src",
}
)
CSP_HEADER = "Content-Security-Policy"
CSP_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"
def is_valid_csp_origin(origin: str) -> bool:
"""Return ``True`` if ``origin`` is a bare ``scheme://host[:port]`` source.
The check is deliberately strict: it rejects wildcards, paths, query strings,
fragments and embedded credentials so that an allowlist entry can only ever
widen the policy to one specific, fully-qualified origin. This is the
server-side enforcement point the frontend performs the same check for UX,
but must not be relied upon for security.
"""
if not origin or any(ch.isspace() for ch in origin):
return False
if "*" in origin:
return False
try:
parsed = urlparse(origin)
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return False
if not parsed.hostname:
return False
if parsed.username or parsed.password:
return False
if parsed.path or parsed.query or parsed.fragment:
return False
return True
def is_valid_csp_directive(directive: str) -> bool:
"""Return ``True`` if ``directive`` is one an entry may widen."""
return directive in ALLOWED_DIRECTIVES
def _parse_csp(value: str) -> "OrderedDict[str, list[str]]":
"""Parse a CSP header string into an ordered ``directive -> sources`` map."""
directives: OrderedDict[str, list[str]] = OrderedDict()
for part in value.split(";"):
tokens = part.split()
if not tokens:
continue
directives[tokens[0]] = tokens[1:]
return directives
def _serialize_csp(directives: "OrderedDict[str, list[str]]") -> str:
"""Serialize a ``directive -> sources`` map back into a CSP header string."""
return "; ".join(
" ".join([name, *sources]).strip() for name, sources in directives.items()
)
def merge_allowlist_into_csp(header_value: str, additions: dict[str, list[str]]) -> str:
"""Merge ``additions`` into an existing CSP header value.
For a directive that already exists, missing origins are appended. For a
directive that does not exist yet (e.g. ``frame-src`` when the base policy
only declares ``default-src``), the directive is seeded with ``'self'`` so the
addition widens rather than unexpectedly narrows the effective policy.
"""
directives = _parse_csp(header_value)
for directive, domains in additions.items():
sources = directives.get(directive)
if sources is None:
directives[directive] = ["'self'", *domains]
else:
for domain in domains:
if domain not in sources:
sources.append(domain)
return _serialize_csp(directives)
class _CSPAllowlistCache:
"""In-process, time-bounded cache of the runtime CSP allowlist.
The metadata DB is the source of truth; this cache only exists to avoid a
query on every response. A write through the REST API invalidates the cache
in the worker that handled it; other workers converge once their copy
expires (``CSP_RUNTIME_ALLOWLIST_CACHE_TTL`` seconds).
"""
def __init__(self) -> None:
self._directive_map: dict[str, list[str]] | None = None
self._loaded_at: float = 0.0
def get(self) -> dict[str, list[str]]:
ttl = current_app.config.get("CSP_RUNTIME_ALLOWLIST_CACHE_TTL", 30)
now = time.monotonic()
if self._directive_map is None or (now - self._loaded_at) > ttl:
self._directive_map = self._load()
self._loaded_at = now
return self._directive_map
@staticmethod
def _load() -> dict[str, list[str]]:
# Imported lazily to avoid a circular import at module load time.
from superset.daos.csp import CSPAllowlistDAO
directive_map: dict[str, list[str]] = {}
for entry in CSPAllowlistDAO.find_all():
if not is_valid_csp_directive(entry.directive):
# Defensive: never trust a stale/legacy row to widen an
# unexpected directive.
continue
directive_map.setdefault(entry.directive, []).append(entry.domain)
return directive_map
def invalidate(self) -> None:
self._directive_map = None
self._loaded_at = 0.0
csp_allowlist_cache = _CSPAllowlistCache()
def invalidate_csp_allowlist_cache() -> None:
"""Drop this worker's cached copy of the allowlist (call after a write)."""
csp_allowlist_cache.invalidate()
def apply_runtime_csp_allowlist(response: Response) -> Response:
"""Merge runtime allowlist entries into the response CSP header(s).
Registered as an ``after_request`` handler *before* flask-talisman so that it
runs *after* Talisman has set the header (Flask invokes ``after_request``
callbacks in reverse registration order). A no-op unless the
``CSP_RUNTIME_ALLOWLIST`` feature flag is enabled and the allowlist is
non-empty.
"""
if not feature_flag_manager.is_feature_enabled("CSP_RUNTIME_ALLOWLIST"):
return response
additions = csp_allowlist_cache.get()
if not additions:
return response
for header in (CSP_HEADER, CSP_REPORT_ONLY_HEADER):
value = response.headers.get(header)
if value:
response.headers[header] = merge_allowlist_into_csp(value, additions)
return response
+3
View File
@@ -1418,6 +1418,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
ADMIN_ONLY_VIEW_MENUS = {
"Access Requests",
"Action Logs",
# Runtime CSP allowlist: punching holes in the Content Security Policy is
# a trusted, security-sensitive operation reserved for Admins.
"CSPAllowlist",
"Extensions",
"Log",
"List Users",
+21 -1
View File
@@ -209,7 +209,7 @@ class GenericDataType(IntEnum):
STRING = 1
TEMPORAL = 2
BOOLEAN = 3
# ARRAY = 4 # Mapping all the complex data types to STRING for now
MULTI_VALUE = 4 # array-typed columns (e.g. ClickHouse Array, Postgres ARRAY)
# JSON = 5 # and leaving these as a reminder.
# MAP = 6
# ROW = 7
@@ -299,6 +299,17 @@ class FilterOperator(StrEnum):
IS_TRUE = "IS TRUE"
IS_FALSE = "IS FALSE"
TEMPORAL_RANGE = "TEMPORAL_RANGE"
# Element-level operators for MULTI_VALUE (array) columns
CONTAINS_ANY = "CONTAINS_ANY"
CONTAINS_ALL = "CONTAINS_ALL"
IS_EMPTY = "IS_EMPTY"
IS_NOT_EMPTY = "IS_NOT_EMPTY"
# Length (element-count) comparison operators for array columns
LENGTH_EQUALS = "LENGTH_EQUALS"
LENGTH_GREATER_THAN = "LENGTH_GREATER_THAN"
LENGTH_LESS_THAN = "LENGTH_LESS_THAN"
LENGTH_GREATER_THAN_OR_EQUALS = "LENGTH_GREATER_THAN_OR_EQUALS"
LENGTH_LESS_THAN_OR_EQUALS = "LENGTH_LESS_THAN_OR_EQUALS"
class FilterStringOperators(StrEnum):
@@ -317,6 +328,15 @@ class FilterStringOperators(StrEnum):
LATEST_PARTITION = ("LATEST_PARTITION",)
IS_TRUE = ("IS_TRUE",)
IS_FALSE = ("IS_FALSE",)
CONTAINS_ANY = ("CONTAINS_ANY",)
CONTAINS_ALL = ("CONTAINS_ALL",)
IS_EMPTY = ("IS_EMPTY",)
IS_NOT_EMPTY = ("IS_NOT_EMPTY",)
LENGTH_EQUALS = ("LENGTH_EQUALS",)
LENGTH_GREATER_THAN = ("LENGTH_GREATER_THAN",)
LENGTH_LESS_THAN = ("LENGTH_LESS_THAN",)
LENGTH_GREATER_THAN_OR_EQUALS = ("LENGTH_GREATER_THAN_OR_EQUALS",)
LENGTH_LESS_THAN_OR_EQUALS = ("LENGTH_LESS_THAN_OR_EQUALS",)
class PostProcessingBoxplotWhiskerType(StrEnum):
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from collections.abc import Sequence
from functools import partial
from functools import partial, wraps
from typing import Any, Callable
import numpy as np
@@ -122,6 +122,10 @@ def scalar_to_sequence(val: Any) -> Sequence[str]:
def validate_column_args(*argnames: str) -> Callable[..., Any]:
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
# `wraps` keeps `func` reachable through `__wrapped__`, so that
# `inspect.signature` reports the parameters of the decorated operation
# rather than the `(df, **options)` of this wrapper.
@wraps(func)
def wrapped(df: DataFrame, **options: Any) -> Any:
if _is_multi_index_on_columns(df):
# MultiIndex column validate first level
@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
@@ -0,0 +1,124 @@
# 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.
"""Integration tests for the CSP allowlist REST API."""
import pytest
import tests.integration_tests.test_app # noqa: F401
from superset import db
from superset.models.csp import CSPAllowlistEntry
from superset.utils import json
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
class TestCSPAllowlistApi(SupersetTestCase):
def insert_entry(
self,
domain: str,
directive: str = "frame-src",
) -> CSPAllowlistEntry:
admin = self.get_user("admin")
entry = CSPAllowlistEntry(
domain=domain,
directive=directive,
created_by=admin,
changed_by=admin,
)
db.session.add(entry)
db.session.commit()
return entry
@pytest.fixture
def create_entries(self):
with self.create_app().app_context():
entries = [
self.insert_entry("https://a.example.com"),
self.insert_entry("https://b.example.com"),
]
yield entries
for entry in entries:
db.session.delete(entry)
db.session.commit()
@pytest.mark.usefixtures("create_entries")
def test_get_list_as_admin(self):
self.login(ADMIN_USERNAME)
rv = self.client.get("/api/v1/csp_allowlist/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] >= 2
def test_gamma_cannot_list(self):
"""The CSPAllowlist view-menu is admin-only."""
self.login(GAMMA_USERNAME)
rv = self.client.get("/api/v1/csp_allowlist/")
assert rv.status_code in (401, 403, 404)
def test_admin_can_create_valid_entry(self):
self.login(ADMIN_USERNAME)
rv = self.client.post(
"/api/v1/csp_allowlist/",
json={"domain": "https://new.example.com", "directive": "frame-src"},
)
assert rv.status_code == 201
data = json.loads(rv.data.decode("utf-8"))
created = db.session.query(CSPAllowlistEntry).get(data["id"])
assert created.domain == "https://new.example.com"
db.session.delete(created)
db.session.commit()
def test_create_rejects_invalid_origin(self):
self.login(ADMIN_USERNAME)
rv = self.client.post(
"/api/v1/csp_allowlist/",
json={"domain": "https://*.evil.com/path"},
)
assert rv.status_code == 400
assert (
db.session.query(CSPAllowlistEntry)
.filter_by(domain="https://*.evil.com/path")
.first()
is None
)
def test_create_rejects_disallowed_directive(self):
self.login(ADMIN_USERNAME)
rv = self.client.post(
"/api/v1/csp_allowlist/",
json={"domain": "https://ok.example.com", "directive": "script-src"},
)
assert rv.status_code == 400
def test_gamma_cannot_create(self):
self.login(GAMMA_USERNAME)
rv = self.client.post(
"/api/v1/csp_allowlist/",
json={"domain": "https://nope.example.com"},
)
assert rv.status_code in (401, 403, 404)
@pytest.mark.usefixtures("create_entries")
def test_admin_can_delete(self):
self.login(ADMIN_USERNAME)
entry = (
db.session.query(CSPAllowlistEntry)
.filter_by(domain="https://a.example.com")
.one()
)
rv = self.client.delete(f"/api/v1/csp_allowlist/{entry.id}")
assert rv.status_code == 200

Some files were not shown because too many files have changed in this diff Show More