You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ri...@apache.org on 2026/04/24 20:37:23 UTC
(superset) branch master updated: fix(mcp): redact dashboard data model metadata (#39632)
This is an automated email from the ASF dual-hosted git repository.
richardfogaca 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 57e563b1771 fix(mcp): redact dashboard data model metadata (#39632)
57e563b1771 is described below
commit 57e563b177122e5e58dd7bb9a47b24c5e5558302
Author: Richard Fogaca Nienkotter <63...@users.noreply.github.com>
AuthorDate: Fri Apr 24 17:37:15 2026 -0300
fix(mcp): redact dashboard data model metadata (#39632)
---
superset/mcp_service/dashboard/schemas.py | 86 ++++++++---
.../tool/add_chart_to_existing_dashboard.py | 14 +-
.../dashboard/tool/generate_dashboard.py | 14 +-
.../dashboard/tool/get_dashboard_info.py | 9 +-
superset/mcp_service/privacy.py | 2 +
.../dashboard/test_dashboard_schemas.py | 154 ++++++++++++++++++-
.../dashboard/tool/test_dashboard_generation.py | 78 ++++++++++
.../dashboard/tool/test_dashboard_tools.py | 169 +++++++++++++++++++++
8 files changed, 494 insertions(+), 32 deletions(-)
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index 5ca06beca5e..9c50258b9ab 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -86,7 +86,10 @@ if TYPE_CHECKING:
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
-from superset.mcp_service.privacy import filter_user_directory_fields
+from superset.mcp_service.privacy import (
+ filter_user_directory_fields,
+ user_can_view_data_model_metadata,
+)
from superset.mcp_service.system.schemas import (
PaginationInfo,
RoleInfo,
@@ -353,8 +356,8 @@ class DashboardInfo(BaseModel):
default_factory=list,
description=(
"Native filters configured on this dashboard. Extracted from "
- "json_metadata for LLM consumption — includes filter name, type, "
- "and target columns."
+ "json_metadata for LLM consumption. Includes filter name/type, "
+ "and target columns only when data-model metadata is allowed."
),
)
cross_filters_enabled: bool | None = Field(
@@ -386,7 +389,8 @@ class DashboardInfo(BaseModel):
description=(
"Filter state from permalink. Contains dataMask (native filter values), "
"activeTabs, anchor, and urlParams. When present, represents the actual "
- "filters the user has applied to the dashboard."
+ "filters the user has applied to the dashboard. For users without "
+ "data-model metadata access, dataMask and chartStates are omitted."
),
)
is_permalink_state: bool = Field(
@@ -609,12 +613,17 @@ def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None
return metadata
-def _extract_native_filters(json_metadata_str: str | None) -> List[NativeFilterSummary]:
+def _extract_native_filters(
+ json_metadata_str: str | None,
+ *,
+ include_data_model_metadata: bool = False,
+) -> List[NativeFilterSummary]:
"""Extract native filter summaries from raw json_metadata string.
Parses the json_metadata JSON blob and pulls out only the filter
- name, type, and targets — dropping verbose fields like controlValues,
- defaultDataMask, scope, and cascadeParentIds.
+ name, type, and optionally targets — dropping verbose fields like controlValues,
+ defaultDataMask, scope, and cascadeParentIds. Restricted users keep filter
+ names and types, but target columns and dataset IDs are data-model metadata.
"""
metadata = _parse_json_metadata(json_metadata_str)
if metadata is None:
@@ -631,7 +640,11 @@ def _extract_native_filters(json_metadata_str: str | None) -> List[NativeFilterS
raw_targets = f.get("targets", [])
if not isinstance(raw_targets, list):
raw_targets = []
- targets = [t for t in raw_targets if isinstance(t, dict)]
+ targets = (
+ [t for t in raw_targets if isinstance(t, dict)]
+ if include_data_model_metadata
+ else []
+ )
summaries.append(
NativeFilterSummary(
id=f.get("id"),
@@ -686,7 +699,11 @@ def _build_omitted_fields(
)
-def serialize_chart_summary(chart: Any) -> DashboardChartSummary | None:
+def serialize_chart_summary(
+ chart: Any,
+ *,
+ include_data_model_metadata: bool = False,
+) -> DashboardChartSummary | None:
"""Serialize a chart to a lightweight summary for dashboard context."""
if not chart:
return None
@@ -701,18 +718,34 @@ def serialize_chart_summary(chart: Any) -> DashboardChartSummary | None:
id=chart_id,
slice_name=getattr(chart, "slice_name", None),
viz_type=getattr(chart, "viz_type", None),
- datasource_name=getattr(chart, "datasource_name", None),
+ datasource_name=getattr(chart, "datasource_name", None)
+ if include_data_model_metadata
+ else None,
url=chart_url,
description=getattr(chart, "description", None),
)
+def redact_filter_state_data_model_metadata(
+ filter_state: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Remove permalink filter state fields that expose data-model metadata."""
+ return {
+ key: value
+ for key, value in filter_state.items()
+ if key not in {"dataMask", "chartStates"}
+ }
+
+
def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
from superset.mcp_service.utils.url_utils import get_superset_base_url
+ include_data_model_metadata = user_can_view_data_model_metadata()
base_url = get_superset_base_url()
relative_url = dashboard.url # e.g. "/superset/dashboard/{slug_or_id}/"
absolute_url = f"{base_url}{relative_url}" if relative_url else None
+ json_metadata_str = getattr(dashboard, "json_metadata", None)
+ position_json_str = getattr(dashboard, "position_json", None)
return DashboardInfo(
id=dashboard.id,
@@ -733,14 +766,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
changed_on_humanized=dashboard.changed_on_humanized,
chart_count=len(dashboard.slices) if dashboard.slices else 0,
native_filters=_extract_native_filters(
- getattr(dashboard, "json_metadata", None)
- ),
- cross_filters_enabled=_extract_cross_filters_enabled(
- getattr(dashboard, "json_metadata", None)
+ json_metadata_str,
+ include_data_model_metadata=include_data_model_metadata,
),
+ cross_filters_enabled=_extract_cross_filters_enabled(json_metadata_str),
omitted_fields=_build_omitted_fields(
- getattr(dashboard, "json_metadata", None),
- getattr(dashboard, "position_json", None),
+ json_metadata_str,
+ position_json_str,
),
tags=[
TagInfo.model_validate(tag, from_attributes=True) for tag in dashboard.tags
@@ -750,7 +782,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
charts=[
summary
for chart in dashboard.slices
- if (summary := serialize_chart_summary(chart)) is not None
+ if (
+ summary := serialize_chart_summary(
+ chart,
+ include_data_model_metadata=include_data_model_metadata,
+ )
+ )
+ is not None
]
if dashboard.slices
else [],
@@ -780,6 +818,7 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
json_metadata_str = getattr(dashboard, "json_metadata", None)
position_json_str = getattr(dashboard, "position_json", None)
+ include_data_model_metadata = user_can_view_data_model_metadata()
return DashboardInfo(
id=dashboard_id,
@@ -799,7 +838,10 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
css=getattr(dashboard, "css", None),
certified_by=getattr(dashboard, "certified_by", None),
certification_details=getattr(dashboard, "certification_details", None),
- native_filters=_extract_native_filters(json_metadata_str),
+ native_filters=_extract_native_filters(
+ json_metadata_str,
+ include_data_model_metadata=include_data_model_metadata,
+ ),
cross_filters_enabled=_extract_cross_filters_enabled(json_metadata_str),
omitted_fields=_build_omitted_fields(json_metadata_str, position_json_str),
is_managed_externally=getattr(dashboard, "is_managed_externally", None),
@@ -817,7 +859,13 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
charts=[
summary
for chart in getattr(dashboard, "slices", [])
- if (summary := serialize_chart_summary(chart)) is not None
+ if (
+ summary := serialize_chart_summary(
+ chart,
+ include_data_model_metadata=include_data_model_metadata,
+ )
+ )
+ is not None
]
if getattr(dashboard, "slices", None)
else [],
diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index a323c42d918..026275c9e4b 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -42,6 +42,7 @@ from superset.mcp_service.dashboard.schemas import (
DashboardInfo,
serialize_chart_summary,
)
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
@@ -531,6 +532,7 @@ def add_chart_to_existing_dashboard(
serialize_tag_object,
)
+ include_data_model_metadata = user_can_view_data_model_metadata()
dashboard_info = DashboardInfo(
id=updated_dashboard.id,
dashboard_title=updated_dashboard.dashboard_title,
@@ -539,22 +541,24 @@ def add_chart_to_existing_dashboard(
published=updated_dashboard.published,
created_on=updated_dashboard.created_on,
changed_on=updated_dashboard.changed_on,
- created_by=None,
- changed_by=None,
uuid=str(updated_dashboard.uuid) if updated_dashboard.uuid else None,
url=f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/",
chart_count=len(updated_dashboard.slices),
- owners=[],
tags=[
serialize_tag_object(tag)
for tag in getattr(updated_dashboard, "tags", [])
if serialize_tag_object(tag) is not None
],
- roles=[],
charts=[
obj
for chart in getattr(updated_dashboard, "slices", [])
- if (obj := serialize_chart_summary(chart)) is not None
+ if (
+ obj := serialize_chart_summary(
+ chart,
+ include_data_model_metadata=include_data_model_metadata,
+ )
+ )
+ is not None
],
)
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 8c0697aded8..968d5ca74f2 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -39,6 +39,7 @@ from superset.mcp_service.dashboard.schemas import (
GenerateDashboardRequest,
GenerateDashboardResponse,
)
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
@@ -405,6 +406,7 @@ def generate_dashboard( # noqa: C901
serialize_tag_object,
)
+ include_data_model_metadata = user_can_view_data_model_metadata()
dashboard_info = DashboardInfo(
id=dashboard.id,
dashboard_title=dashboard.dashboard_title,
@@ -413,22 +415,24 @@ def generate_dashboard( # noqa: C901
published=dashboard.published,
created_on=dashboard.created_on,
changed_on=dashboard.changed_on,
- created_by=None,
- changed_by=None,
uuid=str(dashboard.uuid) if dashboard.uuid else None,
url=f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/",
chart_count=len(request.chart_ids),
- owners=[],
tags=[
serialize_tag_object(tag)
for tag in getattr(dashboard, "tags", [])
if serialize_tag_object(tag) is not None
],
- roles=[], # Dashboard roles not typically set at creation
charts=[
obj
for chart in getattr(dashboard, "slices", [])
- if (obj := serialize_chart_summary(chart)) is not None
+ if (
+ obj := serialize_chart_summary(
+ chart,
+ include_data_model_metadata=include_data_model_metadata,
+ )
+ )
+ is not None
],
)
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
index 71e60279003..9c52bbc7f2d 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
@@ -37,8 +37,10 @@ from superset.mcp_service.dashboard.schemas import (
DashboardError,
DashboardInfo,
GetDashboardInfoRequest,
+ redact_filter_state_data_model_metadata,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
logger = logging.getLogger(__name__)
@@ -165,15 +167,20 @@ async def get_dashboard_info(
permalink_state = (
dict(raw_state) if isinstance(raw_state, dict) else {}
)
+ if not user_can_view_data_model_metadata():
+ permalink_state = redact_filter_state_data_model_metadata(
+ permalink_state
+ )
result.permalink_key = request.permalink_key
result.filter_state = permalink_state
result.is_permalink_state = True
await ctx.info(
"Filter state retrieved from permalink: "
- "has_dataMask=%s, has_activeTabs=%s"
+ "has_dataMask=%s, has_chartStates=%s, has_activeTabs=%s"
% (
"dataMask" in permalink_state,
+ "chartStates" in permalink_state,
"activeTabs" in permalink_state,
)
)
diff --git a/superset/mcp_service/privacy.py b/superset/mcp_service/privacy.py
index 74949c84f2c..25b34a5b6c8 100644
--- a/superset/mcp_service/privacy.py
+++ b/superset/mcp_service/privacy.py
@@ -106,6 +106,8 @@ def user_can_view_data_model_metadata() -> bool:
Dataset drill/write permissions indicate active data-model introspection access.
Dashboard-only viewers may have Dataset read access for chart rendering, but that
should not expose dataset/database metadata through MCP tools.
+ These resource-type permissions intentionally gate metadata globally rather
+ than per dashboard chart.
"""
try:
from superset import security_manager
diff --git a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
index 07f99004d45..9850b2bb2cb 100644
--- a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
+++ b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py
@@ -30,7 +30,9 @@ from pydantic import ValidationError
from superset.mcp_service.dashboard.schemas import (
_extract_cross_filters_enabled,
_extract_native_filters,
+ dashboard_serializer,
GenerateDashboardRequest,
+ serialize_chart_summary,
serialize_dashboard_object,
)
from superset.utils.json import dumps as json_dumps
@@ -138,9 +140,15 @@ class TestSerializeDashboardObject:
assert not hasattr(result, "json_metadata")
assert not hasattr(result, "position_json")
+ @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
@patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
- def test_native_filters_extracted_from_json_metadata(self, mock_base_url):
+ def test_native_filters_extracted_from_json_metadata(
+ self,
+ mock_base_url,
+ mock_can_view_data_model_metadata,
+ ):
"""Native filters should be extracted from json_metadata."""
+ mock_can_view_data_model_metadata.return_value = True
mock_base_url.return_value = "http://localhost:8088"
metadata = {
@@ -178,9 +186,49 @@ class TestSerializeDashboardObject:
assert result.native_filters[1].name == "Date Range"
assert result.cross_filters_enabled is True
+ @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
@patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
- def test_chart_summaries_are_lightweight(self, mock_base_url):
+ def test_restricted_user_redacts_native_filter_targets(
+ self,
+ mock_base_url,
+ mock_can_view_data_model_metadata,
+ ):
+ mock_can_view_data_model_metadata.return_value = False
+ mock_base_url.return_value = "http://localhost:8088"
+
+ metadata = {
+ "native_filter_configuration": [
+ {
+ "id": "NATIVE_FILTER-abc123",
+ "name": "Product Line",
+ "filterType": "filter_select",
+ "targets": [
+ {"column": {"name": "product_line"}, "datasetId": 3},
+ ],
+ },
+ ],
+ "cross_filters_enabled": True,
+ }
+ dashboard = _mock_dashboard(id=1)
+ dashboard.json_metadata = json_dumps(metadata)
+
+ result = serialize_dashboard_object(dashboard)
+
+ assert len(result.native_filters) == 1
+ assert result.native_filters[0].name == "Product Line"
+ assert result.native_filters[0].filter_type == "filter_select"
+ assert result.native_filters[0].targets == []
+ assert result.cross_filters_enabled is True
+
+ @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+ @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+ def test_chart_summaries_are_lightweight(
+ self,
+ mock_base_url,
+ mock_can_view_data_model_metadata,
+ ):
"""Charts in dashboard response should only have core fields."""
+ mock_can_view_data_model_metadata.return_value = True
mock_base_url.return_value = "http://localhost:8088"
chart = MagicMock()
@@ -204,6 +252,71 @@ class TestSerializeDashboardObject:
assert not hasattr(result.charts[0], "tags")
assert not hasattr(result.charts[0], "owners")
+ @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+ @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+ def test_restricted_user_redacts_chart_datasource_name(
+ self,
+ mock_base_url,
+ mock_can_view_data_model_metadata,
+ ):
+ mock_can_view_data_model_metadata.return_value = False
+ mock_base_url.return_value = "http://localhost:8088"
+
+ chart = MagicMock()
+ chart.id = 5
+ chart.slice_name = "Revenue Chart"
+ chart.viz_type = "echarts_timeseries_bar"
+ chart.datasource_name = "sales"
+ chart.description = "Monthly revenue"
+
+ dashboard = _mock_dashboard(id=1, slices=[chart])
+ result = serialize_dashboard_object(dashboard)
+
+ assert len(result.charts) == 1
+ assert result.charts[0].slice_name == "Revenue Chart"
+ assert result.charts[0].viz_type == "echarts_timeseries_bar"
+ assert result.charts[0].datasource_name is None
+ assert result.charts[0].url == "http://localhost:8088/explore/?slice_id=5"
+
+ @patch("superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata")
+ @patch("superset.mcp_service.utils.url_utils.get_superset_base_url")
+ def test_dashboard_serializer_restricted_user_redacts_data_model_metadata(
+ self,
+ mock_base_url,
+ mock_can_view_data_model_metadata,
+ ):
+ mock_can_view_data_model_metadata.return_value = False
+ mock_base_url.return_value = "http://localhost:8088"
+
+ chart = MagicMock()
+ chart.id = 5
+ chart.slice_name = "Revenue Chart"
+ chart.viz_type = "echarts_timeseries_bar"
+ chart.datasource_name = "sales"
+ chart.description = "Monthly revenue"
+
+ metadata = {
+ "native_filter_configuration": [
+ {
+ "id": "NATIVE_FILTER-abc123",
+ "name": "Product Line",
+ "filterType": "filter_select",
+ "targets": [
+ {"column": {"name": "product_line"}, "datasetId": 3},
+ ],
+ },
+ ],
+ "cross_filters_enabled": True,
+ }
+ dashboard = _mock_dashboard(id=1, slices=[chart])
+ dashboard.url = "/superset/dashboard/1/"
+ dashboard.json_metadata = json_dumps(metadata)
+
+ result = dashboard_serializer(dashboard)
+
+ assert result.charts[0].datasource_name is None
+ assert result.native_filters[0].targets == []
+
class TestExtractNativeFilters:
"""Tests for _extract_native_filters helper."""
@@ -241,6 +354,26 @@ class TestExtractNativeFilters:
assert result[0].id == "f1"
assert result[0].name == "Filter 1"
assert result[0].filter_type == "filter_select"
+ assert result[0].targets == []
+
+ def test_valid_filters_include_targets_when_metadata_allowed(self):
+ metadata = json_dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "f1",
+ "name": "Filter 1",
+ "filterType": "filter_select",
+ "targets": [{"column": {"name": "col1"}}],
+ }
+ ]
+ }
+ )
+ result = _extract_native_filters(
+ metadata,
+ include_data_model_metadata=True,
+ )
+ assert result[0].targets == [{"column": {"name": "col1"}}]
def test_skips_non_dict_entries(self):
metadata = json_dumps(
@@ -285,6 +418,23 @@ class TestExtractCrossFiltersEnabled:
assert _extract_cross_filters_enabled('"just a string"') is None
+class TestSerializeChartSummary:
+ """Tests for serialize_chart_summary helper."""
+
+ def test_datasource_name_redacted_by_default(self):
+ chart = MagicMock()
+ chart.id = 5
+ chart.slice_name = "Revenue Chart"
+ chart.viz_type = "echarts_timeseries_bar"
+ chart.datasource_name = "sales"
+ chart.description = "Monthly revenue"
+
+ result = serialize_chart_summary(chart)
+
+ assert result is not None
+ assert result.datasource_name is None
+
+
class TestOmittedFieldsBuilder:
"""Tests for the shared OmittedFieldsBuilder utility."""
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
index b5df3e91abc..e5e99f57b5a 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
@@ -20,6 +20,7 @@ Unit tests for dashboard generation MCP tools
"""
import logging
+from importlib import import_module
from unittest.mock import Mock, patch
import pytest
@@ -41,6 +42,12 @@ from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
+add_chart_to_existing_dashboard_module = import_module(
+ "superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard"
+)
+generate_dashboard_module = import_module(
+ "superset.mcp_service.dashboard.tool.generate_dashboard"
+)
@pytest.fixture
@@ -201,6 +208,39 @@ class TestGenerateDashboard:
"/superset/dashboard/10/" in result.structured_content["dashboard_url"]
)
+ @patch("superset.models.dashboard.Dashboard")
+ @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+ @patch("superset.db.session")
+ @pytest.mark.asyncio
+ async def test_generate_dashboard_restricted_user_redacts_chart_datasource_name(
+ self, mock_db_session, mock_find_by_id, mock_dashboard_cls, mcp_server
+ ):
+ chart = _mock_chart(id=1, slice_name="Sales Chart")
+ chart.datasource_name = "Vehicle Sales"
+ dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
+ dashboard.slices = [chart]
+ _setup_generate_dashboard_mocks(
+ mock_db_session, mock_find_by_id, mock_dashboard_cls, [chart], dashboard
+ )
+
+ request = {"chart_ids": [1], "dashboard_title": "Analytics Dashboard"}
+
+ with patch.object(
+ generate_dashboard_module,
+ "user_can_view_data_model_metadata",
+ return_value=False,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_dashboard", {"request": request}
+ )
+
+ assert result.structured_content["error"] is None
+ assert (
+ result.structured_content["dashboard"]["charts"][0]["datasource_name"]
+ is None
+ )
+
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_missing_charts(self, mock_db_session, mcp_server):
@@ -625,6 +665,44 @@ class TestAddChartToExistingDashboard:
assert column_key in layout
assert layout[column_key]["type"] == "COLUMN"
+ @patch("superset.commands.dashboard.update.UpdateDashboardCommand")
+ @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+ @patch("superset.db.session")
+ @pytest.mark.asyncio
+ async def test_add_chart_restricted_user_redacts_chart_datasource_name(
+ self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
+ ):
+ mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
+ mock_dashboard.slices = []
+ mock_dashboard.position_json = "{}"
+
+ chart = _mock_chart(id=30, slice_name="New Chart")
+ chart.datasource_name = "Vehicle Sales"
+ mock_db_session.get.return_value = chart
+
+ updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
+ updated_dashboard.slices = [chart]
+ mock_update_command.return_value.run.return_value = updated_dashboard
+ mock_find_dashboard.side_effect = [mock_dashboard, updated_dashboard]
+
+ request = {"dashboard_id": 1, "chart_id": 30}
+
+ with patch.object(
+ add_chart_to_existing_dashboard_module,
+ "user_can_view_data_model_metadata",
+ return_value=False,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "add_chart_to_existing_dashboard", {"request": request}
+ )
+
+ assert result.structured_content["error"] is None
+ assert (
+ result.structured_content["dashboard"]["charts"][0]["datasource_name"]
+ is None
+ )
+
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_add_chart_dashboard_not_found(self, mock_find_dashboard, mcp_server):
diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
index d47c7651bd2..05d224fc513 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py
@@ -20,6 +20,7 @@ Unit tests for MCP dashboard tools (list_dashboards, get_dashboard_info)
"""
import logging
+from importlib import import_module
from unittest.mock import Mock, patch
import pytest
@@ -34,6 +35,9 @@ from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
+get_dashboard_info_module = import_module(
+ "superset.mcp_service.dashboard.tool.get_dashboard_info"
+)
@pytest.fixture
@@ -448,6 +452,171 @@ async def test_get_dashboard_info_does_not_expose_access_list_or_roles(
assert "owners" not in result.data["charts"][0]
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_info_restricted_user_redacts_data_model_metadata(
+ mock_info,
+ mcp_server,
+):
+ chart = Mock()
+ chart.id = 10
+ chart.slice_name = "Revenue by Deal Size"
+ chart.viz_type = "echarts_timeseries_bar"
+ chart.datasource_name = "Vehicle Sales"
+ chart.description = None
+ chart.tags = []
+
+ dashboard = Mock()
+ dashboard.id = 1
+ dashboard.dashboard_title = "Sales Dashboard"
+ dashboard.slug = "sales"
+ dashboard.description = None
+ dashboard.css = None
+ dashboard.certified_by = None
+ dashboard.certification_details = None
+ dashboard.json_metadata = json.dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "NATIVE_FILTER-product-line",
+ "name": "Product Line",
+ "filterType": "filter_select",
+ "targets": [
+ {"column": {"name": "product_line"}, "datasetId": 3},
+ ],
+ },
+ ],
+ "cross_filters_enabled": True,
+ }
+ )
+ dashboard.position_json = None
+ dashboard.published = True
+ dashboard.is_managed_externally = False
+ dashboard.external_url = None
+ dashboard.created_on = None
+ dashboard.changed_on = None
+ dashboard.created_by = None
+ dashboard.changed_by = None
+ dashboard.uuid = None
+ dashboard.url = "/dashboard/1"
+ dashboard.created_on_humanized = None
+ dashboard.changed_on_humanized = None
+ dashboard.slices = [chart]
+ dashboard.owners = []
+ dashboard.tags = []
+ dashboard.roles = []
+
+ mock_info.return_value = dashboard
+
+ with patch(
+ "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
+ return_value=False,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_info",
+ {"request": {"identifier": 1}},
+ )
+
+ assert result.data["dashboard_title"] == "Sales Dashboard"
+ assert result.data["charts"][0]["slice_name"] == "Revenue by Deal Size"
+ assert result.data["charts"][0]["viz_type"] == "echarts_timeseries_bar"
+ assert result.data["charts"][0]["datasource_name"] is None
+ assert result.data["native_filters"][0]["name"] == "Product Line"
+ assert result.data["native_filters"][0]["targets"] == []
+
+
+@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_get_dashboard_info_restricted_user_redacts_permalink_filter_state(
+ mock_info,
+ mcp_server,
+):
+ dashboard = Mock()
+ dashboard.id = 1
+ dashboard.dashboard_title = "Sales Dashboard"
+ dashboard.slug = "sales"
+ dashboard.description = None
+ dashboard.css = None
+ dashboard.certified_by = None
+ dashboard.certification_details = None
+ dashboard.json_metadata = None
+ dashboard.position_json = None
+ dashboard.published = True
+ dashboard.is_managed_externally = False
+ dashboard.external_url = None
+ dashboard.created_on = None
+ dashboard.changed_on = None
+ dashboard.created_by = None
+ dashboard.changed_by = None
+ dashboard.uuid = None
+ dashboard.url = "/dashboard/1"
+ dashboard.created_on_humanized = None
+ dashboard.changed_on_humanized = None
+ dashboard.slices = []
+ dashboard.owners = []
+ dashboard.tags = []
+ dashboard.roles = []
+
+ mock_info.return_value = dashboard
+
+ permalink_value = {
+ "dashboardId": "1",
+ "state": {
+ "dataMask": {
+ "NATIVE_FILTER-product-line": {
+ "extraFormData": {
+ "filters": [
+ {
+ "col": "product_line",
+ "op": "IN",
+ "val": ["Classic Cars"],
+ "datasetId": 3,
+ }
+ ],
+ },
+ "filterState": {"value": ["Classic Cars"]},
+ },
+ },
+ "chartStates": {
+ "42": {
+ "state": {
+ "columnState": [{"colId": "customer_email"}],
+ "filterModel": {"revenue": {"filter": 100}},
+ },
+ },
+ },
+ "activeTabs": ["TAB-products"],
+ },
+ }
+
+ with (
+ patch(
+ "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
+ return_value=False,
+ ),
+ patch.object(
+ get_dashboard_info_module,
+ "user_can_view_data_model_metadata",
+ return_value=False,
+ ),
+ patch.object(
+ get_dashboard_info_module,
+ "_get_permalink_state",
+ return_value=permalink_value,
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "get_dashboard_info",
+ {"request": {"identifier": 1, "permalink_key": "abc123"}},
+ )
+
+ assert result.data["permalink_key"] == "abc123"
+ assert result.data["is_permalink_state"] is True
+ assert result.data["filter_state"] == {"activeTabs": ["TAB-products"]}
+
+
@patch("superset.daos.dashboard.DashboardDAO.list")
@pytest.mark.asyncio
async def test_list_dashboards_omits_requested_user_directory_fields(