mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
Co-authored-by: Superset Dev <dev@superset.apache.org> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
6.2 KiB
Python
162 lines
6.2 KiB
Python
# 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.
|
|
"""Unit tests for ExecuteSqlCommand's authorization of the SQL it executes."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from superset.commands.sql_lab.execute import ExecuteSqlCommand
|
|
from superset.sqllab.command_status import SqlJsonExecutionStatus
|
|
from superset.sqllab.exceptions import QueryIsForbiddenToAccessException
|
|
|
|
|
|
def _make_command(**overrides: MagicMock) -> ExecuteSqlCommand:
|
|
kwargs: dict[str, MagicMock] = {
|
|
"execution_context": MagicMock(),
|
|
"query_dao": MagicMock(),
|
|
"database_dao": MagicMock(),
|
|
"access_validator": MagicMock(),
|
|
"sql_query_render": MagicMock(),
|
|
"sql_json_executor": MagicMock(),
|
|
"execution_context_convertor": MagicMock(),
|
|
}
|
|
kwargs.update(overrides)
|
|
return ExecuteSqlCommand(
|
|
sqllab_ctas_no_limit_flag=False,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _validate_rendered_access: pin / delegate / reset
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_rendered_access_authorizes_the_literal_rendered_sql() -> None:
|
|
"""
|
|
``_validate_rendered_access`` must pin ``query.executed_sql`` to the
|
|
already-rendered text *before* delegating to the access validator, so
|
|
``raise_for_access``'s "prefer executed_sql" path authorizes exactly
|
|
the SQL that is about to execute rather than re-rendering the Jinja
|
|
source (which could pick a different table for a nondeterministic
|
|
template). Afterwards ``executed_sql`` must be reset so the execution
|
|
path can assign its own final (limited / per-block mutated) SQL.
|
|
"""
|
|
command = _make_command()
|
|
query = MagicMock()
|
|
query.executed_sql = None
|
|
seen_executed_sql = []
|
|
|
|
def _capture_validate(q: MagicMock, template_params: object) -> None:
|
|
seen_executed_sql.append(q.executed_sql)
|
|
|
|
command._access_validator.validate.side_effect = _capture_validate # type: ignore[attr-defined] # noqa: E501
|
|
|
|
command._validate_rendered_access(query, "SELECT * FROM sales")
|
|
|
|
assert seen_executed_sql == ["SELECT * FROM sales"]
|
|
assert query.executed_sql is None
|
|
|
|
|
|
def test_validate_rendered_access_resets_executed_sql_on_denial() -> None:
|
|
"""
|
|
A denial for the rendered SQL must still surface as
|
|
``QueryIsForbiddenToAccessException`` and must not leave
|
|
``query.executed_sql`` pinned to the rejected text.
|
|
"""
|
|
command = _make_command()
|
|
query = MagicMock()
|
|
query.executed_sql = None
|
|
command._access_validator.validate.side_effect = Exception("access denied") # type: ignore[attr-defined] # noqa: E501
|
|
|
|
with pytest.raises(QueryIsForbiddenToAccessException):
|
|
command._validate_rendered_access(query, "SELECT * FROM secret_payroll")
|
|
|
|
assert query.executed_sql is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: nondeterministic templates must not let execution diverge
|
|
# from the authorized render
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("superset.commands.sql_lab.execute.db")
|
|
def test_run_sql_json_exec_from_scratch_revalidates_rendered_sql(
|
|
mock_db: MagicMock,
|
|
) -> None:
|
|
"""
|
|
Regression: previously only ``query.sql`` + ``template_params`` was
|
|
authorized (render #1, inside the access validator) while a second,
|
|
independent render (``sql_query_render.render()``) produced the SQL
|
|
that was actually handed to the executor. A nondeterministic Jinja
|
|
construct (e.g. the ``random`` filter picking a table) could make the
|
|
two renders diverge, letting a query read a table the authorization
|
|
check never saw. The literal rendered SQL must now be re-validated
|
|
before it reaches the executor.
|
|
"""
|
|
execution_context = MagicMock()
|
|
execution_context.template_params = {}
|
|
# Skip the query-limit machinery entirely; it is unrelated to this fix.
|
|
execution_context.select_as_cta = True
|
|
|
|
query = MagicMock()
|
|
query.id = 1
|
|
query.executed_sql = None
|
|
execution_context.create_query.return_value = query
|
|
|
|
database_dao = MagicMock()
|
|
database_dao.find_by_id.return_value = MagicMock()
|
|
|
|
access_validator = MagicMock()
|
|
validate_calls: list[object] = []
|
|
|
|
def _capture_validate(q: MagicMock, template_params: object) -> None:
|
|
validate_calls.append(q.executed_sql)
|
|
|
|
access_validator.validate.side_effect = _capture_validate
|
|
|
|
sql_query_render = MagicMock()
|
|
sql_query_render.render.return_value = "SELECT * FROM sales"
|
|
|
|
sql_json_executor = MagicMock()
|
|
sql_json_executor.execute.return_value = SqlJsonExecutionStatus.HAS_RESULTS
|
|
|
|
command = _make_command(
|
|
execution_context=execution_context,
|
|
database_dao=database_dao,
|
|
access_validator=access_validator,
|
|
sql_query_render=sql_query_render,
|
|
sql_json_executor=sql_json_executor,
|
|
)
|
|
command._sqllab_ctas_no_limit = True
|
|
|
|
command._run_sql_json_exec_from_scratch()
|
|
|
|
# Authorized twice: once before rendering (macros with side effects run
|
|
# at render time) and again against the literal, already-rendered SQL
|
|
# that the executor is about to run.
|
|
assert access_validator.validate.call_count == 2
|
|
assert validate_calls == [None, "SELECT * FROM sales"]
|
|
# executed_sql is not left pinned; the execution path assigns its own
|
|
# final value.
|
|
assert query.executed_sql is None
|
|
sql_json_executor.execute.assert_called_once_with(
|
|
execution_context, "SELECT * FROM sales", None
|
|
)
|