You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/14 18:13:40 UTC
(superset) branch master updated: fix(chart): keep query-context updates bound to the chart's datasource (#40955)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c04a0295ef6 fix(chart): keep query-context updates bound to the chart's datasource (#40955)
c04a0295ef6 is described below
commit c04a0295ef6dc6971d1bdb4c41770a9ec40b743c
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Tue Jul 14 11:13:33 2026 -0700
fix(chart): keep query-context updates bound to the chart's datasource (#40955)
Co-authored-by: Claude Code <no...@anthropic.com>
---
superset/commands/chart/exceptions.py | 13 ++++
superset/commands/chart/update.py | 54 +++++++++++++++
tests/unit_tests/commands/chart/update_test.py | 91 +++++++++++++++++++++++++-
3 files changed, 157 insertions(+), 1 deletion(-)
diff --git a/superset/commands/chart/exceptions.py b/superset/commands/chart/exceptions.py
index 43fef980249..2e5ae7c36a0 100644
--- a/superset/commands/chart/exceptions.py
+++ b/superset/commands/chart/exceptions.py
@@ -103,6 +103,19 @@ class DatasourceTypeUpdateRequiredValidationError(ValidationError):
)
+class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
+ """
+ Raised when a query-context-only update carries a datasource that does not
+ match the chart's own datasource.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(
+ _("The query context datasource does not match the chart datasource"),
+ field_name="query_context",
+ )
+
+
class ChartNotFoundError(CommandException):
message = "Chart not found."
diff --git a/superset/commands/chart/update.py b/superset/commands/chart/update.py
index cc862d1e198..38f44d0ff9a 100644
--- a/superset/commands/chart/update.py
+++ b/superset/commands/chart/update.py
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
+ ChartQueryContextDatasourceMismatchValidationError,
ChartUpdateFailedError,
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
@@ -46,6 +47,7 @@ from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
+from superset.utils import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -106,6 +108,55 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not security_manager.is_editor(dash):
raise DashboardsForbiddenError()
+ def _validate_query_context_datasource(
+ self, exceptions: list[ValidationError]
+ ) -> None:
+ """
+ Ensure a query-context-only update keeps the chart's own datasource.
+
+ The submitted query context is only verified when it carries a parseable
+ ``datasource`` object; a payload that references a different datasource than
+ the chart's persisted one is rejected. Payloads without a datasource fall
+ back to the chart's datasource at execution time and need no check.
+ """
+ if not self._model:
+ return
+
+ raw_query_context = self._properties.get("query_context")
+ if not raw_query_context:
+ return
+
+ try:
+ query_context = json.loads(raw_query_context)
+ except (TypeError, ValueError):
+ # TypeError covers a query_context that isn't a string (e.g. an
+ # already-parsed dict); that shape is intentionally out of scope
+ # here since the schema serializes it as a JSON string. ValueError
+ # covers a string that failed to parse. Either way, an unverifiable
+ # payload is left for downstream handling rather than guessed at.
+ return
+
+ datasource = (
+ query_context.get("datasource") if isinstance(query_context, dict) else None
+ )
+ if not isinstance(datasource, dict):
+ return
+
+ try:
+ ids_match = int(datasource["id"]) == self._model.datasource_id
+ except (KeyError, TypeError, ValueError):
+ ids_match = False
+
+ # A datasource object must carry a type that matches the chart's own.
+ # Treating a missing type as valid would let an id-only payload through,
+ # and query-context loading reads datasource["type"] directly, so that
+ # payload raises KeyError when the saved context is later replayed.
+ datasource_type = datasource.get("type")
+ types_match = str(datasource_type) == self._model.datasource_type
+
+ if not ids_match or not types_match:
+ exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
+
def validate(self) -> None: # noqa: C901
exceptions: list[ValidationError] = []
dashboard_ids = self._properties.get("dashboards")
@@ -140,6 +191,9 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
security_manager.raise_for_access(chart=self._model)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
+ # Keep the refreshed payload bound to the chart's own datasource so it
+ # cannot be repointed at an unrelated one.
+ self._validate_query_context_datasource(exceptions)
# validate tags
try:
diff --git a/tests/unit_tests/commands/chart/update_test.py b/tests/unit_tests/commands/chart/update_test.py
index f1e50a7e907..440e98cf91f 100644
--- a/tests/unit_tests/commands/chart/update_test.py
+++ b/tests/unit_tests/commands/chart/update_test.py
@@ -17,10 +17,11 @@
import pytest
from pytest_mock import MockerFixture
-from superset.commands.chart.exceptions import ChartForbiddenError
+from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.update import UpdateChartCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
+from superset.utils import json
def _editorship_exc() -> SupersetSecurityException:
@@ -149,3 +150,91 @@ def test_update_chart_editor_can_perform_regular_update(
exceptions = compute_subjects.call_args.args[2]
assert properties["editors"] == [2]
assert exceptions == []
+
+
+def _query_context_payload(datasource: object) -> dict[str, object]:
+ """Build a query-context-only update payload targeting ``datasource``."""
+ return {
+ "query_context": json.dumps({"datasource": datasource, "queries": []}),
+ "query_context_generation": True,
+ }
+
+
+@pytest.mark.parametrize(
+ "datasource_type",
+ [
+ "table",
+ "query", # non-table datasource types must also be accepted when matching
+ ],
+)
+def test_update_chart_query_context_matching_datasource_is_allowed(
+ mocker: MockerFixture,
+ datasource_type: str,
+) -> None:
+ """A query context that targets the chart's own datasource is accepted."""
+ find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
+ find_by_id.return_value = mocker.MagicMock(
+ id=1,
+ tags=[],
+ dashboards=[],
+ datasource_id=42,
+ datasource_type=datasource_type,
+ )
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
+
+ UpdateChartCommand(
+ 1, _query_context_payload({"id": 42, "type": datasource_type})
+ ).validate()
+
+
+@pytest.mark.parametrize(
+ "datasource",
+ [
+ {"id": 99, "type": "table"}, # different id
+ {"id": 42, "type": "query"}, # different type
+ {"id": "99", "type": "table"}, # different id as string
+ {"id": 42}, # matching id but missing type
+ {"id": "5f7b3c1a-...-uuid", "type": "table"}, # non-numeric id
+ ],
+)
+def test_update_chart_query_context_mismatched_datasource_is_rejected(
+ mocker: MockerFixture,
+ datasource: dict[str, object],
+) -> None:
+ """A query context pointing at a different datasource is rejected with a 4xx."""
+ find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
+ find_by_id.return_value = mocker.MagicMock(
+ id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
+ )
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
+
+ with pytest.raises(ChartInvalidError):
+ UpdateChartCommand(1, _query_context_payload(datasource)).validate()
+
+
+@pytest.mark.parametrize(
+ "query_context",
+ [
+ "{}", # no datasource key
+ '{"datasource": null}', # null datasource
+ "not-json", # unparseable payload
+ ],
+)
+def test_update_chart_query_context_without_datasource_is_allowed(
+ mocker: MockerFixture,
+ query_context: str,
+) -> None:
+ """Payloads with no verifiable datasource fall back to the chart's own."""
+ find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
+ find_by_id.return_value = mocker.MagicMock(
+ id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
+ )
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
+ mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
+
+ UpdateChartCommand(
+ 1,
+ {"query_context": query_context, "query_context_generation": True},
+ ).validate()