Compare commits

...
Author SHA1 Message Date
amaannawab923 d59eb79cf9 Merge branch 'master' into feat/embedded-chart 2026-09-09 21:44:02 +05:30
amaannawab923 f559837f1c Merge branch 'master' into feat/embedded-chart 2026-09-09 18:03:56 +05:30
amaannawab923 4c8fb70e5c docs(embedded): regenerate the API spec for the chart embed endpoints
The chart embed routes and their schemas were never reflected in
openapi.json, and the guest-token resource enum still listed dashboard
alone. Regenerated on the pinned requirements.
2026-09-09 17:07:48 +05:30
amaannawab923 4c9e4f3d17 refactor(embedded): name the hook after what it fetches
The hook no longer reads from the explore endpoint, so useExploreData named a
detail that is no longer true. Rename it to useEmbeddedChartData.
2026-09-09 16:23:20 +05:30
amaannawab923 e36912aa41 fix(embedded): chain the migration onto the current master head
The migration and b3e9c1a75d24 both claimed 8f31c5d726ab as their parent, which
left the chain with two heads. Point it at the current head and correct the
docstring, which still named an older revision.
2026-09-09 16:23:20 +05:30
amaannawab923 a733b1fd84 fix(dashboard): refetch the embedded config when the resource type changes
The fetch effect depended on dashboardId alone while the endpoint it calls is
derived from resourceType too, so a change to the resource type without a change
of id would have shown the previous resource's settings. Both call sites pass a
fixed resource type today, so this is a latent hazard rather than a reachable
bug, but the dependency was wrong either way.
2026-09-09 16:23:20 +05:30
amaannawab923 e5187b7b91 fix(embedded): rename the chart description for the chart stack
The API spells the field description_markeddown and the chart stack reads
description_markdown, a rename that hydrate.ts and sliceEntities.ts both
perform. The fabricated payload passed the API spelling straight through, so an
expanded description never appeared on an embedded chart.
2026-09-09 16:23:20 +05:30
amaannawab923 6b30778d81 perf(embedded): load the chart stack lazily
A static import pulled the dashboard chart stack into the initial embedded
bundle for every consumer, including deployments that only ever embed
dashboards and never render this path. Import it lazily instead, following the
LazyDashboardPage pattern already in this file; it renders inside the Suspense
boundary that component already sits in, so no new boundary is needed.
2026-09-09 16:23:20 +05:30
amaannawab923 3337218ea7 fix(embedded): honour guest token revocation for charts
embedded_charts carries guest_token_revoked_before, but nothing enforced it:
the revocation check skipped every resource that was not a dashboard, and
revoke_guest_token_access resolved embedded dashboards only. Setting a cutoff on
a chart therefore did nothing, and the only way to cut access was to delete the
embed config, which destroys the uuid that revocation exists to preserve.

Dispatch on resource type in the check, and fall back to the chart DAO when a
uuid does not resolve to an embedded dashboard. The two uuid spaces are
distinct, so the fallthrough is unambiguous, and it matches the order the
embedded view already uses to decide which resource a uuid names. Charts are
only ever addressed by their embed uuid, so the chart branch needs no equivalent
of the dashboard branch's legacy raw-id lookup.
2026-09-09 16:23:20 +05:30
amaannawab923 7ae1f859c0 fix(embedded): load an embedded chart under the chart permission
The standalone chart page read its slice and dataset from /api/v1/explore/,
which sits under the Explore permission. An embedded guest role does not hold
it, so a chart guest token was refused there and the page stopped at its error
state. Explore also assembles its payload from request-supplied form data and
resolves its datasource from that, so granting it to the guest role would widen
a guest well past the one chart its token names.

Serve the pair from a chart-scoped endpoint instead. It sits under the Chart
permission the guest already needs for /api/v1/chart/data and resolves one fixed
chart. Dataset narrowing follows the dashboard's rule, treating a guest holding
a token for that chart as entitled to it so the chart keeps the columns and
metrics it needs to render; params stays withheld either way, being
operator-authored configuration the renderer never reads.

ChartFilter's guest branch now resolves chart resources as well as dashboard
ones. A chart-only token previously produced an empty dashboard scope, which the
branch turned into a deny-all clause, so every Chart-API read was refused for
exactly the tokens this feature issues. A token granting neither kind still
denies all charts.

Document the guest-role grants, which were not written down anywhere.
2026-09-09 16:23:20 +05:30
amaannawab923 7bf63bdd8c fix(embedded): dump the chart id on embedded chart responses
EmbeddedChartResponseSchema declared chart_id, but EmbeddedChart stores the
identifier as slice_id, so the field never resolved and every embedded-chart
response omitted it. Bind the field to the model attribute and keep the public
name, which matches how the embedded-dashboard schema names its own.

Point changed_by at a schema dedicated to this response so the two embedded
response shapes are identical, rather than changing the shared UserSchema that
other chart endpoints dump through.
2026-09-09 16:23:19 +05:30
amaannawab923 2a3cca40d8 fix(embedded): chain the migration onto the current master head
Master gained migrations while this branch was open, so down_revision pointed
at a revision that is no longer the tip and the chain had two heads.
2026-09-09 16:22:48 +05:30
amaannawab923 976707dc8e fix(embedded): register the embedded chart response schema
The embedded endpoints reference EmbeddedChartResponseSchema by $ref, but it
was never added to openapi_spec_component_schemas, so the generated spec
pointed at a component that did not exist and OpenAPI validation failed. The
dashboard API registers its equivalent the same way. The config schema needs no
entry, since it is inlined rather than referenced.
2026-09-09 16:22:48 +05:30
amaannawab923 29cc378010 fix(embedded): resolve the guest user before the chart lookup
Two unit test failures, one real and one a fixture gap.

has_embedded_chart_access queried the slice before establishing that a guest
user exists, so it issued a database lookup on every datasource check that
reached it, including paths where the grant could never hold. Resolving the
guest user first skips the query entirely in that case.

The embedded guest chart test builds its security manager as a mock specced
against the real class, so has_guest_access_to_chart returned a truthy mock and
the denial assertion no longer held. It is closed by default alongside the other
paths the helper already closes, and the chart-direct grant now has a test of
its own rather than only being exercised implicitly.
2026-09-09 16:22:48 +05:30
amaannawab923 31ad989238 fix(embedded): document the embedded PUT and expect the new chart permission
set_embedded is exposed for POST and PUT but only documented post, so the
generated spec carried a put operation with no responses and failed OpenAPI
validation. The chart info permission set also needed can_set_embedded, which
the dashboard equivalent already lists.
2026-09-09 16:22:48 +05:30
amaannawab923 3b68d0897e fix(embedded): declare embedded_charts in the chart purge policy
The purge registry requires every inbound foreign key to a supported model to
be declared, so adding embedded_charts.slice_id left Slice with an incomplete
policy. Declared as OWNED with its three outbound keys, mirroring how
embedded_dashboards is declared against Dashboard, which matches the cascade
already on the relationship and the migration.
2026-09-09 16:22:48 +05:30
amaannawab923 f573fa0c14 fix(embedded): type the chart's datasource as Datasource
tsc rejected passing the loosely typed dataset to setDatasources, which stores
it as a Datasource. The explore endpoint returns the full datasource, so the
field is declared as one rather than cast at the call site. Also applies oxfmt
formatting the frontend hook expects.
2026-09-09 16:22:48 +05:30
amaannawab923 dad9b3e113 chore(embedded): apply lint and formatting fixes
Import ordering and formatting from ruff, and dropping an unused alembic op
import from the migration, which builds its table through the shared helpers.
2026-09-09 16:22:48 +05:30
amaannawab923 b4d2b470f5 fix(embedded): pad the embedded chart and size it to its container
The chart title and the header menu rendered flush against the iframe edge,
because on a dashboard that breathing room comes from the grid gutter and an
embed has no grid. The holder supplies it instead.

Sizing moved from the viewport to the holder's content box, so the chart lays
out inside that padding rather than overflowing it, and the holder also carries
dashboard-component-chart-holder, which is the class the fullscreen styles
select on.
2026-09-09 16:22:47 +05:30
amaannawab923 116815a77f test(embedded): cover the fabricated hydrate payload
Runs the real dashboard reducers against the real payload, so a slice that a
HYDRATE_DASHBOARD handler dereferences without optional chaining cannot go
missing again. That failure only surfaces at runtime and only in the embedded
path, which is why it went unnoticed. Also fills in dashboardLayout and
nativeFilters on HydrateEmbeddedAction, which had gone stale.
2026-09-09 16:22:47 +05:30
amaannawab923 4b223c1851 fix(embedded): don't serve the mobile experience inside an iframe
useIsMobile matches a media query against the current viewport, which inside an
iframe is the size the host chose for the embed rather than the size of the
device. A narrow embed on a desktop was therefore treated as a phone and lost
its chart controls entirely, since SliceHeader hides them when isMobile. Mobile
consumption mode is a whole-app experience (route guarding, drawer navigation)
that an embed does not have to begin with.
2026-09-09 16:22:47 +05:30
amaannawab923 223cf0e432 fix(embedded): restore fullscreen and image exports for embedded charts
The dashboard gives every chart a holder element that owns two things the
header controls reach for: the node handed to requestFullscreen, and the
dashboard-chart-id-<id> class the jpeg and PDF exports select on. Rendering
Chart directly meant neither existed, so fullscreen always reported
'not supported in this browser' and the image exports silently produced
nothing. The embed now supplies its own holder rather than adopting
ChartHolder, which would drag in drag-and-drop, resize and edit-mode logic an
iframe has no use for.

