Compare commits

...

5 Commits

Author SHA1 Message Date
Evan
d796fc37de test(chart): address review — deterministic access test + nits
- Patch ChartDAO.find_by_id in the integration test to bypass the
  ChartFilter base filter so the new raise_for_access gate is what
  denies; assert ChartForbiddenError only (fails on master).
- Add -> None, fix mock param names, filter by slice_name.
- Pin raise_for_access(chart=...) in unit tests; add _access_exc()
  helper so the access-denied case uses an access error, not an
  ownership error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:00:23 -07:00
Evan
c5a683a589 test(chart): add positive query_context backfill test for non-owner with access
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:57:55 -07:00
Claude Code
f915b46be7 fix(chart): reconcile tests with the query_context access check
Rebased on master and updated the tests for the new raise_for_access on the
query_context-only update path:
- the unit test now mocks raise_for_access (so it no longer hits an unmocked
  g.user) and asserts access is enforced while ownership is relaxed; adds a
  forbidden-access case.
- the integration test also accepts ChartNotFoundError, since a no-access user
  is filtered by the DAO access filter before the explicit check is reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:37:46 -07:00
Evan
186aa99ecb fix(chart): add missing mock parameter in test_query_context_update_requires_chart_access
Three @patch decorators but only two function parameters meant the mock for
superset.commands.chart.update.g was never captured and its .user was never
set. Added mock_u_g as the third parameter and set mock_u_g.user = gamma so
all three g objects are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:34:00 -07:00
Claude Code
2f0e4861a4 fix(chart): require chart access for query_context-only updates
UpdateChartCommand skips the ownership check for "query_context-only" updates
(payload == {query_context, query_context_generation:true}) so report workers
and the UI's lazy query_context backfill can run as non-owners. But it skipped
ALL authorization, so any user with can_write on Chart could rewrite the
query_context of a chart they don't own (CWE-639).

Replace the unconditional skip with security_manager.raise_for_access(chart=...)
on that path. That still permits the legitimate non-owner flows (admins, owners,
and any user with access to the chart's datasource — which includes viewers who
can render the chart and the report executor), while rejecting users who cannot
access the chart at all.

DRAFT: needs CI / manual validation that the report-execution screenshot path
(executor user) passes raise_for_access in all configurations before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:34:00 -07:00
3 changed files with 111 additions and 3 deletions

View File

@@ -120,8 +120,13 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not self._model:
raise ChartNotFoundError()
# Check and update ownership; when only updating query context we ignore
# ownership so the update can be performed by report workers
# Check and update ownership; when only updating query context we relax
# the ownership requirement so that non-owners (report workers and any
# viewer whose UI lazily backfills a missing query_context) can perform
# the update. We still require access to the chart in that case, so a
# user cannot rewrite the query_context of a chart they cannot access
# (raise_for_access permits admins, owners, and users with access to the
# chart's datasource).
if not is_query_context_update(self._properties):
try:
security_manager.raise_for_ownership(self._model)
@@ -134,6 +139,11 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
raise ChartForbiddenError() from ex
except ValidationError as ex:
exceptions.append(ex)
else:
try:
security_manager.raise_for_access(chart=self._model)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
# validate tags
try:

View File

@@ -25,6 +25,7 @@ from flask import g # noqa: F401
from superset import db, security_manager
from superset.commands.chart.create import CreateChartCommand
from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartNotFoundError,
WarmUpCacheChartNotFoundError,
)
@@ -452,6 +453,43 @@ class TestChartsUpdateCommand(SupersetTestCase):
assert len(chart.owners) == 1
assert chart.owners[0] == admin
@patch("superset.commands.chart.update.ChartDAO.find_by_id")
@patch("superset.commands.chart.update.g")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
@pytest.mark.usefixtures("load_energy_table_with_slice")
def test_query_context_update_requires_chart_access(
self, mock_sm_g, mock_core_g, mock_update_g, mock_find_by_id
) -> None:
"""
A query_context-only update relaxes the ownership 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-owner 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.
"""
chart = db.session.query(Slice).filter_by(slice_name="Energy Sankey").one()
pk = chart.id
admin = security_manager.find_user(username="admin")
chart.owners = [admin]
db.session.commit()
# Return the chart directly, bypassing ChartFilter, so the command's
# own raise_for_access gate is what denies the request.
mock_find_by_id.return_value = chart
# gamma has no access to the energy datasource and does not own the chart
gamma = security_manager.find_user(username="gamma")
mock_core_g.user = mock_sm_g.user = mock_update_g.user = gamma
json_obj = {
"query_context_generation": True,
"query_context": json.dumps({"foo": "bar"}),
}
with pytest.raises(ChartForbiddenError):
UpdateChartCommand(pk, json_obj).run()
@patch("superset.commands.chart.update.g")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")

View File

@@ -33,6 +33,16 @@ def _ownership_exc() -> SupersetSecurityException:
)
def _access_exc() -> SupersetSecurityException:
return SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.CHART_SECURITY_ACCESS_ERROR,
message="User does not have access to this chart",
level=ErrorLevel.ERROR,
)
)
def test_update_chart_ownership_enforced_for_regular_update(
mocker: MockerFixture,
) -> None:
@@ -54,20 +64,70 @@ def test_update_chart_ownership_enforced_for_regular_update(
def test_update_chart_query_context_skips_ownership_check(
mocker: MockerFixture,
) -> None:
"""Query-context-only updates skip ownership so report workers can save context."""
"""Query-context-only updates skip the ownership check (so report workers can
save context) but still require access to the chart."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
raise_for_ownership = mocker.patch(
"superset.commands.chart.update.security_manager.raise_for_ownership",
side_effect=_ownership_exc(),
)
raise_for_access = mocker.patch(
"superset.commands.chart.update.security_manager.raise_for_access",
)
UpdateChartCommand(
1, {"query_context": "{}", "query_context_generation": True}
).validate()
find_by_id.assert_called_once_with(1)
# ownership is relaxed, but chart access is still enforced
raise_for_ownership.assert_not_called()
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
def test_update_chart_query_context_requires_chart_access(
mocker: MockerFixture,
) -> None:
"""A query-context-only update by someone without access to the chart is
rejected, even though the ownership check is relaxed for this path."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch(
"superset.commands.chart.update.security_manager.raise_for_access",
side_effect=_access_exc(),
)
with pytest.raises(ChartForbiddenError):
UpdateChartCommand(
1, {"query_context": "{}", "query_context_generation": True}
).validate()
def test_update_chart_query_context_non_owner_with_access_allowed(
mocker: MockerFixture,
) -> None:
"""A non-owner who *does* have access to the chart (e.g. an alpha user with
datasource access, or a report worker) can perform a query-context-only
backfill: ownership is relaxed and ``raise_for_access`` does not deny."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
raise_for_ownership = mocker.patch(
"superset.commands.chart.update.security_manager.raise_for_ownership",
side_effect=_ownership_exc(),
)
# access check passes (no exception) -> the non-owner is permitted
raise_for_access = mocker.patch(
"superset.commands.chart.update.security_manager.raise_for_access",
)
# Should not raise: the update is allowed despite the user not being an owner
UpdateChartCommand(
1, {"query_context": "{}", "query_context_generation": True}
).validate()
raise_for_ownership.assert_not_called()
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
def test_update_chart_owner_can_perform_regular_update(