-
Notifications
You must be signed in to change notification settings - Fork 18.3k
fix(export): apply SQL mutation and user impersonation in streaming CSV export #42412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,13 +23,12 @@ | |
| import logging | ||
| import time | ||
| from abc import abstractmethod | ||
| from contextlib import contextmanager | ||
| from contextlib import closing, contextmanager | ||
| from decimal import Decimal | ||
| from numbers import Real | ||
| from typing import Any, Callable, Generator | ||
|
|
||
| from flask import current_app as app, g, has_app_context | ||
| from sqlalchemy import text | ||
|
|
||
| from superset import db | ||
| from superset.commands.base import BaseCommand | ||
|
|
@@ -219,19 +218,44 @@ def _execute_query_and_stream( | |
| delimiter = csv_export_config.get("sep", ",") | ||
| decimal_separator = csv_export_config.get("decimal", ".") | ||
|
|
||
| # Apply SQL mutations (e.g. SQL_QUERY_MUTATOR config hook) before | ||
| # execution. All non-streaming paths go through this — the streaming | ||
| # path was originally skipping it, which left trailing semicolons | ||
| # unstripped for engines like Trino that reject them. | ||
| sql = database.mutate_sql_based_on_config(sql) | ||
|
|
||
| with db.session(future=True) as session: | ||
| # Merge database to prevent DetachedInstanceError | ||
| merged_database = session.merge(database) | ||
|
|
||
| with merged_database.get_sqla_engine( | ||
| catalog=catalog, schema=schema | ||
| ) as engine: | ||
| with engine.connect() as connection: | ||
| result_proxy = connection.execution_options( | ||
| stream_results=True | ||
| ).execute(text(sql)) | ||
|
|
||
| columns = list(result_proxy.keys()) | ||
| # Use get_raw_connection() instead of get_sqla_engine() directly. | ||
| # This is critical for: | ||
| # 1. User impersonation — get_raw_connection() goes through the | ||
| # ENGINE_CONTEXT_MANAGER which applies impersonate_user settings | ||
| # (e.g. X-Trino-User header). Without this, all streaming CSV | ||
| # exports run as the service principal, breaking audit trails | ||
| # and potentially bypassing per-user authorization (Ranger, OPA, | ||
| # RLS views). | ||
| # 2. SSH tunnels — get_raw_connection() sets up SSH tunnels if | ||
| # configured on the database. | ||
| # 3. OAuth2 — get_raw_connection() wraps execution in | ||
| # check_for_oauth2() context. | ||
| with closing( | ||
| merged_database.get_raw_connection(catalog=catalog, schema=schema) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
every other call site enters it directly (e.g. with merged_database.get_raw_connection(
catalog=catalog, schema=schema
) as conn:
cursor = conn.cursor()
|
||
| ) as conn: | ||
|
Comment on lines
+243
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Critical 🚨- ❌ Streaming CSV exports fail before query execution.
- ❌ User impersonation and OAuth2 setup are bypassed.
- ❌ SSH tunnel lifecycle handling is not entered or exited.
- ⚠️ Export consumers receive stream errors instead of CSV output.Steps of Reproduction ✅1. Start a streaming CSV export through the streaming export command; `run()` at
`superset/commands/streaming_export/base.py:295` returns a generator whose consumption
executes `_execute_query_and_stream()`.
2. Consume the returned generator so execution reaches
`superset/commands/streaming_export/base.py:243-245`, where
`merged_database.get_raw_connection(catalog=catalog, schema=schema)` is wrapped with
`contextlib.closing` instead of being entered with a `with` statement.
3. `get_raw_connection()` provides a context-managed connection so its `__enter__` logic
establishes the raw connection and its impersonation, OAuth2, and SSH-tunnel contexts;
`closing` does not invoke that context manager's `__enter__`.
4. The assigned `conn` is therefore the context-manager wrapper rather than the DBAPI
connection, and `conn.cursor()` at `superset/commands/streaming_export/base.py:246` fails
before `cursor.execute(sql)` at line 248. The export generator emits a stream failure
instead of CSV data; cleanup is also not performed through the connection context.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** superset/commands/streaming_export/base.py
**Line:** 243:245
**Comment:**
*Resource Leak: `get_raw_connection()` is used with `closing()`, which only calls `close()` and does not enter or exit a context manager. Since this API provides the connection through a context manager to apply lifecycle handling such as OAuth2 cleanup, SSH tunnel management, and impersonation context, `conn` becomes the context-manager object rather than the raw DBAPI connection; `conn.cursor()` therefore fails or bypasses the required setup. Use the connection as a context manager directly and keep cursor cleanup inside that context.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| cursor = conn.cursor() | ||
|
Comment on lines
+243
to
+246
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth confirming the streaming behaviour on this path: the old code used |
||
| # Set cursor.arraysize to control the batch size for fetchmany(). | ||
| # This ensures DBAPI drivers (Trino, PostgreSQL, etc.) fetch | ||
| # rows in manageable chunks instead of buffering the entire | ||
| # result set client-side. | ||
| cursor.arraysize = limit | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Critical 🚨- ❌ Unlimited chart CSV exports can fail during cursor setup.
- ❌ Unlimited SQL Lab exports can fail during cursor setup.
- ⚠️ Driver behavior becomes database-dependent.Steps of Reproduction ✅1. Request a chart streaming CSV export through superset/charts/data/api.py:756-765;
StreamingCSVExportCommand._get_row_limit() explicitly returns None for every chart export
at superset/commands/chart/data/streaming_export_command.py:76-83.
2. After entering the raw connection context, execution reaches
BaseStreamingCSVExportCommand._execute_query_and_stream() at
superset/commands/streaming_export/base.py:194.
3. The code assigns None to cursor.arraysize at
superset/commands/streaming_export/base.py:251. DBAPI cursor arraysize is an integer
fetch-size attribute, and drivers may reject None with a TypeError or equivalent driver
error.
4. The subsequent _process_rows() call at base.py:281-283 already invokes
fetchmany(self._chunk_size), so arraysize is not needed to implement the configured batch
size. The export therefore fails for unlimited chart exports before rows are streamed; SQL
Lab exports with select_sql set also return None from
superset/commands/sql_lab/streaming_export_command.py:119-127.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** superset/commands/streaming_export/base.py
**Line:** 251:251
**Comment:**
*Type Error: `limit` is `None` for chart exports and unlimited SQL Lab exports, but DBAPI `cursor.arraysize` requires an integer. This assignment can raise a driver-specific type error for every unlimited streaming export, and it is unnecessary because `_process_rows()` already passes `self._chunk_size` explicitly to `fetchmany()`.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. two issues here: |
||
| try: | ||
| cursor.execute(sql) | ||
| columns = ( | ||
| [desc[0] for desc in cursor.description] | ||
| if cursor.description | ||
| else [] | ||
| ) | ||
|
|
||
| # Use StringIO with csv.writer for proper escaping | ||
| # Apply delimiter from CSV_EXPORT config | ||
|
|
@@ -247,10 +271,15 @@ def _execute_query_and_stream( | |
| total_bytes += header_bytes | ||
| yield header_data | ||
|
|
||
| # Process rows and yield chunks | ||
| # Process rows and yield chunks — cursor supports the | ||
| # same fetchmany() interface that _process_rows expects. | ||
| row_count = 0 | ||
| for data_chunk, rows_processed, chunk_bytes in self._process_rows( | ||
| result_proxy, csv_writer, buffer, limit, decimal_separator | ||
| for ( | ||
| data_chunk, | ||
| rows_processed, | ||
| chunk_bytes, | ||
| ) in self._process_rows( | ||
| cursor, csv_writer, buffer, limit, decimal_separator | ||
| ): | ||
| total_bytes += chunk_bytes | ||
| row_count = rows_processed | ||
|
|
@@ -265,6 +294,8 @@ def _execute_query_and_stream( | |
| total_mb, | ||
| total_time, | ||
| ) | ||
| finally: | ||
| cursor.close() | ||
|
|
||
| def run(self) -> Callable[[], Generator[str, None, None]]: | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -92,9 +92,9 @@ def test_csv_generation_with_small_dataset(mocker: MockerFixture) -> None: | |
| """Test CSV generation with a small dataset.""" | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_result_proxy = mocker.MagicMock() | ||
| mock_result_proxy.keys.return_value = ["col1", "col2", "col3"] | ||
| mock_result_proxy.fetchmany.side_effect = [ | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",), ("col2",), ("col3",)] | ||
| mock_cursor.fetchmany.side_effect = [ | ||
| [ | ||
| ("row1_val1", "row1_val2", "row1_val3"), | ||
| ("row2_val1", "row2_val2", "row2_val3"), | ||
|
|
@@ -103,17 +103,11 @@ def test_csv_generation_with_small_dataset(mocker: MockerFixture) -> None: | |
| [], | ||
| ] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value.execute.return_value = ( | ||
| mock_result_proxy | ||
| ) | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
Comment on lines
+109
to
111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The test configures Severity Level: Critical 🚨- ❌ Unit tests miss production connection-entry failures.
- ⚠️ Streaming export regressions can pass CI undetected.
- ⚠️ Connection cleanup behavior is not verified.Steps of Reproduction ✅1. Run the chart streaming unit tests, whose common setup patches the database at
`tests/unit_tests/commands/chart/streaming_export_command_test.py:27-48`.
2. Each test assigns a raw `mock_conn` directly as `get_raw_connection.return_value`, for
example at `tests/unit_tests/commands/chart/streaming_export_command_test.py:106-111`,
rather than configuring `return_value.__enter__.return_value`.
3. Production `Database.get_raw_connection()` is a context manager at
`superset/models/core.py:675-692`, and the production code is supposed to enter it with
`with ... as conn`.
4. Consequently, these tests exercise a different API contract: they allow `conn.cursor()`
directly and cannot detect the current `closing(get_raw_connection(...))` failure at
`superset/commands/streaming_export/base.py:243-247`. Configure the entered connection
mock and assert context entry and exit.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** tests/unit_tests/commands/chart/streaming_export_command_test.py
**Line:** 109:111
**Comment:**
*Api Mismatch: The test configures `get_raw_connection()` to return a raw `MagicMock`, but the real API returns a context manager that must be entered with `with`. This causes the tests to exercise an artificial contract and allows the production `closing(get_raw_connection(...))` regression to pass unnoticed. Configure the mock as an entered context manager and assert that the context is entered and exited.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
|
|
||
| command = StreamingCSVExportCommand(query_context, chunk_size=2) | ||
|
|
@@ -136,22 +130,18 @@ def test_csv_generation_with_special_characters(mocker: MockerFixture) -> None: | |
| """Test CSV generation properly escapes special characters.""" | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_result = mocker.MagicMock() | ||
| mock_result.keys.return_value = ["name", "description"] | ||
| mock_result.fetchmany.side_effect = [ | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("name",), ("description",)] | ||
| mock_cursor.fetchmany.side_effect = [ | ||
| [("John, Jr.", 'Quote"Test'), ("Line\nBreak", "Comma,Value")], | ||
| [], | ||
| ] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value.execute.return_value = mock_result | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context, chunk_size=10) | ||
|
|
@@ -169,22 +159,18 @@ def test_streaming_with_null_values(mocker: MockerFixture) -> None: | |
| """Test CSV generation handles NULL values correctly.""" | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_result = mocker.MagicMock() | ||
| mock_result.keys.return_value = ["col1", "col2", "col3"] | ||
| mock_result.fetchmany.side_effect = [ | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",), ("col2",), ("col3",)] | ||
| mock_cursor.fetchmany.side_effect = [ | ||
| [("value1", None, "value3"), (None, "value2", None)], | ||
| [], | ||
| ] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value.execute.return_value = mock_result | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context, chunk_size=10) | ||
|
|
@@ -199,12 +185,12 @@ def test_streaming_with_null_values(mocker: MockerFixture) -> None: | |
|
|
||
|
|
||
| def test_streaming_execution_options_enabled(mocker: MockerFixture) -> None: | ||
| """Test that streaming execution options are enabled.""" | ||
| """Test that get_raw_connection is used for streaming (not get_sqla_engine).""" | ||
|
Comment on lines
187
to
+188
|
||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_result_proxy = mocker.MagicMock() | ||
| mock_result_proxy.keys.return_value = ["col1", "col2", "col3"] | ||
| mock_result_proxy.fetchmany.side_effect = [ | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",), ("col2",), ("col3",)] | ||
| mock_cursor.fetchmany.side_effect = [ | ||
| [ | ||
| ("row1_val1", "row1_val2", "row1_val3"), | ||
| ("row2_val1", "row2_val2", "row2_val3"), | ||
|
|
@@ -213,44 +199,37 @@ def test_streaming_execution_options_enabled(mocker: MockerFixture) -> None: | |
| [], | ||
| ] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_execution_options = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value = mock_execution_options | ||
| mock_execution_options.execute.return_value = mock_result_proxy | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context) | ||
| csv_generator_callable = command.run() | ||
| generator = csv_generator_callable() | ||
| list(generator) | ||
|
|
||
| mock_connection.execution_options.assert_called_once_with(stream_results=True) | ||
| # Verify get_raw_connection is used (not get_sqla_engine) | ||
| datasource.database.get_raw_connection.assert_called_once_with( | ||
| catalog=None, schema=None | ||
| ) | ||
|
|
||
|
|
||
| def test_empty_result_set(mocker: MockerFixture) -> None: | ||
| """Test CSV generation with empty result set.""" | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_result = mocker.MagicMock() | ||
| mock_result.keys.return_value = ["col1", "col2"] | ||
| mock_result.fetchmany.side_effect = [[]] | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",), ("col2",)] | ||
| mock_cursor.fetchmany.side_effect = [[]] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value.execute.return_value = mock_result | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context) | ||
|
|
@@ -264,35 +243,91 @@ def test_empty_result_set(mocker: MockerFixture) -> None: | |
|
|
||
|
|
||
| def test_catalog_and_schema_passed_to_engine(mocker: MockerFixture) -> None: | ||
| """Test that catalog and schema are forwarded to get_sqla_engine. | ||
|
|
||
| Prequeries (e.g. SET search_path for PostgreSQL) are now run automatically | ||
| via a connect event listener registered inside get_sqla_engine, not by the | ||
| streaming command itself. | ||
| """ | ||
| """Test that catalog and schema are forwarded to get_raw_connection.""" | ||
| mock_db, query_context, datasource = _setup_chart_mocks( | ||
| mocker, catalog="my_catalog", schema="my_schema" | ||
| ) | ||
|
|
||
| mock_result = mocker.MagicMock() | ||
| mock_result.keys.return_value = ["col1"] | ||
| mock_result.fetchmany.side_effect = [[("val",)], []] | ||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",)] | ||
| mock_cursor.fetchmany.side_effect = [[("val",)], []] | ||
|
|
||
| mock_connection = mocker.MagicMock() | ||
| mock_connection.execution_options.return_value.execute.return_value = mock_result | ||
| mock_connection.__enter__.return_value = mock_connection | ||
| mock_connection.__exit__.return_value = None | ||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| mock_engine = mocker.MagicMock() | ||
| mock_engine.connect.return_value = mock_connection | ||
| datasource.database.get_sqla_engine.return_value.__enter__.return_value = ( | ||
| mock_engine | ||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context) | ||
| list(command.run()()) | ||
|
|
||
| datasource.database.get_sqla_engine.assert_called_once_with( | ||
| datasource.database.get_raw_connection.assert_called_once_with( | ||
| catalog="my_catalog", | ||
| schema="my_schema", | ||
| ) | ||
|
|
||
|
|
||
| def test_sql_mutation_applied_before_execution(mocker: MockerFixture) -> None: | ||
| """Test that mutate_sql_based_on_config is called before executing SQL. | ||
|
|
||
| Regression test for #40465: the streaming export path was executing raw | ||
| SQL without applying SQL_QUERY_MUTATOR config, leaving trailing semicolons | ||
| unstripped for engines like Trino that reject them. | ||
| """ | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
| datasource.database.mutate_sql_based_on_config.return_value = "SELECT 1 LIMIT 10" | ||
|
|
||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",)] | ||
| mock_cursor.fetchmany.side_effect = [[(1,)], []] | ||
|
|
||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context) | ||
| list(command.run()()) | ||
|
|
||
| # SQL mutation must be called before execution | ||
| datasource.database.mutate_sql_based_on_config.assert_called_once_with( | ||
| "SELECT * FROM test" | ||
| ) | ||
| # The mutated SQL (not the original) should be sent to the cursor | ||
| mock_cursor.execute.assert_called_once_with("SELECT 1 LIMIT 10") | ||
|
|
||
|
|
||
| def test_get_raw_connection_used_instead_of_get_sqla_engine( | ||
| mocker: MockerFixture, | ||
| ) -> None: | ||
| """Test that get_raw_connection is used for proper user impersonation. | ||
|
|
||
| Regression test for #40465: the streaming export path used | ||
| get_sqla_engine() directly, bypassing user impersonation. This meant | ||
| all streaming CSV exports ran as the service principal instead of the | ||
| logged-in user, breaking audit trails and potentially bypassing | ||
| per-user authorization (Ranger, OPA, RLS views). | ||
| """ | ||
| mock_db, query_context, datasource = _setup_chart_mocks(mocker) | ||
|
|
||
| mock_cursor = mocker.MagicMock() | ||
| mock_cursor.description = [("col1",)] | ||
| mock_cursor.fetchmany.side_effect = [[("val",)], []] | ||
|
|
||
| mock_conn = mocker.MagicMock() | ||
| mock_conn.cursor.return_value = mock_cursor | ||
|
|
||
| datasource.database.get_raw_connection.return_value = ( | ||
| mock_conn | ||
| ) | ||
|
|
||
| command = StreamingCSVExportCommand(query_context) | ||
| list(command.run()()) | ||
|
|
||
| # Must use get_raw_connection (handles impersonation, SSH, OAuth2) | ||
| datasource.database.get_raw_connection.assert_called_once() | ||
| # Must NOT use get_sqla_engine directly (bypasses impersonation) | ||
| datasource.database.get_sqla_engine.assert_not_called() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The SQL mutator receives the original database object before it is merged into the active session. Streaming generators run after the request session may have ended, so a configured
SQL_QUERY_MUTATORthat reads a lazy or session-bound database attribute can raiseDetachedInstanceError. Merge the database first and invoke the mutator with the merged instance. [stale reference]Severity Level: Major⚠️
Steps of Reproduction ✅
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