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

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


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new b5c1e9b3a67 [v3-2-test] Apply per-DAG audit log permission to event 
log detail endpoint (#67112) (#67159)
b5c1e9b3a67 is described below

commit b5c1e9b3a6708ffd65ce00699532081e293e190e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue May 19 14:48:06 2026 +0200

    [v3-2-test] Apply per-DAG audit log permission to event log detail endpoint 
(#67112) (#67159)
    
    Align GET /eventLogs/{event_log_id} with the collection endpoint
    GET /eventLogs, which already scopes results to the user's permitted
    Dags via ReadableEventLogsFilterDep. The detail endpoint only enforced
    the generic DagAccessEntity.AUDIT_LOG check via requires_access_dag with
    no dag_id.
    
    Introduce requires_access_event_log, mirroring requires_access_backfill:
    resolve the dag_id from the event log row, then delegate to
    requires_access_dag scoped to that dag_id.
    (cherry picked from commit 4498582dd1ee553c403db5119ddadd7151d6fde1)
    
    Co-authored-by: Pierre Jeambrun <[email protected]>
---
 .../core_api/routes/public/event_logs.py           |  3 +-
 .../src/airflow/api_fastapi/core_api/security.py   | 29 +++++++
 .../core_api/routes/public/test_event_logs.py      | 36 +++++++++
 .../unit/api_fastapi/core_api/test_security.py     | 89 ++++++++++++++++++++++
 4 files changed, 156 insertions(+), 1 deletion(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
index e5b0965a905..c4fb56b5bb5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py
@@ -49,6 +49,7 @@ from airflow.api_fastapi.core_api.security import (
     DagAccessEntity,
     ReadableEventLogsFilterDep,
     requires_access_dag,
+    requires_access_event_log,
 )
 from airflow.models import Log
 
@@ -58,7 +59,7 @@ event_logs_router = AirflowRouter(tags=["Event Log"], 
prefix="/eventLogs")
 @event_logs_router.get(
     "/{event_log_id}",
     responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
-    dependencies=[Depends(requires_access_dag("GET", 
DagAccessEntity.AUDIT_LOG))],
+    dependencies=[Depends(requires_access_event_log("GET"))],
 )
 def get_event_log(
     event_log_id: int,
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 941a4c418f5..4b2ed7cadb3 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -331,6 +331,35 @@ def requires_access_backfill(
     return inner
 
 
+def requires_access_event_log(
+    method: ResourceMethod,
+) -> Callable[[Request, BaseUser, Session], Coroutine[Any, Any, None]]:
+    """Wrap ``requires_access_dag`` and extract the dag_id from the 
event_log_id."""
+
+    async def inner(
+        request: Request,
+        user: GetUserDep,
+        session: SessionDep,
+    ) -> None:
+        dag_id = None
+
+        event_log_id_raw = request.path_params.get("event_log_id")
+        try:
+            event_log_id = int(event_log_id_raw) if event_log_id_raw is not 
None else None
+        except ValueError:
+            event_log_id = None
+
+        if event_log_id is not None:
+            dag_id = session.scalar(select(Log.dag_id).where(Log.id == 
event_log_id))
+
+        requires_access_dag(method, DagAccessEntity.AUDIT_LOG, dag_id)(
+            request,
+            user,
+        )
+
+    return inner
+
+
 class PermittedPoolFilter(OrmClause[set[str]]):
     """A parameter that filters the permitted pools for the user."""
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
index 3decc63918a..2812143b186 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py
@@ -17,9 +17,11 @@
 from __future__ import annotations
 
 from datetime import datetime, timezone
+from unittest import mock
 
 import pytest
 
+from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity, DagDetails
 from airflow.models.log import Log
 from airflow.utils.session import provide_session
 
@@ -196,6 +198,40 @@ class TestGetEventLog(TestEventLogsEndpoint):
         response = unauthorized_test_client.get(f"/eventLogs/{event_log_id}")
         assert response.status_code == 403
 
+    def test_should_respond_403_when_user_lacks_dag_audit_log_permission(self, 
test_client, setup):
+        """The detail endpoint must enforce the per-DAG audit log permission 
of the event log's dag_id."""
+        event_log_id = setup[TASK_INSTANCE_EVENT].id
+        with mock.patch(
+            
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_dag",
+            return_value=False,
+        ) as mock_is_authorized_dag:
+            response = test_client.get(f"/eventLogs/{event_log_id}")
+
+        assert response.status_code == 403
+        mock_is_authorized_dag.assert_called_once_with(
+            method="GET",
+            access_entity=DagAccessEntity.AUDIT_LOG,
+            details=DagDetails(id=DAG_ID, team_name=None),
+            user=mock.ANY,
+        )
+
+    def test_should_authorize_with_event_log_dag_id(self, test_client, setup):
+        """When the event log is bound to a DAG, authorization must scope to 
that DAG id."""
+        event_log_id = setup[TASK_INSTANCE_EVENT].id
+        with mock.patch(
+            
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_dag",
+            return_value=True,
+        ) as mock_is_authorized_dag:
+            response = test_client.get(f"/eventLogs/{event_log_id}")
+
+        assert response.status_code == 200
+        mock_is_authorized_dag.assert_called_once_with(
+            method="GET",
+            access_entity=DagAccessEntity.AUDIT_LOG,
+            details=DagDetails(id=DAG_ID, team_name=None),
+            user=mock.ANY,
+        )
+
 
 class TestGetEventLogs(TestEventLogsEndpoint):
     @pytest.mark.parametrize(
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
index 9b8a3abd762..b6d29732fb3 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
@@ -43,6 +43,7 @@ from airflow.api_fastapi.core_api.security import (
     requires_access_connection,
     requires_access_connection_bulk,
     requires_access_dag,
+    requires_access_event_log,
     requires_access_pool,
     requires_access_pool_bulk,
     requires_access_variable,
@@ -379,6 +380,94 @@ class TestFastApiSecurity:
             user=user,
         )
 
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @patch.object(DagModel, "get_team_name")
+    @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+    async def test_requires_access_event_log_authorized_from_path(
+        self, mock_get_auth_manager, mock_get_team_name
+    ):
+        """When event_log_id is in path and the Log exists, dag_id from the 
row is used."""
+        auth_manager = Mock()
+        auth_manager.is_authorized_dag.return_value = True
+        mock_get_auth_manager.return_value = auth_manager
+        mock_get_team_name.return_value = "team1"
+
+        session = Mock()
+        session.scalar.return_value = "event_log_dag_id"
+
+        request = Mock()
+        request.path_params = {"event_log_id": "42"}
+        user = Mock()
+
+        inner = requires_access_event_log("GET")
+        await inner(request, user, session)
+
+        auth_manager.is_authorized_dag.assert_called_once_with(
+            method="GET",
+            access_entity=DagAccessEntity.AUDIT_LOG,
+            details=DagDetails(id="event_log_dag_id", team_name="team1"),
+            user=user,
+        )
+
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @patch.object(DagModel, "get_team_name")
+    @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+    async def test_requires_access_event_log_unauthorized(self, 
mock_get_auth_manager, mock_get_team_name):
+        """When is_authorized_dag returns False for the event log's dag_id, 
Forbidden is raised."""
+        auth_manager = Mock()
+        auth_manager.is_authorized_dag.return_value = False
+        mock_get_auth_manager.return_value = auth_manager
+        mock_get_team_name.return_value = None
+
+        session = Mock()
+        session.scalar.return_value = "unauthorized_dag"
+
+        request = Mock()
+        request.path_params = {"event_log_id": "1"}
+        user = Mock()
+
+        inner = requires_access_event_log("GET")
+        with pytest.raises(HTTPException, match="Forbidden"):
+            await inner(request, user, session)
+
+        auth_manager.is_authorized_dag.assert_called_once_with(
+            method="GET",
+            access_entity=DagAccessEntity.AUDIT_LOG,
+            details=DagDetails(id="unauthorized_dag", team_name=None),
+            user=user,
+        )
+
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @patch.object(DagModel, "get_team_name")
+    @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+    async def test_requires_access_event_log_row_not_found(self, 
mock_get_auth_manager, mock_get_team_name):
+        """When the Log row does not exist, dag_id is None and the generic 
AUDIT_LOG check applies."""
+        auth_manager = Mock()
+        auth_manager.is_authorized_dag.return_value = True
+        mock_get_auth_manager.return_value = auth_manager
+
+        session = Mock()
+        session.scalar.return_value = None
+
+        request = Mock()
+        request.path_params = {"event_log_id": "999"}
+        request.query_params = {}
+        user = Mock()
+
+        inner = requires_access_event_log("GET")
+        await inner(request, user, session)
+
+        auth_manager.is_authorized_dag.assert_called_once_with(
+            method="GET",
+            access_entity=DagAccessEntity.AUDIT_LOG,
+            details=DagDetails(id=None, team_name=None),
+            user=user,
+        )
+        mock_get_team_name.assert_not_called()
+
     @pytest.mark.parametrize(
         ("url", "expected_is_safe"),
         [

Reply via email to