This is an automated email from the ASF dual-hosted git repository.

aminghadersohi 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 e229ea4a543 fix(mcp): resolve saved semantic-view chart sources by 
type (#45067)
e229ea4a543 is described below

commit e229ea4a543546b94bc3e751f611109d111cc8db
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 17:55:54 2026 -0600

    fix(mcp): resolve saved semantic-view chart sources by type (#45067)
    
    Co-authored-by: Mike Bridge <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 docs/admin_docs/configuration/mcp-server.mdx       |   8 +-
 superset/mcp_service/auth.py                       |  13 +-
 superset/mcp_service/chart/chart_utils.py          |  47 ++-
 superset/mcp_service/chart/tool/get_chart_data.py  |  17 +-
 superset/mcp_service/chart/tool/get_chart_info.py  |  12 +-
 superset/mcp_service/chart/tool/update_chart.py    |  26 +-
 .../chart/tool/test_saved_chart_datasource.py      | 422 +++++++++++++++++++++
 7 files changed, 531 insertions(+), 14 deletions(-)

diff --git a/docs/admin_docs/configuration/mcp-server.mdx 
b/docs/admin_docs/configuration/mcp-server.mdx
index 0ad3774e7f9..3ab2485cb2f 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1413,8 +1413,12 @@ Both fields are redacted for users without data-model 
metadata access.
 Chart and dashboard chart summaries resolve dataset names from the live table
 or semantic view, so renames are reflected without resaving the chart. Query 
and
 saved-query charts retain their stored datasource names; missing tables or
-semantic views do not fall back to stale names. Dataset-access validation 
remains
-unchanged.
+semantic views do not fall back to stale names. Saved-chart `get_chart_data`
+and `get_chart_info` prechecks, and the chart access checks in
+`add_chart_to_existing_dashboard`, `generate_dashboard`, and `update_chart`,
+resolve semantic-view sources by type and ID, then check access before
+continuing. `update_chart` then refuses semantic-view charts, which it cannot
+yet edit. SQL-dataset charts retain their existing dataset validation.
 
 `list_charts` filters on `datasource_name` use these same live names before
 pagination and counting. For tables with a schema, filter using
diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py
index a4ee569cf54..746e9044cb4 100644
--- a/superset/mcp_service/auth.py
+++ b/superset/mcp_service/auth.py
@@ -949,14 +949,23 @@ def check_chart_data_access(chart: Any) -> 
"DatasetValidationResult":
     permissions before tool execution; this function enforces data-level
     permissions inside tools after retrieving specific objects.
 
+    Semantic views share the numeric id space with datasets, so the source is
+    resolved by ``datasource_type``, never by id alone.
+
     Args:
-        chart: A Slice ORM object with datasource_id attribute
+        chart: A Slice ORM object with datasource_id and datasource_type
 
     Returns:
         DatasetValidationResult with is_valid, error, etc.
     """
-    from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+    from superset.mcp_service.chart.chart_utils import (
+        validate_chart_dataset,
+        validate_chart_semantic_view,
+    )
+    from superset.utils.core import DatasourceType
 
+    if getattr(chart, "datasource_type", None) == 
DatasourceType.SEMANTIC_VIEW.value:
+        return validate_chart_semantic_view(chart.datasource_id, 
check_access=True)
     return validate_chart_dataset(chart.datasource_id, check_access=True)
 
 
diff --git a/superset/mcp_service/chart/chart_utils.py 
b/superset/mcp_service/chart/chart_utils.py
index 0ed9852e7f4..da9b7f1bf4f 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -26,15 +26,18 @@ import hashlib
 import logging
 from collections.abc import Mapping
 from dataclasses import dataclass
-from typing import Any, Dict, TYPE_CHECKING
+from typing import Any, cast, Dict, TYPE_CHECKING
 
 from pydantic import TypeAdapter, ValidationError
 from sqlalchemy.exc import SQLAlchemyError
 
 if TYPE_CHECKING:
     from superset.connectors.sqla.models import SqlaTable
+    from superset.semantic_layers.models import SemanticView
 
 from superset.constants import NO_TIME_RANGE
+from superset.daos.exceptions import DatasourceNotFound, 
DatasourceValueIsIncorrect
+from superset.exceptions import SupersetSecurityException
 from superset.mcp_service.chart.schemas import (
     BigNumberChartConfig,
     BoxPlotChartConfig,
@@ -68,7 +71,7 @@ from superset.mcp_service.chart.validation.dataset_validator 
import (
 from superset.mcp_service.common.error_schemas import DatasetContext
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
-from superset.utils.core import FilterOperator
+from superset.utils.core import DatasourceType, FilterOperator
 
 logger = logging.getLogger(__name__)
 
@@ -89,6 +92,46 @@ class DatasetValidationResult:
     error: str | None = None
 
 
+def validate_chart_semantic_view(
+    datasource_id: int | None,
+    check_access: bool = True,
+) -> DatasetValidationResult:
+    """Resolve and authorize a saved semantic source before ordinary chart 
reads.
+
+    With ``check_access=False`` (embedded guests) only existence is checked: a
+    guest is authorized later by the dashboard-scoped query check, which also
+    refuses guest row-level security on semantic views. Callers passing False
+    must run that check (``ChartDataCommand.validate()``) before executing.
+    """
+    # Avoid app-init regression: DatasourceDAO imports Database encrypted 
columns.
+    from superset.daos.datasource import DatasourceDAO
+
+    if datasource_id is not None:
+        try:
+            view: SemanticView = cast(
+                "SemanticView",
+                DatasourceDAO.get_datasource(
+                    DatasourceType.SEMANTIC_VIEW, datasource_id
+                ),
+            )
+            if check_access:
+                view.raise_for_access()
+            return DatasetValidationResult(True, datasource_id, view.name, [])
+        except (
+            DatasourceNotFound,
+            DatasourceValueIsIncorrect,
+            SupersetSecurityException,
+        ):
+            pass
+    return DatasetValidationResult(
+        False,
+        datasource_id,
+        None,
+        [],
+        error="Semantic view not found or not accessible.",
+    )
+
+
 def validate_chart_dataset(
     datasource_id: int | None,
     check_access: bool = True,
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py 
b/superset/mcp_service/chart/tool/get_chart_data.py
index ded6b7f08ee..38ae16bdf96 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -57,7 +57,11 @@ from superset.mcp_service.chart.chart_helpers import (
     rejected_requested_filter_columns,
     resolve_form_data_datasource,
 )
-from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.chart_utils import (
+    DatasetValidationResult,
+    validate_chart_dataset,
+    validate_chart_semantic_view,
+)
 from superset.mcp_service.chart.query_result import (
     normalize_chart_query_result,
     null_data_is_empty,
@@ -86,7 +90,7 @@ from superset.mcp_service.utils.response_utils import (
     format_data_columns,
     format_data_quality,
 )
-from superset.utils.core import GenericDataType
+from superset.utils.core import DatasourceType, GenericDataType
 from superset.utils.json import JSONDecodeError
 
 logger = logging.getLogger(__name__)
@@ -552,10 +556,13 @@ async def _get_chart_data(  # noqa: C901
         logger.info("Getting data for chart %s: %s", chart_id, chart_name)
 
         # Guests skip the RBAC check (authorize_query covers it) but keep the
-        # existence check, so a deleted dataset still returns
+        # existence check, so a deleted dataset or semantic view still returns
         # DatasetNotAccessible.
-        validation_result = validate_chart_dataset(
-            chart_datasource_id, check_access=not guest_scope.is_guest_read()
+        check_access: bool = not guest_scope.is_guest_read()
+        validation_result: DatasetValidationResult = (
+            validate_chart_semantic_view(chart_datasource_id, 
check_access=check_access)
+            if chart_datasource_type == DatasourceType.SEMANTIC_VIEW.value
+            else validate_chart_dataset(chart_datasource_id, 
check_access=check_access)
         )
         if not validation_result.is_valid:
             await ctx.warning(
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py 
b/superset/mcp_service/chart/tool/get_chart_info.py
index d3503294378..1f94526fe60 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -43,7 +43,11 @@ from superset.mcp_service.chart.chart_helpers import (
     ChartNotOnDashboardError,
     get_cached_form_data,
 )
-from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.chart_utils import (
+    DatasetValidationResult,
+    validate_chart_dataset,
+    validate_chart_semantic_view,
+)
 from superset.mcp_service.chart.schemas import (
     ChartError,
     ChartFiltersInfo,
@@ -241,7 +245,11 @@ async def _validate_chart_dataset_access(
     chart = ChartDAO.find_by_id(result.id)
     if not chart:
         return None
-    validation_result = validate_chart_dataset(chart.datasource_id, 
check_access=True)
+    validation_result: DatasetValidationResult = (
+        validate_chart_semantic_view(chart.datasource_id)
+        if chart.datasource_type == DatasourceType.SEMANTIC_VIEW.value
+        else validate_chart_dataset(chart.datasource_id, check_access=True)
+    )
     if not validation_result.is_valid:
         await ctx.warning(
             "Chart found but dataset is not accessible: %s" % 
(validation_result.error,)
diff --git a/superset/mcp_service/chart/tool/update_chart.py 
b/superset/mcp_service/chart/tool/update_chart.py
index 2be172c02d3..eb94e20dd54 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -70,6 +70,7 @@ from superset.mcp_service.utils.oauth2_utils import (
 )
 from superset.mcp_service.utils.url_utils import get_superset_base_url
 from superset.utils import json
+from superset.utils.core import DatasourceType
 
 logger = logging.getLogger(__name__)
 
@@ -607,7 +608,6 @@ def _create_preview_url(
     """
     from superset.commands.explore.form_data.parameters import 
CommandParameters
     from superset.mcp_service.commands.create_form_data import 
MCPCreateFormDataCommand
-    from superset.utils.core import DatasourceType
 
     base_url = get_superset_base_url()
 
@@ -787,6 +787,30 @@ async def update_chart(  # noqa: C901
                 }
             )
 
+        if (
+            getattr(chart, "datasource_type", None)
+            == DatasourceType.SEMANTIC_VIEW.value
+        ):
+            # The update path builds table-typed form data from datasource_id,
+            # which would rebind the chart to a same-id table.
+            unsupported_msg: str = (
+                f"Chart {chart.id} uses a semantic view; update_chart supports 
"
+                "only dataset-backed charts."
+            )
+            return GenerateChartResponse.model_validate(
+                {
+                    "chart": None,
+                    "error": {
+                        "error_type": "UnsupportedDatasourceType",
+                        "message": unsupported_msg,
+                        "details": unsupported_msg,
+                    },
+                    "success": False,
+                    "schema_version": "2.0",
+                    "api_version": "v1",
+                }
+            )
+
         updated_chart: Any = None
         explore_url: str
         form_data_key: str | None = None
diff --git 
a/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py 
b/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py
new file mode 100644
index 00000000000..90cb1b64f6f
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/tool/test_saved_chart_datasource.py
@@ -0,0 +1,422 @@
+# 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.
+
+"""Saved-chart reads resolve semantic and SQL sources without changing query 
work."""
+
+import importlib
+from contextlib import nullcontext
+from types import ModuleType, SimpleNamespace
+from typing import Any
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+from fastmcp.client.client import CallToolResult
+from mcp.types import TextContent
+
+from superset.daos.exceptions import DatasourceNotFound
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service import guest_scope
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.chart_utils import DatasetValidationResult
+from superset.mcp_service.chart.schemas import ChartInfo
+from superset.semantic_layers.models import SemanticView
+from superset.utils import json
+from superset.utils.core import DatasourceType
+
+
[email protected]
[email protected]("tool_name", ["get_chart_data", "get_chart_info"])
[email protected](
+    "source_type, outcome",
+    [
+        ("semantic_view", "allowed"),
+        ("table", "allowed"),
+        ("semantic_view", "missing"),
+        ("semantic_view", "denied"),
+    ],
+)
+async def test_saved_chart_reads_use_the_source_type(
+    tool_name: str, source_type: str, outcome: str
+) -> None:
+    """A saved semantic chart must pass the same read boundary as a SQL 
chart."""
+    data_module: ModuleType = importlib.import_module(
+        "superset.mcp_service.chart.tool.get_chart_data"
+    )
+    info_module: ModuleType = importlib.import_module(
+        "superset.mcp_service.chart.tool.get_chart_info"
+    )
+    chart: SimpleNamespace = SimpleNamespace(
+        id=9,
+        slice_name="Sales",
+        viz_type="table",
+        datasource_id=17,
+        datasource_type=source_type,
+        params=json.dumps({"viz_type": "table", "datasource": 
f"17__{source_type}"}),
+        query_context=json.dumps(
+            {"datasource": {"id": 17, "type": source_type}, "queries": []}
+        ),
+    )
+    info: ChartInfo = ChartInfo(
+        id=9,
+        slice_name="Sales",
+        viz_type="table",
+        datasource_id=17,
+        datasource_type=source_type,
+    )
+    view: SemanticView = SemanticView(id=17, name="Sales")
+    dataset: SimpleNamespace = SimpleNamespace(id=17, table_name="Sales", 
sql=None)
+    query_context: SimpleNamespace = SimpleNamespace(queries=[], form_data={})
+    command: Mock = Mock()
+    command.run.return_value = {
+        "queries": [
+            {
+                "data": [{"sales": 7}],
+                "colnames": ["sales"],
+                "coltypes": [0],
+                "rowcount": 1,
+            }
+        ]
+    }
+    table_lookup: Mock
+    view_lookup: Mock
+    view_access: Mock
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1, username="admin"),
+        ),
+        patch("superset.mcp_service.auth.check_tool_permission", 
return_value=True),
+        patch("superset.mcp_service.auth.has_dataset_access", 
return_value=True),
+        patch("superset.mcp_service.guest_scope.is_guest_read", 
return_value=False),
+        patch("superset.mcp_service.guest_scope.guest_dashboard_id", 
return_value=None),
+        patch.object(
+            data_module.event_logger, "log_context", return_value=nullcontext()
+        ),
+        patch.object(data_module, "find_chart_by_identifier", 
return_value=chart),
+        patch.object(info_module.ModelGetInfoCore, "run_tool", 
return_value=info),
+        patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+        patch(
+            "superset.daos.dataset.DatasetDAO.find_by_id",
+            return_value=dataset if source_type == "table" else None,
+        ) as table_lookup,
+        patch(
+            "superset.daos.datasource.DatasourceDAO.get_datasource",
+            return_value=view,
+            side_effect=DatasourceNotFound() if outcome == "missing" else None,
+        ) as view_lookup,
+        patch.object(
+            SemanticView,
+            "raise_for_access",
+            side_effect=SupersetSecurityException(
+                SupersetError(
+                    message="private source detail",
+                    
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+                    level=ErrorLevel.ERROR,
+                )
+            )
+            if outcome == "denied"
+            else None,
+        ) as view_access,
+        patch(
+            "superset.charts.schemas.ChartDataQueryContextSchema.load",
+            return_value=query_context,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        async with Client(mcp) as client:
+            response: CallToolResult = await client.call_tool(
+                tool_name, {"request": {"identifier": "9"}}
+            )
+    assert isinstance(response.content[0], TextContent)
+    result: dict[str, Any] = json.loads(response.content[0].text)
+    if outcome != "allowed":
+        assert result["error_type"] == "DatasetNotAccessible"
+        assert "private source detail" not in result["error"]
+        command.run.assert_not_called()
+        view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+        table_lookup.assert_not_called()
+        if outcome == "missing":
+            view_access.assert_not_called()
+        else:
+            view_access.assert_called_once()
+        return
+    assert "error_type" not in result, result
+    if tool_name == "get_chart_data":
+        assert result["data"] == [{"sales": 7}]
+        command.validate.assert_called_once()
+    else:
+        assert result["slice_name"] == "Sales"
+        command.run.assert_not_called()
+    if source_type == "semantic_view":
+        view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+        view_access.assert_called_once()
+        table_lookup.assert_not_called()
+    else:
+        table_lookup.assert_called_once_with(17, skip_base_filter=False)
+        view_lookup.assert_not_called()
+
+
[email protected]
[email protected]("outcome", ["allowed", "rls_refused", "missing"])
+async def test_guest_semantic_chart_data_uses_dashboard_authorization(
+    outcome: str,
+) -> None:
+    """A guest reads a semantic chart through the dashboard-scoped query check,
+    like a table chart, instead of the semantic view's direct grants."""
+    data_module: ModuleType = importlib.import_module(
+        "superset.mcp_service.chart.tool.get_chart_data"
+    )
+    chart: SimpleNamespace = SimpleNamespace(
+        id=9,
+        slice_name="Sales",
+        viz_type="table",
+        datasource_id=17,
+        datasource_type="semantic_view",
+        params=json.dumps({"viz_type": "table", "datasource": 
"17__semantic_view"}),
+        query_context=json.dumps(
+            {"datasource": {"id": 17, "type": "semantic_view"}, "queries": []}
+        ),
+    )
+    view: SemanticView = SemanticView(id=17, name="Sales")
+    query_context: SimpleNamespace = SimpleNamespace(queries=[], form_data={})
+    command: Mock = Mock()
+    command.run.return_value = {
+        "queries": [
+            {
+                "data": [{"sales": 7}],
+                "colnames": ["sales"],
+                "coltypes": [0],
+                "rowcount": 1,
+            }
+        ]
+    }
+    # The query-context check refuses guest row-level security on semantic
+    # views (raise_for_unsupported_guest_rls via command.validate()).
+    refusal: SupersetSecurityException = SupersetSecurityException(
+        SupersetError(
+            message="Semantic views cannot enforce guest row-level security 
rules.",
+            error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+            level=ErrorLevel.WARNING,
+        )
+    )
+    if outcome == "rls_refused":
+        command.validate.side_effect = refusal
+    view_lookup: Mock
+    view_access: Mock
+    authorize_query: Mock
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=None, username="guest"),
+        ),
+        patch("superset.mcp_service.auth.check_tool_permission", 
return_value=True),
+        patch("superset.mcp_service.guest_scope.is_guest_read", 
return_value=True),
+        patch("superset.mcp_service.guest_scope.guest_dashboard_id", 
return_value=5),
+        patch(
+            "superset.mcp_service.guest_scope.authorize_query",
+            wraps=guest_scope.authorize_query,
+        ) as authorize_query,
+        patch.object(
+            data_module.event_logger, "log_context", return_value=nullcontext()
+        ),
+        patch.object(data_module, "find_chart_by_identifier", 
return_value=chart),
+        patch(
+            "superset.daos.datasource.DatasourceDAO.get_datasource",
+            return_value=view,
+            side_effect=DatasourceNotFound() if outcome == "missing" else None,
+        ) as view_lookup,
+        patch.object(
+            SemanticView,
+            "raise_for_access",
+            side_effect=SupersetSecurityException(
+                SupersetError(
+                    message="no direct grant",
+                    
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+                    level=ErrorLevel.ERROR,
+                )
+            ),
+        ) as view_access,
+        patch(
+            "superset.charts.schemas.ChartDataQueryContextSchema.load",
+            return_value=query_context,
+        ),
+        patch(
+            "superset.commands.chart.data.get_data_command.ChartDataCommand",
+            return_value=command,
+        ),
+    ):
+        async with Client(mcp) as client:
+            response: CallToolResult = await client.call_tool(
+                "get_chart_data", {"request": {"identifier": "9"}}
+            )
+    assert isinstance(response.content[0], TextContent)
+    result: dict[str, Any] = json.loads(response.content[0].text)
+    view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+    view_access.assert_not_called()
+    if outcome == "missing":
+        assert result["error_type"] == "DatasetNotAccessible"
+        authorize_query.assert_not_called()
+        command.run.assert_not_called()
+        return
+    authorize_query.assert_called_once()
+    assert query_context.form_data == {"dashboardId": 5, "slice_id": 9}
+    command.validate.assert_called_once()
+    if outcome == "rls_refused":
+        assert "error_type" in result, result
+        command.run.assert_not_called()
+        return
+    assert "error_type" not in result, result
+    assert result["data"] == [{"sales": 7}]
+
+
[email protected](
+    "source_type, outcome",
+    [
+        ("semantic_view", "allowed"),
+        ("semantic_view", "missing"),
+        ("semantic_view", "denied"),
+        ("table", "allowed"),
+    ],
+)
+def test_chart_data_access_check_uses_the_source_type(
+    source_type: str, outcome: str
+) -> None:
+    """The shared check behind add_chart_to_existing_dashboard, 
generate_dashboard
+    and update_chart must not authorize a semantic chart against a same-id 
table."""
+    from superset.mcp_service.auth import check_chart_data_access
+
+    chart: SimpleNamespace = SimpleNamespace(
+        id=9, datasource_id=17, datasource_type=source_type
+    )
+    view: SemanticView = SemanticView(id=17, name="Sales")
+    dataset: SimpleNamespace = SimpleNamespace(id=17, table_name="Sales", 
sql=None)
+    table_lookup: Mock
+    view_lookup: Mock
+    view_access: Mock
+    with (
+        patch("superset.mcp_service.auth.has_dataset_access", 
return_value=True),
+        patch(
+            "superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset
+        ) as table_lookup,
+        patch(
+            "superset.daos.datasource.DatasourceDAO.get_datasource",
+            return_value=view,
+            side_effect=DatasourceNotFound() if outcome == "missing" else None,
+        ) as view_lookup,
+        patch.object(
+            SemanticView,
+            "raise_for_access",
+            side_effect=SupersetSecurityException(
+                SupersetError(
+                    message="private source detail",
+                    
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+                    level=ErrorLevel.ERROR,
+                )
+            )
+            if outcome == "denied"
+            else None,
+        ) as view_access,
+    ):
+        result: DatasetValidationResult = check_chart_data_access(chart)
+    assert result.is_valid is (outcome == "allowed")
+    if source_type == "table":
+        table_lookup.assert_called_once_with(17, skip_base_filter=False)
+        view_lookup.assert_not_called()
+        return
+    table_lookup.assert_not_called()
+    view_lookup.assert_called_once_with(DatasourceType.SEMANTIC_VIEW, 17)
+    if outcome == "missing":
+        view_access.assert_not_called()
+    else:
+        view_access.assert_called_once()
+    if outcome != "allowed":
+        assert result.error is not None
+        assert "private source detail" not in result.error
+
+
[email protected]
[email protected]("outcome", ["allowed", "denied"])
+async def test_update_chart_refuses_saved_semantic_charts(outcome: str) -> 
None:
+    """update_chart builds table-typed form data, so an authorized semantic 
chart
+    is refused rather than rebound to a same-id table; denial stays uniform."""
+    update_module: ModuleType = importlib.import_module(
+        "superset.mcp_service.chart.tool.update_chart"
+    )
+    chart: SimpleNamespace = SimpleNamespace(
+        id=9,
+        slice_name="Sales",
+        viz_type="table",
+        datasource_id=17,
+        datasource_type="semantic_view",
+        params=json.dumps({"viz_type": "table", "datasource": 
"17__semantic_view"}),
+    )
+    view: SemanticView = SemanticView(id=17, name="Sales")
+    table_lookup: Mock
+    update_command: Mock
+    with (
+        patch(
+            "superset.mcp_service.auth.get_user_from_request",
+            return_value=Mock(id=1, username="admin"),
+        ),
+        patch("superset.mcp_service.auth.check_tool_permission", 
return_value=True),
+        patch.object(
+            update_module.event_logger, "log_context", 
return_value=nullcontext()
+        ),
+        patch.object(update_module, "find_chart_by_identifier", 
return_value=chart),
+        patch(
+            "superset.daos.dataset.DatasetDAO.find_by_id", return_value=None
+        ) as table_lookup,
+        patch(
+            "superset.daos.datasource.DatasourceDAO.get_datasource",
+            return_value=view,
+        ),
+        patch.object(
+            SemanticView,
+            "raise_for_access",
+            side_effect=SupersetSecurityException(
+                SupersetError(
+                    message="private source detail",
+                    
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+                    level=ErrorLevel.ERROR,
+                )
+            )
+            if outcome == "denied"
+            else None,
+        ),
+        patch("superset.commands.chart.update.UpdateChartCommand") as 
update_command,
+    ):
+        async with Client(mcp) as client:
+            response: CallToolResult = await client.call_tool(
+                "update_chart",
+                {"request": {"identifier": 9, "chart_name": "Renamed"}},
+            )
+    result: dict[str, Any] = response.structured_content or {}
+    assert result["success"] is False, result
+    error: dict[str, Any] = result["error"]
+    table_lookup.assert_not_called()
+    update_command.assert_not_called()
+    if outcome == "denied":
+        assert error["error_type"] == "DatasetNotAccessible"
+        assert "private source detail" not in error["message"]
+    else:
+        assert error["error_type"] == "UnsupportedDatasourceType"
+        assert "semantic view" in error["message"]

Reply via email to