This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 72502d61652 Keep SQL deep scan on for custom read-only allowed_types 
(#76688)
72502d61652 is described below

commit 72502d61652da7b9410e830de4d9b2d867fd7600
Author: Jyun-An Chen <[email protected]>
AuthorDate: Sun Nov 8 10:52:12 2026 +0800

    Keep SQL deep scan on for custom read-only allowed_types (#76688)
    
    * Keep SQL deep scan on for custom read-only allowed_types
    
    validate_sql() skipped the data-modifying deep scan whenever allowed_types
    was not exactly DEFAULT_ALLOWED_TYPES. A narrower or reordered read-only
    list such as (exp.Select,) therefore let statements like
    "WITH d AS (DELETE ...) SELECT ..." or "SELECT ... INTO ..." through.
    
    Skip the deep scan only when the allow-list itself admits a data-modifying
    type.
    
    * Keep allow-lists that admit writes through a base class working
    
    An allow-list such as (exp.Select, exp.DML) admits an INSERT at the type
    check, but the one-way issubclass test left the deep scan on and rejected
    the same INSERT as read-only. Treat an allowed type that is a write node or
    a base of one as admitting writes. Scanning only the write types the list
    does not admit is not an option: MERGE nests Insert and Update, and ALTER
    ... DROP COLUMN nests Drop, so it would reject writes the list allows.
    
    Also update the denylist comment and stability.rst to the new rule.
    
    ---------
    
    Co-authored-by: Kaxil Naik <[email protected]>
---
 providers/common/ai/docs/stability.rst             |  5 ++-
 .../providers/common/ai/utils/sql_validation.py    | 27 +++++++-----
 .../unit/common/ai/utils/test_sql_validation.py    | 50 ++++++++++++++++++----
 3 files changed, 61 insertions(+), 21 deletions(-)

diff --git a/providers/common/ai/docs/stability.rst 
b/providers/common/ai/docs/stability.rst
index f3e6e415730..733ccedc052 100644
--- a/providers/common/ai/docs/stability.rst
+++ b/providers/common/ai/docs/stability.rst
@@ -98,8 +98,9 @@ Pydantic AI toolsets, but the Pydantic AI class they inherit 
from can change.
      - Generates SQL from the prompt and returns it without running it. With
        ``validate_sql=True``, the default, the task fails unless the output is 
a single
        statement that parses for ``dialect`` and is one of 
``allowed_sql_types``
-       (``SELECT``, ``UNION``, ``INTERSECT`` and ``EXCEPT`` by default); with 
the default
-       types, a write nested inside the statement fails it too. A reviewer's 
edit under
+       (``SELECT``, ``UNION``, ``INTERSECT`` and ``EXCEPT`` by default); unless
+       ``allowed_sql_types`` admits a write, a write nested inside the 
statement fails it
+       too. A reviewer's edit under
        ``allow_modifications=True`` is checked the same way. ``db_conn_id`` 
with
        ``table_names``, ``schema_context`` or ``datasource_config`` give the 
model the
        schema. ``datasource_config`` reads object storage through the 
DataFusion engine of
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/sql_validation.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/sql_validation.py
index 17d0120b15e..225b5a31308 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/utils/sql_validation.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/utils/sql_validation.py
@@ -90,7 +90,7 @@ READ_ONLY_METADATA_TYPES: tuple[type[exp.Expr], ...] = (
 # Note: exp.Command is sqlglot's fallback for any syntax it doesn't recognize.
 # Including it makes the denylist fail-closed (safer), but may block legitimate
 # vendor-specific SQL that sqlglot can't parse. Callers who need such syntax 
can
-# provide custom allowed_types to bypass the deep scan entirely.
+# add exp.Command to a custom allowed_types, which also turns the deep scan 
off.
 _DATA_MODIFYING_NODES: tuple[type[exp.Expr], ...] = (
     exp.Insert,
     exp.Update,
@@ -433,7 +433,9 @@ def validate_sql(
     :param allowed_types: Tuple of sqlglot expression types to permit.
         Defaults to ``(Select, Union, Intersect, Except)``. When supplied, the
         caller takes full control of the allow-list and 
``allow_read_only_metadata``
-        is ignored.
+        is ignored. The deep scan for data-modifying nodes still runs unless 
the
+        allow-list admits a write, directly (``exp.Insert``) or through a base 
class
+        (``exp.DML``).
     :param dialect: SQL dialect for parsing (``postgres``, ``mysql``, etc.).
     :param allow_multiple_statements: Whether to allow multiple 
semicolon-separated
         statements. Default ``False``.
@@ -445,18 +447,23 @@ def validate_sql(
     :raises SQLSafetyError: If the SQL is empty, contains disallowed statement 
types,
         or has multiple statements when not permitted.
     """
-    # A caller-supplied ``allowed_types`` is an explicit opt-out of the curated
-    # read-only defaults (and the data-modifying deep scan). Otherwise we use 
the
-    # read-only defaults, optionally widened with metadata statements, and keep
-    # the deep scan on.
     if allowed_types is None:
         types: tuple[type[exp.Expr], ...] = DEFAULT_ALLOWED_TYPES
         if allow_read_only_metadata:
             types = types + READ_ONLY_METADATA_TYPES
-        run_data_modifying_scan = True
     else:
         types = allowed_types
-        run_data_modifying_scan = types == DEFAULT_ALLOWED_TYPES
+    # Only an allow-list that admits a write opts out of the deep scan, 
whether it names
+    # the write type (``exp.Insert``) or a base of it (``exp.DML``). Comparing 
to
+    # DEFAULT_ALLOWED_TYPES would also switch it off for a narrower or 
reordered
+    # read-only list such as ``(exp.Select,)``, letting ``WITH d AS (DELETE 
...)
+    # SELECT ...`` through. Scanning a list that admits writes would instead 
reject the
+    # writes it allows: MERGE nests Insert and Update, ALTER ... DROP COLUMN 
nests Drop.
+    run_data_modifying_scan = not any(
+        issubclass(allowed, node) or issubclass(node, allowed)
+        for allowed in types
+        for node in _DATA_MODIFYING_NODES
+    )
 
     parsed = parse_sql(sql, dialect=dialect, 
allow_multiple_statements=allow_multiple_statements)
 
@@ -468,9 +475,7 @@ def validate_sql(
             )
 
     # Deep scan: reject data-modifying nodes hidden inside otherwise-allowed 
statements
-    # (e.g. data-modifying CTEs, SELECT INTO, EXPLAIN <write>). Runs for the 
curated
-    # read-only allow-list — callers who provide custom allowed_types have 
explicitly
-    # opted into non-read-only operations.
+    # (e.g. data-modifying CTEs, SELECT INTO, EXPLAIN <write>).
     if run_data_modifying_scan:
         _check_for_data_modifying_nodes(parsed)
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
index 2fd6d31ce41..5fc3ca114e0 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
@@ -211,14 +211,48 @@ class TestDataModifyingNodeDetection:
         result = validate_sql("SELECT * FROM users WHERE id IN (SELECT user_id 
FROM orders)")
         assert len(result) == 1
 
-    def test_deep_scan_runs_with_explicit_default_types(self):
-        """Deep scan should also block when DEFAULT_ALLOWED_TYPES is passed 
explicitly."""
-        with pytest.raises(SQLSafetyError, match="Data-modifying operation 
'Delete'"):
-            validate_sql(
-                "WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM 
del",
-                allowed_types=DEFAULT_ALLOWED_TYPES,
-                dialect="postgres",
-            )
+    @pytest.mark.parametrize(
+        "allowed_types",
+        [
+            pytest.param(DEFAULT_ALLOWED_TYPES, id="explicit-default"),
+            pytest.param((exp.Select,), id="narrower"),
+            pytest.param(tuple(reversed(DEFAULT_ALLOWED_TYPES)), 
id="reordered"),
+            pytest.param((*DEFAULT_ALLOWED_TYPES, exp.Describe), 
id="widened-read-only"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        ("sql", "node"),
+        [
+            ("WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM del", 
"Delete"),
+            ("SELECT * INTO new_table FROM users", "Into"),
+        ],
+    )
+    def test_deep_scan_runs_with_custom_read_only_types(self, allowed_types, 
sql, node):
+        """A custom allow-list with no write type keeps the deep scan on."""
+        with pytest.raises(SQLSafetyError, match=f"Data-modifying operation 
'{node}'"):
+            validate_sql(sql, allowed_types=allowed_types, dialect="postgres")
+
+    @pytest.mark.parametrize(
+        ("allowed_types", "sql", "expected_type"),
+        [
+            pytest.param((exp.Select, exp.Insert), "INSERT INTO t SELECT * 
FROM s", exp.Insert, id="leaf"),
+            pytest.param((exp.Select, exp.DML), "INSERT INTO t SELECT * FROM 
s", exp.Insert, id="base-class"),
+            pytest.param(
+                (exp.Select, exp.Merge),
+                "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE 
SET v = s.v "
+                "WHEN NOT MATCHED THEN INSERT (id, v) VALUES (s.id, s.v)",
+                exp.Merge,
+                id="merge-nests-insert-and-update",
+            ),
+            pytest.param(
+                (exp.Select, exp.Alter), "ALTER TABLE t DROP COLUMN c", 
exp.Alter, id="alter-nests-drop"
+            ),
+        ],
+    )
+    def test_deep_scan_skipped_when_custom_types_admit_writes(self, 
allowed_types, sql, expected_type):
+        """An allow-list that admits a write, directly or through a base 
class, turns the deep scan off."""
+        (stmt,) = validate_sql(sql, allowed_types=allowed_types, 
dialect="postgres")
+        assert isinstance(stmt, expected_type)
 
 
 class TestReadOnlyMetadata:

Reply via email to