This is an automated email from the ASF dual-hosted git repository.
sha174n 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 7b83ed83746 fix(mcp): align execute_sql table-access checks with the
SQL Lab path (#43998)
7b83ed83746 is described below
commit 7b83ed83746f64e1ad61b28b7340a59269dc54b3
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 9 14:02:35 2026 +0100
fix(mcp): align execute_sql table-access checks with the SQL Lab path
(#43998)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset/mcp_service/sql_lab/tool/execute_sql.py | 101 +++++++----
.../mcp_service/sql_lab/tool/test_execute_sql.py | 186 +++++++++++++++++----
2 files changed, 228 insertions(+), 59 deletions(-)
diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py
b/superset/mcp_service/sql_lab/tool/execute_sql.py
index e79fbe8e930..4e5d980c765 100644
--- a/superset/mcp_service/sql_lab/tool/execute_sql.py
+++ b/superset/mcp_service/sql_lab/tool/execute_sql.py
@@ -28,6 +28,7 @@ from typing import Any
import pandas as pd
from fastmcp import Context
+from jinja2.exceptions import TemplateError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset_core.queries.types import (
CacheOptions,
@@ -37,7 +38,13 @@ from superset_core.queries.types import (
)
from superset.errors import SupersetErrorType
-from superset.exceptions import OAuth2Error, OAuth2RedirectError
+from superset.exceptions import (
+ OAuth2Error,
+ OAuth2RedirectError,
+ SupersetParseError,
+ SupersetSecurityException,
+ SupersetTemplateException,
+)
from superset.extensions import event_logger
from superset.mcp_service.sql_lab.schemas import (
ColumnInfo,
@@ -55,28 +62,39 @@ from superset.sql.parse import SQLScript
logger = logging.getLogger(__name__)
+def _invalid_sql_response() -> ExecuteSqlResponse:
+ """Response for SQL that could not be rendered or parsed for validation."""
+ return ExecuteSqlResponse(
+ success=False,
+ error=(
+ "SQL could not be parsed for security validation. "
+ "Please check your SQL syntax and try again."
+ ),
+ error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
+ )
+
+
async def _validate_non_destructive_sql(
request: ExecuteSqlRequest,
ctx: Context,
database: Any,
sql_preview: str,
+ template_params: dict[str, Any],
) -> ExecuteSqlResponse | None:
"""Return an error response when SQL cannot safely be executed."""
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
try:
- sql_to_check: str = request.sql
- # Render whenever template_params is not None, mirroring the
- # executor (SQLExecutor._render_sql_template), which also renders
- # for an empty dict. A truthiness check would let destructive SQL
- # that only appears after rendering slip past the guard when
- # template_params={}.
- if request.template_params is not None:
- from superset.jinja_context import get_template_processor
-
- tp = get_template_processor(database=database)
- sql_to_check = tp.process_template(
- request.sql, **request.template_params
- )
+ # Render the same way the executor does
+ # (``SQLExecutor._render_sql_template`` -> ``process_template``) so
+ # this guard inspects the string that will actually run and
+ # destructive SQL that only appears after rendering cannot slip
+ # past it. Deliberately not ``process_jinja_sql``, which
+ # neutralizes partition macros for parsing and so yields a
+ # different string.
+ from superset.jinja_context import get_template_processor
+
+ tp = get_template_processor(database=database)
+ sql_to_check = tp.process_template(request.sql, **template_params)
script = SQLScript(sql_to_check, database.db_engine_spec.engine)
if script.has_destructive():
@@ -94,14 +112,7 @@ async def _validate_non_destructive_sql(
await ctx.error(
"DDL pre-check failed to parse SQL, blocking query: %s" %
str(parse_err)
)
- return ExecuteSqlResponse(
- success=False,
- error=(
- "SQL could not be parsed for security validation. "
- "Please check your SQL syntax and try again."
- ),
- error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
- )
+ return _invalid_sql_response()
return None
@@ -143,6 +154,15 @@ async def execute_sql(request: ExecuteSqlRequest, ctx:
Context) -> ExecuteSqlRes
from superset import db, is_feature_enabled, security_manager
from superset.models.core import Database
+ # The access check below renders unconditionally
+ # (``raise_for_access`` -> ``process_jinja_sql``), so execution has to
+ # render too, otherwise the authorized SQL is not the SQL that runs and
+ # a template that hides a table reference from the renderer (e.g. one
+ # wrapped in ``{% if 0 %}`` inside a comment) would be authorized in
+ # its rendered form and executed in its raw form. SQL Lab has no such
+ # gap because its ``template_params`` defaults to ``{}``; match that.
+ template_params = request.template_params or {}
+
# 1. Get database and check access
with event_logger.log_context(action="mcp.execute_sql.db_validation"):
database = (
@@ -161,24 +181,43 @@ async def execute_sql(request: ExecuteSqlRequest, ctx:
Context) -> ExecuteSqlRes
error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR.value,
)
- if not security_manager.can_access_database(database):
+ # Authorize through the same entry point as the SQL Lab
+ # execution path (``superset/sqllab/validators.py``), so both
+ # surfaces scope a query the same way: it covers database-level
+ # access and, for a user without it, requires every table the
+ # query references to resolve to a dataset they are granted.
+ try:
+ security_manager.raise_for_access(
+ database=database,
+ sql=request.sql,
+ catalog=request.catalog,
+ schema=request.schema_name,
+ template_params=template_params,
+ force_dataset_match=True,
+ )
+ except SupersetSecurityException as ex:
await ctx.warning(
- "Access denied to database: %s" % database.database_name
+ "Access denied for query on database: %s" %
database.database_name
)
return ExecuteSqlResponse(
success=False,
- error=f"Access denied to database
{database.database_name}",
-
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value,
+ error=ex.error.message,
+ error_type=ex.error.error_type.value,
)
+ except (SupersetParseError, SupersetTemplateException,
TemplateError):
+ # Authorising a query the user has no database-wide grant on
+ # means rendering and parsing it, so malformed Jinja or SQL can
+ # surface here rather than in the DDL pre-check below.
+ await ctx.error("Query could not be parsed for access
validation")
+ return _invalid_sql_response()
# 2. Block destructive DDL (DROP, TRUNCATE, ALTER)
# Fail-closed: if parsing fails, block the query rather than
# allowing potentially destructive SQL to bypass the check.
# Render Jinja2 templates first so templated SQL can be parsed.
- validation_error: (
- ExecuteSqlResponse | None
- ) = await _validate_non_destructive_sql(request, ctx, database,
sql_preview)
- if validation_error is not None:
+ if validation_error := await _validate_non_destructive_sql(
+ request, ctx, database, sql_preview, template_params
+ ):
return validation_error
# 3. Build QueryOptions and execute query
@@ -188,7 +227,7 @@ async def execute_sql(request: ExecuteSqlRequest, ctx:
Context) -> ExecuteSqlRes
schema=request.schema_name,
limit=request.limit,
timeout_seconds=request.timeout,
- template_params=request.template_params,
+ template_params=template_params,
dry_run=request.dry_run,
cache=cache_opts,
)
diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
index 8116b5601fc..b88ff947139 100644
--- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
+++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
@@ -31,8 +31,15 @@ import pandas as pd
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
+from jinja2.exceptions import TemplateSyntaxError
from superset_core.queries.types import QueryResult, QueryStatus,
StatementResult
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import (
+ SupersetParseError,
+ SupersetSecurityException,
+ SupersetTemplateException,
+)
from superset.mcp_service.app import mcp
from superset.mcp_service.sql_lab.schemas import ColumnInfo
@@ -152,7 +159,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -201,7 +207,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -247,7 +252,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
mock_is_feature_enabled.return_value = False
request = {
@@ -283,7 +287,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
mock_is_feature_enabled.return_value = True
request = {
@@ -315,7 +318,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
mock_is_feature_enabled.return_value = False
request = {"database_id": 1, "sql": "SELECT id FROM users"}
@@ -356,30 +358,148 @@ class TestExecuteSql:
assert data["success"] is False
assert "Database with ID 999 not found" in data["error"]
+ # ``new_callable=MagicMock`` is required here: ``security_manager`` is a
+ # LocalProxy, which a bare ``patch`` replaces with an AsyncMock whose
+ # ``side_effect`` fires only when awaited. ``raise_for_access`` is called
+ # synchronously, so the deny path would never be exercised.
@patch("superset.security_manager", new_callable=MagicMock)
@patch("superset.db")
@pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "error_type, message",
+ [
+ (
+ SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR,
+ "You need access to the following database: examples",
+ ),
+ (
+ SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
+ "You need access to the following tables: secret_table",
+ ),
+ ],
+ )
async def test_execute_sql_access_denied(
- self, mock_db, mock_security_manager, mcp_server
+ self, mock_db, mock_security_manager, mcp_server, error_type, message
):
- """Test error when user lacks database access."""
+ """A query the user is not authorized to run, at either the database or
+ the table level, is rejected and never executed."""
mock_database = _mock_database()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = False
+ mock_security_manager.raise_for_access.side_effect =
SupersetSecurityException(
+ SupersetError(
+ message=message,
+ error_type=error_type,
+ level=ErrorLevel.ERROR,
+ )
+ )
request = {
"database_id": 1,
- "sql": "SELECT 1",
- "limit": 1,
+ "sql": "SELECT * FROM secret_table",
+ "limit": 10,
+ }
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("execute_sql", {"request":
request})
+ data = result.structured_content
+ assert data["success"] is False
+ assert data["error"] == message
+ assert data["error_type"] == error_type.value
+
+ mock_security_manager.raise_for_access.assert_called_once_with(
+ database=mock_database,
+ sql="SELECT * FROM secret_table",
+ catalog=None,
+ schema=None,
+ template_params={},
+ force_dataset_match=True,
+ )
+ mock_database.execute.assert_not_called()
+
+ @patch("superset.security_manager", new_callable=MagicMock)
+ @patch("superset.db")
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("template_params", [None, {}, {"a": 1}])
+ async def test_execute_sql_authorizes_the_sql_that_executes(
+ self, mock_db, mock_security_manager, mcp_server, template_params
+ ):
+ """The access check and the executor must be handed the same
+ template_params, otherwise the authorized SQL is not the SQL that runs
+ and Jinja expanding on only one side escapes the table-access check.
+
+ ``None`` is normalized to ``{}`` rather than passed through: the check
+ renders unconditionally, so leaving the executor unrendered would let a
+ template that hides a table from the renderer be authorized in its
+ rendered form and executed in its raw form.
+ """
+ mock_database = _mock_database()
+ mock_database.execute.return_value = _create_select_result(
+ rows=[{"id": 1}],
+ columns=["id"],
+ )
+
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
+ mock_database
+ )
+
+ request: dict[str, Any] = {
+ "database_id": 1,
+ "sql": "SELECT id FROM users",
+ "limit": 10,
+ }
+ if template_params is not None:
+ request["template_params"] = template_params
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("execute_sql", {"request":
request})
+ assert result.structured_content["success"] is True
+
+ authorized = mock_security_manager.raise_for_access.call_args.kwargs[
+ "template_params"
+ ]
+ executed = mock_database.execute.call_args[0][1].template_params
+ assert authorized == executed
+ assert executed == (template_params or {})
+
+ @patch("superset.security_manager", new_callable=MagicMock)
+ @patch("superset.db")
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "side_effect",
+ [
+ TemplateSyntaxError("unexpected end of template", 1),
+ # raised by macros such as ``metric()`` while rendering; a
+ # SupersetException rather than a jinja2 one, so it needs
+ # catching separately to avoid surfacing as a crash
+ SupersetTemplateException("Please specify the Dataset ID"),
+ SupersetParseError("SELECT", "postgresql", message="cannot parse"),
+ ],
+ )
+ async def test_execute_sql_malformed_template(
+ self, mock_db, mock_security_manager, mcp_server, side_effect
+ ):
+ """Malformed Jinja is reported as invalid SQL, not as a crash: the
+ access check renders the template, so it fails there first."""
+ mock_database = _mock_database()
+
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
+ mock_database
+ )
+ mock_security_manager.raise_for_access.side_effect = side_effect
+
+ request = {
+ "database_id": 1,
+ "sql": "SELECT * FROM {{ table",
+ "limit": 10,
}
async with Client(mcp_server) as client:
result = await client.call_tool("execute_sql", {"request":
request})
data = result.structured_content
assert data["success"] is False
- assert "Access denied to database" in data["error"]
+ assert data["error_type"] ==
SupersetErrorType.INVALID_SQL_ERROR.value
+
+ mock_database.execute.assert_not_called()
@patch("superset.security_manager")
@patch("superset.db")
@@ -397,7 +517,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -431,7 +550,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -465,7 +583,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -515,7 +632,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -560,7 +676,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -611,7 +726,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -694,7 +808,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -774,7 +887,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -837,7 +949,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -934,7 +1045,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
# No 'limit' key — should default to None (no override)
request = {
@@ -968,7 +1078,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -1029,7 +1138,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -1088,7 +1196,6 @@ class TestExecuteSql:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -1216,7 +1323,6 @@ class TestExecuteSqlOAuth2:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -1249,7 +1355,6 @@ class TestExecuteSqlOAuth2:
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
mock_database
)
- mock_security_manager.can_access_database.return_value = True
request = {
"database_id": 1,
@@ -1327,13 +1432,12 @@ class TestDestructiveDDLBlocking:
"""Common mock wiring for DDL blocking tests."""
with (
patch("superset.db") as mock_db,
- patch("superset.security_manager") as mock_sm,
+ patch("superset.security_manager"),
):
mock_database = _mock_database()
mock_database.db_engine_spec.engine = "postgresql"
query_chain = mock_db.session.query.return_value
query_chain.filter_by.return_value.first.return_value =
mock_database
- mock_sm.can_access_database.return_value = True
yield mock_database
@pytest.mark.asyncio
@@ -1445,6 +1549,32 @@ class TestDestructiveDDLBlocking:
mock_tp.process_template.assert_called_once()
ddl_mocks.execute.assert_not_called()
+ @pytest.mark.asyncio
+ async def test_no_template_params_still_renders(self, ddl_mocks,
mcp_server):
+ """Omitting template_params must not skip rendering. The access check
+ renders unconditionally, so the guard and the executor have to render
+ as well, otherwise they inspect and run a different string than the one
+ that was authorized.
+ """
+ sql = "SELECT * FROM logs WHERE msg = 'x'"
+ ddl_mocks.execute.return_value = _create_select_result(
+ rows=[{"msg": "x"}], columns=["msg"], original_sql=sql
+ )
+
+ with patch("superset.jinja_context.get_template_processor") as
mock_get_tp:
+ mock_get_tp.return_value.process_template.return_value = sql
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "execute_sql",
+ {"request": {"database_id": 1, "sql": sql}},
+ )
+
+ data = result.structured_content
+ assert data["success"] is True
+ mock_get_tp.return_value.process_template.assert_called_once_with(sql)
+ # the executor renders the same way, so it is handed {} rather than
None
+ assert ddl_mocks.execute.call_args[0][1].template_params == {}
+
@pytest.mark.asyncio
async def test_select_allowed(self, ddl_mocks, mcp_server):
"""SELECT queries pass through the DDL check."""