Skip to content

Commit 1a2bee4

Browse files
rusackasclaude
andcommitted
fix: scope deck_layers base-filter bypass to guests, harden viz migration, fix Explore layer preview lag
- superset/charts/api.py: only skip the chart base filter for the deck_layers container-based lookup when the caller is an embedded guest; an ordinary logged-in user could otherwise read an arbitrary chart's params/datasource by naming it in a deck_multi container's deck_slices they can edit. - superset/migrations/shared/migrate_viz/base.py: back up a stored query_context with "queries": null, or a non-object query_context, through the same FULL_CONTEXT_BAK_KEY wholesale path used for a missing "queries" key, so downgrade can tell "no context" apart from "context had a null/odd queries" instead of discarding the slice's datasource/form_data, and so a non-dict context doesn't raise mid-migration and leave the row half-migrated. - Multi.tsx: fall back to per-chart reads for any deck_slices id missing from the container's persisted deck_layers response, so a layer just added in Explore (but not yet saved) still previews instead of waiting for a save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3929946 commit 1a2bee4

6 files changed

Lines changed: 244 additions & 11 deletions

File tree

superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,87 @@ test('does not include parent_slice_id when parent has no slice_id', async () =>
636636
expect(formData.parent_slice_id).toBeUndefined();
637637
});
638638
});
639+
640+
test('falls back to a per-chart read for a layer missing from the persisted deck_slices', async () => {
641+
// A saved container's `deck_slices` on the server can lag the in-memory
642+
// Explore selection (e.g. layer 3 was just added but not saved yet), so
643+
// the bulk deck_layers response only resolves layers 1 and 2. Layer 3
644+
// must still be fetched (and previewed) via a per-chart read rather than
645+
// silently dropped until the chart is saved.
646+
jest.clearAllMocks();
647+
featuresByVizType = {};
648+
const parentSliceId = 50;
649+
650+
(SupersetClient.get as jest.Mock).mockImplementation(
651+
({ endpoint }: { endpoint: string }) => {
652+
if (endpoint === `/api/v1/chart/${parentSliceId}/deck_layers/`) {
653+
return Promise.resolve({
654+
json: {
655+
result: [1, 2].map(sliceId => ({
656+
slice_id: sliceId,
657+
viz_type: SUBSLICES[sliceId].vizType,
658+
datasource_id: 1,
659+
datasource_type: 'table',
660+
params: JSON.stringify({
661+
viz_type: SUBSLICES[sliceId].vizType,
662+
datasource: 'test_datasource',
663+
}),
664+
})),
665+
},
666+
});
667+
}
668+
const subslice = { vizType: 'deck_scatter' };
669+
return Promise.resolve({
670+
json: {
671+
result: {
672+
viz_type: subslice.vizType,
673+
datasource_id: 1,
674+
datasource_type: 'table',
675+
params: JSON.stringify({
676+
viz_type: subslice.vizType,
677+
datasource: 'test_datasource',
678+
}),
679+
},
680+
},
681+
});
682+
},
683+
);
684+
(SupersetClient.post as jest.Mock).mockImplementation(
685+
({ jsonPayload }: { jsonPayload: { form_data: { viz_type: string } } }) =>
686+
Promise.resolve({
687+
json: {
688+
result: [
689+
{ data: featuresByVizType[jsonPayload.form_data.viz_type] || [] },
690+
],
691+
},
692+
}),
693+
);
694+
695+
const props = {
696+
...baseMockProps,
697+
formData: {
698+
...baseMockProps.formData,
699+
slice_id: parentSliceId,
700+
deck_slices: [1, 2, 3],
701+
},
702+
};
703+
704+
renderWithProviders(<DeckMulti {...props} />);
705+
706+
await waitFor(() =>
707+
expect(SupersetClient.get).toHaveBeenCalledWith(
708+
expect.objectContaining({ endpoint: '/api/v1/chart/3' }),
709+
),
710+
);
711+
expect(SupersetClient.get).toHaveBeenCalledWith(
712+
expect.objectContaining({
713+
endpoint: `/api/v1/chart/${parentSliceId}/deck_layers/`,
714+
}),
715+
);
716+
expect(SupersetClient.get).not.toHaveBeenCalledWith(
717+
expect.objectContaining({ endpoint: '/api/v1/chart/1' }),
718+
);
719+
expect(SupersetClient.get).not.toHaveBeenCalledWith(
720+
expect.objectContaining({ endpoint: '/api/v1/chart/2' }),
721+
);
722+
});

superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,12 +606,26 @@ const DeckMulti = (props: DeckMultiProps) => {
606606
})
607607
.then(({ json }) => {
608608
const layers = ((json as JsonObject).result || []) as JsonObject[];
609-
return layers
609+
const resolved = layers
610610
.map(layer => toLayerFormData(layer.slice_id, layer))
611611
.filter(
612612
(slice): slice is { slice_id: number } & JsonObject =>
613613
slice !== null && sliceIds.includes(slice.slice_id),
614614
);
615+
// The container's persisted deck_slices can lag the in-memory
616+
// Explore selection (e.g. a layer just added but not yet saved),
617+
// so it won't be in the bulk response above. Fall back to
618+
// per-chart reads for whichever requested ids weren't resolved,
619+
// so newly selected layers still preview before saving.
620+
const resolvedIds = new Set(resolved.map(slice => slice.slice_id));
621+
const missingIds = sliceIds.filter(id => !resolvedIds.has(id));
622+
if (missingIds.length === 0) {
623+
return resolved;
624+
}
625+
return fetchSubslicesPerChart(missingIds).then(extra => [
626+
...resolved,
627+
...extra,
628+
]);
615629
})
616630
.catch(() => fetchSubslicesPerChart(sliceIds));
617631
},

superset/charts/api.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -489,11 +489,18 @@ def deck_layers(self, pk: int) -> Response:
489489
if not deck_slice_ids:
490490
return self.response(200, result=[])
491491

492-
# The container's own access has already been checked above; the
493-
# layers it declares are resolved without the base filter (they
494-
# sit on no dashboard of their own), same as the legacy explore_json
495-
# pipeline resolved them server-side under the container's access.
496-
layers = ChartDAO.find_by_ids(deck_slice_ids, skip_base_filter=True)
492+
# The container's own access has already been checked above. Layer
493+
# charts sit on no dashboard of their own, so for an embedded guest
494+
# (the intended use case, mirroring what the legacy explore_json
495+
# pipeline granted server-side) they are resolved without the base
496+
# filter. An ordinary logged-in principal is not entitled to read an
497+
# arbitrary chart's params/datasource just by naming it in a
498+
# container they can edit, so the base filter still applies to them:
499+
# a referenced layer they can't otherwise read is silently omitted
500+
# below rather than leaked.
501+
layers = ChartDAO.find_by_ids(
502+
deck_slice_ids, skip_base_filter=security_manager.is_guest_user()
503+
)
497504
layers_by_id = {layer.id: layer for layer in layers}
498505
result = [
499506
{

superset/migrations/shared/migrate_viz/base.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,22 @@ def upgrade_slice(cls, slc: Slice) -> None:
171171
query_context = try_load_json(slc.query_context)
172172
queries_bak = None
173173

174-
if query_context:
175-
# A stored query_context is expected to carry "queries", but
176-
# an atypical/malformed one (e.g. hand-edited via the API)
177-
# missing it must not raise here: viz_type was already
174+
if isinstance(query_context, dict) and query_context:
175+
# A stored query_context is expected to be an object carrying
176+
# a non-null "queries" list, but an atypical/malformed one
177+
# (e.g. hand-edited via the API) missing that key, or with
178+
# "queries": null, must not raise here: viz_type was already
178179
# flipped above, so an uncaught exception at this point
179180
# would leave the slice half-migrated (new viz_type, but
180181
# stale params/query_context in the old shape). Back up the
181182
# whole context in that case so downgrade can restore it
182183
# verbatim instead of losing it (see FULL_CONTEXT_BAK_KEY).
183-
if "queries" in query_context:
184+
# Both cases must share this sentinel path rather than
185+
# backing up a bare `None` -- that value is indistinguishable
186+
# from "no context was ever stored", which would make
187+
# downgrade discard the slice's original datasource/form_data
188+
# instead of restoring this context.
189+
if "queries" in query_context and query_context["queries"] is not None:
184190
queries_bak = copy.deepcopy(query_context["queries"])
185191
else:
186192
queries_bak = {FULL_CONTEXT_BAK_KEY: copy.deepcopy(query_context)}
@@ -190,6 +196,15 @@ def upgrade_slice(cls, slc: Slice) -> None:
190196

191197
queries = clz._build_query()["queries"]
192198
query_context["queries"] = queries
199+
elif query_context:
200+
# A parseable but non-object query_context (e.g. a bare
201+
# number or a JSON list -- both accepted by the schema
202+
# validator) can't carry "queries"/"form_data" keys; back it
203+
# up wholesale like the cases above and rebuild a fresh one,
204+
# rather than raising on membership-testing a non-dict (which
205+
# would leave the slice half-migrated, per the note above).
206+
queries_bak = {FULL_CONTEXT_BAK_KEY: copy.deepcopy(query_context)}
207+
query_context = clz._build_query()
193208
else:
194209
query_context = clz._build_query()
195210

tests/integration_tests/charts/api_tests.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,56 @@ def test_get_deck_layers_no_container_access(self):
12231223
db.session.delete(container)
12241224
db.session.commit()
12251225

1226+
@pytest.mark.usefixtures("load_energy_table_with_slice")
1227+
def test_get_deck_layers_omits_inaccessible_layer_for_ordinary_user(self):
1228+
"""
1229+
Chart API: An ordinary (non-guest) user with access to the deck_multi
1230+
container must not have an inaccessible layer's params/datasource
1231+
leaked just because it's named in the container's `deck_slices` --
1232+
that layer is silently omitted from the result instead.
1233+
"""
1234+
admin = self.get_user("admin")
1235+
gamma = self.get_user("gamma")
1236+
layer_visible = self.insert_chart(
1237+
"layer visible",
1238+
[gamma.id],
1239+
1,
1240+
viz_type="deck_scatter",
1241+
params=json.dumps({"viz_type": "deck_scatter"}),
1242+
)
1243+
layer_hidden = self.insert_chart(
1244+
"layer hidden from gamma",
1245+
[admin.id],
1246+
1,
1247+
viz_type="deck_scatter",
1248+
params=json.dumps({"viz_type": "deck_scatter"}),
1249+
)
1250+
container = self.insert_chart(
1251+
"deck multi container for gamma",
1252+
[gamma.id],
1253+
1,
1254+
viz_type="deck_multi",
1255+
params=json.dumps(
1256+
{
1257+
"viz_type": "deck_multi",
1258+
"deck_slices": [layer_visible.id, layer_hidden.id],
1259+
}
1260+
),
1261+
)
1262+
self.login(GAMMA_USERNAME)
1263+
uri = f"api/v1/chart/{container.id}/deck_layers/"
1264+
rv = self.get_assert_metric(uri, "deck_layers")
1265+
assert rv.status_code == 200
1266+
data = json.loads(rv.data.decode("utf-8"))
1267+
assert [layer["slice_id"] for layer in data["result"]] == [
1268+
layer_visible.id,
1269+
]
1270+
1271+
db.session.delete(layer_visible)
1272+
db.session.delete(layer_hidden)
1273+
db.session.delete(container)
1274+
db.session.commit()
1275+
12261276
@pytest.mark.usefixtures(
12271277
"load_energy_table_with_slice",
12281278
"load_birth_names_dashboard_with_slices",

tests/unit_tests/migrations/viz/upgrade_malformed_query_context_test.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,69 @@ def test_upgrade_slice_survives_a_query_context_without_queries() -> None:
5858
assert json.loads(slc.query_context) == original_query_context
5959

6060

61+
def test_downgrade_slice_restores_an_original_null_queries() -> None:
62+
"""An original query_context with "queries": null backs up as a bare
63+
`None`, indistinguishable from "no context was ever stored" -- it must
64+
be routed through the same FULL_CONTEXT_BAK_KEY wholesale backup as a
65+
missing "queries" key so downgrade_slice can tell the two apart and
66+
restore the slice's original datasource/form_data instead of discarding
67+
them."""
68+
source = {"viz_type": "line", "datasource": "1__table", "x_axis_label": "x"}
69+
original_query_context = {"datasource": "1__table", "queries": None}
70+
71+
slc = Slice(
72+
viz_type="line",
73+
datasource_type="table",
74+
params=json.dumps(source),
75+
query_context=json.dumps(original_query_context),
76+
)
77+
78+
MigrateLineChart.upgrade_slice(slc)
79+
upgraded_params = json.loads(slc.params)
80+
assert upgraded_params.get("queries_bak") == {
81+
FULL_CONTEXT_BAK_KEY: original_query_context
82+
}
83+
84+
MigrateLineChart.downgrade_slice(slc)
85+
86+
assert slc.viz_type == "line"
87+
assert json.loads(slc.params) == source
88+
assert json.loads(slc.query_context) == original_query_context
89+
90+
91+
def test_upgrade_slice_survives_a_non_object_query_context() -> None:
92+
"""A parseable but non-object query_context (e.g. a bare number or a
93+
JSON list, both accepted by the schema validator) must not raise when
94+
membership-tested for "queries" -- that would leave the slice
95+
half-migrated (new viz_type already set, but stale params/query_context
96+
in the old shape) since the exception is swallowed by the broad
97+
top-level catch."""
98+
source = {"viz_type": "line", "datasource": "1__table", "x_axis_label": "x"}
99+
100+
slc = Slice(
101+
viz_type="line",
102+
datasource_type="table",
103+
params=json.dumps(source),
104+
query_context=json.dumps(1),
105+
)
106+
107+
MigrateLineChart.upgrade_slice(slc)
108+
109+
assert slc.viz_type == "echarts_timeseries_line"
110+
upgraded_params = json.loads(slc.params)
111+
assert upgraded_params.get("queries_bak") == {FULL_CONTEXT_BAK_KEY: 1}
112+
# A fresh query_context was rebuilt rather than left in the old shape.
113+
assert json.loads(slc.query_context)["form_data"]["viz_type"] == (
114+
"echarts_timeseries_line"
115+
)
116+
117+
MigrateLineChart.downgrade_slice(slc)
118+
119+
assert slc.viz_type == "line"
120+
assert json.loads(slc.params) == source
121+
assert json.loads(slc.query_context) == 1
122+
123+
61124
def test_downgrade_slice_restores_an_original_empty_queries_list() -> None:
62125
"""An original query_context with "queries": [] backs up as a falsy-but-
63126
present list, not None -- downgrade_slice must not mistake that for "no

0 commit comments

Comments
 (0)