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 082961ba168 fix(chart): require edit rights for query-context-only
updates (#44494)
082961ba168 is described below
commit 082961ba168f18422a356c2df8b724d735eec9ca
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 23 22:09:21 2026 +0100
fix(chart): require edit rights for query-context-only updates (#44494)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../explore/components/ExploreChartPanel/index.tsx | 12 +-
superset/commands/chart/update.py | 11 +-
tests/integration_tests/charts/commands_tests.py | 133 +++++++++++++++++----
tests/integration_tests/reports/commands_tests.py | 65 +++++++++-
tests/unit_tests/commands/chart/update_test.py | 25 ++++
5 files changed, 219 insertions(+), 27 deletions(-)
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index f9c769b5c91..4df280bc09f 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -29,6 +29,7 @@ import {
JsonObject,
getExtensionsRegistry,
} from '@superset-ui/core';
+import { logging } from '@apache-superset/core/utils';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -255,7 +256,16 @@ const ExploreChartPanel = ({
);
useEffect(() => {
- updateQueryContext();
+ updateQueryContext().catch(error => {
+ // Best-effort backfill; the chart renders either way. A 403 is expected
+ // (no write access, or a managed chart), so it stays at debug.
+ const message = 'Skipped background query context backfill';
+ if (error?.status === 403) {
+ logging.debug(message, error);
+ } else {
+ logging.warn(message, error);
+ }
+ });
}, [updateQueryContext]);
useEffect(() => {
diff --git a/superset/commands/chart/update.py
b/superset/commands/chart/update.py
index 57d1e407913..74be92654f5 100644
--- a/superset/commands/chart/update.py
+++ b/superset/commands/chart/update.py
@@ -169,8 +169,9 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
raise ChartNotFoundError()
# Check and update editorship; when only updating query context we
relax
- # editorship so report workers can save context. We still require chart
- # access so users cannot rewrite query context for charts they cannot
access.
+ # editorship so background workers can save context. Report and
thumbnail
+ # executors resolve against the schedule or the requesting user, not
the
+ # chart, so they are frequently not chart editors. Access is still
required.
if not is_query_context_update(self._properties):
try:
security_manager.raise_for_editorship(self._model)
@@ -181,6 +182,12 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
exceptions.append(ex)
raise_if_managed_externally(self._model, ChartForbiddenError)
else:
+ # ``raise_for_access`` admits a guest for every chart on the
dashboard
+ # its token embeds, but a guest holds no write capability (see the
+ # capability matrix in ``SECURITY.md``). The strict path inherits
this
+ # deny from ``is_editor``; this branch has to state it.
+ if security_manager.is_guest_user():
+ raise ChartForbiddenError()
try:
security_manager.raise_for_access(chart=self._model)
except SupersetSecurityException as ex:
diff --git a/tests/integration_tests/charts/commands_tests.py
b/tests/integration_tests/charts/commands_tests.py
index 40e7fbc32ea..a87215c5474 100644
--- a/tests/integration_tests/charts/commands_tests.py
+++ b/tests/integration_tests/charts/commands_tests.py
@@ -40,8 +40,11 @@ from superset.commands.exceptions import CommandInvalidError
from superset.commands.importers.exceptions import IncorrectVersionError
from superset.connectors.sqla.models import SqlaTable
from superset.daos.chart import ChartDAO
+from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.models.core import Database
+from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.models.slice import Slice
+from superset.security.guest_token import GuestTokenResourceType
from superset.utils import json
from superset.utils.core import override_user
from tests.integration_tests.base_tests import (
@@ -505,31 +508,47 @@ class TestChartsUpdateCommand(SupersetTestCase):
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
@pytest.mark.usefixtures("load_energy_table_with_slice")
- @pytest.mark.skip(reason="This test will be changed to use the
api/v1/data")
def test_query_context_update_command(self, mock_sm_g, mock_g):
"""
- Test that a user can generate the chart query context
- payload without affecting editors
+ A query-context-only update requires chart access, not editorship, so a
+ non-editor who has access to the chart's datasource can refresh the
+ stored query context. The editor list is left untouched.
"""
chart = db.session.query(Slice).all()[0]
pk = chart.id
admin = security_manager.find_user(username="admin")
- chart.editors = subjects_from_users([admin])
- db.session.commit()
+ # alpha is not an editor of this chart but has all-datasource access,
so
+ # ``raise_for_access(chart=...)`` admits it on the relaxed path. Bind
the
+ # user before the first commit, so the audit columns the commit stamps
+ # get a real user rather than the bare ``MagicMock``.
user = security_manager.find_user(username="alpha")
mock_g.user = mock_sm_g.user = user
- query_context = json.dumps({"foo": "bar"})
- json_obj = {
- "query_context_generation": True,
- "query_context": query_context,
- }
- command = UpdateChartCommand(pk, json_obj)
- command.run()
- chart = db.session.query(Slice).get(pk)
- assert chart.query_context == query_context
- assert len(chart.editors) == 1
- assert user_is_editor(admin, chart)
+
+ # This chart row is shared with every other test that selects one
+ # positionally, and both writes below are committed, so restore them.
+ original_query_context = chart.query_context
+ original_editors = list(chart.editors)
+
+ try:
+ chart.editors = subjects_from_users([admin])
+ db.session.commit()
+ query_context = json.dumps({"foo": "bar"})
+ json_obj = {
+ "query_context_generation": True,
+ "query_context": query_context,
+ }
+ command = UpdateChartCommand(pk, json_obj)
+ command.run()
+ chart = db.session.query(Slice).get(pk)
+ assert chart.query_context == query_context
+ assert len(chart.editors) == 1
+ assert user_is_editor(admin, chart)
+ finally:
+ chart = db.session.query(Slice).get(pk)
+ chart.query_context = original_query_context
+ chart.editors = original_editors
+ db.session.commit()
@patch("superset.commands.chart.update.ChartDAO.find_by_id")
@patch("superset.commands.chart.update.g")
@@ -540,13 +559,12 @@ class TestChartsUpdateCommand(SupersetTestCase):
self, mock_sm_g, mock_core_g, mock_update_g, mock_find_by_id
) -> None:
"""
- A query_context-only update relaxes the editor requirement but must
- still require access to the chart. We bypass the DAO ``ChartFilter``
- base filter (by patching ``find_by_id`` to return the chart directly)
- so the request reaches the new explicit ``raise_for_access`` check, and
- assert that a non-editor with no access to the chart's datasource is
- rejected with ``ChartForbiddenError``. This deterministically exercises
- the new branch and would fail on master, where the check is absent.
+ A query-context-only update relaxes the editor requirement but still
+ gates on chart access via ``raise_for_access(chart=...)``. We bypass
the
+ DAO ``ChartFilter`` base filter (by patching ``find_by_id`` to return
+ the chart directly) so the request reaches that check, and assert that
a
+ non-editor with no access to the chart's datasource is rejected with
+ ``ChartForbiddenError``.
"""
chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
pk = chart.id
@@ -573,6 +591,75 @@ class TestChartsUpdateCommand(SupersetTestCase):
with pytest.raises(ChartForbiddenError):
UpdateChartCommand(pk, json_obj).run()
+ @patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags",
+ EMBEDDED_SUPERSET=True,
+ )
+ @patch("superset.commands.chart.update.ChartDAO.find_by_id")
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_query_context_update_denies_guest(self, mock_find_by_id) -> None:
+ """
+ The relaxed path gates on chart access, which a guest token does pass
+ for the member charts of the dashboard it embeds. A guest nonetheless
+ holds no write capability, so a query-context-only update is denied.
+ """
+ dashboard = self.get_dash_by_slug("births")
+ chart = dashboard.slices[0]
+ original_query_context = chart.query_context
+ # Snapshot before ``upsert``, which returns the existing row when the
+ # dashboard is already embedded: only a row this test inserted may be
+ # deleted below.
+ dashboard_was_embedded = bool(dashboard.embedded)
+ embedded = EmbeddedDashboardDAO.upsert(dashboard, [])
+ db.session.flush() # the uuid is only populated on flush
+ embedded_uuid = embedded.uuid
+
+ # A real guest principal for a dashboard that actually contains the
+ # chart, so ``is_guest_user`` and ``raise_for_access`` both run for
+ # real rather than a mock standing in for either.
+ guest = security_manager.get_guest_user_from_token(
+ {
+ "user": {},
+ "resources": [
+ {
+ "type": GuestTokenResourceType.DASHBOARD,
+ "id": str(embedded.uuid),
+ }
+ ],
+ "rls_rules": [],
+ "iat": 10,
+ "exp": 20,
+ }
+ )
+
+ # Bypass ChartFilter so the command's own gates decide the outcome.
+ mock_find_by_id.return_value = chart
+
+ json_obj = {
+ "query_context_generation": True,
+ "query_context": json.dumps({"foo": "bar"}),
+ }
+ try:
+ with override_user(guest):
+ # Precondition: this guest clears the access gate, so the deny
+ # below can only come from the guest check itself.
+ security_manager.raise_for_access(chart=chart)
+
+ with pytest.raises(ChartForbiddenError):
+ UpdateChartCommand(chart.id, json_obj).run()
+ finally:
+ # Should the guest gate regress, ``run()`` commits before
+ # ``pytest.raises`` fails, and a rollback cannot undo a commit.
Drop
+ # the row this test created rather than leak it into later tests;
+ # on the passing path the rollback already discarded it.
+ db.session.rollback()
+ if not dashboard_was_embedded:
+ db.session.query(EmbeddedDashboard).filter_by(
+ uuid=embedded_uuid
+ ).delete()
+ chart.query_context = original_query_context
+ db.session.commit()
+
@patch("superset.commands.chart.update.g")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
diff --git a/tests/integration_tests/reports/commands_tests.py
b/tests/integration_tests/reports/commands_tests.py
index c3555de37c1..264e55c54e3 100644
--- a/tests/integration_tests/reports/commands_tests.py
+++ b/tests/integration_tests/reports/commands_tests.py
@@ -47,7 +47,8 @@ except ImportError: # pragma: no cover
# Flask-SQLAlchemy 2.x
from flask_sqlalchemy import BaseQuery
-from superset import db
+from superset import db, security_manager
+from superset.commands.chart.update import UpdateChartCommand
from superset.commands.report.exceptions import (
AlertQueryError,
AlertQueryInvalidTypeError,
@@ -93,7 +94,9 @@ from superset.reports.notifications.exceptions import (
NotificationParamException,
)
from superset.tasks.types import ExecutorType
+from superset.tasks.utils import get_executor
from superset.utils import json
+from superset.utils.core import override_user
from superset.utils.database import get_example_database
from superset.utils.report_execution import ReportExecutionContext
from superset.utils.webdriver import PlaywrightTimeout
@@ -371,6 +374,33 @@ def create_report_email_chart_with_csv_no_query_context():
cleanup_report_schedule(report_schedule)
[email protected]
+def create_report_csv_no_query_context_executor_not_chart_editor(get_user):
+ """A CSV report on a chart with no stored query context, whose executor
+ edits the report but is deliberately not an editor of the chart."""
+ alpha = get_user("alpha")
+ admin = get_user("admin")
+ chart = db.session.query(Slice).first()
+ original_query_context = chart.query_context
+ original_editors = list(chart.editors)
+ chart.query_context = None
+ # Only admin may edit the chart, so the report's executor is not a chart
editor.
+ chart.editors = _subjects_for_users([admin])
+ report_schedule = create_report_notification(
+ email_target="[email protected]",
+ chart=chart,
+ report_format=ReportDataFormat.CSV,
+ name="report_csv_no_query_context_executor_not_chart_editor",
+ editors=_subjects_for_users([alpha]),
+ )
+ yield report_schedule
+
+ # Shared chart row: restore what this fixture changed (cleanup commits).
+ chart.query_context = original_query_context
+ chart.editors = original_editors
+ cleanup_report_schedule(report_schedule)
+
+
@pytest.fixture
def create_report_email_dashboard():
dashboard = db.session.query(Dashboard).first()
@@ -1219,6 +1249,39 @@ def
test_email_chart_report_schedule_with_csv_no_query_context(
screenshot_mock.assert_called_once()
[email protected]("load_birth_names_dashboard_with_slices")
+def test_csv_report_query_context_backfill_allows_non_chart_editor_executor(
+ create_report_csv_no_query_context_executor_not_chart_editor,
+):
+ """
+ A report executor that is not a chart editor can still backfill the chart's
+ stored query context, which the CSV path depends on.
+
+ Driven directly rather than through ``AsyncExecuteReportScheduleCommand``:
+ the full report path mocks out the screenshot, which is the only thing that
+ issues the query-context-only ``PUT``, so it would pass either way.
+ """
+ report_schedule =
create_report_csv_no_query_context_executor_not_chart_editor
+ chart = report_schedule.chart
+
+ # The executor ALERT_REPORTS_EXECUTORS resolves to for this report.
+ _, username = get_executor(executors=[ExecutorType.EDITOR],
model=report_schedule)
+ assert username == "alpha"
+
+ query_context = json.dumps({"mock": "query_context"})
+ with override_user(security_manager.find_user(username)):
+ # The executor is not an editor of the chart, which is what makes this
+ # the regression-prone case.
+ assert not security_manager.is_editor(chart)
+ UpdateChartCommand(
+ chart.id,
+ {"query_context_generation": True, "query_context": query_context},
+ ).run()
+
+ db.session.refresh(chart)
+ assert chart.query_context == query_context
+
+
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices",
"create_report_email_chart_with_text",
diff --git a/tests/unit_tests/commands/chart/update_test.py
b/tests/unit_tests/commands/chart/update_test.py
index 413be4acc98..a7ddfa941b6 100644
--- a/tests/unit_tests/commands/chart/update_test.py
+++ b/tests/unit_tests/commands/chart/update_test.py
@@ -255,6 +255,31 @@ def
test_update_chart_query_context_non_editor_with_access_allowed(
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
+def test_update_chart_query_context_denied_for_guest_user(
+ mocker: MockerFixture,
+) -> None:
+ """An embedded guest token holds no write capability on any resource, so a
+ query-context-only update is refused before the access check even though
+ ``raise_for_access`` admits a guest for the charts its dashboard embeds."""
+ # The guest deny raises before any chart attribute is read, so the default
+ # MagicMock the patch installs is enough of a model here.
+ mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
+ mocker.patch(
+ "superset.commands.chart.update.security_manager.is_guest_user",
+ return_value=True,
+ )
+ raise_for_access = mocker.patch(
+ "superset.commands.chart.update.security_manager.raise_for_access",
+ )
+
+ with pytest.raises(ChartForbiddenError):
+ UpdateChartCommand(
+ 1, {"query_context": "{}", "query_context_generation": True}
+ ).validate()
+
+ raise_for_access.assert_not_called()
+
+
def test_update_chart_editor_can_perform_regular_update(
mocker: MockerFixture,
) -> None: