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 3b355b6a854 fix(chart): allow semantic view datasource when saving 
charts (#44169)
3b355b6a854 is described below

commit 3b355b6a854c45bb6a5c023357d1902cfa7584cb
Author: Kunal <[email protected]>
AuthorDate: Thu Sep 24 03:43:12 2026 +0530

    fix(chart): allow semantic view datasource when saving charts (#44169)
    
    Co-authored-by: Gabriel Torres Ruiz <[email protected]>
---
 superset/models/dashboard.py                       |   3 +
 superset/security/manager.py                       |  38 +++-
 tests/integration_tests/charts/api_tests.py        | 109 ++++++++++
 tests/unit_tests/charts/test_filters.py            |  96 +++++++++
 tests/unit_tests/commands/chart/create_test.py     |  33 ++-
 tests/unit_tests/commands/chart/update_test.py     |  41 +++-
 tests/unit_tests/dao/test_query_stop_race.py       |  68 +++++--
 tests/unit_tests/models/dashboard_test.py          |  41 +++-
 tests/unit_tests/semantic_layers/models_test.py    | 225 +++++++++++++++++++++
 tests/unit_tests/subjects/test_raise_for_access.py | 122 +++++++++++
 10 files changed, 753 insertions(+), 23 deletions(-)

diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index c2adb0fdfab..e6a09759e17 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -377,6 +377,9 @@ class Dashboard(CoreDashboard, SoftDeleteMixin, 
AuditMixinNullable, ImportExport
         self,
     ) -> list[tuple[BaseDatasource | SemanticView, dict[str, Any]]]:
         """Return trimmed chart metadata, keeping datasource types distinct."""
+        # Key by (datasource_type, datasource_id): SqlaTable and SemanticView
+        # have independent auto-increment id spaces, so grouping by the bare
+        # datasource_id would merge unrelated datasources with colliding ids.
         slices_by_datasource: dict[tuple[str, int], set[Slice]] = 
defaultdict(set)
 
         for slc in self.slices:
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 7d685f33772..18a74250304 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -4232,7 +4232,9 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
                 .values(perm=new_perm)
             )
 
-            # Cascade: update view perms that embed the layer name
+            # Cascade: update view perms that embed the layer name, and
+            # dependent charts so their denormalized perm stays in sync with
+            # the views (chart-list access filters match on Slice.perm).
             sv_table = SemanticView.__table__  # pylint: disable=no-member
             views = connection.execute(
                 sv_table.select().where(sv_table.c.semantic_layer_uuid == 
target.uuid)
@@ -4254,6 +4256,24 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
                         .values(perm=new_view_perm)
                     )
 
+                    # Update dependent charts so their denormalized perm stays
+                    # in sync with the view: chart-list access filters match
+                    # on Slice.perm.
+                    from superset.models.slice import (  # pylint: 
disable=import-outside-toplevel
+                        Slice,
+                    )
+
+                    chart_table = Slice.__table__  # pylint: disable=no-member
+                    connection.execute(
+                        chart_table.update()
+                        .where(
+                            chart_table.c.datasource_type
+                            == DatasourceType.SEMANTIC_VIEW,
+                            chart_table.c.datasource_id == view_row.id,
+                        )
+                        .values(perm=new_view_perm)
+                    )
+
     def semantic_layer_after_delete(
         self,
         mapper: Mapper,
@@ -4356,6 +4376,22 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
                 .values(perm=new_perm)
             )
 
+            # Update dependent charts so their denormalized perm stays in sync
+            # with the view: chart-list access filters match on Slice.perm.
+            from superset.models.slice import (  # pylint: 
disable=import-outside-toplevel
+                Slice,
+            )
+
+            chart_table = Slice.__table__  # pylint: disable=no-member
+            connection.execute(
+                chart_table.update()
+                .where(
+                    chart_table.c.datasource_type == 
DatasourceType.SEMANTIC_VIEW,
+                    chart_table.c.datasource_id == target.id,
+                )
+                .values(perm=new_perm)
+            )
+
     def semantic_view_after_delete(
         self,
         mapper: Mapper,
diff --git a/tests/integration_tests/charts/api_tests.py 
b/tests/integration_tests/charts/api_tests.py
index 37cfdc7caae..b9501de7ddd 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -38,6 +38,7 @@ from superset.models.dashboard import Dashboard
 from superset.models.slice import Slice
 from superset.models.sql_lab import SavedQuery
 from superset.reports.models import ReportSchedule, ReportScheduleType
+from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.subjects.models import Subject
 from superset.subjects.types import SubjectType
 from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
@@ -704,6 +705,114 @@ class TestChartApi(ApiEditorsTestCaseMixin, 
InsertChartMixin, SupersetTestCase):
             db.session.delete(db.session.query(SavedQuery).get(saved_query_id))
             db.session.commit()
 
+    def test_create_chart_from_semantic_view(self):
+        """
+        Chart API: creating a chart with datasource_type="semantic_view" must
+        succeed (apache/superset#44167). Semantic views are first-class
+        resolvable datasources (Slice resolves them through the type-guarded
+        ``semantic_view`` relationship), so the non-table datasource_type
+        guard must explicitly allow them rather than rejecting them the way
+        it rejects saved_query. This reproduces the exact API call shape from
+        the bug report: a real semantic view row, then POST /api/v1/chart/
+        with datasource_type="semantic_view".
+        """
+        self.login(ADMIN_USERNAME)
+        suffix = uuid.uuid4().hex
+        layer = SemanticLayer(
+            uuid=uuid.uuid4(),
+            name=f"issue-44167-layer-{suffix}",
+            type="test",
+            configuration="{}",
+        )
+        view = SemanticView(
+            uuid=uuid.uuid4(),
+            name=f"issue-44167-view-{suffix}",
+            semantic_layer_uuid=layer.uuid,
+            configuration="{}",
+        )
+        db.session.add_all([layer, view])
+        db.session.commit()
+        view_id = view.id
+
+        chart_data = {
+            "slice_name": "issue-44167-repro-chart",
+            "datasource_id": view_id,
+            "datasource_type": "semantic_view",
+            "viz_type": "table",
+        }
+        chart_id = None
+        try:
+            rv = self.post_assert_metric("/api/v1/chart/", chart_data, "post")
+
+            assert rv.status_code == 201
+            data = json.loads(rv.data.decode("utf-8"))
+            chart_id = data.get("id")
+            model = db.session.query(Slice).get(chart_id)
+            assert model.datasource_type == "semantic_view"
+            assert model.datasource_id == view_id
+
+            # The saved chart is now resolvable: its owner (admin) can
+            # retrieve it, and the chart's perm carries the view perm.
+            rv = self.get_assert_metric(f"/api/v1/chart/{chart_id}", "get")
+            assert rv.status_code == 200
+            assert model.perm == view.perm
+
+            gamma = self.get_user("gamma")
+            uri = "api/v1/chart/?q=" + rison.dumps(
+                {
+                    "filters": [
+                        {
+                            "col": "slice_name",
+                            "opr": "ct",
+                            "value": "issue-44167-repro-chart",
+                        }
+                    ]
+                }
+            )
+
+            # Drop the admin session before impersonating gamma: logging in as
+            # the temporary user does not replace an already-authenticated
+            # session, so the admin would otherwise leak into these checks.
+            self.logout()
+
+            # Without the view's datasource_access perm, a non-owner cannot
+            # list/open the chart.
+            with self.temporary_user(gamma, login=True):
+                rv = self.client.get(uri, "get_list")
+                assert rv.status_code == 200
+                assert json.loads(rv.data.decode("utf-8"))["count"] == 0
+
+            # all_database_access short-circuits the chart filter just like
+            # all_datasource_access: a user who can access every database sees
+            # every chart, including semantic-view charts that have no database
+            # of their own.
+            perm = ("all_database_access", "all_database_access")
+            with self.temporary_user(gamma, extra_pvms=[perm], login=True):
+                rv = self.client.get(uri, "get_list")
+                assert rv.status_code == 200
+                assert json.loads(rv.data.decode("utf-8"))["count"] == 1
+
+            # With the view's datasource_access perm, a non-owner can
+            # list and retrieve the chart.
+            perm = ("datasource_access", view.perm)
+            with self.temporary_user(gamma, extra_pvms=[perm], login=True):
+                rv = self.client.get(uri, "get_list")
+                assert rv.status_code == 200
+                data = json.loads(rv.data.decode("utf-8"))
+                assert data["count"] == 1
+                rv = self.get_assert_metric(f"/api/v1/chart/{chart_id}", "get")
+                assert rv.status_code == 200
+        finally:
+            if chart_id:
+                model = db.session.query(Slice).get(chart_id)
+                if model:
+                    db.session.delete(model)
+            view = db.session.query(SemanticView).get(view_id)
+            if view:
+                db.session.delete(view)
+            db.session.delete(layer)
+            db.session.commit()
+
     @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
     def test_create_chart_validate_user_is_dashboard_editor(self):
         """
diff --git a/tests/unit_tests/charts/test_filters.py 
b/tests/unit_tests/charts/test_filters.py
index 112f7fab0a9..7c560cad1e2 100644
--- a/tests/unit_tests/charts/test_filters.py
+++ b/tests/unit_tests/charts/test_filters.py
@@ -179,3 +179,99 @@ def test_chart_filter_guest_no_resources_denied(mocker: 
MockerFixture) -> None:
     assert filt.apply(query, None) is query
     query.filter.assert_called_once()  # scoped (to nothing), not role-based
     viewers.assert_not_called()
+
+
+def _compile(clause: Any) -> str:
+    return str(
+        clause.statement.compile(
+            create_engine("sqlite://"),
+            compile_kwargs={"literal_binds": True},
+        )
+    )
+
+
+def _no_viewer_fallbacks(mocker: MockerFixture, perms: set[str]) -> Any:
+    """Build the no-viewer fallback clauses for a non-admin user.
+
+    Returns the fully-filtered Query so tests can compile and assert on the 
SQL.
+    get_user_id() is mocked to None to keep the editor/viewer subqueries out.
+    """
+    from superset import db
+    from superset.charts.filters import ChartFilter
+    from superset.extensions import security_manager
+    from superset.models.slice import Slice
+
+    mocker.patch("superset.charts.filters.get_user_id", return_value=None)
+    mocker.patch.object(
+        security_manager, "get_accessible_databases", return_value=[1, 2, 3]
+    )
+
+    def _perms(perm_name: str) -> set[str]:
+        return perms if perm_name == "datasource_access" else set()
+
+    mocker.patch.object(security_manager, "user_view_menu_names", 
side_effect=_perms)
+
+    filt: ChartFilter = ChartFilter.__new__(ChartFilter)
+    filt.model = Slice
+    return filt._apply_viewers(db.session.query(Slice))
+
+
+def test_chart_filter_no_viewer_semantic_view_matches_by_perm(
+    mocker: MockerFixture,
+) -> None:
+    """A semantic-view chart with no viewers is matched through the chart's own
+    perm (the view's ``datasource_access`` perm), not through a numeric-id join
+    to SqlaTable. The view and its parent layer are joined so the layer's perm
+    can participate in the access check."""
+    compiled = _compile(_no_viewer_fallbacks(mocker, 
perms={"[layer].[view](id:1)"}))
+
+    assert "slices.datasource_type = 'semantic_view'" in compiled
+    assert "'[layer].[view](id:1)'" in compiled
+    assert "JOIN semantic_views" in compiled
+    assert "JOIN semantic_layers" in compiled
+    # the table branch is still guarded by datasource_type so semantic-view
+    # foreign ids can never ride along on the table/database join
+    assert "slices.datasource_type = 'table'" in compiled
+    assert "JOIN tables AS tables_1" in compiled
+
+
+def test_chart_filter_no_viewer_semantic_view_matches_layer_perm(
+    mocker: MockerFixture,
+) -> None:
+    """A datasource_access grant on a parent semantic layer matches its views'
+    charts through the layer perm (mirrors SemanticView.raise_for_access)."""
+    layer_perm = "[test_layer](id:11111111-2222-3333-4444-555566667777)"
+    compiled = _compile(_no_viewer_fallbacks(mocker, perms={layer_perm}))
+
+    assert "slices.datasource_type = 'semantic_view'" in compiled
+    assert "JOIN semantic_views" in compiled
+    assert "JOIN semantic_layers" in compiled
+    assert f"'{layer_perm}'" in compiled
+    # the layer perm is ANDed with the semantic-view type guard, never the
+    # table branch
+    assert "slices.datasource_type = 'table'" in compiled
+    assert "JOIN tables AS tables_1" in compiled
+
+
+def test_chart_filter_no_viewer_semantic_view_excluded_without_perm(
+    mocker: MockerFixture,
+) -> None:
+    """Without the view's datasource_access perm, the semantic-view branch
+    carries no matching perm literal."""
+    compiled = _compile(_no_viewer_fallbacks(mocker, perms=set()))
+
+    assert "slices.datasource_type = 'semantic_view'" in compiled
+    assert "'[layer].[view](id:1)'" not in compiled
+    assert "slices.datasource_type = 'table'" in compiled
+
+
+def test_chart_filter_no_viewer_table_matches_by_database_access(
+    mocker: MockerFixture,
+) -> None:
+    """Table charts with no viewers keep the pre-existing database-access join:
+    matched via the databases the user can access even without explicit 
perms."""
+    compiled = _compile(_no_viewer_fallbacks(mocker, perms=set()))
+
+    assert "slices.datasource_type = 'table'" in compiled
+    assert "JOIN tables AS tables_1" in compiled
+    assert "dbs.id IN (1, 2, 3)" in compiled
diff --git a/tests/unit_tests/commands/chart/create_test.py 
b/tests/unit_tests/commands/chart/create_test.py
index 2585aec44c3..93d8b06c002 100644
--- a/tests/unit_tests/commands/chart/create_test.py
+++ b/tests/unit_tests/commands/chart/create_test.py
@@ -35,6 +35,7 @@ from superset.commands.chart.exceptions import (
 from superset.commands.exceptions import DatasourceTypeInvalidError
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import SupersetSecurityException
+from superset.semantic_layers.models import SemanticView
 from superset.utils import json
 
 
@@ -48,7 +49,7 @@ def _base_mocks(mocker: MockerFixture) -> None:
     )
 
 
[email protected]("datasource_type", ["saved_query", "query"])
[email protected]("datasource_type", ["saved_query", "query", "bogus"])
 def test_create_chart_rejects_non_table_datasource_type(
     mocker: MockerFixture, datasource_type: str
 ) -> None:
@@ -122,6 +123,36 @@ def test_create_chart_accepts_table_datasource(mocker: 
MockerFixture) -> None:
     assert cmd._properties["datasource_name"] == "my_table"
 
 
+def test_create_chart_accepts_semantic_view_datasource(
+    mocker: MockerFixture,
+) -> None:
+    """A chart backed by a SIP-182 semantic view must be accepted: the view
+    is a first-class resolvable datasource (Slice resolves it through the
+    type-guarded ``semantic_view`` relationship), so the non-table guard
+    must explicitly allow it (apache/superset#44167)."""
+    _base_mocks(mocker)
+    datasource = mocker.MagicMock(spec=SemanticView)
+    datasource.name = "my_semantic_view"
+    get_datasource_by_id = mocker.patch(
+        "superset.commands.chart.create.get_datasource_by_id",
+        return_value=datasource,
+    )
+    
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
+
+    cmd = CreateChartCommand(
+        {
+            "datasource_id": 11,
+            "datasource_type": "semantic_view",
+            "slice_name": "some_name",
+            "viz_type": "table",
+        }
+    )
+    cmd.validate()
+
+    assert cmd._properties["datasource_name"] == "my_semantic_view"
+    get_datasource_by_id.assert_called_once_with(11, "semantic_view")
+
+
 def test_create_chart_datasource_access_denied_still_raises_forbidden(
     mocker: MockerFixture,
 ) -> None:
diff --git a/tests/unit_tests/commands/chart/update_test.py 
b/tests/unit_tests/commands/chart/update_test.py
index a7ddfa941b6..ded20053462 100644
--- a/tests/unit_tests/commands/chart/update_test.py
+++ b/tests/unit_tests/commands/chart/update_test.py
@@ -34,6 +34,7 @@ from superset.commands.exceptions import (
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import SupersetSecurityException
 from superset.models.slice import Slice
+from superset.semantic_layers.models import SemanticView
 from superset.utils import json
 
 
@@ -406,7 +407,7 @@ def 
test_update_chart_query_context_without_datasource_is_allowed(
     ).validate()
 
 
[email protected]("datasource_type", ["saved_query", "query"])
[email protected]("datasource_type", ["saved_query", "query", "bogus"])
 def test_update_chart_rejects_repointing_to_non_table_datasource(
     mocker: MockerFixture, datasource_type: str
 ) -> None:
@@ -443,6 +444,42 @@ def 
test_update_chart_rejects_repointing_to_non_table_datasource(
     get_datasource_by_id.assert_not_called()
 
 
+def test_update_chart_accepts_semantic_view_datasource(
+    mocker: MockerFixture,
+) -> None:
+    """Repointing a chart at a SIP-182 semantic view must be accepted: the
+    view is a first-class resolvable datasource (Slice resolves it through
+    the type-guarded ``semantic_view`` relationship), so the non-table guard
+    must explicitly allow it (apache/superset#44167)."""
+    find_by_id = 
mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
+    find_by_id.return_value = mocker.MagicMock(
+        is_managed_externally=False, id=1, tags=[], dashboards=[]
+    )
+    
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
+    mocker.patch(
+        "superset.commands.chart.update.compute_subjects",
+        side_effect=lambda model, properties, exceptions: None,
+    )
+    datasource = mocker.MagicMock(spec=SemanticView)
+    datasource.name = "my_semantic_view"
+    get_datasource_by_id = mocker.patch(
+        "superset.commands.chart.update.get_datasource_by_id",
+        return_value=datasource,
+    )
+    raise_for_access = mocker.patch(
+        "superset.commands.chart.update.security_manager.raise_for_access"
+    )
+
+    cmd = UpdateChartCommand(
+        1, {"datasource_id": 11, "datasource_type": "semantic_view"}
+    )
+    cmd.validate()
+
+    get_datasource_by_id.assert_called_once_with(11, "semantic_view")
+    raise_for_access.assert_called_once_with(datasource=datasource)
+    assert cmd._properties["datasource_name"] == "my_semantic_view"
+
+
 def test_update_chart_missing_datasource_type_keeps_required_error(
     mocker: MockerFixture,
 ) -> None:
@@ -478,7 +515,7 @@ def 
test_update_chart_missing_datasource_type_keeps_required_error(
     get_datasource_by_id.assert_not_called()
 
 
[email protected]("datasource_type", ["saved_query", "query"])
[email protected]("datasource_type", ["saved_query", "query", "bogus"])
 def test_update_chart_rejects_type_only_non_table_datasource(
     mocker: MockerFixture, datasource_type: str
 ) -> None:
diff --git a/tests/unit_tests/dao/test_query_stop_race.py 
b/tests/unit_tests/dao/test_query_stop_race.py
index e719605e9b4..201753facc5 100644
--- a/tests/unit_tests/dao/test_query_stop_race.py
+++ b/tests/unit_tests/dao/test_query_stop_race.py
@@ -75,6 +75,14 @@ import pytest
 from pytest_mock import MockerFixture
 from sqlalchemy.orm.session import Session
 
+# Generous thread handshake/join timeouts: CI runs the full unit suite on
+# loaded shards, and both threads here share a single StaticPool sqlite
+# connection, so a momentarily-blocked main thread must not time out the
+# worker's pause. A healthy run finishes these handshakes in well under a
+# second; the timeouts exist only to fail loudly on a genuine deadlock.
+HANDSHAKE_TIMEOUT = 60
+JOIN_TIMEOUT = 120
+
 
 def test_stop_before_worker_starts_marks_stopped_without_dispatch(
     app: Any, session: Session
@@ -256,7 +264,9 @@ def 
test_stop_during_pre_dispatch_pause_marks_stopped_without_dispatch(
         # but well before the DB connection is opened -- squarely inside the
         # race window from the RCA.
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         return real_apply_limit(query, parsed_statement)
 
     mocker.patch("superset.sql_lab.apply_limit", 
side_effect=paused_apply_limit)
@@ -269,7 +279,9 @@ def 
test_stop_during_pre_dispatch_pause_marks_stopped_without_dispatch(
     worker = harness.run_execution(app, query_id, execution_result)
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         query = harness.fresh_query(query_id)
         assert query.status == QueryStatus.RUNNING
@@ -285,7 +297,7 @@ def 
test_stop_during_pre_dispatch_pause_marks_stopped_without_dispatch(
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     # The statement itself was never sent to the database -- this is the
@@ -339,7 +351,9 @@ def 
test_stop_after_dispatch_with_no_cancel_support_raises_honestly(
         # -- i.e. after QUERY_DISPATCHED_KEY is committed, right as the
         # statement is about to be (or already is being) sent to the engine.
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         return real_execute_query(query, cursor, log_params)
 
     execute_query_spy = mocker.patch(
@@ -350,7 +364,9 @@ def 
test_stop_after_dispatch_with_no_cancel_support_raises_honestly(
     worker = harness.run_execution(app, query_id, execution_result)
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         query = harness.fresh_query(query_id)
         assert query.status == QueryStatus.RUNNING
@@ -372,7 +388,7 @@ def 
test_stop_after_dispatch_with_no_cancel_support_raises_honestly(
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     # The statement genuinely ran (real execute_query was called through).
@@ -619,7 +635,9 @@ def 
test_stop_racing_normal_completion_keeps_payload_and_results_write_consisten
         # payload/results-write built", which is what this test is about.
         result = real_execute_query(query, cursor, log_params)
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         return result
 
     mocker.patch("superset.sql_lab.execute_query", 
side_effect=paused_execute_query)
@@ -628,7 +646,9 @@ def 
test_stop_racing_normal_completion_keeps_payload_and_results_write_consisten
     worker = harness.run_execution(app, query_id, execution_result, 
store_results=True)
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         query = harness.fresh_query(query_id)
         assert query.status == QueryStatus.RUNNING
@@ -644,7 +664,7 @@ def 
test_stop_racing_normal_completion_keeps_payload_and_results_write_consisten
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     real_cancel_spy.assert_called_once()
@@ -689,7 +709,9 @@ def 
test_stop_racing_exception_path_keeps_stopped_not_failed(
 
     def paused_failing_execute_query(query: Any, cursor: Any, log_params: Any) 
-> Any:
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         raise RuntimeError("simulated unrelated failure")
 
     mocker.patch(
@@ -700,7 +722,9 @@ def 
test_stop_racing_exception_path_keeps_stopped_not_failed(
     worker = harness.run_execution(app, query_id, execution_result)
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         QueryDAO.stop_query(client_id)
         stopped_query = harness.fresh_query(query_id)
@@ -708,7 +732,7 @@ def 
test_stop_racing_exception_path_keeps_stopped_not_failed(
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     final_query = harness.fresh_query(query_id)
@@ -812,7 +836,9 @@ def 
test_stop_racing_results_backend_write_failure_keeps_stopped_not_failed(
         # statement has already finished executing), then reports failure --
         # the exact window the reviewer's repro targets.
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         return False
 
     results_backend_mock.set.side_effect = paused_failing_set
@@ -842,7 +868,9 @@ def 
test_stop_racing_results_backend_write_failure_keeps_stopped_not_failed(
     worker.start()
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         query = harness.fresh_query(query_id)
         assert query.status == QueryStatus.RUNNING
@@ -853,7 +881,7 @@ def 
test_stop_racing_results_backend_write_failure_keeps_stopped_not_failed(
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     assert "error" not in execution_error, (
@@ -1049,7 +1077,9 @@ def 
test_timed_out_exception_racing_stop_keeps_stopped_not_resurrected(
 
     def paused_timed_out_execute_query(query: Any, cursor: Any, log_params: 
Any) -> Any:
         reached_pause.set()
-        assert release_execution.wait(timeout=5), "test deadlocked waiting for 
release"
+        assert release_execution.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "test deadlocked waiting for release"
+        )
         # Mirrors execute_query()'s own `except SoftTimeLimitExceeded`
         # handler exactly: set status locally (uncommitted), then raise. Also
         # dirties an unrelated field the same way real code paths would
@@ -1075,7 +1105,9 @@ def 
test_timed_out_exception_racing_stop_keeps_stopped_not_resurrected(
     worker = harness.run_execution(app, query_id, execution_result)
 
     try:
-        assert reached_pause.wait(timeout=5), "execution never reached the 
pause point"
+        assert reached_pause.wait(timeout=HANDSHAKE_TIMEOUT), (
+            "execution never reached the pause point"
+        )
 
         query = harness.fresh_query(query_id)
         assert query.status == QueryStatus.RUNNING
@@ -1089,7 +1121,7 @@ def 
test_timed_out_exception_racing_stop_keeps_stopped_not_resurrected(
     finally:
         release_execution.set()
 
-    worker.join(timeout=10)
+    worker.join(timeout=JOIN_TIMEOUT)
     assert not worker.is_alive(), "execute_sql_statements did not finish in 
time"
 
     final_query = harness.fresh_query(query_id)
diff --git a/tests/unit_tests/models/dashboard_test.py 
b/tests/unit_tests/models/dashboard_test.py
index a7934722ec5..40e0836f2fb 100644
--- a/tests/unit_tests/models/dashboard_test.py
+++ b/tests/unit_tests/models/dashboard_test.py
@@ -16,11 +16,12 @@
 # under the License.
 
 import logging
-from unittest.mock import patch, PropertyMock
+from unittest.mock import Mock, patch, PropertyMock
 
 import pytest
 from flask import current_app
 
+from superset.connectors.sqla.models import BaseDatasource
 from superset.models.dashboard import Dashboard
 from superset.utils import json
 
@@ -698,3 +699,41 @@ def 
test_tabs_places_a_node_the_layout_reaches_twice_only_once(
     assert "skipping layout node TAB-1, the layout reaches it more than once" 
in (
         caplog.text
     )
+
+
+def test_datasets_trimmed_for_slices_keeps_colliding_ids_separate() -> None:
+    """Keys slices by (datasource_type, datasource_id) to avoid id collisions.
+
+    SqlaTable and SemanticView use independent auto-increment id spaces, so a
+    table chart and a semantic-view chart can carry the same datasource_id.
+    Grouping by the bare id would merge the table chart into the semantic-view
+    group (whose non-table datasource is dropped), losing the table chart's
+    metadata from the dashboard payload.
+    """
+    table_datasource = Mock(spec=BaseDatasource)
+    table_datasource.table_name = "orders"
+    table_datasource.data_for_slices.return_value = {"cols": ["column"]}
+
+    sesh_table_slice = Mock()
+    sesh_table_slice.datasource_id = 1
+    sesh_table_slice.datasource_type = "table"
+    sesh_table_slice.resolved_datasource = table_datasource
+
+    semantic_view_slice = Mock()
+    semantic_view_slice.datasource_id = 1
+    semantic_view_slice.datasource_type = "semantic_view"
+    semantic_view_slice.resolved_datasource = None
+
+    dash = Dashboard()
+    with patch.object(
+        Dashboard,
+        "slices",
+        new_callable=PropertyMock,
+        return_value=[sesh_table_slice, semantic_view_slice],
+    ):
+        result = dash.datasets_trimmed_for_slices()
+
+    # Only the table-backed chart is kept; its datasource must be reported
+    # with the exact slice list (no semantic-view slice leaking into it).
+    assert result == [(table_datasource, {"cols": ["column"]})]
+    
table_datasource.data_for_slices.assert_called_once_with([sesh_table_slice])
diff --git a/tests/unit_tests/semantic_layers/models_test.py 
b/tests/unit_tests/semantic_layers/models_test.py
index e260476310f..6b1b26469e7 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -1601,6 +1601,181 @@ def test_semantic_view_before_update_updates_perm(app: 
Any) -> None:
         db.session.rollback()
 
 
+def test_semantic_view_before_update_syncs_dependent_slice_perms(app: Any) -> 
None:
+    """Renaming a view also updates dependent charts' denormalized perm.
+
+    The chart-list access filter matches no-viewer semantic-view charts on
+    ``Slice.perm``, so a rename must propagate the new perm to the chart or the
+    chart loses visibility in lists even for entitled users.
+    """
+    from superset import security_manager
+    from superset.charts.filters import ChartFilter
+    from superset.extensions import db
+    from superset.models.slice import Slice
+    from superset.utils.core import DatasourceType
+
+    layer = SemanticLayer()
+    layer.name = "Sync Layer"
+    layer.uuid = uuid.UUID("bbbb1111-2222-3333-4444-555566667777")
+    layer.type = "test"
+
+    view = SemanticView()
+    view.name = "Old Sync View"
+    view.semantic_layer_uuid = layer.uuid
+
+    db.session.add(layer)
+    db.session.add(view)
+    db.session.flush()
+
+    chart = Slice(
+        slice_name="On the view",
+        datasource_type=DatasourceType.SEMANTIC_VIEW,
+        datasource_id=view.id,
+        datasource_name="Old Sync View",
+        viz_type="table",
+        params="{}",
+    )
+    db.session.add(chart)
+    db.session.flush()
+
+    try:
+        assert chart.perm == view.perm
+
+        view.name = "New Sync View"
+        db.session.flush()
+        db.session.expire(chart)
+        db.session.expire(view)
+
+        new_perm = view.perm
+        assert chart.perm == new_perm
+
+        # The chart stays discoverable through the chart-list access filter
+        # (ChartFilter._apply_viewers) that matches by Slice.perm.
+        with (
+            patch("superset.charts.filters.get_user_id", return_value=None),
+            patch.object(
+                security_manager, "user_view_menu_names", 
return_value={new_perm}
+            ),
+            patch.object(security_manager, "get_accessible_databases", 
return_value=[]),
+        ):
+            filt: ChartFilter = ChartFilter.__new__(ChartFilter)
+            filt.model = Slice
+            visible = filt._apply_viewers(db.session.query(Slice)).all()
+            assert chart.id in {slc.id for slc in visible}
+    finally:
+        db.session.rollback()
+
+
+def test_chart_filter_no_viewer_semantic_view_layer_perm_grants_visibility(
+    app: Any,
+) -> None:
+    """A datasource_access grant on the parent layer makes a no-viewer
+    semantic-view chart discoverable through the chart-list access filter
+    (mirrors SemanticView.raise_for_access)."""
+    from superset import security_manager
+    from superset.charts.filters import ChartFilter
+    from superset.extensions import db
+    from superset.models.slice import Slice
+    from superset.utils.core import DatasourceType
+
+    layer = SemanticLayer()
+    layer.name = "Layer Grant Layer"
+    layer.uuid = uuid.UUID("cccc1111-2222-3333-4444-555566667777")
+    layer.type = "test"
+
+    view = SemanticView()
+    view.name = "Layer Grant View"
+    view.semantic_layer_uuid = layer.uuid
+
+    db.session.add(layer)
+    db.session.add(view)
+    db.session.flush()
+
+    chart = Slice(
+        slice_name="On the layer-granted view",
+        datasource_type=DatasourceType.SEMANTIC_VIEW,
+        datasource_id=view.id,
+        datasource_name="Layer Grant View",
+        viz_type="table",
+        params="{}",
+    )
+    db.session.add(chart)
+    db.session.flush()
+
+    try:
+        # The insert listener stamps the computed perms on flush.
+        layer_perm = layer.perm
+        assert layer_perm
+        assert chart.perm == view.perm
+
+        with (
+            patch("superset.charts.filters.get_user_id", return_value=None),
+            patch.object(
+                security_manager, "user_view_menu_names", 
return_value={layer_perm}
+            ),
+            patch.object(security_manager, "get_accessible_databases", 
return_value=[]),
+        ):
+            filt: ChartFilter = ChartFilter.__new__(ChartFilter)
+            filt.model = Slice
+            visible = filt._apply_viewers(db.session.query(Slice)).all()
+            assert chart.id in {slc.id for slc in visible}
+    finally:
+        db.session.rollback()
+
+
+def test_chart_filter_no_viewer_semantic_view_unrelated_perm_denies_visibility(
+    app: Any,
+) -> None:
+    """An unrelated datasource_access grant does not expose a no-viewer
+    semantic-view chart through the chart-list access filter."""
+    from superset import security_manager
+    from superset.charts.filters import ChartFilter
+    from superset.extensions import db
+    from superset.models.slice import Slice
+    from superset.utils.core import DatasourceType
+
+    layer = SemanticLayer()
+    layer.name = "Unrelated Perm Layer"
+    layer.uuid = uuid.UUID("dddd1111-2222-3333-4444-555566667777")
+    layer.type = "test"
+
+    view = SemanticView()
+    view.name = "Unrelated Perm View"
+    view.semantic_layer_uuid = layer.uuid
+
+    db.session.add(layer)
+    db.session.add(view)
+    db.session.flush()
+
+    chart = Slice(
+        slice_name="On the unrelated-perm view",
+        datasource_type=DatasourceType.SEMANTIC_VIEW,
+        datasource_id=view.id,
+        datasource_name="Unrelated Perm View",
+        viz_type="table",
+        params="{}",
+    )
+    db.session.add(chart)
+    db.session.flush()
+
+    try:
+        with (
+            patch("superset.charts.filters.get_user_id", return_value=None),
+            patch.object(
+                security_manager,
+                "user_view_menu_names",
+                return_value={"[someone][else](id:98765)"},
+            ),
+            patch.object(security_manager, "get_accessible_databases", 
return_value=[]),
+        ):
+            filt: ChartFilter = ChartFilter.__new__(ChartFilter)
+            filt.model = Slice
+            visible = filt._apply_viewers(db.session.query(Slice)).all()
+            assert chart.id not in {slc.id for slc in visible}
+    finally:
+        db.session.rollback()
+
+
 def test_semantic_layer_after_delete_calls_security_manager() -> None:
     """Test SemanticLayer.after_delete delegates to security manager."""
     from superset import security_manager
@@ -1659,6 +1834,56 @@ def 
test_semantic_layer_rename_cascades_to_view_perms(app: Any) -> None:
         db.session.rollback()
 
 
+def test_semantic_layer_rename_cascades_to_slice_perms(app: Any) -> None:
+    """Renaming a layer updates dependent charts' denormalized perm.
+
+    The chart-list access filter matches no-viewer semantic-view charts on
+    ``Slice.perm`` (mirroring ``set_related_perm``), so a layer rename that
+    rewrites the view perms must also rewrite the perm of charts pinned to
+    those views or the charts lose list visibility for entitled users.
+    """
+    from superset.extensions import db
+    from superset.models.slice import Slice
+    from superset.utils.core import DatasourceType
+
+    layer = SemanticLayer()
+    layer.name = "Old Slice Layer"
+    layer.uuid = uuid.UUID("dddd1111-2222-3333-4444-555566667777")
+    layer.type = "test"
+
+    view = SemanticView()
+    view.name = "Slice View"
+    view.semantic_layer_uuid = layer.uuid
+
+    db.session.add(layer)
+    db.session.add(view)
+    db.session.flush()
+
+    chart = Slice(
+        slice_name="On cascade view",
+        datasource_type=DatasourceType.SEMANTIC_VIEW,
+        datasource_id=view.id,
+        datasource_name="Slice View",
+        viz_type="table",
+        params="{}",
+    )
+    db.session.add(chart)
+    db.session.flush()
+
+    assert chart.perm == view.perm
+
+    try:
+        layer.name = "New Slice Layer"
+        db.session.flush()
+
+        # Cascade update is via raw SQL, so refresh the ORM objects
+        db.session.refresh(view)
+        db.session.refresh(chart)
+        assert chart.perm == f"[New Slice Layer].[Slice View](id:{view.id})"
+    finally:
+        db.session.rollback()
+
+
 # =============================================================================
 # build_semantic_view_query dual perm tests
 # =============================================================================
diff --git a/tests/unit_tests/subjects/test_raise_for_access.py 
b/tests/unit_tests/subjects/test_raise_for_access.py
index 7c8666b3aa4..7e1f131a44d 100644
--- a/tests/unit_tests/subjects/test_raise_for_access.py
+++ b/tests/unit_tests/subjects/test_raise_for_access.py
@@ -267,6 +267,128 @@ def 
test_raise_for_access_chart_editor_allows(app_context):
         sm.raise_for_access(chart=chart)
 
 
+def _make_real_slice_chart(*, kind: str):
+    """Build a real Slice whose ``datasource`` resolves through the real
+    ``table`` / ``semantic_view`` relationships (no MagicMock of the 
property)."""
+    from superset.connectors.sqla.models import SqlaTable
+    from superset.models.slice import Slice
+    from superset.semantic_layers.models import SemanticView
+    from superset.utils.core import DatasourceType
+
+    chart = Slice()
+    chart.datasource_id = 1
+    chart.viewers = []
+    chart.editors = []
+
+    if kind == DatasourceType.SEMANTIC_VIEW:
+        chart.datasource_type = DatasourceType.SEMANTIC_VIEW
+        view = MagicMock(spec=SemanticView)
+        view.id = 1
+        view.perm = "[layer].[view](id:1)"
+        view.schema_perm = None
+        view.catalog_perm = None
+        chart.semantic_view = view
+    else:
+        chart.datasource_type = DatasourceType.TABLE
+        table = MagicMock(spec=SqlaTable)
+        table.id = 1
+        table.perm = "[db].[table](id:1)"
+        table.schema_perm = None
+        table.catalog_perm = None
+        chart.table = table
+
+    return chart
+
+
+def test_raise_for_access_semantic_view_chart_allows_with_datasource_access(
+    app_context,
+):
+    """A semantic-view chart with no viewers resolves its datasource through 
the
+    ``semantic_view`` relationship and is granted when the user holds the 
view's
+    ``datasource_access`` perm."""
+    sm = _make_sm()
+    chart = _make_real_slice_chart(kind="semantic_view")
+
+    with (
+        patch.object(sm, "is_admin", return_value=False),
+        patch.object(sm, "is_editor", return_value=False),
+        patch.object(sm, "is_viewer", return_value=False),
+        patch.object(sm, "is_guest_user", return_value=False),
+        patch.object(sm, "can_access", return_value=True),
+        patch("superset.is_feature_enabled", return_value=False),
+    ):
+        assert chart.resolved_datasource is chart.semantic_view
+        sm.raise_for_access(chart=chart)
+
+
+def test_raise_for_access_semantic_view_chart_denied_without_datasource_access(
+    app_context,
+):
+    """A semantic-view chart with no viewers is denied when the user does not
+    hold the view's ``datasource_access`` perm."""
+    sm = _make_sm()
+    chart = _make_real_slice_chart(kind="semantic_view")
+
+    with (
+        patch.object(sm, "is_admin", return_value=False),
+        patch.object(sm, "is_editor", return_value=False),
+        patch.object(sm, "is_viewer", return_value=False),
+        patch.object(sm, "is_guest_user", return_value=False),
+        patch.object(sm, "can_access", return_value=False),
+        patch.object(sm, "can_access_schema", return_value=False),
+        patch.object(sm, "get_chart_access_error_object", 
return_value=MagicMock()),
+        patch("superset.is_feature_enabled", return_value=False),
+    ):
+        with pytest.raises(SupersetSecurityException):
+            sm.raise_for_access(chart=chart)
+
+
+def test_raise_for_access_semantic_view_chart_editor_allows(app_context):
+    """An editor of the chart is granted regardless of datasource perms."""
+    sm = _make_sm()
+    chart = _make_real_slice_chart(kind="semantic_view")
+
+    with (
+        patch.object(sm, "is_admin", return_value=False),
+        patch.object(sm, "is_editor", side_effect=lambda r: r is chart),
+        patch.object(sm, "is_guest_user", return_value=False),
+        patch("superset.is_feature_enabled", return_value=False),
+    ):
+        sm.raise_for_access(chart=chart)
+
+
+def test_raise_for_access_semantic_view_chart_admin_allows(app_context):
+    """Admin is granted regardless of datasource perms."""
+    sm = _make_sm()
+    chart = _make_real_slice_chart(kind="semantic_view")
+
+    with (
+        patch.object(sm, "is_admin", return_value=True),
+        patch.object(sm, "is_editor", return_value=False),
+        patch.object(sm, "is_guest_user", return_value=False),
+        patch("superset.is_feature_enabled", return_value=False),
+    ):
+        sm.raise_for_access(chart=chart)
+
+
+def test_raise_for_access_table_chart_resolution_unchanged(app_context):
+    """A table chart still resolves through the ``table`` relationship and is
+    granted via its table perm; the semantic-view path is not consulted."""
+    sm = _make_sm()
+    chart = _make_real_slice_chart(kind="table")
+
+    with (
+        patch.object(sm, "is_admin", return_value=False),
+        patch.object(sm, "is_editor", return_value=False),
+        patch.object(sm, "is_viewer", return_value=False),
+        patch.object(sm, "is_guest_user", return_value=False),
+        patch.object(sm, "can_access", return_value=True),
+        patch("superset.is_feature_enabled", return_value=False),
+    ):
+        assert chart.datasource is chart.table
+        sm.raise_for_access(chart=chart)
+
+
 # -- Datasource chart-viewer promiscuous mode tests --
 
 

Reply via email to