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 e26a4cfe458 [v3-3-test] Scope /assets/events to the Dags the caller 
may read (#71741) (#71785)
e26a4cfe458 is described below

commit e26a4cfe458def722555d38568c97bbe7b7442d7
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 18 20:58:00 2026 +0200

    [v3-3-test] Scope /assets/events to the Dags the caller may read (#71741) 
(#71785)
    
    GET /api/v2/assets/events returned AssetEvent rows for every Dag. It is 
gated
    on requires_access_asset(method="GET"), which under the FAB auth manager 
checks
    the global "Assets" resource and does not consider which Dag produced the
    event, and the query applied no per-Dag row filter.
    
    A caller with read on a single Dag plus the global "Assets" resource could
    therefore read the source Dag, task and run identifiers, the created dag 
runs,
    and the task-authored "extra" payload of events belonging to every other 
Dag,
    and could target a specific one with ?source_dag_id=.
    
    The six sibling queued-events routes in the same file already apply
    ReadableDagsFilterDep; only this one did not.
    
    Add PermittedAssetEventFilter and apply it to the query. Events produced by 
a
    Dag's task are scoped to that Dag's readability. Events with no source Dag —
    created through the API, or emitted by a watcher — carry no per-Dag key to
    authorize on and stay visible to any caller who may read assets.
    
    The filter is applied inside paginated_select rather than after the fact, so
    total_entries and pagination are scoped too and the existence of hidden 
events
    does not leak either.
    
    Test fixtures that create events with a source_dag_id now register the
    corresponding Dag, since the scoping resolves against DagModel and the 
fixtures
    previously referenced Dags that did not exist. The query-count assertion 
moves
    from 4 to 5: resolving the caller's readable Dags costs one query, the same
    cost the queued-events routes already pay.
    (cherry picked from commit f01520cbd1b20ac6beef80e244452adfa39566c9)
    
    
    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]>
---
 .../api_fastapi/core_api/routes/public/assets.py   |   3 +
 .../src/airflow/api_fastapi/core_api/security.py   |  21 ++++
 .../core_api/routes/public/test_assets.py          | 112 ++++++++++++++++++++-
 3 files changed, 135 insertions(+), 1 deletion(-)

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 b3c41514b7d..b5aaa18cef3 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
@@ -65,6 +65,7 @@ from airflow.api_fastapi.core_api.datamodels.dag_run import 
DAGRunResponse
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import (
     GetUserDep,
+    ReadableAssetEventsFilterDep,
     ReadableDagsFilterDep,
     requires_access_asset,
     requires_access_asset_alias,
@@ -324,6 +325,7 @@ def get_asset_events(
     name_pattern: QueryAssetNamePatternSearch,
     name_prefix_pattern: QueryAssetNamePrefixPatternSearch,
     timestamp_range: Annotated[RangeFilter, 
Depends(datetime_range_filter_factory("timestamp", AssetEvent))],
+    readable_asset_events_filter: ReadableAssetEventsFilterDep,
     session: SessionDep,
 ) -> AssetEventCollectionResponse:
     """Get asset events."""
@@ -342,6 +344,7 @@ def get_asset_events(
             name_pattern,
             name_prefix_pattern,
             timestamp_range,
+            readable_asset_events_filter,
         ],
         order_by=order_by,
         offset=offset,
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/security.py 
b/airflow-core/src/airflow/api_fastapi/core_api/security.py
index f5c86a5d6a9..c814aec5956 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -70,6 +70,7 @@ from airflow.api_fastapi.core_api.datamodels.pools import 
PoolBody
 from airflow.api_fastapi.core_api.datamodels.variables import VariableBody
 from airflow.configuration import conf
 from airflow.models import Connection, Pool, Variable
+from airflow.models.asset import AssetEvent
 from airflow.models.backfill import Backfill
 from airflow.models.dag import DagModel, DagRun, DagTag
 from airflow.models.dag_version import DagVersion
@@ -274,6 +275,23 @@ class PermittedEventLogFilter(PermittedDagFilter):
         return select.where(or_(Log.dag_id.in_(self.value or set()), 
Log.dag_id.is_(None)))
 
 
+class PermittedAssetEventFilter(PermittedDagFilter):
+    """A parameter that filters asset events to those produced by Dags the 
user may read."""
+
+    def to_orm(self, select: Select) -> Select:
+        # Asset events created through the API, or emitted by a watcher, have 
no source Dag.
+        # They carry no per-Dag key to authorize on, so they stay visible to 
any caller who
+        # may read assets; only events produced by a Dag's task are scoped to 
that Dag's
+        # readability. Filtering here rather than after the fact keeps 
unauthorized rows out
+        # of the count and pagination too, so their existence does not leak 
either.
+        return select.where(
+            or_(
+                AssetEvent.source_dag_id.in_(self.value or set()),
+                AssetEvent.source_dag_id.is_(None),
+            )
+        )
+
+
 class PermittedTIFilter(PermittedDagFilter):
     """A parameter that filters the permitted task instances for the user."""
 
@@ -334,6 +352,9 @@ ReadableDagsFilterDep = Annotated[PermittedDagFilter, 
Depends(permitted_dag_filt
 ReadableDagRunsFilterDep = Annotated[
     PermittedDagRunFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedDagRunFilter))
 ]
+ReadableAssetEventsFilterDep = Annotated[
+    PermittedAssetEventFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedAssetEventFilter))
+]
 ReadableDagWarningsFilterDep = Annotated[
     PermittedDagWarningFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedDagWarningFilter))
 ]
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 29327d1d404..20801eecd14 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
@@ -27,6 +27,7 @@ from sqlalchemy import delete, func, select, update
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity, DagDetails
+from airflow.api_fastapi.core_api.security import PermittedAssetEventFilter
 from airflow.models import DagModel
 from airflow.models.asset import (
     AssetActive,
@@ -68,6 +69,10 @@ pytestmark = pytest.mark.db_test
 
 
 def _create_assets(session, num: int = 2) -> list[AssetModel]:
+    # Event fixtures in this module attribute their events to these Dag ids. 
/assets/events
+    # scopes results to the Dags the caller may read, so the Dags have to 
exist for the
+    # fixtures to represent a real deployment.
+    _ensure_dags(session, "source_dag_id", "d", "d1", "d2")
     assets = [
         AssetModel(
             id=i,
@@ -173,7 +178,29 @@ def _create_provided_asset_alias(session, asset_alias: 
AssetAliasModel) -> None:
     session.commit()
 
 
+def _ensure_dags(session, *dag_ids: str) -> None:
+    """Register the Dags that asset-event fixtures attribute their events to.
+
+    ``/assets/events`` scopes events to the Dags the caller may read, and that 
scoping
+    resolves against ``DagModel``. Fixtures that create events with a 
``source_dag_id``
+    therefore need the corresponding Dag to exist, as it would in a real 
deployment.
+    """
+    from airflow.models.dagbundle import DagBundleModel
+
+    session.merge(DagBundleModel(name="testing"))
+    session.flush()
+    for dag_id in dag_ids:
+        if session.get(DagModel, dag_id) is None:
+            session.add(DagModel(dag_id=dag_id, bundle_name="testing"))
+    session.commit()
+
+
+def _ensure_source_dag(session) -> None:
+    _ensure_dags(session, "source_dag_id")
+
+
 def _create_assets_events(session, num: int = 2, varying_timestamps=False) -> 
None:
+    _ensure_source_dag(session)
     assets_events = [
         AssetEvent(
             id=i,
@@ -191,6 +218,7 @@ def _create_assets_events(session, num: int = 2, 
varying_timestamps=False) -> No
 
 
 def _create_assets_events_with_sensitive_extra(session, num: int = 2) -> None:
+    _ensure_source_dag(session)
     assets_events = [
         AssetEvent(
             id=i,
@@ -208,11 +236,13 @@ def _create_assets_events_with_sensitive_extra(session, 
num: int = 2) -> None:
 
 
 def _create_provided_asset_event(session, asset_event: AssetEvent) -> None:
+    _ensure_source_dag(session)
     session.add(asset_event)
     session.commit()
 
 
 def _create_dag_run(session, num: int = 2):
+    _ensure_source_dag(session)
     dag_runs = [
         DagRun(
             dag_id="source_dag_id",
@@ -814,6 +844,84 @@ class 
TestGetAssetAliasesEndpointPagination(TestAssetAliases):
         assert len(response.json()["asset_aliases"]) == 50
 
 
+class TestGetAssetEventsPerDagScoping(TestAssets):
+    """``/assets/events`` returns only events the caller is entitled to see.
+
+    An event produced by a Dag's task is scoped to that Dag's readability. An 
event with no
+    source Dag — created through the API, or emitted by a watcher — carries no 
per-Dag key to
+    authorize on and stays visible.
+    """
+
+    def test_filter_scopes_to_source_dag_and_keeps_dagless_events(self):
+        """The clause admits the readable Dags and rows with no source Dag."""
+        rendered = 
str(PermittedAssetEventFilter({"readable_dag"}).to_orm(select(AssetEvent)))
+
+        assert "source_dag_id IN" in rendered
+        assert "source_dag_id IS NULL" in rendered
+
+    def test_filter_with_no_readable_dags_still_admits_dagless_events(self, 
session):
+        """A caller who may read no Dag at all still sees events that belong 
to no Dag."""
+        self.create_assets(session=session, num=1)
+        session.add(AssetEvent(id=1, asset_id=1, extra={}, 
timestamp=DEFAULT_DATE))
+        session.add(
+            AssetEvent(id=2, asset_id=1, extra={}, 
source_dag_id="source_dag_id", timestamp=DEFAULT_DATE)
+        )
+        session.commit()
+
+        statement = PermittedAssetEventFilter(set()).to_orm(select(AssetEvent))
+        visible = session.scalars(statement).all()
+
+        assert [event.id for event in visible] == [1]
+
+    def test_filter_admits_only_events_from_readable_dags(self, session):
+        """An event produced by a Dag the caller cannot read is not 
returned."""
+        self.create_assets(session=session, num=1)
+        session.add(
+            AssetEvent(id=1, asset_id=1, extra={}, 
source_dag_id="source_dag_id", timestamp=DEFAULT_DATE)
+        )
+        session.add(AssetEvent(id=2, asset_id=1, extra={}, 
source_dag_id="other_dag", timestamp=DEFAULT_DATE))
+        session.commit()
+
+        statement = 
PermittedAssetEventFilter({"source_dag_id"}).to_orm(select(AssetEvent))
+        visible = session.scalars(statement).all()
+
+        assert [event.id for event in visible] == [1]
+
+    @pytest.mark.parametrize(
+        ("readable_dags", "expected_ids"),
+        [
+            pytest.param(["source_dag_id"], [1, 3], 
id="one-readable-dag-plus-dagless"),
+            pytest.param(["source_dag_id", "other_dag"], [1, 2, 3], 
id="both-dags-readable"),
+            pytest.param([], [3], id="no-readable-dags-still-sees-dagless"),
+        ],
+    )
+    
@mock.patch("airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids")
+    def test_endpoint_returns_only_events_the_caller_may_read(
+        self, mock_get_authorized_dag_ids, test_client, session, 
readable_dags, expected_ids
+    ):
+        """End-to-end: the route itself scopes the response, not just the 
filter class."""
+        mock_get_authorized_dag_ids.return_value = set(readable_dags)
+
+        self.create_assets(session=session, num=1)
+        session.add_all(
+            [
+                AssetEvent(id=1, asset_id=1, extra={}, 
source_dag_id="source_dag_id", timestamp=DEFAULT_DATE),
+                AssetEvent(id=2, asset_id=1, extra={}, 
source_dag_id="other_dag", timestamp=DEFAULT_DATE),
+                # No source Dag: created through the API or emitted by a 
watcher.
+                AssetEvent(id=3, asset_id=1, extra={}, timestamp=DEFAULT_DATE),
+            ]
+        )
+        session.commit()
+
+        response = test_client.get("/assets/events")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert sorted(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)
+
+
 class TestGetAssetEvents(TestAssets):
     def test_should_respond_200(self, test_client, session):
         asset1, asset2 = self.create_assets(session=session)
@@ -824,7 +932,9 @@ class TestGetAssetEvents(TestAssets):
         session.commit()
         assert len(assets) == 2
 
-        with assert_queries_count(4):
+        # 5 rather than 4: resolving the caller's readable Dags, so events can 
be scoped
+        # to them, costs one additional query — the same cost the 
queued-events routes pay.
+        with assert_queries_count(5):
             response = test_client.get("/assets/events")
 
         assert response.status_code == 200

Reply via email to