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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 621af71add1 [v3-3-test] Require Dag edit to delete asset queued events 
(#71736) (#71828)
621af71add1 is described below

commit 621af71add104a96f0d5678319020bb166466a70
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Aug 19 11:52:26 2026 +0200

    [v3-3-test] Require Dag edit to delete asset queued events (#71736) (#71828)
    
    The three asset queued-events DELETE endpoints authorized the Dag axis with
    requires_access_dag(method="GET") — a read-level check — while deleting rows
    from AssetDagRunQueue, which cancels a Dag's pending asset-triggered
    scheduling.
    
    Every other Dag-scheduling mutation in the API requires Dag edit: Dag run
    clear, patch and delete use method="PUT"/"DELETE" with access_entity=RUN, 
and
    task-instance state changes use method="PUT". These three deletes were the
    only Dag-scheduling-state writes gated on read, so a caller holding delete 
on
    the global "Assets" resource plus only read on a Dag could suppress that 
Dag's
    asset-triggered runs.
    
    Change the Dag-axis gate on the three routes to method="PUT". The paired
    requires_access_asset(method="DELETE") and the ReadableDagsFilterDep row
    filter are unchanged; only the Dag axis moves from read to edit. The 
generated
    REST API permission reference is updated to match.
    
    The GET queued-events routes keep method="GET" — reading queued events is a
    read.
    
    Added a structural test asserting the Dag-axis method each queued-events 
route
    carries, so the read/write asymmetry cannot silently return. "dependant" is
    added to the spelling wordlist: it is FastAPI's own attribute name on a 
route.
    (cherry picked from commit 04145448135cfcc53644e8ca1a15fa188b129851)
    
    
    Generated-by: Claude Opus 5 (1M context) following the guidelines at
    https: 
//github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 airflow-core/docs/security/api_permissions_ref.rst |  6 +-
 .../api_fastapi/core_api/routes/public/assets.py   |  6 +-
 .../core_api/routes/public/test_assets.py          | 67 ++++++++++++++++++++++
 docs/spelling_wordlist.txt                         |  1 +
 4 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/airflow-core/docs/security/api_permissions_ref.rst 
b/airflow-core/docs/security/api_permissions_ref.rst
index 11097ad5a42..aed4c235b1d 100644
--- a/airflow-core/docs/security/api_permissions_ref.rst
+++ b/airflow-core/docs/security/api_permissions_ref.rst
@@ -89,7 +89,7 @@ source code so it stays up to date as endpoints are added or 
changed.
    * - ``DELETE``
      - ``/api/v2/assets/{asset_id}/queuedEvents``
      - ``DAG``
-     - ``GET``
+     - ``PUT``
    * - ``GET``
      - ``/api/v2/assets/{asset_id}/queuedEvents``
      - ``Asset``
@@ -225,7 +225,7 @@ source code so it stays up to date as endpoints are added 
or changed.
    * - ``DELETE``
      - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
      - ``DAG``
-     - ``GET``
+     - ``PUT``
    * - ``GET``
      - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
      - ``Asset``
@@ -241,7 +241,7 @@ source code so it stays up to date as endpoints are added 
or changed.
    * - ``DELETE``
      - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
      - ``DAG``
-     - ``GET``
+     - ``PUT``
    * - ``GET``
      - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
      - ``Asset``
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
index b5aaa18cef3..c65d4c3dfa5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py
@@ -653,7 +653,7 @@ def get_dag_asset_queued_event(
     responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
     dependencies=[
         Depends(requires_access_asset(method="DELETE")),
-        Depends(requires_access_dag(method="GET")),
+        Depends(requires_access_dag(method="PUT")),
         Depends(action_logging()),
     ],
 )
@@ -687,7 +687,7 @@ def delete_asset_queued_events(
     ),
     dependencies=[
         Depends(requires_access_asset(method="DELETE")),
-        Depends(requires_access_dag(method="GET")),
+        Depends(requires_access_dag(method="PUT")),
         Depends(action_logging()),
     ],
 )
@@ -719,7 +719,7 @@ def delete_dag_asset_queued_events(
     ),
     dependencies=[
         Depends(requires_access_asset(method="DELETE")),
-        Depends(requires_access_dag(method="GET")),
+        Depends(requires_access_dag(method="PUT")),
         Depends(action_logging()),
     ],
 )
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 20801eecd14..65af5792bef 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -1388,6 +1388,73 @@ class 
TestGetDagAssetQueuedEvents(TestQueuedEventEndpoint):
         assert response.json() == {"queued_events": [], "total_entries": 0}
 
 
