mirror of
https://github.com/apache/superset.git
synced 2026-06-27 18:35:32 +00:00
Compare commits
4 Commits
fix-dashbo
...
feat/csp-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14fceb79d4 | ||
|
|
5ed6b674f3 | ||
|
|
fa9816bb43 | ||
|
|
7df650ec04 |
262
SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md
Normal file
262
SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md
Normal file
@@ -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
SIP.md
Normal file
232
SIP.md
Normal 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
|
||||
(`a1b2c3d4e5f6`, down-revision `78a40c08b4be`). 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
|
||||
@@ -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',
|
||||
|
||||
6
docs/static/feature-flags.json
vendored
6
docs/static/feature-flags.json
vendored
@@ -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,
|
||||
|
||||
@@ -30,6 +30,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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -32,6 +32,7 @@ export enum FeatureFlag {
|
||||
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
|
||||
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
|
||||
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
|
||||
CspRuntimeAllowlist = 'CSP_RUNTIME_ALLOWLIST',
|
||||
CssTemplates = 'CSS_TEMPLATES',
|
||||
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
|
||||
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
|
||||
|
||||
@@ -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;
|
||||
88
superset-frontend/src/core/dashboardComponents/index.test.ts
Normal file
88
superset-frontend/src/core/dashboardComponents/index.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
49
superset-frontend/src/core/dashboardComponents/index.ts
Normal file
49
superset-frontend/src/core/dashboardComponents/index.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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,7 +40,9 @@ const TABS_KEYS = {
|
||||
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
|
||||
};
|
||||
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => {
|
||||
const extensionComponents = useDashboardComponents();
|
||||
return (
|
||||
<div
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
@@ -99,6 +103,14 @@ const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
<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 }) => (
|
||||
@@ -115,6 +127,7 @@ const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderComponentPane;
|
||||
|
||||
@@ -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,143 @@
|
||||
/**
|
||||
* 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('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,212 @@
|
||||
/**
|
||||
* 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')}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
/>
|
||||
) : (
|
||||
<div className="dashboard-iframe-empty">
|
||||
{editMode
|
||||
? t('Enter a URL above to embed content')
|
||||
: t('No URL configured')}
|
||||
</div>
|
||||
)}
|
||||
</IframeStyles>
|
||||
);
|
||||
}
|
||||
42
superset-frontend/src/dashboard/extensions/iframe/index.ts
Normal file
42
superset-frontend/src/dashboard/extensions/iframe/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -277,6 +277,8 @@ 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;
|
||||
/** Allow additional meta properties used by different component types */
|
||||
|
||||
@@ -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,14 +19,22 @@
|
||||
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,
|
||||
) > -1
|
||||
[COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf(entity.type) >
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
88
superset-frontend/src/dashboard/util/cspAllowlist.test.ts
Normal file
88
superset-frontend/src/dashboard/util/cspAllowlist.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
79
superset-frontend/src/dashboard/util/cspAllowlist.ts
Normal file
79
superset-frontend/src/dashboard/util/cspAllowlist.ts
Normal file
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -639,6 +639,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,
|
||||
@@ -2336,6 +2342,16 @@ DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
|
||||
# Enable/disable CSP warning
|
||||
CONTENT_SECURITY_POLICY_WARNING = 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))
|
||||
|
||||
|
||||
16
superset/csp_allowlist/__init__.py
Normal file
16
superset/csp_allowlist/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
179
superset/csp_allowlist/api.py
Normal file
179
superset/csp_allowlist/api.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# 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 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 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 event_logger
|
||||
from superset.models.csp import CSPAllowlistEntry
|
||||
from superset.security.csp import invalidate_csp_allowlist_cache
|
||||
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=("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),
|
||||
),
|
||||
)
|
||||
111
superset/csp_allowlist/schemas.py
Normal file
111
superset/csp_allowlist/schemas.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# 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 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) -> None:
|
||||
validate_origin(value)
|
||||
|
||||
@validates("directive")
|
||||
def validate_directive_field(self, value: str) -> 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) -> None:
|
||||
validate_origin(value)
|
||||
|
||||
@validates("directive")
|
||||
def validate_directive_field(self, value: str) -> None:
|
||||
validate_directive(value)
|
||||
22
superset/daos/csp.py
Normal file
22
superset/daos/csp.py
Normal 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
|
||||
@@ -165,6 +165,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
|
||||
@@ -253,6 +254,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)
|
||||
@@ -1020,6 +1022,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 = (
|
||||
|
||||
@@ -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: a1b2c3d4e5f6
|
||||
Revises: 78a40c08b4be
|
||||
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 = "a1b2c3d4e5f6"
|
||||
down_revision = "78a40c08b4be"
|
||||
|
||||
|
||||
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
superset/models/csp.py
Normal file
55
superset/models/csp.py
Normal 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}>"
|
||||
191
superset/security/csp.py
Normal file
191
superset/security/csp.py
Normal 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
|
||||
@@ -733,6 +733,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",
|
||||
|
||||
16
tests/integration_tests/csp_allowlist/__init__.py
Normal file
16
tests/integration_tests/csp_allowlist/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
124
tests/integration_tests/csp_allowlist/api_tests.py
Normal file
124
tests/integration_tests/csp_allowlist/api_tests.py
Normal file
@@ -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
|
||||
160
tests/unit_tests/security/csp_test.py
Normal file
160
tests/unit_tests/security/csp_test.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Response
|
||||
|
||||
from superset.security import csp as csp_module
|
||||
from superset.security.csp import (
|
||||
apply_runtime_csp_allowlist,
|
||||
is_valid_csp_directive,
|
||||
is_valid_csp_origin,
|
||||
merge_allowlist_into_csp,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"origin",
|
||||
[
|
||||
"https://example.com",
|
||||
"http://example.com",
|
||||
"https://example.com:8443",
|
||||
"http://localhost:9000",
|
||||
],
|
||||
)
|
||||
def test_is_valid_csp_origin_accepts_bare_origins(origin: str) -> None:
|
||||
assert is_valid_csp_origin(origin) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"origin",
|
||||
[
|
||||
"",
|
||||
"example.com", # missing scheme
|
||||
"ftp://example.com", # disallowed scheme
|
||||
"https://*.example.com", # wildcard
|
||||
"https://example.com/path", # path
|
||||
"https://example.com?q=1", # query
|
||||
"https://example.com#frag", # fragment
|
||||
"https://user:pw@example.com", # credentials
|
||||
"javascript:alert(1)", # non-network scheme
|
||||
"https://example.com ", # whitespace
|
||||
],
|
||||
)
|
||||
def test_is_valid_csp_origin_rejects_unsafe_values(origin: str) -> None:
|
||||
assert is_valid_csp_origin(origin) is False
|
||||
|
||||
|
||||
def test_is_valid_csp_directive() -> None:
|
||||
assert is_valid_csp_directive("frame-src") is True
|
||||
assert is_valid_csp_directive("img-src") is True
|
||||
# script-src is intentionally not allowlistable
|
||||
assert is_valid_csp_directive("script-src") is False
|
||||
assert is_valid_csp_directive("nonsense") is False
|
||||
|
||||
|
||||
def test_merge_seeds_a_missing_directive_with_self() -> None:
|
||||
base = "default-src 'self'; img-src 'self' data:"
|
||||
merged = merge_allowlist_into_csp(base, {"frame-src": ["https://maps.example"]})
|
||||
assert "frame-src 'self' https://maps.example" in merged
|
||||
# existing directives are preserved
|
||||
assert "default-src 'self'" in merged
|
||||
assert "img-src 'self' data:" in merged
|
||||
|
||||
|
||||
def test_merge_appends_to_existing_directive_without_duplicates() -> None:
|
||||
base = "default-src 'self'; img-src 'self' data:"
|
||||
merged = merge_allowlist_into_csp(
|
||||
base, {"img-src": ["https://cdn.example", "data:"]}
|
||||
)
|
||||
assert "https://cdn.example" in merged
|
||||
# the already-present 'data:' source is not duplicated
|
||||
assert merged.count("data:") == 1
|
||||
|
||||
|
||||
def test_merge_is_a_noop_for_empty_additions() -> None:
|
||||
base = "default-src 'self'"
|
||||
assert merge_allowlist_into_csp(base, {}) == base
|
||||
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
|
||||
|
||||
def _response_with_csp() -> Response:
|
||||
response = Response()
|
||||
response.headers[CSP_HEADER] = "default-src 'self'"
|
||||
return response
|
||||
|
||||
|
||||
def test_apply_runtime_csp_allowlist_noop_when_flag_disabled() -> None:
|
||||
with patch.object(
|
||||
csp_module.feature_flag_manager, "is_feature_enabled", return_value=False
|
||||
):
|
||||
response = _response_with_csp()
|
||||
apply_runtime_csp_allowlist(response)
|
||||
assert response.headers[CSP_HEADER] == "default-src 'self'"
|
||||
|
||||
|
||||
def test_apply_runtime_csp_allowlist_noop_when_allowlist_empty() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
csp_module.feature_flag_manager,
|
||||
"is_feature_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(csp_module.csp_allowlist_cache, "get", return_value={}),
|
||||
):
|
||||
response = _response_with_csp()
|
||||
apply_runtime_csp_allowlist(response)
|
||||
assert response.headers[CSP_HEADER] == "default-src 'self'"
|
||||
|
||||
|
||||
def test_apply_runtime_csp_allowlist_merges_when_enabled() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
csp_module.feature_flag_manager,
|
||||
"is_feature_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
csp_module.csp_allowlist_cache,
|
||||
"get",
|
||||
return_value={"frame-src": ["https://embed.example"]},
|
||||
),
|
||||
):
|
||||
response = _response_with_csp()
|
||||
apply_runtime_csp_allowlist(response)
|
||||
assert "frame-src 'self' https://embed.example" in response.headers[CSP_HEADER]
|
||||
|
||||
|
||||
def test_apply_runtime_csp_allowlist_skips_response_without_header() -> None:
|
||||
with (
|
||||
patch.object(
|
||||
csp_module.feature_flag_manager,
|
||||
"is_feature_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
csp_module.csp_allowlist_cache,
|
||||
"get",
|
||||
return_value={"frame-src": ["https://embed.example"]},
|
||||
),
|
||||
):
|
||||
response = Response() # no CSP header set
|
||||
apply_runtime_csp_allowlist(response)
|
||||
assert CSP_HEADER not in response.headers
|
||||
Reference in New Issue
Block a user