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