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

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


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 4ae8da9d53c [v3-4-test] Scope upstream asset events of a Dag run to 
assets and Dags the caller may read (#72924) (#75108)
4ae8da9d53c is described below

commit 4ae8da9d53c9b881874e35257a38bcc39fab039c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 19 22:41:32 2026 +0200

    [v3-4-test] Scope upstream asset events of a Dag run to assets and Dags the 
caller may read (#72924) (#75108)
    
    * [v3-4-test] Scope upstream asset events of a Dag run to assets and Dags 
the caller may read (#72924)
    
    GET /dags/{dag_id}/dagRuns/{dag_run_id}/upstreamAssetEvents returned every
    asset event a run consumed as long as the caller could read the consuming 
Dag.
    Events of assets, or produced by source Dags, the caller is not allowed to 
read
    were exposed, including their name, uri, extra and source Dag/task ids, even
    though GET /assets/events hides the same events.
    
    Query the consumed events through the dagrun_asset_event table and apply the
    same per-asset and per-source-Dag readability filters as GET /assets/events,
    so hidden events are excluded from both the response and total_entries.
    (cherry picked from commit 6f408e1715b411525e2c8395b4a3c39121c5d97d)
    
    Co-authored-by: Henry Chen <[email protected]>
    
    * Mock the simple auth manager in the upstream asset events permission test 
(#75126)
    
    SimpleAuthManager overrides get_authorized_assets since #72934, so patching 
the BaseAuthManager method left the real one in place, every asset stayed 
readable, and the test added in #72924 failed on main.
    
    Generated-by: Claude Opus 5
    (cherry picked from commit 33e6b5f1fa9f0e0a96fd52075e89000d47b781ee)
    
    ---------
    
    Co-authored-by: Henry Chen <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../api_fastapi/core_api/routes/public/dag_run.py  | 34 ++++++---
 .../core_api/routes/public/test_dag_run.py         | 88 +++++++++++++++++++++-
 2 files changed, 108 insertions(+), 14 deletions(-)

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 9eef9661799..974f086a059 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
@@ -26,7 +26,7 @@ from fastapi.exceptions import RequestValidationError
 from fastapi.responses import StreamingResponse
 from pydantic import ValidationError
 from sqlalchemy import select
-from sqlalchemy.orm import joinedload
+from sqlalchemy.orm import joinedload, subqueryload
 
 from airflow.api_fastapi.app import get_auth_manager
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity, DagDetails
@@ -102,6 +102,8 @@ from airflow.api_fastapi.core_api.datamodels.task_instances 
import (
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import (
     GetUserDep,
+    ReadableAssetEventsByAssetFilterDep,
+    ReadableAssetEventsFilterDep,
     ReadableDagRunsFilterDep,
     requires_access_asset,
     requires_access_dag,
@@ -122,7 +124,7 @@ from airflow.api_fastapi.core_api.services.public.dag_run 
import (
 from airflow.api_fastapi.logging.decorators import action_logging
 from airflow.exceptions import DagVersionNotFound, ParamValidationError
 from airflow.models import DagModel, DagRun
-from airflow.models.asset import AssetEvent
+from airflow.models.asset import AssetEvent, association_table as 
dagrun_asset_event_table
 from airflow.models.dag_version import DagVersion
 from airflow.utils.state import DagRunState
 from airflow.utils.types import DagRunTriggeredByType, DagRunType
@@ -282,26 +284,34 @@ def bulk_dag_runs(
     ],
 )
 def get_upstream_asset_events(
-    dag_id: str, dag_run_id: str, session: SessionDep
+    dag_id: str,
+    dag_run_id: str,
+    readable_asset_events_filter: ReadableAssetEventsFilterDep,
+    readable_asset_events_by_asset_filter: ReadableAssetEventsByAssetFilterDep,
+    session: SessionDep,
 ) -> AssetEventCollectionResponse:
     """If dag run is asset-triggered, return the asset events that triggered 
it."""
-    dag_run: DagRun | None = session.scalar(
-        select(DagRun)
-        .where(
+    dag_run_pk = session.scalar(
+        select(DagRun.id).where(
             DagRun.dag_id == dag_id,
             DagRun.run_id == dag_run_id,
         )
-        .options(
-            
joinedload(DagRun.consumed_asset_events).joinedload(AssetEvent.asset),
-            
joinedload(DagRun.consumed_asset_events).subqueryload(AssetEvent.created_dagruns),
-        )
     )
-    if dag_run is None:
+    if dag_run_pk is None:
         raise HTTPException(
             status.HTTP_404_NOT_FOUND,
             f"The DagRun with dag_id: `{dag_id}` and run_id: `{dag_run_id}` 
was not found",
         )
-    events = dag_run.consumed_asset_events
+    # Reading the consuming run must not expose events of assets or source 
Dags the caller may not read.
+    events_select = apply_filters_to_select(
+        statement=select(AssetEvent)
+        .join(dagrun_asset_event_table, dagrun_asset_event_table.c.event_id == 
AssetEvent.id)
+        .where(dagrun_asset_event_table.c.dag_run_id == dag_run_pk)
+        .options(joinedload(AssetEvent.asset), 
subqueryload(AssetEvent.created_dagruns))
+        .order_by(AssetEvent.id),
+        filters=[readable_asset_events_filter, 
readable_asset_events_by_asset_filter],
+    )
+    events = session.scalars(events_select).unique().all()
     return AssetEventCollectionResponse(
         asset_events=serialize_asset_events(events, session=session),
         total_entries=len(events),
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 36b9e4b6822..0fec3f0fe15 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
@@ -2019,7 +2019,7 @@ class TestGetDagRunAssetTriggerEvents:
         session.commit()
         assert event.timestamp
 
-        with assert_queries_count(4):
+        with assert_queries_count(7):
             response = test_client.get(
                 
"/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents",
             )
@@ -2102,7 +2102,7 @@ class TestGetDagRunAssetTriggerEvents:
         session.commit()
 
         # Constant regardless of the number of consumed events (parametrized 1 
vs 5).
-        with assert_queries_count(4):
+        with assert_queries_count(7):
             response = 
test_client.get("/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents")
 
         assert response.status_code == 200
@@ -2116,6 +2116,90 @@ class TestGetDagRunAssetTriggerEvents:
         assert triggering_by_event[newest_event_id] is True
         assert all(value is False for eid, value in 
triggering_by_event.items() if eid != newest_event_id)
 
+    def _create_consuming_run_with_events(self, dag_maker, session):
+        """
+        Create a run consuming three events: one per source Dag, one with no 
source Dag.
+
+        Returns the asset ids keyed by index and the event ids keyed by 
scenario.
+        """
+        asset_ids = {}
+        for index in (1, 2):
+            asset = AssetModel(name=f"ds{index}", uri=f"file:///da{index}", 
group="asset")
+            session.add(asset)
+            session.flush()
+            asset_ids[index] = asset.id
+        events = {
+            "dag_a": AssetEvent(asset_id=asset_ids[1], 
source_dag_id="source_dag_a", timestamp=START_DATE1),
+            "dag_b": AssetEvent(asset_id=asset_ids[2], 
source_dag_id="source_dag_b", timestamp=START_DATE1),
+            "dagless": AssetEvent(asset_id=asset_ids[1], 
timestamp=START_DATE1),
+        }
+        session.add_all(events.values())
+        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.extend(events.values())
+        session.commit()
+        return asset_ids, {key: event.id for key, event in events.items()}
+
+    @pytest.mark.parametrize(
+        ("readable_dags", "expected_events"),
+        [
+            pytest.param(["source_dag_a"], ["dag_a", "dagless"], 
id="one-readable-source-dag-plus-dagless"),
+            pytest.param(
+                ["source_dag_a", "source_dag_b"],
+                ["dag_a", "dag_b", "dagless"],
+                id="both-source-dags-readable",
+            ),
+            pytest.param([], ["dagless"], 
id="no-readable-source-dags-still-sees-dagless"),
+        ],
+    )
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids",
+        autospec=True,
+    )
+    def test_returns_only_events_from_source_dags_the_caller_may_read(
+        self, mock_get_authorized_dag_ids, test_client, dag_maker, session, 
readable_dags, expected_events
+    ):
+        """Reading the consuming run must not expose events produced by Dags 
the caller cannot read."""
+        mock_get_authorized_dag_ids.return_value = set(readable_dags)
+        _, event_ids = self._create_consuming_run_with_events(dag_maker, 
session)
+        expected_ids = [event_ids[key] for key in expected_events]
+
+        response = 
test_client.get("/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert [event["id"] for event in body["asset_events"]] == expected_ids
+        # The count must be scoped too, so the existence of hidden events does 
not leak.
+        assert body["total_entries"] == len(expected_ids)
+
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids",
+        autospec=True,
+    )
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
+        autospec=True,
+    )
+    def test_returns_only_events_of_assets_the_caller_may_read(
+        self, mock_get_authorized_assets, mock_get_authorized_dag_ids, 
test_client, dag_maker, session
+    ):
+        """An event of an asset the caller cannot read is hidden even when its 
source Dag is readable."""
+        asset_ids, event_ids = 
self._create_consuming_run_with_events(dag_maker, session)
+        mock_get_authorized_dag_ids.return_value = {"source_dag_a", 
"source_dag_b"}
+        mock_get_authorized_assets.return_value = {asset_ids[2]}
+
+        response = 
test_client.get("/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert [event["id"] for event in body["asset_events"]] == 
[event_ids["dag_b"]]
+        assert {event["uri"] for event in body["asset_events"]} == 
{"file:///da2"}
+        assert body["total_entries"] == 1
+
     def test_should_respond_401(self, unauthenticated_test_client):
         response = unauthenticated_test_client.get(
             "/dags/TEST_DAG_ID/dagRuns/TEST_DAG_RUN_ID/upstreamAssetEvents",

Reply via email to