This is an automated email from the ASF dual-hosted git repository.
sha174n 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 57a92170bd5 fix(chart): apply the query-context datasource check on
create and update (#43997)
57a92170bd5 is described below
commit 57a92170bd531d6db83d1a8d008757664d92f0bf
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 9 14:03:14 2026 +0100
fix(chart): apply the query-context datasource check on create and update
(#43997)
Co-authored-by: sha174n <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset/commands/chart/create.py | 8 +++
superset/commands/chart/update.py | 47 ++------------
superset/commands/chart/utils.py | 71 +++++++++++++++++++++
tests/unit_tests/commands/chart/create_test.py | 87 +++++++++++++++++++++++++-
4 files changed, 171 insertions(+), 42 deletions(-)
diff --git a/superset/commands/chart/create.py
b/superset/commands/chart/create.py
index 3fbd5715fa3..c53bf7f2a23 100644
--- a/superset/commands/chart/create.py
+++ b/superset/commands/chart/create.py
@@ -32,6 +32,7 @@ from superset.commands.chart.exceptions import (
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
)
+from superset.commands.chart.utils import validate_query_context_datasource
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import get_datasource_by_id, populate_subjects
from superset.daos.chart import ChartDAO
@@ -91,6 +92,13 @@ class CreateChartCommand(CreateMixin, BaseCommand):
except ValidationError as ex:
exceptions.append(ex)
+ validate_query_context_datasource(
+ self._properties.get("query_context"),
+ datasource_id,
+ datasource_type,
+ exceptions,
+ )
+
# Validate/Populate dashboards
dashboards = DashboardDAO.find_by_ids(dashboard_ids)
if len(dashboards) != len(dashboard_ids):
diff --git a/superset/commands/chart/update.py
b/superset/commands/chart/update.py
index 25603056d3a..069c9829a41 100644
--- a/superset/commands/chart/update.py
+++ b/superset/commands/chart/update.py
@@ -29,12 +29,12 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
- ChartQueryContextDatasourceMismatchValidationError,
ChartUpdateFailedError,
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
DatasourceTypeUpdateRequiredValidationError,
)
+from superset.commands.chart.utils import validate_query_context_datasource
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import (
compute_subjects,
@@ -49,7 +49,6 @@ from superset.extensions import db
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.core import DatasourceType
from superset.utils.decorators import on_error, transaction
from superset.versioning.changes.normalization import (
@@ -135,49 +134,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
) -> 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
+ validate_query_context_datasource(
+ self._properties.get("query_context"),
+ self._model.datasource_id,
+ self._model.datasource_type,
+ exceptions,
)
- 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] = []
diff --git a/superset/commands/chart/utils.py b/superset/commands/chart/utils.py
new file mode 100644
index 00000000000..62a55334d87
--- /dev/null
+++ b/superset/commands/chart/utils.py
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any
+
+from marshmallow import ValidationError
+
+from superset.commands.chart.exceptions import (
+ ChartQueryContextDatasourceMismatchValidationError,
+)
+from superset.utils import json
+
+
+def validate_query_context_datasource(
+ raw_query_context: Any,
+ expected_datasource_id: Any,
+ expected_datasource_type: Any,
+ exceptions: list[ValidationError],
+) -> None:
+ """
+ Ensure a submitted query context targets the expected datasource.
+
+ Shared by the chart create and update commands so both apply the same
+ binding. The query context is only checked when it carries a parseable
+ ``datasource`` object; a payload that references a different datasource
than
+ the expected one is rejected. A payload without a datasource falls back to
+ the chart's datasource at execution time and needs no check.
+ """
+ if not raw_query_context:
+ return
+
+ try:
+ query_context = json.loads(raw_query_context)
+ except (TypeError, ValueError):
+ # A query context that isn't a parseable JSON string (e.g. an
+ # already-parsed dict, or an unparseable string) 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"]) == int(expected_datasource_id)
+ except (KeyError, TypeError, ValueError):
+ ids_match = False
+
+ # A datasource object must carry a type that matches the expected one.
+ # 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.
+ types_match = str(datasource.get("type")) == str(expected_datasource_type)
+
+ if not ids_match or not types_match:
+ exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
diff --git a/tests/unit_tests/commands/chart/create_test.py
b/tests/unit_tests/commands/chart/create_test.py
index 3cc26b4e7a2..e61a46f0009 100644
--- a/tests/unit_tests/commands/chart/create_test.py
+++ b/tests/unit_tests/commands/chart/create_test.py
@@ -28,10 +28,15 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.create import CreateChartCommand
-from superset.commands.chart.exceptions import ChartForbiddenError,
ChartInvalidError
+from superset.commands.chart.exceptions import (
+ ChartForbiddenError,
+ ChartInvalidError,
+ ChartQueryContextDatasourceMismatchValidationError,
+)
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
+from superset.utils import json
def _base_mocks(mocker: MockerFixture) -> None:
@@ -152,3 +157,83 @@ def
test_create_chart_datasource_access_denied_still_raises_forbidden(
"viz_type": "table",
}
).validate()
+
+
+def _create_payload(query_context: str) -> dict[str, object]:
+ return {
+ "datasource_id": 42,
+ "datasource_type": "table",
+ "slice_name": "some_name",
+ "viz_type": "table",
+ "query_context": query_context,
+ }
+
+
+def _mock_table_datasource(mocker: MockerFixture) -> None:
+ _base_mocks(mocker)
+ datasource = mocker.MagicMock()
+ datasource.name = "my_table"
+ mocker.patch(
+ "superset.commands.chart.create.get_datasource_by_id",
+ return_value=datasource,
+ )
+
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
+
+
+def test_create_chart_query_context_matching_datasource_is_allowed(
+ mocker: MockerFixture,
+) -> None:
+ """A query context targeting the chart's own datasource is accepted."""
+ _mock_table_datasource(mocker)
+
+ CreateChartCommand(
+ _create_payload(
+ json.dumps({"datasource": {"id": 42, "type": "table"}, "queries":
[]})
+ )
+ ).validate()
+
+
[email protected](
+ "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
+ ],
+)
+def test_create_chart_query_context_mismatched_datasource_is_rejected(
+ mocker: MockerFixture,
+ datasource: dict[str, object],
+) -> None:
+ """A query context pointing at a different datasource than the one the
+ chart is created against is rejected."""
+ _mock_table_datasource(mocker)
+
+ with pytest.raises(ChartInvalidError) as exc_info:
+ CreateChartCommand(
+ _create_payload(json.dumps({"datasource": datasource, "queries":
[]}))
+ ).validate()
+
+ assert any(
+ isinstance(ex, ChartQueryContextDatasourceMismatchValidationError)
+ for ex in exc_info.value._exceptions
+ )
+
+
[email protected](
+ "query_context",
+ [
+ "{}", # no datasource key
+ '{"datasource": null}', # null datasource
+ "not-json", # unparseable payload
+ ],
+)
+def test_create_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."""
+ _mock_table_datasource(mocker)
+
+ CreateChartCommand(_create_payload(query_context)).validate()