Popups are portaled into the fullscreen element for the same reason the
dashboard does it: only that subtree is painted, so the header menu would
otherwise be unreachable while fullscreen.
2026-09-09 16:22:47 +05:30
amaannawab923 e5a85d89a0 fix(embedded): don't 500 on a malformed Referer
same_origin parses the referrer eagerly, so an attacker-controlled value whose
authority looks like it carries a non-numeric port (for example
http://localhost:3007.evil.com/) raised ValueError instead of returning False.
Access already failed closed, but any anonymous client could turn one header
into a stack trace. This is not specific to embedded charts: the same path
serves embedded dashboards today.
2026-09-09 16:22:47 +05:30
amaannawab923 287698785f fix(embedded): say 'chart' in the embed modal when embedding a chart
The modal is shared with the dashboard flow and its copy was hardcoded to
'dashboard', so a chart embed read 'This dashboard is ready to embed'.
2026-09-09 16:22:47 +05:30
amaannawab923 e36787ea80 feat(embedded): complete the standalone chart render path
Two gaps surfaced once a chart was actually rendered on its own:

- The nativeFilters and dashboardLayout reducers dereference their slice
  unconditionally on HYDRATE_DASHBOARD, so the fabricated state has to carry
  both even though a lone chart has no layout tree and no native filters.

- raise_for_access gates guest datasource access entirely on a dashboardId in
  the form data, which a standalone chart never has. Added a chart leg that
  authorizes the datasource when the guest token was issued for that chart and
  the request targets the chart's own datasource.
2026-09-09 16:22:47 +05:30
amaannawab923 5e5e37e521 feat(embedded): allow guest tokens scoped to a chart
Adds a CHART guest token resource type, validates it against the embedded
chart uuid, and lets a directly-embedded chart satisfy the guest branch of
raise_for_access without belonging to a dashboard.
2026-09-09 16:22:47 +05:30
amaannawab923 76a87ee241 fix(embedded): correct import paths for the 6.x module layout
t moved to @apache-superset/core/translation, css/styled to
@apache-superset/core/theme, and ErrorBoundary is a named export.
2026-09-09 16:22:47 +05:30
amaannawab923 6cfef05967 feat(embedded): expose Embed chart from the chart menu
Generalises the embed modal with an optional resourceType (defaulting to
dashboard, so existing call sites are unchanged) and adds an Embed chart
item to the chart header menu, gated on EMBEDDED_SUPERSET and the
can_set_embedded permission on Chart.
2026-09-09 16:22:47 +05:30
amaannawab923 1d6768523a feat(embedded): render an embedded chart via fabricated dashboard state
Builds the minimum slice of dashboard state a single chart needs and renders
the existing dashboard Chart component against it, so cross-filtering, drill
and the header controls work without reimplementation.

Reuses HYDRATE_DASHBOARD rather than adding a parallel action, so no
dashboard reducer changes are required; datasources is populated through its
own setDatasources action.
2026-09-09 16:22:47 +05:30
amaannawab923 b62e0e5d2d feat(embedded): resolve a uuid as either an embedded dashboard or chart
The embed view now falls back to EmbeddedChartDAO and passes resource_type
plus chart_id into the bootstrap payload.
2026-09-09 16:22:47 +05:30
amaannawab923 0775f0dacb feat(embedded): add EmbeddedChartDAO, schemas and chart embedded endpoints
Adds GET/POST/PUT/DELETE on /api/v1/chart/<pk>/embedded, mirroring the
dashboard embedded endpoints, with a set_embedded permission for writes.
2026-09-09 16:22:47 +05:30
amaannawab923 7a230c0090 feat(embedded): add EmbeddedChart model and migration
Mirrors EmbeddedDashboard so both embeddable resource types share the same
guest-token and allowed-domain semantics, including
guest_token_revoked_before.
2026-09-09 16:22:47 +05:30
28 changed files with 2073 additions and 54 deletions
+21
View File
@@ -61,6 +61,27 @@ embedDashboard({
---
## Guest role permissions
Embedded viewers authenticate with a guest token rather than a session, and Superset resolves them onto the role named by `GUEST_ROLE_NAME` (default: `Public`). What that role grants therefore decides whether an embed loads.
Set `PUBLIC_ROLE_LIKE = "Public"` and run `superset init` to sync the built-in defaults, which already cover both embeddable resource types. This is the supported path, and an embedded chart needs nothing added to it.
If you maintain the guest role by hand, an embedded chart needs at least:
| Grant | Needed for |
| ------------------------------ | ---------------------------------------------------------------- |
| `can read on Chart` | The chart definition, its dataset, and `/api/v1/chart/data` |
| `can read on CurrentUserRestApi` | `/api/v1/me/roles/`, which the embedded page calls before it renders anything |
Omitting the second one is easy to miss: the embed fails during authentication rather than while loading the chart, so the browser console reports an authentication failure with no mention of a missing grant.
An embedded chart needs **no** grant on Explore. It reads its definition and dataset through the same `Chart` permission it already needs to fetch data, so a guest role that can open an embedded dashboard can open an embedded chart.
The guest token itself, not the role, decides *which* dashboards and charts are in scope — a role grant alone never widens a guest past the resources its token names.
---
## Callbacks
### `resolvePermalinkUrl`
+350 -1
View File
@@ -7019,6 +7019,44 @@
},
"type": "object"
},
"EmbeddedChartConfig": {
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"allowed_domains"
],
"type": "object"
},
"EmbeddedChartResponseSchema": {
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
},
"changed_by": {
"$ref": "#/components/schemas/EmbeddedResponseUser"
},
"changed_on": {
"format": "date-time",
"type": "string"
},
"chart_id": {
"type": "string"
},
"uuid": {
"type": "string"
}
},
"type": "object"
},
"EmbeddedDashboardConfig": {
"properties": {
"allowed_domains": {
@@ -7093,6 +7131,23 @@
},
"type": "object"
},
"EmbeddedResponseUser": {
"properties": {
"first_name": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_name": {
"type": "string"
},
"username": {
"type": "string"
}
},
"type": "object"
},
"EngineInformation": {
"properties": {
"disable_ssh_tunneling": {
@@ -10542,7 +10597,8 @@
},
"type": {
"enum": [
"dashboard"
"dashboard",
"chart"
]
}
},
@@ -16682,6 +16738,299 @@
]
}
},
"/api/v1/chart/{pk}/embedded": {
"delete": {
"parameters": [
{
"description": "The chart id",
"in": "path",
"name": "pk",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Successfully removed the configuration"
},
"401": {
"$ref": "#/components/responses/401"
},
"404": {
"$ref": "#/components/responses/404"
},
"500": {
"$ref": "#/components/responses/500"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Delete a chart's embedded configuration",
"tags": [
"Charts"
]
},
"get": {
"parameters": [
{
"description": "The chart id",
"in": "path",
"name": "pk",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
}
},
"type": "object"
}
}
},
"description": "Result contains the embedded chart config"
},
"401": {
"$ref": "#/components/responses/401"
},
"404": {
"$ref": "#/components/responses/404"
},
"500": {
"$ref": "#/components/responses/500"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Get the chart's embedded configuration",
"tags": [
"Charts"
]
},
"post": {
"parameters": [
{
"description": "The chart id",
"in": "path",
"name": "pk",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EmbeddedChartConfig"
}
}
},
"description": "The embedded configuration to set",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
}
},
"type": "object"
}
}
},
"description": "Successfully set the configuration"
},
"401": {
"$ref": "#/components/responses/401"
},
"404": {
"$ref": "#/components/responses/404"
},
"500": {
"$ref": "#/components/responses/500"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Set a chart's embedded configuration",
"tags": [
"Charts"
]
},
"put": {
"parameters": [
{
"description": "The chart id",
"in": "path",
"name": "pk",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EmbeddedChartConfig"
}
}
},
"description": "The embedded configuration to set",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"$ref": "#/components/schemas/EmbeddedChartResponseSchema"
}
},
"type": "object"
}
}
},
"description": "Successfully set the configuration"
},
"401": {
"$ref": "#/components/responses/401"
},
"404": {
"$ref": "#/components/responses/404"
},
"500": {
"$ref": "#/components/responses/500"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Update a chart's embedded configuration",
"tags": [
"Charts"
]
}
},
"/api/v1/chart/{pk}/embedded_context": {
"get": {
"description": "The chart analogue of a dashboard's ``/charts`` and ``/datasets`` sub-resources, collapsed into one call because a chart has exactly one of each. Sits under the ``Chart`` read permission, so a standalone embedded chart loads with the same grant its guest token already needs to fetch that chart's data.",
"parameters": [
{
"description": "The chart id",
"in": "path",
"name": "pk",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"properties": {
"dataset": {
"type": "object"
},
"slice": {
"$ref": "#/components/schemas/ChartEntityResponseSchema"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": "The chart and its dataset"
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
},
"500": {
"$ref": "#/components/responses/500"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Get a chart and its dataset in one payload",
"tags": [
"Charts"
]
}
},
"/api/v1/chart/{pk}/favorites/": {
"delete": {
"parameters": [
@@ -168,6 +168,35 @@ test('enables Save Changes button when allowed domains are modified', async () =
expect(saveChangesBtn).toBeEnabled();
});
test('refetches the configuration when resourceType changes but dashboardId does not', async () => {
const chartResponse = {
result: { uuid: 'chart-uuid', dashboard_id: '1', allowed_domains: [] },
};
(makeApi as any)
.mockReturnValueOnce(jest.fn().mockResolvedValue(defaultResponse))
.mockReturnValueOnce(jest.fn().mockResolvedValue(chartResponse));
const { rerender } = render(
<DashboardEmbedModal {...defaultProps} resourceType="dashboard" />,
{ useRedux: true },
);
const dashboardDomainsInput = (await screen.findByRole('textbox', {
name: /Allowed Domains/i,
})) as HTMLInputElement;
await waitFor(() => {
expect(dashboardDomainsInput.value).toBe('example.com');
});
rerender(<DashboardEmbedModal {...defaultProps} resourceType="chart" />);
const chartDomainsInput = (await screen.findByRole('textbox', {
name: /Allowed Domains/i,
})) as HTMLInputElement;
await waitFor(() => {
expect(chartDomainsInput.value).toBe('');
});
});
test('renders extension component when registered', async () => {
const extensionsRegistry = getExtensionsRegistry();
@@ -44,6 +44,9 @@ const extensionsRegistry = getExtensionsRegistry();
type Props = {
dashboardId: string;
// Which resource the id refers to. Defaults to 'dashboard' so existing
// call sites are unaffected; charts reuse the same controls.
resourceType?: 'dashboard' | 'chart';
show: boolean;
onHide: () => void;
};
@@ -59,7 +62,11 @@ const ButtonRow = styled.div`
justify-content: flex-end;
`;
export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
export const DashboardEmbedControls = ({
dashboardId,
resourceType = 'dashboard',
onHide,
}: Props) => {
const { addInfoToast, addDangerToast } = useToasts();
const [ready, setReady] = useState(true); // whether we have initialized yet
const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
@@ -67,7 +74,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
const [allowedDomains, setAllowedDomains] = useState<string>('');
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
const endpoint = `/api/v1/${resourceType}/${dashboardId}/embedded`;
// whether saveable changes have been made to the config
const isDirty =
!embedded ||
@@ -149,7 +156,10 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
setEmbedded(result);
setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
});
}, [dashboardId]);
// `endpoint` already incorporates both `dashboardId` and `resourceType`;
// depending on it (rather than `dashboardId` alone) keeps this in sync if
// a resource type ever changes without a `dashboardId` change.
}, [endpoint]);
if (!ready) {
return <Loading />;
@@ -172,18 +182,26 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
<DocsConfigDetails embeddedId={embedded.uuid} />
) : (
<p>
{t(
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
)}
{resourceType === 'chart'
? t(
'This chart is ready to embed. In your application, pass the following id to the SDK:',
)
: t(
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
)}
<br />
<code>{embedded.uuid}</code>
</p>
)
) : (
<p>
{t(
'Configure this dashboard to embed it into an external web application.',
)}
{resourceType === 'chart'
? t(
'Configure this chart to embed it into an external web application.',
)
: t(
'Configure this dashboard to embed it into an external web application.',
)}
</p>
)}
<p>
@@ -64,6 +64,8 @@ import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
import DashboardEmbedModal from 'src/dashboard/components/EmbeddedModal';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { openInNewTab } from 'src/utils/navigationUtils';
import { usePermissions } from 'src/hooks/usePermissions';
@@ -173,6 +175,13 @@ const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [embedModalIsOpen, setEmbedModalIsOpen] = useState(false);
const user = useSelector((state: RootState) => state.user);
// Mirrors the dashboard's `userCanCurate`: embedding must be enabled and the
// user must hold the chart-level set_embedded permission.
const canEmbed =
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
findPermission('can_set_embedded', 'Chart', user?.roles);
// setting openKeys undefined falls back to uncontrolled behaviour
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
@@ -272,6 +281,9 @@ const SliceHeaderControls = (
refreshChart();
props.addSuccessToast(t('Data refreshed'));
break;
case MenuKeys.ManageEmbedded:
setEmbedModalIsOpen(true);
break;
case MenuKeys.ToggleChartDescription:
// eslint-disable-next-line no-unused-expressions
props.toggleExpandSlice?.(props.slice.slice_id);
@@ -522,6 +534,14 @@ const SliceHeaderControls = (
key: MenuKeys.Fullscreen,
label: fullscreenLabel,
},
...(canEmbed
? [
{
key: MenuKeys.ManageEmbedded,
label: t('Embed chart'),
},
]
: []),
{
type: 'divider',
},
@@ -815,6 +835,14 @@ const SliceHeaderControls = (
dataset={datasetWithVerboseMap}
/>
{canEditCrossFilters && scopingModal}
{canEmbed && (
<DashboardEmbedModal
show={embedModalIsOpen}
onHide={() => setEmbedModalIsOpen(false)}
dashboardId={String(slice.slice_id)}
resourceType="chart"
/>
)}
{isFullSize && <Global styles={fullscreenStyles(theme)} />}
</>
);
@@ -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.
*/
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import dashboardLayout from 'src/dashboard/reducers/dashboardLayout';
import nativeFilters from 'src/dashboard/reducers/nativeFilters';
import dashboardStateReducer from 'src/dashboard/reducers/dashboardState';
import sliceEntities from 'src/dashboard/reducers/sliceEntities';
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
import hydrateEmbedded, { EmbeddedChartData } from './hydrateEmbedded';
const SLICE_ID = 103;
const chartData = {
slice: {
slice_id: SLICE_ID,
slice_url: `/explore/?slice_id=${SLICE_ID}`,
slice_name: 'Preferred Employment Style',
form_data: {
viz_type: 'treemap_v2',
datasource: '4__table',
slice_id: SLICE_ID,
},
description: null,
changed_on: '2026-01-01T00:00:00',
},
dataset: { uid: '4__table', id: 4 },
} as unknown as EmbeddedChartData;
const common = { locale: 'en' } as unknown as CommonBootstrapData;
const build = () => hydrateEmbedded(chartData, common);
test('dispatches HYDRATE_DASHBOARD rather than a parallel action', () => {
expect(build().type).toEqual(HYDRATE_DASHBOARD);
});
test('keys the fabricated state by slice id', () => {
const { data } = build();
expect(Object.keys(data.charts)).toEqual([String(SLICE_ID)]);
expect(data.sliceEntities.slices[SLICE_ID].slice_name).toEqual(
'Preferred Employment Style',
);
expect(data.dataMask[SLICE_ID]).toBeDefined();
expect(data.dashboardState.sliceIds).toEqual([SLICE_ID]);
});
test('keeps the actions that would navigate out of the iframe switched off', () => {
const { dashboardInfo } = build().data;
expect(dashboardInfo.superset_can_explore).toBe(false);
expect(dashboardInfo.superset_can_share).toBe(false);
expect(dashboardInfo.crossFiltersEnabled).toBe(false);
// Chart.tsx reads this one, so downloads stay available.
expect(dashboardInfo.superset_can_download).toBe(true);
});
/**
* The regression this file exists for. `nativeFilters` and `dashboardLayout`
* read `action.data.<slice>` with no optional chaining, so omitting either one
* throws at runtime — and only in the embedded path, where a dashboard
* developer would never see it. Run the real reducers against the real payload
* rather than asserting on shape, so this keeps holding if they change.
*/
describe('every HYDRATE_DASHBOARD handler survives the fabricated payload', () => {
const cases: [string, (state: any, action: any) => unknown][] = [
['dashboardLayout', dashboardLayout],
['nativeFilters', nativeFilters],
['dashboardState', dashboardStateReducer],
['sliceEntities', sliceEntities],
];
test.each(cases)('%s', (_name, reducer) => {
expect(() => reducer(undefined, build())).not.toThrow();
});
});
test('carries a layout tree so the layout reducer has a root to hydrate', () => {
const layout = build().data.dashboardLayout.present;
expect(layout.ROOT_ID).toBeDefined();
expect(layout.GRID_ID).toBeDefined();
});
test('carries an empty native filter map', () => {
expect(build().data.nativeFilters.filters).toEqual({});
});
test('renames the misspelled API field to description_markdown for the chart stack', () => {
const withDescription = {
...chartData,
slice: { ...chartData.slice, description_markeddown: '<p>hello</p>' },
} as unknown as EmbeddedChartData;
const { data } = hydrateEmbedded(withDescription, common);
expect(data.sliceEntities.slices[SLICE_ID].description_markdown).toEqual(
'<p>hello</p>',
);
});
@@ -0,0 +1,167 @@
/**
* 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 { DataMaskWithId, JsonObject } from '@superset-ui/core';
import { chart } from 'src/components/Chart/chartReducer';
import { getInitialDataMask } from 'src/dataMask/reducer';
import { applyDefaultFormData } from 'src/explore/store';
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import { Datasource } from 'src/dashboard/types';
import {
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
} from 'src/dashboard/util/constants';
import {
DASHBOARD_ROOT_TYPE,
DASHBOARD_GRID_TYPE,
} from 'src/dashboard/util/componentTypes';
/**
* A chart embedded on its own still renders through the dashboard's chart
* stack, because that is where cross-filtering, drill, and the header controls
* live. Rather than reimplement any of that, this builds the minimum slice of
* dashboard state a single chart needs and lets the existing components run
* against it unchanged.
*
* It reuses HYDRATE_DASHBOARD rather than introducing a parallel action, so
* every dashboard reducer stays untouched: `charts`, `sliceEntities`,
* `dataMask`, `dashboardInfo` and `dashboardState` all already handle it.
* `dashboardLayout` and `nativeFilters` handle it too but dereference their
* slice unconditionally, so the payload carries an empty stand-in for each.
* `datasources` is the one slice with no hydrate handler at all, so the caller
* dispatches `setDatasources` for it separately.
*
* Every slice any HYDRATE_DASHBOARD handler reads has to appear here; the
* accompanying test asserts that, because a missing one only fails at runtime
* and only in the embedded path.
*/
export interface EmbeddedChartData {
slice: {
slice_id: number;
slice_url: string;
slice_name: string;
form_data: JsonObject & { viz_type: string; datasource: string };
description?: string | null;
description_markeddown?: string | null;
modified?: string | null;
changed_on?: string | number | null;
};
// The explore endpoint returns the full datasource, and `setDatasources`
// stores it as one, so it is typed as such rather than loosely.
dataset: Datasource;
}
export interface HydrateEmbeddedAction {
type: typeof HYDRATE_DASHBOARD;
data: {
charts: Record<number, JsonObject>;
sliceEntities: { slices: Record<number, JsonObject> };
dataMask: Record<number, DataMaskWithId>;
dashboardInfo: JsonObject;
dashboardState: JsonObject;
dashboardLayout: { present: JsonObject };
nativeFilters: { filters: JsonObject };
};
}
const hydrateEmbedded = (
{ slice }: EmbeddedChartData,
common: CommonBootstrapData,
): HydrateEmbeddedAction => {
const key = slice.slice_id;
return {
type: HYDRATE_DASHBOARD,
data: {
charts: {
[key]: {
...chart,
id: key,
form_data: applyDefaultFormData(slice.form_data),
},
},
sliceEntities: {
slices: {
[key]: {
slice_id: key,
slice_url: slice.slice_url,
slice_name: slice.slice_name,
form_data: slice.form_data,
viz_type: slice.form_data.viz_type,
datasource: slice.form_data.datasource,
description: slice.description,
// The API field is spelled `description_markeddown`; the chart
// stack (see Chart.tsx) reads the correctly spelled
// `description_markdown` off the store slice, matching the same
// rename `hydrate.ts` and `sliceEntities.ts` already perform.
description_markdown: slice.description_markeddown,
modified: slice.modified,
changed_on: slice.changed_on
? new Date(slice.changed_on).getTime()
: undefined,
},
},
},
dataMask: {
[key]: getInitialDataMask(key) as DataMaskWithId,
},
dashboardInfo: {
common,
// A guest viewing an embedded chart has no Superset UI to navigate to,
// so the actions that would leave the iframe stay off.
metadata: {},
superset_can_explore: false,
superset_can_share: false,
// Chart.tsx reads `superset_can_download`; `superset_can_csv` is not
// a key the current dashboard chart stack looks at.
superset_can_download: true,
crossFiltersEnabled: false,
},
dashboardState: {
expandedSlices: { [key]: false },
sliceIds: [key],
},
// Both of these reducers dereference their slice unconditionally on
// HYDRATE_DASHBOARD, so they have to be present even though a lone chart
// has no layout tree and no native filters of its own.
dashboardLayout: {
present: {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
type: DASHBOARD_GRID_TYPE,
parents: [DASHBOARD_ROOT_ID],
children: [],
meta: {},
},
},
},
nativeFilters: {
filters: {},
},
},
};
};
export default hydrateEmbedded;
@@ -0,0 +1,166 @@
/**
* 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 { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { AntdThemeProvider, Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import getBootstrapData from 'src/utils/getBootstrapData';
import { setDatasources } from 'src/dashboard/actions/datasources';
import useEmbeddedChartData from './useEmbeddedChartData';
import hydrateEmbedded from './hydrateEmbedded';
/**
* Fills the iframe. The chart is measured by its container rather than the
* dashboard grid, so the wrapper owns the dimensions the dashboard would
* normally supply.
*/
const Fill = styled.div`
${() => css`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: hidden;
`}
`;
/**
* The dashboard gives each chart a holder element that owns two things the
* header controls reach for: the node passed to `requestFullscreen`, and the
* `dashboard-chart-id-<id>` class the screenshot exports select on. An embedded
* chart renders without `ChartHolder`, so it has to provide both itself.
*/
const Holder = styled.div`
${({ theme }) => css`
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
/* Without this the chart title and the header menu sit flush against the
iframe edge. On a dashboard the grid gutter supplies this breathing
room; an embed has no grid, so the holder supplies it. */
padding: ${theme.sizeUnit * 4}px;
`}
`;
/**
* Tracks the holder's content box, which excludes its padding, so the chart is
* laid out inside that padding rather than overflowing it. Falls back to the
* viewport for the first paint and where ResizeObserver is unavailable.
*/
const useContainerSize = (
ref: RefObject<HTMLElement>,
enabled: boolean,
): { width: number; height: number } => {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const element = ref.current;
if (!enabled || !element || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(entries => {
const box = entries[0]?.contentRect;
if (box?.width && box?.height) {
setSize({ width: box.width, height: box.height });
}
});
observer.observe(element);
return () => observer.disconnect();
}, [ref, enabled]);
return size;
};
export default function EmbeddedChart({ chartId }: { chartId: string }) {
const dispatch = useDispatch();
const { data, loading, error } = useEmbeddedChartData(chartId);
const [hydrated, setHydrated] = useState(false);
const [isFullSize, setIsFullSize] = useState(false);
const holderRef = useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(holderRef, hydrated);
const handleToggleFullSize = useCallback(() => {
setIsFullSize(current => !current);
}, []);
useEffect(() => {
if (!data) return;
const bootstrapData = getBootstrapData();
// `datasources` has no HYDRATE_DASHBOARD handler, so it is populated
// through its own action rather than the hydrate payload.
dispatch(setDatasources([data.dataset]));
dispatch(hydrateEmbedded(data, bootstrapData.common));
setHydrated(true);
}, [data, dispatch]);
if (loading || (!hydrated && !error)) return <Loading />;
if (error || !data)
return <div>{error ?? t('The chart could not be loaded.')}</div>;
return (
<Fill>
<ErrorBoundary>
<Holder
ref={holderRef}
className={`dashboard-component-chart-holder dashboard-chart-id-${data.slice.slice_id}`}
>
<AntdThemeProvider
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body,
// otherwise the header menu is unreachable while fullscreen.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
>
<Chart
id={data.slice.slice_id}
componentId={`EMBEDDED_CHART-${data.slice.slice_id}`}
// There is no dashboard behind an embedded chart; the fabricated
// state is keyed by slice id and nothing reads this as a lookup.
dashboardId={0}
width={width}
height={height}
sliceName={data.slice.slice_name}
isComponentVisible
isInView
chartHolderRef={holderRef}
isFullSize={isFullSize}
handleToggleFullSize={handleToggleFullSize}
// Renaming is a dashboard-owner action with no meaning here.
updateSliceName={() => {}}
/>
</AntdThemeProvider>
</Holder>
</ErrorBoundary>
</Fill>
);
}
@@ -0,0 +1,98 @@
/**
* 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 { useEffect, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { EmbeddedChartData } from './hydrateEmbedded';
interface State {
data: EmbeddedChartData | null;
loading: boolean;
error: string | null;
}
/**
* Fetches the one chart this iframe renders, in the shape `hydrateEmbedded`
* expects: the slice and its dataset together, which is exactly the pair the
* fabricated dashboard state needs.
*
* It reads them from the chart's own embedded-context endpoint rather than from
* `/api/v1/explore/`. Explore sits under its own `Explore` permission, which an
* embedded guest role does not hold, so a guest token would get a 403 and the
* page would dead-end in its error state. This endpoint sits under the `Chart`
* permission the guest already needs for `/api/v1/chart/data`, and it resolves
* one fixed chart instead of assembling a payload from request-supplied form
* data, which keeps the guest's reachable surface to what the embed needs.
*/
export default function useEmbeddedChartData(chartId: string | number): State {
const [state, setState] = useState<State>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
SupersetClient.get({
endpoint: `/api/v1/chart/${chartId}/embedded_context`,
})
.then(({ json }) => {
if (cancelled) return;
const result = json?.result;
if (!result?.slice || !result?.dataset) {
setState({
data: null,
loading: false,
error: t('The chart could not be loaded.'),
});
return;
}
setState({
data: {
// `form_data` on the payload already carries the datasource and
// viz_type the chart stack keys off.
slice: {
...result.slice,
// The payload identifies the chart as `id`; the rest of the
// embedded chart stack keys off `slice_id`.
slice_id: Number(chartId),
},
dataset: result.dataset,
},
loading: false,
error: null,
});
})
.catch(() => {
if (cancelled) return;
setState({
data: null,
loading: false,
error: t('The chart could not be loaded.'),
});
});
return () => {
cancelled = true;
};
}, [chartId]);
return state;
}
+17 -1
View File
@@ -108,6 +108,12 @@ const LazyDashboardPage = lazy(
),
);
// Keeps the dashboard chart stack out of the initial embedded bundle for
// consumers who only ever embed dashboards, which never render this path.
const LazyEmbeddedChart = lazy(
() => import(/* webpackChunkName: "EmbeddedChart" */ './embeddedChart'),
);
const EmbeddedLazyDashboardPage = () => {
const uiConfig = useUiConfig();
const emitDataMasks = uiConfig?.emitDataMasks;
@@ -136,6 +142,16 @@ const EmbeddedLazyDashboardPage = () => {
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
};
// A uuid resolves to either a dashboard or a single chart. Payloads written
// before charts were embeddable omit `resource_type`, so anything other than
// an explicit 'chart' keeps the original dashboard behaviour.
const EmbeddedResource = () =>
bootstrapData.embedded?.resource_type === 'chart' ? (
<LazyEmbeddedChart chartId={bootstrapData.embedded.chart_id!} />
) : (
<EmbeddedLazyDashboardPage />
);
const EmbeddedRoute = () => (
<EmbeddedContextProviders>
<Global
@@ -150,7 +166,7 @@ const EmbeddedRoute = () => (
/>
<Suspense fallback={<Loading />}>
<ErrorBoundary>
<EmbeddedLazyDashboardPage />
<EmbeddedResource />
</ErrorBoundary>
<ToastContainer position="top" />
</Suspense>
+7 -1
View File
@@ -19,6 +19,7 @@
import { useEffect, useState } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
// Matches antd's screenSMMax token; used only when no theme is in scope.
const FALLBACK_MOBILE_MAX_WIDTH = 767;
@@ -29,7 +30,12 @@ const FALLBACK_MOBILE_MAX_WIDTH = 767;
* interpolations; prefer `useIsMobile` in components.
*/
export function isMobileConsumptionEnabled(): boolean {
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
// Inside an iframe the viewport is the size the host chose for the embed,
// not the size of the device, so a narrow embed on a desktop would
// otherwise be served the phone experience and lose its chart controls.
// Mobile consumption mode is a whole-app experience (route guarding,
// drawer navigation) that an embed does not have in the first place.
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode) && !isEmbedded();
}
/**
@@ -186,8 +186,12 @@ export interface BootstrapData {
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
};
embedded?: {
// Which resource this uuid resolves to. Older payloads predate charts
// being embeddable and omit it, so treat a missing value as a dashboard.
resource_type?: 'dashboard' | 'chart';
dashboard_id: string;
// Domains allowed to embed this dashboard. An empty/undefined list means
chart_id?: string;
// Domains allowed to embed this resource. An empty/undefined list means
// any domain is allowed (no restriction).
allowed_domains?: string[];
};
+296 -3
View File
@@ -67,9 +67,12 @@ from superset.charts.schemas import (
chart_get_list_schema,
CHART_SCHEMAS,
ChartCacheWarmUpRequestSchema,
ChartEntityResponseSchema,
ChartGetResponseSchema,
ChartPostSchema,
ChartPutSchema,
EmbeddedChartConfigSchema,
EmbeddedChartResponseSchema,
get_delete_ids_schema,
get_export_ids_schema,
get_fav_star_ids_schema,
@@ -105,11 +108,14 @@ from superset.commands.importers.exceptions import (
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.chart import ChartDAO
from superset.daos.chart import ChartDAO, EmbeddedChartDAO
from superset.dashboards.schemas import DashboardDatasetSchema
from superset.exceptions import (
ScreenshotImageNotAvailableException,
SupersetSecurityException,
)
from superset.extensions import event_logger, security_manager
from superset.extensions import db, event_logger, security_manager
from superset.models.embedded_chart import EmbeddedChart
from superset.models.slice import Slice
from superset.security.manager import (
get_extra_editor_subject_ids,
@@ -234,6 +240,10 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
"get_version",
"activity",
"restore_version",
"get_embedded",
"get_embedded_context",
"set_embedded",
"delete_embedded",
}
class_permission_name = "Chart"
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -253,6 +263,10 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
# single chart's metadata can resolve a Multiple Layers container's
# declared layers too.
"deck_layers": "read",
"get_embedded": "read",
"get_embedded_context": "read",
"set_embedded": "set_embedded",
"delete_embedded": "set_embedded",
}
list_columns = [
@@ -366,9 +380,19 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
edit_model_schema = ChartPutSchema()
chart_get_response_schema = ChartGetResponseSchema()
embedded_response_schema = EmbeddedChartResponseSchema()
chart_entity_response_schema = ChartEntityResponseSchema()
dashboard_dataset_schema = DashboardDatasetSchema()
embedded_config_schema = EmbeddedChartConfigSchema()
openapi_spec_tag = "Charts"
""" Override the name set for this collection of endpoints """
openapi_spec_component_schemas = CHART_SCHEMAS + (VersionListItemSchema,)
openapi_spec_component_schemas = CHART_SCHEMAS + (
VersionListItemSchema,
# Referenced by $ref from the embedded endpoints, so it has to be
# registered as a component rather than only inlined.
EmbeddedChartResponseSchema,
)
apispec_parameter_schemas = {
"chart_get_list_schema": chart_get_list_schema,
@@ -2016,3 +2040,272 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
return restore_version_endpoint(
self, Slice, RestoreChartVersionCommand, uuid_str, version_uuid_str
)
@expose("/<pk>/embedded", methods=("GET",))
@protect()
@safe
@permission_name("read")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded",
log_to_statsd=False,
)
def get_embedded(self, pk: int) -> Response:
"""Get the chart's embedded configuration.
---
get:
summary: Get the chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
responses:
200:
description: Result contains the embedded chart config
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
if not chart.embedded:
return self.response(404)
embedded: EmbeddedChart = chart.embedded[0]
result = self.embedded_response_schema.dump(embedded)
return self.response(200, result=result)
@expose("/<pk>/embedded_context", methods=("GET",))
@protect()
@safe
@permission_name("read")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.get_embedded_context"
),
log_to_statsd=False,
)
def get_embedded_context(self, pk: int) -> Response:
"""Get a chart together with the dataset needed to render it.
---
get:
summary: Get a chart and its dataset in one payload
description: >-
The chart analogue of a dashboard's ``/charts`` and ``/datasets``
sub-resources, collapsed into one call because a chart has exactly
one of each. Sits under the ``Chart`` read permission, so a
standalone embedded chart loads with the same grant its guest token
already needs to fetch that chart's data.
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
responses:
200:
description: The chart and its dataset
content:
application/json:
schema:
type: object
properties:
result:
type: object
properties:
slice:
$ref: '#/components/schemas/ChartEntityResponseSchema'
dataset:
type: object
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
# pylint: disable=import-outside-toplevel
from superset.dashboards.api import DASHBOARD_DATASET_INACCESSIBLE_FIELDS
# Resolved through the base filters so ChartFilter's scoping -- including
# its embedded-guest branch -- decides what is visible here.
chart = self.datamodel.get(pk, self._base_filters)
if not chart:
return self.response_404()
try:
security_manager.raise_for_access(chart=chart)
except SupersetSecurityException:
return self.response_403()
datasource = chart.datasource
if datasource is None:
return self.response_404()
dataset = self.dashboard_dataset_schema.dump(datasource.data)
# A dashboard narrows member datasets the caller cannot access on their
# own, because it returns many datasets of uneven sensitivity. Here there
# is exactly one and it belongs to the chart the caller was just
# authorized on, so a guest holding a token for that chart keeps the
# rendering metadata -- columns, metrics, verbose map -- it needs to draw
# the chart. ``params`` is withheld even then: it is operator-authored
# free-form configuration rather than anything the renderer reads.
entitled_guest = security_manager.has_guest_access_to_chart(chart)
if not (security_manager.can_access_datasource(datasource) or entitled_guest):
for key in DASHBOARD_DATASET_INACCESSIBLE_FIELDS:
dataset.pop(key, None)
elif entitled_guest:
dataset.pop("params", None)
return self.response(
200,
result={
"slice": self.chart_entity_response_schema.dump(chart),
"dataset": dataset,
},
)
@expose("/<pk>/embedded", methods=("POST", "PUT"))
@protect()
@safe
@permission_name("set_embedded")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded",
log_to_statsd=False,
)
def set_embedded(self, pk: int) -> Response:
"""Set a chart's embedded configuration.
---
post:
summary: Set a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
requestBody:
description: The embedded configuration to set
required: true
content:
application/json:
schema: EmbeddedChartConfigSchema
responses:
200:
description: Successfully set the configuration
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
put:
summary: Update a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
requestBody:
description: The embedded configuration to set
required: true
content:
application/json:
schema: EmbeddedChartConfigSchema
responses:
200:
description: Successfully set the configuration
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
try:
body = self.embedded_config_schema.load(request.json)
embedded = EmbeddedChartDAO.upsert(chart, body["allowed_domains"])
db.session.commit() # pylint: disable=consider-using-transaction
result = self.embedded_response_schema.dump(embedded)
return self.response(200, result=result)
except ValidationError as error:
db.session.rollback() # pylint: disable=consider-using-transaction
return self.response_400(message=error.messages)
@expose("/<pk>/embedded", methods=("DELETE",))
@protect()
@safe
@permission_name("set_embedded")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.delete_embedded"
),
log_to_statsd=False,
)
def delete_embedded(self, pk: int) -> Response:
"""Delete a chart's embedded configuration.
---
delete:
summary: Delete a chart's embedded configuration
parameters:
- in: path
schema:
type: integer
name: pk
description: The chart id
responses:
200:
description: Successfully removed the configuration
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
chart.embedded = []
db.session.commit() # pylint: disable=consider-using-transaction
return self.response(200, message="OK")
+40 -5
View File
@@ -14,13 +14,14 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any
from typing import Any, Optional
from flask import current_app
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased
from sqlalchemy.orm.query import Query
from sqlalchemy.sql.elements import ColumnElement
from superset import db, security_manager
from superset.connectors.sqla import models
@@ -43,6 +44,35 @@ from superset.views.base_api import BaseFavoriteFilter
from superset.views.filters import BaseDeletedRecencyFilter, BaseDeletedStateFilter
def guest_embedded_chart_filter() -> Optional[ColumnElement[bool]]:
"""SQLAlchemy condition matching the charts embedded in their own right that
the current guest token grants, or None when it grants none.
The chart counterpart of ``guest_embedded_dashboard_filter``, which only ever
resolves the dashboard resources of a token. A chart is addressed solely by
its embed uuid, so there is no legacy raw-id form to route around here.
Returning None means "adds nothing to the guest's scope", never "not a
guest": the caller decides what an empty scope means, so this can be OR-ed
into the dashboard scope without widening a token that grants no charts.
"""
# pylint: disable=import-outside-toplevel
from superset.models.embedded_chart import EmbeddedChart
from superset.security.guest_token import GuestTokenResourceType
guest = security_manager.get_current_guest_user_if_guest()
if guest is None:
return None
uuids = [
str(resource["id"])
for resource in guest.resources
if resource["type"] == GuestTokenResourceType.CHART.value
]
if not uuids:
return None
return Slice.embedded.any(EmbeddedChart.uuid.in_(uuids))
class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
name = _("All Text")
arg_name = "chart_all_text"
@@ -111,11 +141,16 @@ class ChartCertifiedFilter(BaseFilter): # pylint: disable=too-few-public-method
class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
def apply(self, query: Query, value: Any) -> Query:
# Embedded guests are scoped to their token's dashboards first. A guest
# is never entitled to all charts, regardless of what its role grants,
# and an empty token scope denies all charts (a deny-all clause).
# Embedded guests are scoped to what their token grants first: charts on
# one of the token's embedded dashboards, plus charts embedded in their
# own right. A guest is never entitled to all charts, regardless of what
# its role grants, and a token granting neither denies all charts (the
# dashboard branch contributes a deny-all clause).
if (guest_dashboards := guest_embedded_dashboard_filter()) is not None:
return query.filter(self.model.dashboards.any(guest_dashboards))
guest_scope: list[Any] = [self.model.dashboards.any(guest_dashboards)]
if (guest_charts := guest_embedded_chart_filter()) is not None:
guest_scope.append(guest_charts)
return query.filter(or_(*guest_scope))
if security_manager.can_access_all_datasources():
return query
+28
View File
@@ -2112,3 +2112,31 @@ CHART_SCHEMAS = (
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
)
class EmbeddedChartConfigSchema(Schema):
allowed_domains = fields.List(fields.String(), required=True)
class EmbeddedResponseUserSchema(Schema):
"""
``changed_by`` shape for ``EmbeddedChartResponseSchema``.
Deliberately separate from the module-level ``UserSchema`` (which other
chart schemas dump ``email`` through): this mirrors
``superset.dashboards.schemas.UserSchema``, the embedded-dashboard twin,
so the two embedded-resource response shapes stay identical.
"""
id = fields.Int()
username = fields.String()
first_name = fields.String()
last_name = fields.String()
class EmbeddedChartResponseSchema(Schema):
uuid = fields.String()
allowed_domains = fields.List(fields.String())
chart_id = fields.String(attribute="slice_id")
changed_on = fields.DateTime()
changed_by = fields.Nested(EmbeddedResponseUserSchema)
@@ -415,6 +415,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
# avoid circular import: model listener registration imports neutral event helpers
from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.embedded_chart import EmbeddedChart # noqa: F401
from superset.models.embedded_dashboard import EmbeddedDashboard # noqa: F401
from superset.models.slice import Slice
from superset.models.user_attributes import UserAttribute # noqa: F401
@@ -550,6 +551,13 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
fk("slices", "chart_editors", "id", "chart_id", "inbound"),
fk("slices", "chart_viewers", "id", "chart_id", "inbound"),
fk("slices", "dashboard_slices", "id", "slice_id", "inbound"),
fk(
"slices",
"embedded_charts",
"id",
"slice_id",
"inbound",
),
fk("slices", "report_schedule", "id", "chart_id", "inbound"),
version("slices", "slices_version"),
relationship("slices", "tables", "manytoone", "table"),
@@ -574,6 +582,27 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
"id",
"outbound",
),
fk(
"embedded_charts",
"ab_user",
"changed_by_fk",
"id",
"outbound",
),
fk(
"embedded_charts",
"ab_user",
"created_by_fk",
"id",
"outbound",
),
fk(
"embedded_charts",
"slices",
"slice_id",
"id",
"outbound",
),
),
(
DependencyClassification.PRESERVE,
@@ -582,6 +611,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.ASSOCIATION,
DependencyClassification.ASSOCIATION,
DependencyClassification.ASSOCIATION,
DependencyClassification.OWNED,
DependencyClassification.BLOCK,
DependencyClassification.VERSION_OWNED,
DependencyClassification.PRESERVE,
@@ -592,6 +622,9 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
DependencyClassification.PRESERVE,
),
(tag_cleanup, chart_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
+32 -1
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, List
from typing import Any, Dict, List
from flask_appbuilder.models.sqla.interface import SQLAInterface
from sqlalchemy import or_, select
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import ChartNotFoundError
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.embedded_chart import EmbeddedChart
from superset.models.slice import id_or_uuid_filter, Slice
from superset.utils.core import get_user_id
@@ -166,3 +167,33 @@ class ChartDAO(BaseDAO[Slice]):
)
if fav:
db.session.delete(fav)
class EmbeddedChartDAO(BaseDAO[EmbeddedChart]):
# There isn't really a regular scenario where we would rather get Embedded by id
id_column_name = "uuid"
@staticmethod
def upsert(chart: Slice, allowed_domains: list[str]) -> EmbeddedChart:
"""
Sets up a chart to be embeddable.
Upsert is used to preserve the embedded_chart uuid across updates.
"""
embedded: EmbeddedChart = (
chart.embedded[0] if chart.embedded else EmbeddedChart()
)
embedded.allow_domain_list = ",".join(allowed_domains)
chart.embedded = [embedded]
return embedded
@classmethod
def create(
cls,
item: EmbeddedChart | None = None,
attributes: dict[str, Any] | None = None,
) -> Any:
"""
Use EmbeddedChartDAO.upsert() instead.
At least, until we are ok with more than one embedded item per chart.
"""
raise NotImplementedError("Use EmbeddedChartDAO.upsert() instead.")
+37 -10
View File
@@ -22,6 +22,7 @@ from flask_login import AnonymousUserMixin, login_user
from flask_wtf.csrf import same_origin
from superset import event_logger, is_feature_enabled
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
@@ -45,28 +46,45 @@ class EmbeddedView(BaseSupersetView):
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
) -> FlaskResponse:
"""
Server side rendering for the embedded dashboard page
:param uuid: identifier for embedded dashboard
Server side rendering for the embedded dashboard or chart page
:param uuid: identifier for the embedded dashboard or chart
:param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
default value to appease pylint
"""
if not is_feature_enabled("EMBEDDED_SUPERSET"):
abort(404)
# A uuid identifies either an embedded dashboard or an embedded chart.
# Dashboards are looked up first since they are the older, more common
# resource; the two id spaces are distinct so ordering is not ambiguous.
resource_type = "dashboard"
embedded = EmbeddedDashboardDAO.find_by_id(uuid)
if not embedded:
embedded = EmbeddedChartDAO.find_by_id(uuid)
resource_type = "chart"
if not embedded:
abort(404)
assert embedded is not None
dashboard = embedded.dashboard
resource = (
embedded.dashboard if resource_type == "dashboard" else embedded.slice
)
# validate request referrer in allowed domains
is_referrer_allowed = not embedded.allowed_domains
for domain in embedded.allowed_domains:
if same_origin(request.referrer, domain):
is_referrer_allowed = True
break
try:
if same_origin(request.referrer, domain):
is_referrer_allowed = True
break
except ValueError:
# The referrer is attacker-controlled and same_origin parses it
# eagerly, so a malformed authority (e.g. a host that looks like
# it carries a non-numeric port) raises rather than returning
# False. Treat it as a non-match instead of a 500.
continue
if not is_referrer_allowed:
abort(403)
@@ -91,7 +109,8 @@ class EmbeddedView(BaseSupersetView):
login_user(AnonymousUserMixin(), force=True)
add_extra_log_payload(
embedded_dashboard_id=uuid,
embedded_id=uuid,
resource_type=resource_type,
dashboard_version="v2",
)
@@ -106,7 +125,11 @@ class EmbeddedView(BaseSupersetView):
},
"common": common_bootstrap_payload(),
"embedded": {
"dashboard_id": embedded.dashboard_id,
"resource_type": resource_type,
"dashboard_id": (
embedded.dashboard_id if resource_type == "dashboard" else None
),
"chart_id": embedded.slice_id if resource_type == "chart" else None,
# The list of domains allowed to embed this dashboard. An empty
# list means any domain is allowed (no restriction). The frontend
# uses this to validate the origin of incoming postMessage events.
@@ -117,8 +140,12 @@ class EmbeddedView(BaseSupersetView):
return self.render_template(
"superset/spa.html",
entry="embedded",
title=dashboard.dashboard_title,
dashboard_description=dashboard.description,
title=(
resource.dashboard_title
if resource_type == "dashboard"
else resource.slice_name
),
dashboard_description=resource.description,
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
@@ -0,0 +1,53 @@
# 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 embedded_charts table
Revision ID: a1c7e4b62f18
Revises: 7e2c9a4f1b83
Create Date: 2026-09-01 10:00:00.000000
"""
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import create_table, drop_table
# revision identifiers, used by Alembic.
revision = "a1c7e4b62f18"
down_revision = "7e2c9a4f1b83"
def upgrade() -> None:
create_table(
"embedded_charts",
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.Column("uuid", UUIDType(binary=True), primary_key=True),
sa.Column("allow_domain_list", sa.Text(), nullable=True),
sa.Column("guest_token_revoked_before", sa.Integer(), nullable=True),
sa.Column("slice_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["slice_id"], ["slices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
)
def downgrade() -> None:
drop_table("embedded_charts")
+64
View File
@@ -0,0 +1,64 @@
# 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 uuid
from flask_appbuilder import Model
from sqlalchemy import Column, ForeignKey, Integer, Text
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from superset.models.helpers import AuditMixinNullable
class EmbeddedChart(Model, AuditMixinNullable):
"""
A configuration of embedding for a chart.
References the chart, and contains a config for embedding that chart.
Mirrors ``EmbeddedDashboard`` so both embeddable resource types share the
same guest-token and allowed-domain semantics.
This data model allows multiple configurations for a given chart,
but at this time the API only allows setting one.
"""
__tablename__ = "embedded_charts"
uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
# Epoch seconds; guest tokens whose `iat` predates this are rejected. Set to
# "now" to revoke all currently-issued guest tokens for this embedded
# chart. NULL = no revocation.
guest_token_revoked_before = Column(Integer, nullable=True)
slice_id = Column(
Integer,
ForeignKey("slices.id", ondelete="CASCADE"),
nullable=False,
)
slice = relationship(
"Slice",
back_populates="embedded",
foreign_keys=[slice_id],
)
@property
def allowed_domains(self) -> list[str]:
"""
A list of domains which are allowed to embed the chart.
An empty list means any domain can embed.
"""
return self.allow_domain_list.split(",") if self.allow_domain_list else []
+5
View File
@@ -156,6 +156,11 @@ class Slice( # pylint: disable=too-many-public-methods
secondary=chart_viewers,
passive_deletes=True,
)
embedded = relationship(
"EmbeddedChart",
back_populates="slice",
cascade="all, delete-orphan",
)
tags = relationship(
"Tag",
+1
View File
@@ -140,6 +140,7 @@ class GuestTokenUser(TypedDict, total=False):
class GuestTokenResourceType(StrEnum):
DASHBOARD = "dashboard"
CHART = "chart"
class GuestTokenResource(TypedDict):
+88 -19
View File
@@ -4792,6 +4792,33 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return self.is_viewer(viewer_slc) or self.is_editor(viewer_slc)
def has_embedded_chart_access() -> bool:
# A chart embedded on its own has no parent dashboard, so the
# dashboard leg below can never authorize it. Grant datasource
# access when the guest token was issued for this very chart and
# the request is for that chart's own datasource.
# Resolve the guest user before touching the database: without
# one the chart grant can never hold, so the lookup below would
# be a query issued on every datasource check for nothing.
if not (
is_feature_enabled("EMBEDDED_SUPERSET")
and self.get_current_guest_user_if_guest()
and form_data
and form_data.get("type") != "NATIVE_FILTER"
and (embedded_slice_id := form_data.get("slice_id"))
and (
embedded_slc := self.session.query(Slice)
.filter(Slice.id == embedded_slice_id)
.one_or_none()
)
):
return False
return (
embedded_slc.datasource == datasource
and self.has_guest_access_to_chart(embedded_slc)
)
if not (
self.can_access_schema(datasource)
or self.can_access("datasource_access", datasource.perm or "")
@@ -4901,6 +4928,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# access if the user is a viewer or editor of the chart
# and promiscuous mode is enabled.
or has_promiscuous_chart_access()
# Standalone embedded chart, authorized by its own guest token.
or has_embedded_chart_access()
):
raise SupersetSecurityException(
self.get_datasource_access_error_object(datasource)
@@ -4999,8 +5028,12 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
if (
is_feature_enabled("EMBEDDED_SUPERSET")
and self.is_guest_user()
and any(
self.has_guest_access(dashboard_) for dashboard_ in chart.dashboards
and (
self.has_guest_access_to_chart(chart)
or any(
self.has_guest_access(dashboard_)
for dashboard_ in chart.dashboards
)
)
# Deliberately table-pinned: the guest token ``datasets``
# allowlist is dataset-id space, so resolving other
@@ -5338,6 +5371,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
from superset.commands.dashboard.embedded.exceptions import (
EmbeddedDashboardNotFoundError,
)
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.models.dashboard import Dashboard
@@ -5353,6 +5387,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# A raw dashboard id must still reference an embedded dashboard;
# otherwise a guest token could be scoped to a non-embedded one.
raise EmbeddedDashboardNotFoundError()
elif resource["type"] == GuestTokenResourceType.CHART.value:
# Charts are only ever addressed by the embedded uuid; there is
# no legacy raw-id path to support.
if not EmbeddedChartDAO.find_by_id(str(resource["id"])):
raise EmbeddedDashboardNotFoundError()
def create_guest_access_token(
self,
@@ -5446,9 +5485,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
version claim and are treated as
:data:`DEFAULT_GUEST_TOKEN_REVOCATION_VERSION` (0), so they only become
revoked once an admin has explicitly bumped the expected version above 0.
- **Per-embedded-dashboard cutoff** (``guest_token_revoked_before``): a
- **Per-embedded-resource cutoff** (``guest_token_revoked_before``): a
token is revoked if its ``iat`` predates the revocation cutoff of any of
its embedded-dashboard resources.
its embedded resources, dashboard or chart.
"""
return cls._is_guest_token_revoked_by_version(
token
@@ -5472,31 +5511,41 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
@staticmethod
def _is_guest_token_revoked_by_embedded(token: dict[str, Any]) -> bool:
"""Return True if the token predates a revocation on any of its
embedded-dashboard resources (``guest_token_revoked_before``).
embedded resources (``guest_token_revoked_before``).
A token missing ``iat`` cannot prove it was issued after a revocation
cutoff, so it is treated as revoked whenever any of its dashboard
cutoff, so it is treated as revoked whenever any of its embedded
resources has an active cutoff; otherwise it is not revoked.
"""
issued_at = token.get("iat")
# pylint: disable=import-outside-toplevel
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.models.dashboard import Dashboard
for resource in token.get("resources") or []:
if resource.get("type") != GuestTokenResourceType.DASHBOARD.value:
continue
resource_type = resource.get("type")
resource_id = str(resource.get("id"))
# A dashboard resource id may be an embedded UUID or, during the
# UUID migration, a legacy dashboard id. Resolve the embedded
# config(s) for either form (mirrors validate_guest_token_resources).
embedded = EmbeddedDashboardDAO.find_by_id(resource_id)
if embedded:
embedded_configs = [embedded]
embedded_configs: list[Any]
if resource_type == GuestTokenResourceType.DASHBOARD.value:
# A dashboard resource id may be an embedded UUID or, during the
# UUID migration, a legacy dashboard id. Resolve the embedded
# config(s) for either form (mirrors
# validate_guest_token_resources).
embedded = EmbeddedDashboardDAO.find_by_id(resource_id)
if embedded:
embedded_configs = [embedded]
else:
dashboard = Dashboard.get(resource_id)
embedded_configs = list(dashboard.embedded) if dashboard else []
elif resource_type == GuestTokenResourceType.CHART.value:
# Charts are only ever addressed by the embedded uuid; there is
# no legacy raw-id path to support.
embedded_chart = EmbeddedChartDAO.find_by_id(resource_id)
embedded_configs = [embedded_chart] if embedded_chart else []
else:
dashboard = Dashboard.get(resource_id)
embedded_configs = dashboard.embedded if dashboard else []
continue
for embedded_config in embedded_configs:
revoked_before = getattr(
embedded_config, "guest_token_revoked_before", None
@@ -5513,13 +5562,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
def revoke_guest_token_access(
self, embedded_uuid: str, before: Optional[int] = None
) -> None:
"""Revoke all guest tokens issued for an embedded dashboard before
``before`` (epoch seconds, default: now). Subsequent tokens are
"""Revoke all guest tokens issued for an embedded dashboard or chart
before ``before`` (epoch seconds, default: now). Subsequent tokens are
unaffected."""
# pylint: disable=import-outside-toplevel
from superset.daos.chart import EmbeddedChartDAO
from superset.daos.dashboard import EmbeddedDashboardDAO
embedded = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
embedded: Any = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
if embedded is None:
# The two embed uuid spaces are distinct, so falling through to
# charts on a dashboard miss is unambiguous (mirrors EmbeddedView).
embedded = EmbeddedChartDAO.find_by_id(str(embedded_uuid))
if embedded is None:
return
# Round the cutoff up to the next whole second so that tokens whose
@@ -5707,6 +5761,21 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
and datasource_id in allowed_datasets
)
def has_guest_access_to_chart(self, chart: "Slice") -> bool:
"""
Whether the current guest token grants this chart directly, i.e. the
chart is embedded on its own rather than through a dashboard.
"""
user = self.get_current_guest_user_if_guest()
if not user or not chart.embedded:
return False
embedded_uuid = str(chart.embedded[0].uuid)
return any(
r["type"] == GuestTokenResourceType.CHART and str(r["id"]) == embedded_uuid
for r in user.resources
)
def has_guest_access(self, dashboard: "Dashboard") -> bool:
user = self.get_current_guest_user_if_guest()
if not user:
@@ -321,6 +321,7 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
"can_write",
"can_export",
"can_warm_up_cache",
"can_set_embedded",
}
def test_delete_chart(self):
+141
View File
@@ -179,3 +179,144 @@ def test_chart_filter_guest_no_resources_denied(mocker: MockerFixture) -> None:
assert filt.apply(query, None) is query
query.filter.assert_called_once() # scoped (to nothing), not role-based
viewers.assert_not_called()
def _guest_with_resources(mocker: MockerFixture, resources: list[dict[str, Any]]):
"""Point the security manager at a guest user carrying ``resources``."""
from superset.extensions import security_manager
guest: MagicMock = MagicMock()
guest.resources = resources
return mocker.patch.object(
security_manager, "get_current_guest_user_if_guest", return_value=guest
)
def test_guest_embedded_chart_filter_matches_token_charts(
mocker: MockerFixture,
) -> None:
"""A token's chart resources become an EXISTS over the chart's embed rows."""
from superset.charts.filters import guest_embedded_chart_filter
_guest_with_resources(
mocker,
[
{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"},
{"type": "dashboard", "id": "22222222-2222-2222-2222-222222222222"},
],
)
clause = guest_embedded_chart_filter()
assert clause is not None
compiled: str = str(clause.compile(create_engine("sqlite://")))
assert "EXISTS" in compiled
# Scoped through the chart's own embed config, not through dashboards.
assert "embedded_charts" in compiled
def test_guest_embedded_chart_filter_none_without_chart_resources(
mocker: MockerFixture,
) -> None:
"""A dashboard-only token contributes nothing to the chart scope, so the
dashboard branch alone decides what the guest sees."""
from superset.charts.filters import guest_embedded_chart_filter
_guest_with_resources(mocker, [{"type": "dashboard", "id": "abc"}])
assert guest_embedded_chart_filter() is None
def test_guest_embedded_chart_filter_none_for_non_guest(mocker: MockerFixture) -> None:
from superset.charts.filters import guest_embedded_chart_filter
from superset.extensions import security_manager
mocker.patch.object(
security_manager, "get_current_guest_user_if_guest", return_value=None
)
assert guest_embedded_chart_filter() is None
def test_chart_filter_scopes_guest_to_token_charts(mocker: MockerFixture) -> None:
"""A token issued for a standalone embedded chart resolves that chart.
Without the chart branch the dashboard scope is a deny-all clause, so the
chart the token was minted for is filtered out of the Chart API entirely.
"""
from sqlalchemy import false
from superset.charts.filters import ChartFilter
from superset.extensions import security_manager
from superset.models.slice import Slice
mocker.patch.object(
security_manager, "can_access_all_datasources", return_value=False
)
mocker.patch(
"superset.charts.filters.guest_embedded_dashboard_filter",
return_value=false(),
)
_guest_with_resources(
mocker, [{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"}]
)
captured: dict[str, Any] = {}
query: MagicMock = MagicMock()
def _capture_filter(clause: object) -> MagicMock:
captured["clause"] = clause
return query
query.filter.side_effect = _capture_filter
filt: ChartFilter = ChartFilter.__new__(ChartFilter)
filt.model = Slice
assert filt.apply(query, None) is query
query.filter.assert_called_once()
query.join.assert_not_called()
compiled: str = str(captured["clause"].compile(create_engine("sqlite://")))
assert "embedded_charts" in compiled
def test_chart_filter_guest_scope_unions_dashboards_and_charts(
mocker: MockerFixture,
) -> None:
"""A token holding both resource kinds resolves both, and nothing else."""
from superset.charts.filters import ChartFilter
from superset.extensions import security_manager
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
mocker.patch.object(
security_manager, "can_access_all_datasources", return_value=False
)
mocker.patch(
"superset.charts.filters.guest_embedded_dashboard_filter",
return_value=Dashboard.id.in_([1, 2]),
)
_guest_with_resources(
mocker,
[
{"type": "dashboard", "id": "1"},
{"type": "chart", "id": "11111111-1111-1111-1111-111111111111"},
],
)
captured: dict[str, Any] = {}
query: MagicMock = MagicMock()
def _capture_filter(clause: object) -> MagicMock:
captured["clause"] = clause
return query
query.filter.side_effect = _capture_filter
filt: ChartFilter = ChartFilter.__new__(ChartFilter)
filt.model = Slice
filt.apply(query, None)
compiled: str = str(captured["clause"].compile(create_engine("sqlite://")))
assert "dashboard_slices" in compiled
assert "embedded_charts" in compiled
assert " OR " in compiled
+62
View File
@@ -15,6 +15,9 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from types import SimpleNamespace
import pandas as pd
import pytest
from flask import current_app
@@ -37,6 +40,7 @@ from superset.charts.schemas import (
ChartPostSchema,
ChartPutSchema,
DEFAULT_MAX_PROPHET_PERIODS,
EmbeddedChartResponseSchema,
get_max_prophet_periods,
get_prophet_time_grain_choices,
get_time_grain_choices,
@@ -683,3 +687,61 @@ def test_prophet_accepts_every_mapped_grain(app_context: None, grain: str) -> No
{"time_grain": grain, "periods": 7, "confidence_interval": 0.8}
)
assert result["time_grain"] == grain
def test_embedded_chart_response_schema_dumps_chart_id(app_context: None) -> None:
"""
The model backing an embedded chart response exposes ``slice_id``, not
``chart_id``. The schema field must be bound to the model's actual
attribute so the identifier is present in the response instead of
silently dropping out.
"""
fake_user = SimpleNamespace(
id=7,
username="alice",
first_name="Alice",
last_name="Doe",
email="alice@example.com",
)
fake_embedded = SimpleNamespace(
uuid="11111111-1111-1111-1111-111111111111",
allowed_domains=["https://example.org"],
slice_id=42,
changed_on=datetime(2024, 1, 1),
changed_by=fake_user,
)
result = EmbeddedChartResponseSchema().dump(fake_embedded)
assert result["chart_id"] == "42"
def test_embedded_chart_response_schema_changed_by_mirrors_dashboard_twin(
app_context: None,
) -> None:
"""
``EmbeddedDashboardResponseSchema.changed_by`` (the twin schema) dumps
``username``, not ``email``. Keeping the two embedded-resource response
shapes identical means the chart side must do the same, through a nested
schema dedicated to this response rather than the shared, email-dumping
``UserSchema`` other chart endpoints rely on.
"""
fake_user = SimpleNamespace(
id=7,
username="alice",
first_name="Alice",
last_name="Doe",
email="alice@example.com",
)
fake_embedded = SimpleNamespace(
uuid="11111111-1111-1111-1111-111111111111",
allowed_domains=["https://example.org"],
slice_id=42,
changed_on=datetime(2024, 1, 1),
changed_by=fake_user,
)
result = EmbeddedChartResponseSchema().dump(fake_embedded)
assert result["changed_by"]["username"] == "alice"
assert "email" not in result["changed_by"]
@@ -60,6 +60,10 @@ def _sm_for_chart_access(is_guest: bool) -> MagicMock:
sm.can_access_datasource.return_value = False
sm.is_guest_user.return_value = is_guest
sm._guest_token_allows_dataset.return_value = True
# A chart embedded on its own is granted through its own token rather than
# through a dashboard. Closed by default so each test opens exactly one
# guest path and the assertion reflects that path alone.
sm.has_guest_access_to_chart.return_value = False
return sm
@@ -90,6 +94,18 @@ def test_guest_cannot_access_chart_outside_granted_dashboards() -> None:
SupersetSecurityManager.raise_for_access(sm, chart=chart)
def test_guest_can_access_chart_granted_directly() -> None:
"""A guest whose token was issued for the chart itself may access it, even
when none of its dashboards are granted."""
chart = _make_chart([MagicMock()])
sm = _sm_for_chart_access(is_guest=True)
sm.has_guest_access.return_value = False
sm.has_guest_access_to_chart.return_value = True
with patch("superset.is_feature_enabled", return_value=True):
SupersetSecurityManager.raise_for_access(sm, chart=chart) # no exception
def test_guest_denied_member_chart_outside_dataset_allowlist() -> None:
"""Even on a granted dashboard, a chart whose dataset the token's allowlist
excludes stays inaccessible."""
@@ -21,12 +21,17 @@ from unittest.mock import MagicMock, patch
from superset.security.manager import SupersetSecurityManager
_DASHBOARD_RESOURCE = {"type": "dashboard", "id": "abc-uuid"}
_CHART_RESOURCE = {"type": "chart", "id": "chart-uuid"}
def _token(iat: int) -> dict[str, Any]:
return {"type": "guest", "iat": iat, "resources": [_DASHBOARD_RESOURCE]}
def _chart_token(iat: int) -> dict[str, Any]:
return {"type": "guest", "iat": iat, "resources": [_CHART_RESOURCE]}
def _embedded(revoked_before) -> MagicMock:
embedded = MagicMock()
embedded.guest_token_revoked_before = revoked_before
@@ -115,6 +120,82 @@ def test_guest_token_not_revoked_when_resource_unresolvable() -> None:
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is False
def test_chart_guest_token_revoked_when_issued_before_revocation() -> None:
# A chart resource carries the same per-embed cutoff semantics as a
# dashboard one: issued at 1000, revoked from 2000 -> rejected.
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(2000),
):
assert (
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is True
)
def test_chart_guest_token_valid_when_issued_after_revocation() -> None:
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(2000),
):
assert (
SupersetSecurityManager._is_guest_token_revoked(_chart_token(3000)) is False
)
def test_chart_guest_token_not_revoked_when_no_revocation_set() -> None:
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(None),
):
assert (
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is False
)
def test_chart_guest_token_without_iat_is_revoked_when_cutoff_set() -> None:
# Same fail-closed rule as the dashboard path: without ``iat`` the token
# cannot be shown to postdate the cutoff.
token = {"type": "guest", "resources": [_CHART_RESOURCE]}
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(2000),
):
assert SupersetSecurityManager._is_guest_token_revoked(token) is True
def test_chart_guest_token_not_revoked_when_resource_unresolvable() -> None:
# A chart resource id is only ever an embedded uuid, so an unresolved id
# leaves no cutoff to enforce.
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=None,
):
assert (
SupersetSecurityManager._is_guest_token_revoked(_chart_token(1000)) is False
)
def test_guest_token_revoked_by_any_resource_in_a_mixed_token() -> None:
# A token scoped to both an embedded dashboard and an embedded chart is
# revoked when either resource's cutoff postdates it.
token = {
"type": "guest",
"iat": 1000,
"resources": [_DASHBOARD_RESOURCE, _CHART_RESOURCE],
}
with (
patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=_embedded(None),
),
patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(2000),
),
):
assert SupersetSecurityManager._is_guest_token_revoked(token) is True
def _manager() -> SupersetSecurityManager:
# Build an instance without running the (heavy) FAB __init__: we only
# exercise revoke_guest_token_access, which depends on nothing but
@@ -149,9 +230,75 @@ def test_revoke_guest_token_access_defaults_to_ceil_of_now() -> None:
def test_revoke_guest_token_access_noop_when_embedded_missing() -> None:
with patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=None,
with (
patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=None,
),
patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=None,
),
):
# Should simply return without raising when the UUID does not resolve.
_manager().revoke_guest_token_access("missing-uuid")
def test_revoke_guest_token_access_resolves_an_embedded_chart() -> None:
# A uuid that is not an embedded dashboard is retried against embedded
# charts, so the cutoff lands on the chart's own config.
embedded_chart = _embedded(None)
with (
patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=None,
),
patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=embedded_chart,
),
):
_manager().revoke_guest_token_access("chart-uuid", before=1234)
assert embedded_chart.guest_token_revoked_before == 1234
def test_revoked_chart_guest_token_is_rejected_by_the_request_loader() -> None:
"""A cutoff on the embedded chart makes the request loader reject the token.
``get_guest_user_from_request`` swallows every failure into ``None``, so the
same request is also replayed with the cutoff cleared: only the cutoff can
explain the difference between the two outcomes.
"""
manager = _manager()
token = dict(_chart_token(1000), user={"username": "guest"}, rls_rules=[])
request = MagicMock()
request.headers = {"X-GuestToken": "raw-chart-guest-token"}
guest_user = object()
with (
patch.object(
SupersetSecurityManager, "parse_jwt_guest_token", return_value=token
),
patch.object(
SupersetSecurityManager,
"get_guest_user_from_token",
return_value=guest_user,
),
patch(
"superset.security.manager.get_conf",
return_value={
"GUEST_TOKEN_HEADER_NAME": "X-GuestToken",
"GUEST_TOKEN_REVOCATION_ENABLED": False,
},
),
):
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(2000),
):
assert manager.get_guest_user_from_request(request) is None
with patch(
"superset.daos.chart.EmbeddedChartDAO.find_by_id",
return_value=_embedded(None),
):
assert manager.get_guest_user_from_request(request) is guest_user