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 fc110d8428f fix(versioning): enforce is_managed_externally on version
restore (#44013)
fc110d8428f is described below
commit fc110d8428f35249a2092778ca0a3e26a2de0b14
Author: Mike Bridge <[email protected]>
AuthorDate: Tue Sep 8 22:55:08 2026 -0600
fix(versioning): enforce is_managed_externally on version restore (#44013)
Co-authored-by: Mike Bridge <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
---
UPDATING.md | 1 +
superset/commands/version_restore.py | 18 +++-
.../charts/version_restore_tests.py | 42 +++++++++
.../commands/test_base_restore_version_command.py | 99 ++++++++++++++++++++++
4 files changed, 157 insertions(+), 3 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index 389d6b90370..4c998c267db 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -230,6 +230,7 @@ before retrying. Preview or recheck failures fail closed
rather than treating
unknown impact as zero. Chart and dashboard purge endpoints are unchanged.
- The dashboard datasource-based visibility fallback now fails closed: a
dashboard whose member charts’ datasources cannot be resolved (deleted
datasource rows, missing `datasource_id`, or unsupported datasource types) is
no longer accessible to users without explicit editor/viewer rights, and a
dashboard composed of semantic-view charts now requires `datasource_access` on
(at least one of) its semantic views or their parent semantic layer —
previously any authenticated user could open s [...]
+- Version restore (`POST
/api/v1/{chart,dashboard,dataset}/<uuid>/versions/<version_uuid>/restore`) now
refuses an **externally managed** entity (`is_managed_externally = True`) with
HTTP 403, enforcing server-side what the docs already promised. Previously the
refusal existed only in the browser, so an otherwise-authorized editor could
restore such an entity by calling the endpoint directly and have the restore
overwritten on the next external sync. Soft-delete recovery is deliberately
[...]
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests
without a valid explicit `per_page`, rather than a hard per-request ceiling;
explicit limits are honored up to the existing global row-limit ceiling,
matching `/chart/data` SAMPLES requests.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now
installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb`
package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0.
Existing environments with the old package installed should `pip uninstall
cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the
extra) to restore CockroachDB connectivity.
diff --git a/superset/commands/version_restore.py
b/superset/commands/version_restore.py
index 7a79a40dde8..4fe3f4f3645 100644
--- a/superset/commands/version_restore.py
+++ b/superset/commands/version_restore.py
@@ -64,9 +64,10 @@ class BaseRestoreVersionCommand(BaseCommand):
#: failure modes. ``not_found_exc`` covers "no such entity",
#: "version_uuid not on this entity", and "capture disabled" (the
#: route is inert under the kill-switch); the API handler maps each
- #: to HTTP 404. ``forbidden_exc`` covers the row-level editorship
- #: denial (HTTP 403). ``failed_exc`` wraps unexpected failures inside
- #: the transaction (HTTP 422).
+ #: to HTTP 404. ``forbidden_exc`` covers the row-level editorship denial
+ #: and the refusal to restore an externally managed entity (both HTTP
+ #: 403). ``failed_exc`` wraps unexpected failures inside the transaction
+ #: (HTTP 422).
not_found_exc: ClassVar[type[Exception]]
forbidden_exc: ClassVar[type[Exception]]
failed_exc: ClassVar[type[Exception]]
@@ -149,4 +150,15 @@ class BaseRestoreVersionCommand(BaseCommand):
security_manager.raise_for_editorship(entity)
except SupersetSecurityException as ex:
raise self.forbidden_exc() from ex
+ # Restore is withheld from externally managed entities: their source of
+ # truth lives outside Superset and would overwrite the restore on the
+ # next sync (documented in version-history.mdx). This must be enforced
+ # server-side, not only in the browser — an authorized editor could
+ # otherwise call the endpoint directly. Raised as forbidden_exc (HTTP
+ # 403); FAB's response_403 returns a fixed ``{"message": "Forbidden"}``
+ # body with no reason detail, identical to the editorship denial above,
+ # so the refusal discloses nothing but also can't be distinguished from
+ # a permission denial.
+ if entity.is_managed_externally:
+ raise self.forbidden_exc()
return entity
diff --git a/tests/integration_tests/charts/version_restore_tests.py
b/tests/integration_tests/charts/version_restore_tests.py
index dd31ea0d501..b3e3e150f96 100644
--- a/tests/integration_tests/charts/version_restore_tests.py
+++ b/tests/integration_tests/charts/version_restore_tests.py
@@ -120,6 +120,48 @@ class TestChartRestoreApi(SupersetTestCase):
chart.slice_name = original_name
db.session.commit()
+ def test_restore_refuses_externally_managed_chart(self) -> None:
+ """sc-115616: restore is withheld server-side from an externally
+ managed chart even for an admin who could otherwise edit it — the
+ endpoint returns 403, not 200, so a direct API call cannot bypass the
+ browser gate.
+
+ Chart is the representative real-model/real-endpoint case; the guard
+ lives in the shared BaseRestoreVersionCommand.validate(), so the
+ dashboard and dataset commands inherit it (pinned across all three by
+ the parametrized unit test in
+ tests/unit_tests/commands/test_base_restore_version_command.py)."""
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
+ )
+ assert chart is not None
+ chart_uuid = str(chart.uuid)
+
+ # A save so there is a prior version to target.
+ chart.slice_name = "Boys v1"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ listing = _json.loads(self._list(chart_uuid).data.decode("utf-8"))
+ target_uuid = listing["result"][-1]["version_uuid"]
+
+ # Mark the chart as externally managed, then attempt the restore.
+ chart.is_managed_externally = True
+ db.session.commit()
+ try:
+ rv = self._restore(chart_uuid, target_uuid)
+ assert rv.status_code == 403, rv.data
+ # The refusal did not mutate the chart.
+ db.session.expire_all()
+ chart = db.session.query(Slice).filter(Slice.uuid ==
chart.uuid).one()
+ assert chart.slice_name == "Boys v1"
+ finally:
+ # Cleanup
+ chart.is_managed_externally = False
+ chart.slice_name = "Boys"
+ db.session.commit()
+
def test_restore_returns_404_for_unknown_uuid(self) -> None:
self.login(ADMIN_USERNAME)
rv = self._restore(
diff --git a/tests/unit_tests/commands/test_base_restore_version_command.py
b/tests/unit_tests/commands/test_base_restore_version_command.py
new file mode 100644
index 00000000000..988b6128829
--- /dev/null
+++ b/tests/unit_tests/commands/test_base_restore_version_command.py
@@ -0,0 +1,99 @@
+# 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.
+"""Unit tests for ``BaseRestoreVersionCommand.validate`` (version restore).
+
+Exercises the shared ``validate()`` through each of the three concrete
+commands so the contract is pinned against the real subclasses (and their real
+``forbidden_exc`` types), guarding against a subclass overriding it. This is
+the *version* restore command (revert to a past version), distinct from the
+soft-delete recovery command covered in ``test_base_restore_command.py``. The
+real-model / real-endpoint 403 is exercised end-to-end in the per-entity
+``version_restore_tests.py`` integration suites (e.g.
+``tests/integration_tests/charts/version_restore_tests.py``).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+from uuid import uuid4
+
+import pytest
+
+from superset.commands.chart.restore_version import RestoreChartVersionCommand
+from superset.commands.dashboard.restore_version import
RestoreDashboardVersionCommand
+from superset.commands.dataset.restore_version import
RestoreDatasetVersionCommand
+from superset.commands.version_restore import BaseRestoreVersionCommand
+
+_COMMAND_CLASSES = [
+ RestoreChartVersionCommand,
+ RestoreDashboardVersionCommand,
+ RestoreDatasetVersionCommand,
+]
+
+
+@contextmanager
+def _validate_context(entity: MagicMock) -> Iterator[None]:
+ """Patch the base command's collaborators so ``validate()`` reaches the
+ is_managed_externally guard: capture is on, the entity is found, and the
+ editorship check passes. What varies between tests is only the entity's
+ ``is_managed_externally`` value.
+ """
+ with (
+ patch("superset.commands.version_restore.capture_enabled",
return_value=True),
+ patch(
+ "superset.commands.version_restore.find_active_by_uuid",
+ return_value=entity,
+ ),
+ patch("superset.commands.version_restore.security_manager") as
mock_sec,
+ ):
+ mock_sec.raise_for_editorship = MagicMock(return_value=None)
+ yield
+
+
[email protected]("command_cls", _COMMAND_CLASSES)
+def test_validate_refuses_externally_managed_entity(
+ command_cls: type[BaseRestoreVersionCommand], app_context: None
+) -> None:
+ """sc-115616: version restore is withheld server-side from externally
+ managed entities — an otherwise-authorized editor must not be able to
+ bypass the browser gate by calling the endpoint directly. Each concrete
+ command raises its own ``forbidden_exc`` (mapped to HTTP 403)."""
+ entity = MagicMock()
+ entity.is_managed_externally = True
+ cmd = command_cls(uuid4(), uuid4())
+
+ with _validate_context(entity):
+ with pytest.raises(command_cls.forbidden_exc):
+ cmd.validate()
+
+
[email protected]("command_cls", _COMMAND_CLASSES)
+def test_validate_returns_entity_when_not_managed_externally(
+ command_cls: type[BaseRestoreVersionCommand], app_context: None
+) -> None:
+ """The reverted-fix control: an editable, non-externally-managed entity
+ passes validation and is returned to ``run()``. Removing the guard would
+ make the refusal test above pass here too, so this pins that the guard is
+ what rejects the managed case."""
+ entity = MagicMock()
+ entity.is_managed_externally = False
+ cmd = command_cls(uuid4(), uuid4())
+
+ with _validate_context(entity):
+ assert cmd.validate() is entity