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()