This is an automated email from the ASF dual-hosted git repository.
kgabryje 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 7943af359c2 feat(mcp): implement RBAC permission checking for MCP
tools (#38407)
7943af359c2 is described below
commit 7943af359c2ec8bcc1dbd215278571a89eac4a5c
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Mar 12 17:35:07 2026 +0100
feat(mcp): implement RBAC permission checking for MCP tools (#38407)
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
superset-core/src/superset_core/mcp/decorators.py | 22 +-
superset/core/mcp/core_mcp_injection.py | 24 ++-
superset/mcp_service/auth.py | 136 +++++++++++--
superset/mcp_service/chart/tool/generate_chart.py | 2 +-
superset/mcp_service/chart/tool/get_chart_data.py | 2 +-
superset/mcp_service/chart/tool/get_chart_info.py | 2 +-
.../mcp_service/chart/tool/get_chart_preview.py | 2 +-
superset/mcp_service/chart/tool/list_charts.py | 2 +-
superset/mcp_service/chart/tool/update_chart.py | 2 +-
.../mcp_service/chart/tool/update_chart_preview.py | 2 +-
.../tool/add_chart_to_existing_dashboard.py | 2 +-
.../dashboard/tool/generate_dashboard.py | 2 +-
.../dashboard/tool/get_dashboard_info.py | 2 +-
.../mcp_service/dashboard/tool/list_dashboards.py | 2 +-
.../mcp_service/dataset/tool/get_dataset_info.py | 2 +-
superset/mcp_service/dataset/tool/list_datasets.py | 2 +-
.../explore/tool/generate_explore_link.py | 2 +-
superset/mcp_service/mcp_config.py | 5 +
superset/mcp_service/sql_lab/tool/execute_sql.py | 6 +-
.../sql_lab/tool/open_sql_lab_with_context.py | 2 +-
tests/unit_tests/mcp_service/conftest.py | 19 +-
tests/unit_tests/mcp_service/test_auth_rbac.py | 225 +++++++++++++++++++++
22 files changed, 431 insertions(+), 36 deletions(-)
diff --git a/superset-core/src/superset_core/mcp/decorators.py
b/superset-core/src/superset_core/mcp/decorators.py
index d8c814a1acd..6ad29c9adc5 100644
--- a/superset-core/src/superset_core/mcp/decorators.py
+++ b/superset-core/src/superset_core/mcp/decorators.py
@@ -48,11 +48,14 @@ def tool(
description: str | None = None,
tags: list[str] | None = None,
protect: bool = True,
+ class_permission_name: str | None = None,
+ method_permission_name: str | None = None,
) -> Any: # Use Any to avoid mypy issues with dependency injection
"""
Decorator to register an MCP tool with optional authentication.
- This decorator combines FastMCP tool registration with optional
authentication.
+ This decorator combines FastMCP tool registration with optional
authentication
+ and RBAC permission checking.
Can be used as:
@tool
@@ -69,6 +72,11 @@ def tool(
description: Tool description (defaults to function docstring)
tags: List of tags for categorizing the tool (defaults to empty list)
protect: Whether to require Superset authentication (defaults to True)
+ class_permission_name: FAB view/resource name for RBAC checking
+ (e.g., "Chart", "Dashboard", "SQLLab"). When set, enables
+ permission checking via security_manager.can_access().
+ method_permission_name: FAB action name (e.g., "read", "write").
+ Defaults to "write" if tags includes "mutate", else "read".
Returns:
Decorator function that registers and wraps the tool, or the wrapped
function
@@ -90,6 +98,18 @@ def tool(
def public_tool() -> str:
'''Public tool accessible without auth'''
return "Hello world"
+
+ @tool(class_permission_name="Chart") # RBAC: requires can_read on
Chart
+ def list_charts() -> list:
+ '''List charts the user can access'''
+ return []
+
+ @tool( # RBAC: can_write on Chart
+ tags=["mutate"], class_permission_name="Chart",
+ )
+ def create_chart(name: str) -> dict:
+ '''Create a new chart'''
+ return {"name": name}
"""
raise NotImplementedError(
"MCP tool decorator not initialized. "
diff --git a/superset/core/mcp/core_mcp_injection.py
b/superset/core/mcp/core_mcp_injection.py
index ad4a7b1211d..fa2ec23738a 100644
--- a/superset/core/mcp/core_mcp_injection.py
+++ b/superset/core/mcp/core_mcp_injection.py
@@ -60,12 +60,14 @@ def create_tool_decorator(
description: Optional[str] = None,
tags: Optional[list[str]] = None,
protect: bool = True,
+ class_permission_name: Optional[str] = None,
+ method_permission_name: Optional[str] = None,
) -> Callable[[F], F] | F:
"""
Create the concrete MCP tool decorator implementation.
- This combines FastMCP tool registration with optional Superset
authentication,
- replacing the need for separate @mcp.tool and @mcp_auth_hook decorators.
+ This combines FastMCP tool registration with optional Superset
authentication
+ and RBAC permission checking.
Supports both @tool and @tool() syntax.
@@ -76,6 +78,10 @@ def create_tool_decorator(
description: Tool description (defaults to function docstring)
tags: List of tags for categorization (defaults to empty list)
protect: Whether to apply Superset authentication (defaults to True)
+ class_permission_name: FAB view/resource name for RBAC
+ (e.g., "Chart", "Dashboard", "SQLLab"). Enables permission
checking.
+ method_permission_name: FAB action name (e.g., "read", "write").
+ Defaults to "write" if tags has "mutate", else "read".
Returns:
Decorator that registers and wraps the tool with optional
authentication,
@@ -95,6 +101,20 @@ def create_tool_decorator(
# Get prefixed ID based on ambient context
tool_name, context_type =
_get_prefixed_id_with_context(base_tool_name)
+ # Store RBAC permission metadata on the function so
+ # mcp_auth_hook can read them at call time.
+ if class_permission_name:
+ from superset.mcp_service.auth import (
+ CLASS_PERMISSION_ATTR,
+ METHOD_PERMISSION_ATTR,
+ )
+
+ setattr(func, CLASS_PERMISSION_ATTR, class_permission_name)
+ actual_method = method_permission_name
+ if actual_method is None:
+ actual_method = "write" if "mutate" in tool_tags else
"read"
+ setattr(func, METHOD_PERMISSION_ATTR, actual_method)
+
# Conditionally apply authentication wrapper
if protect:
from superset.mcp_service.auth import mcp_auth_hook
diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py
index 8675947097f..77544f224cb 100644
--- a/superset/mcp_service/auth.py
+++ b/superset/mcp_service/auth.py
@@ -16,15 +16,16 @@
# under the License.
"""
-Minimal authentication hooks for MCP tools.
-This is a placeholder implementation that provides basic user context.
-
-Future enhancements (to be added in separate PRs):
-- JWT token authentication and validation
-- User impersonation support
-- Permission checking with scopes
-- Comprehensive audit logging
-- Field-level permissions
+Authentication and authorization hooks for MCP tools.
+
+This module provides:
+- User authentication from JWT or configured dev user
+- RBAC permission checking aligned with Superset's REST API permissions
+- Dataset access validation
+- Session lifecycle management
+
+The RBAC enforcement mirrors Flask-AppBuilder's @protect() decorator,
+ensuring MCP tools respect the same permission model as the REST API.
"""
import logging
@@ -42,6 +43,90 @@ F = TypeVar("F", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
+# Constants for RBAC permission attributes (mirrors FAB conventions)
+PERMISSION_PREFIX = "can_"
+CLASS_PERMISSION_ATTR = "_class_permission_name"
+METHOD_PERMISSION_ATTR = "_method_permission_name"
+
+
+class MCPPermissionDeniedError(Exception):
+ """Raised when user lacks required RBAC permission for an MCP tool."""
+
+ def __init__(
+ self,
+ permission_name: str,
+ view_name: str,
+ user: str | None = None,
+ tool_name: str | None = None,
+ ):
+ self.permission_name = permission_name
+ self.view_name = view_name
+ self.user = user
+ self.tool_name = tool_name
+ message = (
+ f"Permission denied: {permission_name} on {view_name}"
+ + (f" for user {user}" if user else "")
+ + (f" (tool: {tool_name})" if tool_name else "")
+ )
+ super().__init__(message)
+
+
+def check_tool_permission(func: Callable[..., Any]) -> bool:
+ """Check if the current user has RBAC permission for an MCP tool.
+
+ Reads permission metadata stored on the function by the @tool decorator
+ and uses Superset's security_manager to verify access.
+
+ Controlled by the ``MCP_RBAC_ENABLED`` config flag (default True).
+ Set to False in superset_config.py to disable RBAC checking.
+
+ Args:
+ func: The tool function with optional permission attributes.
+
+ Returns:
+ True if user has permission or no permission is required.
+ """
+ try:
+ from flask import current_app
+
+ if not current_app.config.get("MCP_RBAC_ENABLED", True):
+ return True
+
+ from superset import security_manager
+
+ if not hasattr(g, "user") or not g.user:
+ logger.warning(
+ "No user context for permission check on tool: %s",
func.__name__
+ )
+ return False
+
+ class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
+ if not class_permission_name:
+ # No RBAC configured for this tool; allow by default.
+ return True
+
+ method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
+ permission_str = f"{PERMISSION_PREFIX}{method_permission_name}"
+
+ has_permission = security_manager.can_access(
+ permission_str, class_permission_name
+ )
+
+ if not has_permission:
+ logger.warning(
+ "Permission denied for user %s: %s on %s (tool: %s)",
+ g.user.username,
+ permission_str,
+ class_permission_name,
+ func.__name__,
+ )
+
+ return has_permission
+
+ except (AttributeError, ValueError, RuntimeError) as e:
+ logger.warning("Error checking tool permission: %s", e)
+ return False
+
def load_user_with_relationships(
username: str | None = None, email: str | None = None
@@ -219,14 +304,15 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901
"""
Authentication and authorization decorator for MCP tools.
- This decorator pushes Flask application context and sets up g.user
- for MCP tool execution.
+ This decorator pushes Flask application context, sets up g.user,
+ and enforces RBAC permission checks for MCP tool execution.
- Supports both sync and async tool functions.
+ Permission metadata (class_permission_name, method_permission_name) is
+ stored on tool_func by the @tool decorator in core_mcp_injection.py.
+ If present, check_tool_permission() verifies the user has the required
+ FAB permission before the tool function runs.
- TODO (future PR): Add permission checking
- TODO (future PR): Add JWT scope validation
- TODO (future PR): Add comprehensive audit logging
+ Supports both sync and async tool functions.
"""
import contextlib
import functools
@@ -264,6 +350,16 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901
)
return await tool_func(*args, **kwargs)
+ # RBAC permission check
+ if not check_tool_permission(tool_func):
+ method_name = getattr(tool_func, METHOD_PERMISSION_ATTR,
"read")
+ raise MCPPermissionDeniedError(
+ permission_name=f"{PERMISSION_PREFIX}{method_name}",
+ view_name=getattr(tool_func, CLASS_PERMISSION_ATTR,
"unknown"),
+ user=user.username,
+ tool_name=tool_func.__name__,
+ )
+
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
@@ -294,6 +390,16 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901
)
return tool_func(*args, **kwargs)
+ # RBAC permission check
+ if not check_tool_permission(tool_func):
+ method_name = getattr(tool_func, METHOD_PERMISSION_ATTR,
"read")
+ raise MCPPermissionDeniedError(
+ permission_name=f"{PERMISSION_PREFIX}{method_name}",
+ view_name=getattr(tool_func, CLASS_PERMISSION_ATTR,
"unknown"),
+ user=user.username,
+ tool_name=tool_func.__name__,
+ )
+
try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
diff --git a/superset/mcp_service/chart/tool/generate_chart.py
b/superset/mcp_service/chart/tool/generate_chart.py
index f09d8f89fdf..7cec6e9e71f 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -118,7 +118,7 @@ def _compile_chart(
return CompileResult(success=False, error=str(exc))
-@tool(tags=["mutate"])
+@tool(tags=["mutate"], class_permission_name="Chart")
@parse_request(GenerateChartRequest)
async def generate_chart( # noqa: C901
request: GenerateChartRequest, ctx: Context
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index 9239a278d9e..96e7b1845ad 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -63,7 +63,7 @@ def _get_cached_form_data(form_data_key: str) -> str | None:
return None
-@tool(tags=["data"])
+@tool(tags=["data"], class_permission_name="Chart")
@parse_request(GetChartDataRequest)
async def get_chart_data( # noqa: C901
request: GetChartDataRequest, ctx: Context
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py
b/superset/mcp_service/chart/tool/get_chart_info.py
index 95d0af23ff3..6c60214f705 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -56,7 +56,7 @@ def _get_cached_form_data(form_data_key: str) -> str | None:
return None
-@tool(tags=["discovery"])
+@tool(tags=["discovery"], class_permission_name="Chart")
@parse_request(GetChartInfoRequest)
async def get_chart_info(
request: GetChartInfoRequest, ctx: Context
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py
b/superset/mcp_service/chart/tool/get_chart_preview.py
index 6ee3ece4df4..579a2d748f5 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -2065,7 +2065,7 @@ async def _get_chart_preview_internal( # noqa: C901
)
-@tool(tags=["data"])
+@tool(tags=["data"], class_permission_name="Chart")
@parse_request(GetChartPreviewRequest)
async def get_chart_preview(
request: GetChartPreviewRequest, ctx: Context
diff --git a/superset/mcp_service/chart/tool/list_charts.py
b/superset/mcp_service/chart/tool/list_charts.py
index a3a72ad8166..3dacb3a7ec8 100644
--- a/superset/mcp_service/chart/tool/list_charts.py
+++ b/superset/mcp_service/chart/tool/list_charts.py
@@ -61,7 +61,7 @@ SORTABLE_CHART_COLUMNS = [
]
-@tool(tags=["core"])
+@tool(tags=["core"], class_permission_name="Chart")
@parse_request(ListChartsRequest)
async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList:
"""List charts with filtering and search.
diff --git a/superset/mcp_service/chart/tool/update_chart.py
b/superset/mcp_service/chart/tool/update_chart.py
index b0a099888e1..7abc1ef9da3 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -45,7 +45,7 @@ from superset.utils import json
logger = logging.getLogger(__name__)
-@tool(tags=["mutate"])
+@tool(tags=["mutate"], class_permission_name="Chart")
@parse_request(UpdateChartRequest)
async def update_chart(
request: UpdateChartRequest, ctx: Context
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py
b/superset/mcp_service/chart/tool/update_chart_preview.py
index 7ff3d2bc84a..117a92a6331 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -44,7 +44,7 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
-@tool(tags=["mutate"])
+@tool(tags=["mutate"], class_permission_name="Chart",
method_permission_name="write")
@parse_request(UpdateChartPreviewRequest)
def update_chart_preview(
request: UpdateChartPreviewRequest, ctx: Context
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 2ffec57f267..19f17c95a5e 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
@@ -304,7 +304,7 @@ def _ensure_layout_structure(
layout["DASHBOARD_VERSION_KEY"] = "v2"
-@tool(tags=["mutate"])
+@tool(tags=["mutate"], class_permission_name="Dashboard")
@parse_request(AddChartToDashboardRequest)
def add_chart_to_existing_dashboard(
request: AddChartToDashboardRequest, ctx: Context
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py
b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 8126ee387ca..61285c1a874 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -177,7 +177,7 @@ def _generate_title_from_charts(chart_objects: List[Any])
-> str:
return title
-@tool(tags=["mutate"])
+@tool(tags=["mutate"], class_permission_name="Dashboard")
@parse_request(GenerateDashboardRequest)
def generate_dashboard(
request: GenerateDashboardRequest, ctx: Context
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
index de688ee8d8d..fa0df2bee14 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
@@ -59,7 +59,7 @@ def _get_permalink_state(permalink_key: str) ->
DashboardPermalinkValue | None:
return None
-@tool(tags=["discovery"])
+@tool(tags=["discovery"], class_permission_name="Dashboard")
@parse_request(GetDashboardInfoRequest)
async def get_dashboard_info(
request: GetDashboardInfoRequest, ctx: Context
diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py
b/superset/mcp_service/dashboard/tool/list_dashboards.py
index eaab375d6c8..78bfec2794a 100644
--- a/superset/mcp_service/dashboard/tool/list_dashboards.py
+++ b/superset/mcp_service/dashboard/tool/list_dashboards.py
@@ -62,7 +62,7 @@ SORTABLE_DASHBOARD_COLUMNS = [
]
-@tool(tags=["core"])
+@tool(tags=["core"], class_permission_name="Dashboard")
@parse_request(ListDashboardsRequest)
async def list_dashboards(
request: ListDashboardsRequest, ctx: Context
diff --git a/superset/mcp_service/dataset/tool/get_dataset_info.py
b/superset/mcp_service/dataset/tool/get_dataset_info.py
index 07333e837a5..59c3603a185 100644
--- a/superset/mcp_service/dataset/tool/get_dataset_info.py
+++ b/superset/mcp_service/dataset/tool/get_dataset_info.py
@@ -42,7 +42,7 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
-@tool(tags=["discovery"])
+@tool(tags=["discovery"], class_permission_name="Dataset")
@parse_request(GetDatasetInfoRequest)
async def get_dataset_info(
request: GetDatasetInfoRequest, ctx: Context
diff --git a/superset/mcp_service/dataset/tool/list_datasets.py
b/superset/mcp_service/dataset/tool/list_datasets.py
index 9404b93179d..1bdbcb381fd 100644
--- a/superset/mcp_service/dataset/tool/list_datasets.py
+++ b/superset/mcp_service/dataset/tool/list_datasets.py
@@ -61,7 +61,7 @@ SORTABLE_DATASET_COLUMNS = [
]
-@tool(tags=["core"])
+@tool(tags=["core"], class_permission_name="Dataset")
@parse_request(ListDatasetsRequest)
async def list_datasets(request: ListDatasetsRequest, ctx: Context) ->
DatasetList:
"""List datasets with filtering and search.
diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py
b/superset/mcp_service/explore/tool/generate_explore_link.py
index 81e12974824..d60c0fd4d42 100644
--- a/superset/mcp_service/explore/tool/generate_explore_link.py
+++ b/superset/mcp_service/explore/tool/generate_explore_link.py
@@ -39,7 +39,7 @@ from superset.mcp_service.chart.schemas import (
from superset.mcp_service.utils.schema_utils import parse_request
-@tool(tags=["explore"])
+@tool(tags=["explore"], class_permission_name="Explore")
@parse_request(GenerateExploreLinkRequest)
async def generate_explore_link(
request: GenerateExploreLinkRequest, ctx: Context
diff --git a/superset/mcp_service/mcp_config.py
b/superset/mcp_service/mcp_config.py
index dab549b9b06..75221f854ef 100644
--- a/superset/mcp_service/mcp_config.py
+++ b/superset/mcp_service/mcp_config.py
@@ -45,6 +45,10 @@ MCP_SERVICE_PORT = 5008
# MCP Debug mode - shows suppressed initialization output in stdio mode
MCP_DEBUG = False
+# MCP RBAC - when True, tools with class_permission_name are checked
+# against the FAB security_manager before execution.
+MCP_RBAC_ENABLED = True
+
# MCP JWT Debug Errors - controls server-side JWT debug logging.
# When False (default), uses the default JWTVerifier with minimal logging.
# When True, uses DetailedJWTVerifier with tiered logging:
@@ -319,6 +323,7 @@ def get_mcp_config(app_config: Dict[str, Any] | None =
None) -> Dict[str, Any]:
"MCP_SERVICE_HOST": MCP_SERVICE_HOST,
"MCP_SERVICE_PORT": MCP_SERVICE_PORT,
"MCP_DEBUG": MCP_DEBUG,
+ "MCP_RBAC_ENABLED": MCP_RBAC_ENABLED,
**MCP_SESSION_CONFIG,
**MCP_CSRF_CONFIG,
}
diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py
b/superset/mcp_service/sql_lab/tool/execute_sql.py
index b9a79b54b28..88fad7d4414 100644
--- a/superset/mcp_service/sql_lab/tool/execute_sql.py
+++ b/superset/mcp_service/sql_lab/tool/execute_sql.py
@@ -50,7 +50,11 @@ from superset.mcp_service.utils.schema_utils import
parse_request
logger = logging.getLogger(__name__)
-@tool(tags=["mutate"])
+@tool(
+ tags=["mutate"],
+ class_permission_name="SQLLab",
+ method_permission_name="execute_sql_query",
+)
@parse_request(ExecuteSqlRequest)
async def execute_sql(request: ExecuteSqlRequest, ctx: Context) ->
ExecuteSqlResponse:
"""Execute SQL query against database using the unified Database.execute()
API."""
diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
index d27b970d609..d9dc6bcd564 100644
--- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
+++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
@@ -38,7 +38,7 @@ from superset.mcp_service.utils.url_utils import
get_superset_base_url
logger = logging.getLogger(__name__)
-@tool(tags=["explore"])
+@tool(tags=["explore"], class_permission_name="SQLLab",
method_permission_name="read")
@parse_request(OpenSqlLabRequest)
def open_sql_lab_with_context(
request: OpenSqlLabRequest, ctx: Context
diff --git a/tests/unit_tests/mcp_service/conftest.py
b/tests/unit_tests/mcp_service/conftest.py
index 92868309552..fbb14f270df 100644
--- a/tests/unit_tests/mcp_service/conftest.py
+++ b/tests/unit_tests/mcp_service/conftest.py
@@ -18,6 +18,21 @@
"""
MCP service test configuration.
-Tool imports are handled by app.py, not here.
-This conftest is empty to prevent test pollution.
+Disables RBAC permission checks for integration tests.
+RBAC logic is tested directly in test_auth_rbac.py.
"""
+
+import pytest
+
+
[email protected](autouse=True)
+def disable_mcp_rbac(app):
+ """Disable RBAC permission checks for MCP integration tests.
+
+ The RBAC permission logic is tested directly in test_auth_rbac.py.
+ Integration tests use mock users that do not have real FAB roles,
+ so we disable RBAC to let them exercise tool logic.
+ """
+ app.config["MCP_RBAC_ENABLED"] = False
+ yield
+ app.config.pop("MCP_RBAC_ENABLED", None)
diff --git a/tests/unit_tests/mcp_service/test_auth_rbac.py
b/tests/unit_tests/mcp_service/test_auth_rbac.py
new file mode 100644
index 00000000000..3abc87be500
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_auth_rbac.py
@@ -0,0 +1,225 @@
+# 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.
+
+"""Tests for MCP RBAC permission checking (auth.py)."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import g
+
+from superset.mcp_service.auth import (
+ check_tool_permission,
+ CLASS_PERMISSION_ATTR,
+ MCPPermissionDeniedError,
+ METHOD_PERMISSION_ATTR,
+ PERMISSION_PREFIX,
+)
+
+
[email protected](autouse=True)
+def enable_mcp_rbac(app):
+ """Re-enable RBAC for dedicated RBAC tests.
+
+ The shared conftest disables RBAC for integration tests. This fixture
+ overrides that so we can test the actual permission checking logic.
+ """
+ app.config["MCP_RBAC_ENABLED"] = True
+ yield
+ app.config.pop("MCP_RBAC_ENABLED", None)
+
+
+class _ToolFunc:
+ """Minimal callable stand-in for a tool function in tests.
+
+ Unlike MagicMock, this does NOT auto-create attributes on access,
+ so ``getattr(func, ATTR, None)`` properly returns None when the
+ attribute hasn't been set.
+ """
+
+ def __init__(self, name: str = "test_tool"):
+ self.__name__ = name
+
+ def __call__(self, *args, **kwargs):
+ pass
+
+
+def _make_tool_func(
+ class_perm: str | None = None,
+ method_perm: str | None = None,
+) -> _ToolFunc:
+ """Create a tool function stub with optional permission attributes."""
+ func = _ToolFunc()
+ if class_perm is not None:
+ setattr(func, CLASS_PERMISSION_ATTR, class_perm)
+ if method_perm is not None:
+ setattr(func, METHOD_PERMISSION_ATTR, method_perm)
+ return func
+
+
+# -- MCPPermissionDeniedError --
+
+
+def test_permission_denied_error_message_basic() -> None:
+ err = MCPPermissionDeniedError(
+ permission_name="can_read",
+ view_name="Chart",
+ )
+ assert "can_read" in str(err)
+ assert "Chart" in str(err)
+ assert err.permission_name == "can_read"
+ assert err.view_name == "Chart"
+
+
+def test_permission_denied_error_message_with_user_and_tool() -> None:
+ err = MCPPermissionDeniedError(
+ permission_name="can_write",
+ view_name="Dashboard",
+ user="alice",
+ tool_name="generate_dashboard",
+ )
+ assert "alice" in str(err)
+ assert "generate_dashboard" in str(err)
+ assert "Dashboard" in str(err)
+
+
+# -- check_tool_permission --
+
+
+def test_check_tool_permission_no_class_permission_allows(app_context) -> None:
+ """Tools without class_permission_name should be allowed by default."""
+ g.user = MagicMock(username="admin")
+ func = _make_tool_func() # no class_permission_name
+ assert check_tool_permission(func) is True
+
+
+def test_check_tool_permission_no_user_denies(app_context) -> None:
+ """If no g.user, permission check should deny."""
+ g.user = None
+ func = _make_tool_func(class_perm="Chart")
+ assert check_tool_permission(func) is False
+
+
+def test_check_tool_permission_granted(app_context) -> None:
+ """When security_manager.can_access returns True, permission is granted."""
+ g.user = MagicMock(username="admin")
+ func = _make_tool_func(class_perm="Chart", method_perm="read")
+
+ mock_sm = MagicMock()
+ mock_sm.can_access = MagicMock(return_value=True)
+ with patch("superset.security_manager", mock_sm):
+ result = check_tool_permission(func)
+
+ assert result is True
+ mock_sm.can_access.assert_called_once_with("can_read", "Chart")
+
+
+def test_check_tool_permission_denied(app_context) -> None:
+ """When security_manager.can_access returns False, permission is denied."""
+ g.user = MagicMock(username="viewer")
+ func = _make_tool_func(class_perm="Dashboard", method_perm="write")
+
+ mock_sm = MagicMock()
+ mock_sm.can_access = MagicMock(return_value=False)
+ with patch("superset.security_manager", mock_sm):
+ result = check_tool_permission(func)
+
+ assert result is False
+ mock_sm.can_access.assert_called_once_with("can_write", "Dashboard")
+
+
+def test_check_tool_permission_default_method_is_read(app_context) -> None:
+ """When no method_permission_name is set, defaults to 'read'."""
+ g.user = MagicMock(username="admin")
+ func = _make_tool_func(class_perm="Dataset")
+ # No method_perm set - should default to "read"
+
+ mock_sm = MagicMock()
+ mock_sm.can_access = MagicMock(return_value=True)
+ with patch("superset.security_manager", mock_sm):
+ result = check_tool_permission(func)
+
+ assert result is True
+ mock_sm.can_access.assert_called_once_with("can_read", "Dataset")
+
+
+def test_check_tool_permission_disabled_via_config(app_context, app) -> None:
+ """When MCP_RBAC_ENABLED is False, permission checks are skipped."""
+ g.user = MagicMock(username="viewer")
+ func = _make_tool_func(class_perm="Chart", method_perm="read")
+
+ app.config["MCP_RBAC_ENABLED"] = False
+ try:
+ assert check_tool_permission(func) is True
+ finally:
+ app.config["MCP_RBAC_ENABLED"] = True
+
+
+# -- Permission constants --
+
+
+def test_permission_prefix() -> None:
+ assert PERMISSION_PREFIX == "can_"
+
+
+def test_class_permission_attr() -> None:
+ assert CLASS_PERMISSION_ATTR == "_class_permission_name"
+
+
+def test_method_permission_attr() -> None:
+ assert METHOD_PERMISSION_ATTR == "_method_permission_name"
+
+
+# -- create_tool_decorator permission metadata --
+
+
+def test_permission_attrs_read_tag() -> None:
+ """Tags with class_permission_name set method_permission to read."""
+ func = _make_tool_func(class_perm="Chart", method_perm="read")
+ assert getattr(func, CLASS_PERMISSION_ATTR) == "Chart"
+ assert getattr(func, METHOD_PERMISSION_ATTR) == "read"
+
+
+def test_permission_attrs_write_tag() -> None:
+ """mutate tag convention → method_permission = 'write'."""
+ func = _make_tool_func(class_perm="Chart", method_perm="write")
+ assert getattr(func, CLASS_PERMISSION_ATTR) == "Chart"
+ assert getattr(func, METHOD_PERMISSION_ATTR) == "write"
+
+
+def test_permission_attrs_custom_method() -> None:
+ """Explicit method_permission_name overrides tag-based default."""
+ func = _make_tool_func(class_perm="SQLLab", method_perm="execute_sql")
+ assert getattr(func, CLASS_PERMISSION_ATTR) == "SQLLab"
+ assert getattr(func, METHOD_PERMISSION_ATTR) == "execute_sql"
+
+
+def test_no_class_permission_means_no_attrs() -> None:
+ """Without class_permission_name, no permission attrs are set."""
+ func = _make_tool_func()
+ assert not hasattr(func, CLASS_PERMISSION_ATTR)
+ assert not hasattr(func, METHOD_PERMISSION_ATTR)
+
+
+# -- Fixture --
+
+
[email protected]
+def app_context(app):
+ """Provide Flask app context for tests needing g.user."""
+ with app.app_context():
+ yield