+class TestQueuedEventsDagAxisAuthorization:
+    """The Dag axis of the queued-events routes must match what the route does 
to the Dag.
+
+    Deleting queued events cancels a Dag's pending asset-triggered scheduling, 
which is a
+    write to that Dag's scheduling state, so those routes require Dag edit. 
Reading them
+    requires only Dag read.
+    """
+
+    @staticmethod
+    def _dag_axis_methods(route) -> list[str]:
+        """Return the ``method`` captured by each ``requires_access_dag`` on a 
route."""
+        from airflow.api_fastapi.core_api.security import requires_access_dag
+
+        module = requires_access_dag.__module__
+        methods = []
+        for dependency in route.dependant.dependencies:
+            call = dependency.call
+            # requires_access_dag returns a closure; the ResourceMethod it was 
built with
+            # is captured in one of that closure's cells.
+            if getattr(call, "__module__", None) != module or not 
call.__closure__:
+                continue
+            if call.__qualname__.split(".")[0] != "requires_access_dag":
+                continue
+            for cell in call.__closure__:
+                value = cell.cell_contents
+                if isinstance(value, str) and value in {"GET", "POST", "PUT", 
"DELETE", "MENU"}:
+                    methods.append(value)
+        return methods
+
+    @pytest.fixture
+    def routes_by_path(self, test_client):
+        return {
+            (r.path, tuple(sorted(r.methods))): r for r in 
test_client.app.routes if hasattr(r, "dependant")
+        }
+
+    @pytest.mark.parametrize(
+        "path",
+        [
+            "/assets/{asset_id}/queuedEvents",
+            "/dags/{dag_id}/assets/queuedEvents",
+            "/dags/{dag_id}/assets/{asset_id}/queuedEvents",
+        ],
+    )
+    def test_delete_queued_events_requires_dag_edit(self, routes_by_path, 
path):
+        matches = [
+            r for (p, methods), r in routes_by_path.items() if 
p.endswith(path) and "DELETE" in methods
+        ]
+        assert matches, f"no DELETE route registered for {path}"
+        for route in matches:
+            assert self._dag_axis_methods(route) == ["PUT"], (
+                f"DELETE {path} must gate the Dag axis on edit, not read"
+            )
+
+    @pytest.mark.parametrize(
+        "path",
+        [
+            "/dags/{dag_id}/assets/queuedEvents",
+            "/dags/{dag_id}/assets/{asset_id}/queuedEvents",
+        ],
+    )
+    def test_get_queued_events_requires_only_dag_read(self, routes_by_path, 
path):
+        matches = [r for (p, methods), r in routes_by_path.items() if 
p.endswith(path) and "GET" in methods]
+        assert matches, f"no GET route registered for {path}"
+        for route in matches:
+            assert self._dag_axis_methods(route) == ["GET"]
+
+
 class TestDeleteDagDatasetQueuedEvents(TestQueuedEventEndpoint):
     @pytest.mark.usefixtures("time_freezer")
     def test_should_respond_204(self, test_client, session, create_dummy_dag):
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index c41eb5427fd..2bb38dd6732 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -451,6 +451,7 @@ delim
 deltalake
 denylist
 dep
+dependant
 DependencyMixin
 Deprecations
 deprecations

Reply via email to