This is an automated email from the ASF dual-hosted git repository.
henry3260 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 e54a6af12d6 Scope asset event created Dag runs to readable Dags
(#76234)
e54a6af12d6 is described below
commit e54a6af12d6006721e946f5bff11c8bec739cd1a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Nov 1 18:14:50 2026 +0100
Scope asset event created Dag runs to readable Dags (#76234)
* Scope asset event created Dag runs to readable Dags
Generated-by: Claude Opus 5
* Rename newsfragment to the PR number
Generated-by: Claude Opus 5
---
airflow-core/newsfragments/76234.bugfix.rst | 1 +
.../api_fastapi/core_api/routes/public/assets.py | 4 +-
.../api_fastapi/core_api/routes/public/dag_run.py | 4 +-
.../api_fastapi/core_api/services/public/assets.py | 17 ++++++-
.../core_api/routes/public/test_assets.py | 42 ++++++++++++++++-
.../core_api/routes/public/test_dag_run.py | 54 ++++++++++++++++++++++
6 files changed, 117 insertions(+), 5 deletions(-)
diff --git a/airflow-core/newsfragments/76234.bugfix.rst
b/airflow-core/newsfragments/76234.bugfix.rst
new file mode 100644
index 00000000000..d733ae3816a
--- /dev/null
+++ b/airflow-core/newsfragments/76234.bugfix.rst
@@ -0,0 +1 @@
+The ``created_dagruns`` list in asset events returned by the asset events and
upstream asset events API endpoints now only includes Dag runs of Dags the
requesting user can read.
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 175e119d62e..003e880b81d 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
@@ -381,7 +381,9 @@ def get_asset_events(
assets_events = session.scalars(assets_event_select).all()
return AssetEventCollectionResponse(
- asset_events=serialize_asset_events(assets_events, session=session),
+ asset_events=serialize_asset_events(
+ assets_events,
readable_dag_ids=readable_asset_events_filter.value, session=session
+ ),
total_entries=total_entries,
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
index bbe47fbde6c..47aa3e39c08 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py
@@ -313,7 +313,9 @@ def get_upstream_asset_events(
)
events = session.scalars(events_select).unique().all()
return AssetEventCollectionResponse(
- asset_events=serialize_asset_events(events, session=session),
+ asset_events=serialize_asset_events(
+ events, readable_dag_ids=readable_asset_events_filter.value,
session=session
+ ),
total_entries=len(events),
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
index f110f15e1fd..c7816ebc577 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/assets.py
@@ -28,18 +28,31 @@ if TYPE_CHECKING:
from airflow.models.asset import AssetEvent
-def serialize_asset_events(events: Sequence[AssetEvent], *, session: Session)
-> list[AssetEventResponse]:
+def serialize_asset_events(
+ events: Sequence[AssetEvent], *, readable_dag_ids: set[str] | None,
session: Session
+) -> list[AssetEventResponse]:
"""
Serialize asset events, flagging on each created dag run whether this
event triggered it.
Only a run's most recent consumed event triggers it; the rest were merely
included. The flag is
resolved per ``(event_id, dag_id, run_id)`` tuple and set on the run
before validation so the response
carries it.
+
+ The created dag runs are narrowed to the Dags in ``readable_dag_ids``.
``None`` is treated as no
+ readable Dag, as the asset events filter the ids come from does. The
argument has no default so that a
+ new caller has to decide, rather than return every run by omission. The
narrowing is applied to the
+ response, never to the ``created_dagruns`` relationship, so the session
does not see a changed
+ collection.
"""
+ readable = readable_dag_ids or set()
triggering_refs = resolve_triggering_event_refs(events, session=session)
asset_events = []
for event in events:
for run in event.created_dagruns:
run.triggering = (event.id, run.dag_id, run.run_id) in
triggering_refs
- asset_events.append(AssetEventResponse.model_validate(event))
+ response = AssetEventResponse.model_validate(event)
+ response = response.model_copy(
+ update={"created_dagruns": [run for run in
response.created_dagruns if run.dag_id in readable]}
+ )
+ asset_events.append(response)
return asset_events
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 ea87274ecd0..655a30e7d96 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
@@ -1101,6 +1101,46 @@ class TestGetAssetEventsPerDagScoping(TestAssets):
assert [event["id"] for event in body["asset_events"]] == [2, 4]
assert body["total_entries"] == 2
+ @pytest.mark.parametrize(
+ ("readable_dags", "expected_run_dags"),
+ [
+ pytest.param({"dag_a"}, ["dag_a"], id="one-readable-consumer-dag"),
+ pytest.param({"dag_a", "dag_b"}, ["dag_a", "dag_b"],
id="both-consumer-dags-readable"),
+ pytest.param(set(), [], id="no-readable-consumer-dags"),
+ ],
+ )
+
@mock.patch("airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids")
+ def test_endpoint_returns_only_created_dagruns_of_readable_dags(
+ self, mock_get_authorized_dag_ids, test_client, session,
readable_dags, expected_run_dags
+ ):
+ """The Dag runs an event created are narrowed to the Dags the caller
may read."""
+ mock_get_authorized_dag_ids.return_value = readable_dags
+ _ensure_dags(session, "dag_a", "dag_b")
+ self.create_assets(session=session, num=1)
+ # No source Dag, so the event itself is visible to every caller.
+ event = AssetEvent(id=1, asset_id=1, extra={}, timestamp=DEFAULT_DATE)
+ session.add(event)
+ session.flush()
+ for dag_id in ("dag_a", "dag_b"):
+ dag_run = DagRun(
+ dag_id=dag_id,
+ run_id=f"{dag_id}_run",
+ run_type=DagRunType.ASSET_TRIGGERED,
+ logical_date=DEFAULT_DATE,
+ start_date=DEFAULT_DATE,
+ data_interval=(DEFAULT_DATE, DEFAULT_DATE),
+ state=DagRunState.SUCCESS,
+ )
+ dag_run.consumed_asset_events.append(event)
+ session.add(dag_run)
+ session.commit()
+
+ response = test_client.get("/assets/events")
+
+ assert response.status_code == 200
+ [returned_event] = response.json()["asset_events"]
+ assert sorted(run["dag_id"] for run in
returned_event["created_dagruns"]) == expected_run_dags
+
class TestGetAssetEvents(TestAssets):
@pytest.mark.parametrize("unknown", [False, True])
@@ -1268,7 +1308,7 @@ class TestGetAssetEvents(TestAssets):
def test_should_return_created_dag_run_without_start_date(self,
test_client, session):
self.create_assets(num=1, session=session)
- _ensure_dags(session, "producer_dag")
+ _ensure_dags(session, "producer_dag", "consumer_dag")
asset_event = AssetEvent(
asset_id=1,
source_dag_id="producer_dag",
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
index 6c6aa57990d..9610949dded 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py
@@ -2217,6 +2217,60 @@ class TestGetDagRunAssetTriggerEvents:
assert {event["uri"] for event in body["asset_events"]} ==
{"file:///da2"}
assert body["total_entries"] == 1
+ @pytest.mark.parametrize(
+ ("readable_dags", "expected_run_dags"),
+ [
+ pytest.param({"TEST_DAG_ID"}, ["TEST_DAG_ID"],
id="other-consumer-dag-not-readable"),
+ pytest.param(
+ {"TEST_DAG_ID", "other_consumer"}, ["TEST_DAG_ID",
"other_consumer"], id="both-readable"
+ ),
+ ],
+ )
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids",
+ autospec=True,
+ )
+ def test_returns_only_created_dagruns_of_readable_dags(
+ self, mock_get_authorized_dag_ids, test_client, dag_maker, session,
readable_dags, expected_run_dags
+ ):
+ """An event's created Dag runs are narrowed to the Dags the caller may
read."""
+ mock_get_authorized_dag_ids.return_value = readable_dags
+ asset = AssetModel(name="ds1", uri="file:///da1", group="asset")
+ session.add(asset)
+ session.flush()
+ # No source Dag, so the event itself is visible to every caller.
+ event = AssetEvent(asset_id=asset.id, timestamp=START_DATE1)
+ session.add(event)
+ session.flush()
+ with dag_maker(
+ dag_id="TEST_DAG_ID", start_date=START_DATE1,
schedule=timedelta(days=1), session=session
+ ):
+ pass
+ dag_run = dag_maker.create_dagrun(run_id="TEST_DAG_RUN_ID",
run_type=DagRunType.ASSET_TRIGGERED)
+ dag_run.consumed_asset_events.append(event)
+ session.merge(DagBundleModel(name="testing"))
+ session.flush()
+ session.add(DagModel(dag_id="other_consumer", bundle_name="testing"))
+ session.flush()
+ other_run = DagRun(
+ dag_id="other_consumer",
+ run_id="other_consumer_run",
+ run_type=DagRunType.ASSET_TRIGGERED,
+ logical_date=START_DATE1,
+ start_date=START_DATE1,
+ data_interval=(START_DATE1, START_DATE1),
+ state=DagRunState.SUCCESS,
+ )
+ other_run.consumed_asset_events.append(event)
+ session.add(other_run)
+ session.commit()
+
+ response =
test_client.get("/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents")
+
+ assert response.status_code == 200
+ [returned_event] = response.json()["asset_events"]
+ assert sorted(run["dag_id"] for run in
returned_event["created_dagruns"]) == expected_run_dags
+
def test_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.get(
"/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents",