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

potiuk 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 dfaff482594 Gate audit log rows not tied to a Dag on a dedicated 
AccessView (#70759)
dfaff482594 is described below

commit dfaff4825944d8dce1b5c40e83f28706a2354984
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Aug 1 02:47:09 2026 +0200

    Gate audit log rows not tied to a Dag on a dedicated AccessView (#70759)
    
    * Gate audit log rows not tied to a Dag on a dedicated AccessView
    
    `PermittedEventLogFilter` returned every `dag_id IS NULL` row 
unconditionally,
    and the detail endpoint's guard resolved such a row to `dag_id=None` and 
then
    authorized it as Dag-level `DagAccessEntity.AUDIT_LOG` access. Those rows 
record
    Connection, Variable and Pool operations, so they carry no per-Dag key to
    authorize on -- and Dag-level audit access is held by any viewer under the
    default auth manager, which is not the audience for them.
    
    Add `AccessView.AUDIT_LOGS_ALL` and gate both read paths on it, mirroring 
the
    `IMPORT_ERRORS_ALL` view that covers the same shape of problem for import 
errors
    of files with no registered Dag. The list endpoint filters the rows out in 
the
    query, so they stay out of `total_entries` and pagination too; the detail
    endpoint answers 403. `SimpleAuthManager` grants the view to admins only, 
and the
    FAB auth manager maps it to a new `All Audit Logs` resource that lands in
    `ADMIN_PERMISSIONS`.
    
    The detail guard now selects `Log.id` alongside `Log.dag_id`: a missing row 
and a
    NULL `dag_id` both read back as `None` from a bare scalar, and they 
authorize
    differently. A missing row keeps the Dag-level check so the route can still
    answer 404 rather than turning an unknown id into a permission error.
    
    `authorize_view` also stops propagating a `KeyError` from an auth manager 
that
    cannot map an `AccessView`. `AccessView` members are added by core, but auth
    managers ship as separately released providers, so a core newer than the
    installed manager can name a view the manager has never heard of -- the FAB
    manager translates the enum through a lookup table and would have raised, 
which
    surfaces as a 500 on the endpoint. It now denies and warns instead, which 
keeps
    the endpoint working and fails closed.
    
    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
    
    * Add newsfragment for the audit log view gate
    
    * Update event log guard tests for the row lookup shape
    
    The guard now selects Log.id alongside Log.dag_id so a missing row and a 
NULL
    dag_id can be told apart. These tests mocked session.scalar, so with a Mock
    session the new call returned a truthy Mock whose dag_id was itself a Mock.
    
    Mock the execute(...).one_or_none() shape instead, and add the two cases the
    unit tests did not cover: a row that exists with a NULL dag_id authorizes on
    AUDIT_LOGS_ALL rather than the Dag check, and is Forbidden without it.
---
 airflow-core/newsfragments/70759.significant.rst   | 24 ++++++
 .../api_fastapi/auth/managers/base_auth_manager.py | 48 +++++++++---
 .../auth/managers/models/resource_details.py       |  4 +
 .../auth/managers/simple/simple_auth_manager.py    |  7 +-
 .../src/airflow/api_fastapi/core_api/security.py   | 64 ++++++++++++++--
 .../managers/simple/test_simple_auth_manager.py    | 27 +++++++
 .../auth/managers/test_base_auth_manager.py        | 20 +++++
 .../core_api/routes/public/test_event_logs.py      | 89 +++++++++++++++++++++-
 .../unit/api_fastapi/core_api/test_security.py     | 57 +++++++++++++-
 .../common/compat/security/access_view.py          | 10 ++-
 .../common/compat/security/test_access_view.py     | 28 ++++---
 providers/fab/docs/auth-manager/access-control.rst |  6 +-
 .../providers/fab/auth_manager/fab_auth_manager.py | 13 +++-
 .../fab/auth_manager/security_manager/override.py  |  1 +
 .../providers/fab/www/security/permissions.py      |  1 +
 15 files changed, 354 insertions(+), 45 deletions(-)

diff --git a/airflow-core/newsfragments/70759.significant.rst 
b/airflow-core/newsfragments/70759.significant.rst
new file mode 100644
index 00000000000..3b3c864d53b
--- /dev/null
+++ b/airflow-core/newsfragments/70759.significant.rst
@@ -0,0 +1,24 @@
+Audit log rows not tied to a Dag now require the ``AUDIT_LOGS_ALL`` view
+
+Rows in the audit log whose ``dag_id`` is ``NULL`` record operations that are 
not tied to a
+Dag, such as Connection, Variable and Pool changes. They were returned to any 
caller that
+could read event logs at all, because there is no per-Dag key to authorize 
them on and both
+read paths fell back to a Dag-level audit log check.
+
+They are now gated on a dedicated ``AccessView.AUDIT_LOGS_ALL``, in the same 
way
+``AccessView.IMPORT_ERRORS_ALL`` gates import errors for files with no 
registered Dag. Rows
+bound to a Dag keep their existing per-Dag check.
+
+**Behaviour changes:**
+
+- ``GET /eventLogs`` no longer returns rows with a ``NULL`` ``dag_id`` to 
callers without the
+  new view, and excludes them from ``total_entries`` and pagination.
+  ``GET /eventLogs/{event_log_id}`` returns ``403`` for such a row. An id 
matching no row
+  still returns ``404``.
+- With the simple auth manager the view is granted to the ``ADMIN`` role only. 
With the FAB
+  auth manager it maps to a new ``All Audit Logs`` resource, which ships in 
the ``Admin``
+  role; custom roles that need to read these rows must be granted
+  ``All Audit Logs.can_read`` explicitly.
+- An auth manager that cannot map a requested ``AccessView`` now denies that 
view and emits
+  a ``UserWarning`` instead of raising ``KeyError``. This affects auth 
managers older than
+  the core they run against, which previously failed the request with a server 
error.
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
index e0d56258148..f780097cea4 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -383,18 +383,48 @@ class BaseAuthManager(Generic[T], LoggingMixin, 
metaclass=ABCMeta):
         Core calls this instead of :meth:`is_authorized_view` on team-scoped 
paths: an
         override still on the old ``(access_view, user)`` signature would 
otherwise raise
         ``TypeError``. Removed in Airflow 4.
+
+        A manager that does not recognise ``access_view`` at all is also 
tolerated, and
+        denied -- see :meth:`_authorize_view_unmapped_denied`.
+        """
+        try:
+            if self._is_authorized_view_team_aware:
+                return self.is_authorized_view(access_view=access_view, 
user=user, team_name=team_name)
+            warnings.warn(
+                f"The '{type(self).__name__}' auth manager is not team-aware, 
so team-scoped views are "
+                "authorized across all teams and may be visible to users of 
other teams. Add the "
+                "'team_name' argument to its is_authorized_view (or upgrade 
the provider). Airflow 4 "
+                "will require team-aware auth managers.",
+                RemovedInAirflow4Warning,
+                stacklevel=2,
+            )
+            return self.is_authorized_view(access_view=access_view, user=user)
+        except KeyError:
+            return self._authorize_view_unmapped_denied(access_view)
+
+    def _authorize_view_unmapped_denied(self, access_view: AccessView) -> bool:
+        """
+        Deny an ``AccessView`` the auth manager cannot map, instead of raising.
+
+        ``AccessView`` members are added by core, but auth managers ship as 
separately
+        released providers, so a core newer than the installed auth manager 
can name a
+        view the manager has never heard of. Managers that translate the enum 
through a
+        lookup table (the FAB auth manager, for one) raise ``KeyError`` on 
such a member,
+        which would surface as a 500 on the endpoint that consults it.
+
+        Denying keeps the endpoint working and fails closed: an unmappable 
view means the
+        manager cannot express who may see the records, and these views gate 
records with
+        no other authorization key. Upgrading the auth manager provider to a 
version that
+        maps the view restores access.
         """
-        if self._is_authorized_view_team_aware:
-            return self.is_authorized_view(access_view=access_view, user=user, 
team_name=team_name)
         warnings.warn(
-            f"The '{type(self).__name__}' auth manager is not team-aware, so 
team-scoped views are "
-            "authorized across all teams and may be visible to users of other 
teams. Add the "
-            "'team_name' argument to its is_authorized_view (or upgrade the 
provider). Airflow 4 "
-            "will require team-aware auth managers.",
-            RemovedInAirflow4Warning,
-            stacklevel=2,
+            f"The '{type(self).__name__}' auth manager cannot map the 
'{access_view.name}' view, so "
+            "access to it is denied. This usually means the auth manager 
provider is older than "
+            "Airflow core; upgrade it to a version that supports this view.",
+            UserWarning,
+            stacklevel=3,
         )
-        return self.is_authorized_view(access_view=access_view, user=user)
+        return False
 
     @abstractmethod
     def is_authorized_custom_view(self, *, method: ResourceMethod, 
resource_name: str, user: T) -> bool:
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/models/resource_details.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/models/resource_details.py
index e4222603058..89319b9237b 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/models/resource_details.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/models/resource_details.py
@@ -98,6 +98,10 @@ class VariableDetails:
 class AccessView(Enum):
     """Enum of specific views the user tries to access."""
 
+    # Audit log rows not tied to a Dag -- Connection, Variable, Pool, … 
operations:
+    # there is no per-Dag key to authorize on, so they get their own 
admin-by-default
+    # view rather than riding on Dag-level ``DagAccessEntity.AUDIT_LOG`` 
access.
+    AUDIT_LOGS_ALL = "AUDIT_LOGS_ALL"
     CLUSTER_ACTIVITY = "CLUSTER_ACTIVITY"
     DOCS = "DOCS"
     IMPORT_ERRORS = "IMPORT_ERRORS"
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
index ae8aafdb037..a9858081cb5 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
@@ -354,11 +354,12 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
     def is_authorized_view(
         self, *, access_view: AccessView, user: SimpleAuthManagerUser, 
team_name: str | None = None
     ) -> bool:
-        # Import errors for files with no registered Dag are admin-only; every 
other
-        # view stays readable by viewers.
+        # Views covering records that have no per-Dag key to authorize on are 
admin-only --
+        # import errors for files with no registered Dag, and audit log rows 
not tied to a
+        # Dag. Every other view stays readable by viewers.
         allow_role = (
             SimpleAuthManagerRole.ADMIN
-            if access_view == AccessView.IMPORT_ERRORS_ALL
+            if access_view in (AccessView.IMPORT_ERRORS_ALL, 
AccessView.AUDIT_LOGS_ALL)
             else SimpleAuthManagerRole.VIEWER
         )
         return self._is_authorized(method="GET", allow_role=allow_role, 
user=user, team_name=team_name)
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 720423797b5..96478eb5a14 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -265,12 +265,22 @@ class PermittedDagWarningFilter(PermittedDagFilter):
 
 
 class PermittedEventLogFilter(PermittedDagFilter):
-    """A parameter that filters the permitted even logs for the user."""
+    """A parameter that filters the permitted event logs for the user."""
+
+    def __init__(self, value: set[str] | None = None, *, include_non_dag_logs: 
bool = False):
+        super().__init__(value)
+        self.include_non_dag_logs = include_non_dag_logs
 
     def to_orm(self, select: Select) -> Select:
-        # Event Logs not related to Dags have dag_id as None and are always 
returned.
-        # return select.where(Log.dag_id.in_(self.value or set()) or 
Log.dag_id.is_(None))
-        return select.where(or_(Log.dag_id.in_(self.value or set()), 
Log.dag_id.is_(None)))
+        permitted_dag_logs = Log.dag_id.in_(self.value or set())
+        if not self.include_non_dag_logs:
+            return select.where(permitted_dag_logs)
+        # Event logs not related to a Dag have dag_id as None. They record 
Connection,
+        # Variable, Pool, … operations, so they carry no per-Dag key to 
authorize on and
+        # are gated on ``AccessView.AUDIT_LOGS_ALL`` instead of Dag-level 
audit access.
+        # 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_(permitted_dag_logs, Log.dag_id.is_(None)))
 
 
 class PermittedTIFilter(PermittedDagFilter):
@@ -339,9 +349,32 @@ ReadableDagWarningsFilterDep = Annotated[
 ReadableTIFilterDep = Annotated[
     PermittedTIFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedTIFilter))
 ]
-ReadableEventLogsFilterDep = Annotated[
-    PermittedTIFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedEventLogFilter))
-]
+
+
+def readable_event_logs_filter_factory() -> Callable[[BaseUser, 
BaseAuthManager], PermittedEventLogFilter]:
+    """
+    Create a callable for Depends in FastAPI that returns the event-log filter 
for the user.
+
+    Event logs need their own factory rather than 
``permitted_dag_filter_factory``: besides
+    the readable Dag ids, the filter needs to know whether the user may read 
audit rows that
+    are not tied to a Dag, which is a separate authorization decision.
+    """
+
+    def depends_readable_event_logs_filter(
+        user: GetUserDep,
+        auth_manager: AuthManagerDep,
+    ) -> PermittedEventLogFilter:
+        return PermittedEventLogFilter(
+            auth_manager.get_authorized_dag_ids(user=user, method="GET"),
+            include_non_dag_logs=auth_manager.authorize_view(
+                access_view=AccessView.AUDIT_LOGS_ALL, user=user
+            ),
+        )
+
+    return depends_readable_event_logs_filter
+
+
+ReadableEventLogsFilterDep = Annotated[PermittedEventLogFilter, 
Depends(readable_event_logs_filter_factory())]
 ReadableXComFilterDep = Annotated[
     PermittedXComFilter, Depends(permitted_dag_filter_factory("GET", 
PermittedXComFilter))
 ]
@@ -423,7 +456,22 @@ def requires_access_event_log(
                     status_code=status.HTTP_400_BAD_REQUEST,
                     detail="'event_log_id' must be an integer",
                 )
-            dag_id = session.scalar(select(Log.dag_id).where(Log.id == 
event_log_id))
+            # Select the id alongside dag_id: a NULL dag_id and a missing row 
both come back
+            # as None from a bare ``Log.dag_id`` scalar, and they authorize 
differently.
+            row = session.execute(select(Log.id, Log.dag_id).where(Log.id == 
event_log_id)).one_or_none()
+            if row is not None and row.dag_id is None:
+                # The row records an operation that is not tied to a Dag, so 
there is no
+                # per-Dag key to authorize on: gate it on 
``AccessView.AUDIT_LOGS_ALL``,
+                # the same view the list endpoint's filter uses for these rows.
+                _requires_access(
+                    is_authorized_callback=lambda: 
get_auth_manager().authorize_view(
+                        access_view=AccessView.AUDIT_LOGS_ALL, user=user
+                    ),
+                )
+                return
+            # A missing row keeps the Dag-level check so the route can answer 
404 rather
+            # than turning an unknown id into a permission error.
+            dag_id = row.dag_id if row is not None else None
 
         requires_access_dag(method, DagAccessEntity.AUDIT_LOG, dag_id)(
             request,
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
index c10e1273d16..b51329006d4 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
@@ -347,6 +347,33 @@ class TestSimpleAuthManager:
             is result
         )
 
+    @pytest.mark.parametrize(
+        "access_view",
+        [AccessView.IMPORT_ERRORS_ALL, AccessView.AUDIT_LOGS_ALL],
+    )
+    @pytest.mark.parametrize(
+        ("role", "result"),
+        [
+            ("ADMIN", True),
+            ("OP", False),
+            ("USER", False),
+            ("VIEWER", False),
+        ],
+    )
+    def test_is_authorized_view_admin_only_views(self, auth_manager, 
access_view, role, result):
+        """The views covering records with no per-Dag key to authorize on are 
admin-only.
+
+        Every other view is readable by a viewer (asserted above); these two 
gate records
+        that carry no other authorization key -- import errors for files with 
no registered
+        Dag, and audit log rows not tied to a Dag -- so they must not ride on 
viewer access.
+        """
+        assert (
+            auth_manager.is_authorized_view(
+                access_view=access_view, 
user=SimpleAuthManagerUser(username="test", role=role)
+            )
+            is result
+        )
+
     @pytest.mark.parametrize(
         "api",
         [
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
index 5ca46908e3a..d7f2286aa15 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
@@ -190,6 +190,26 @@ class TestBaseAuthManager:
 
         assert result is True
 
+    def test_authorize_view_denies_a_view_the_manager_cannot_map(self):
+        # AccessView members are added by core, but auth managers ship as 
separately
+        # released providers, so a core newer than the installed manager can 
name a view
+        # the manager has never heard of. Managers that translate the enum 
through a lookup
+        # table raise KeyError on such a member, which would surface as a 500 
on the
+        # endpoint. authorize_view denies instead -- the endpoint keeps 
working and the
+        # records these views gate stay closed.
+        class LookupTableAuthManager(EmptyAuthManager):
+            def is_authorized_view(self, *, access_view, user=None, 
team_name=None):
+                return {AccessView.WEBSITE: True}[access_view]
+
+        manager = LookupTableAuthManager()
+
+        with pytest.warns(UserWarning, match="cannot map the 'DOCS' view"):
+            result = manager.authorize_view(access_view=AccessView.DOCS, 
user=None)
+
+        assert result is False
+        # A view the manager does map is unaffected.
+        assert manager.authorize_view(access_view=AccessView.WEBSITE, 
user=None) is True
+
     def test_authorize_view_treats_kwargs_override_as_team_aware(self):
         class KwargsAuthManager(EmptyAuthManager):
             def is_authorized_view(self, *, access_view, user=None, **kwargs):
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 871692f98d2..75fd6229d5d 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
@@ -22,7 +22,11 @@ from unittest import mock
 import pytest
 from sqlalchemy.orm import Session
 
-from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity, DagDetails
+from airflow.api_fastapi.auth.managers.models.resource_details import (
+    AccessView,
+    DagAccessEntity,
+    DagDetails,
+)
 from airflow.models.log import Log
 from airflow.utils.session import NEW_SESSION, provide_session
 
@@ -236,6 +240,49 @@ class TestGetEventLog(TestEventLogsEndpoint):
             user=mock.ANY,
         )
 
+    @pytest.mark.parametrize(
+        ("can_view_all_audit_logs", "expected_status_code"),
+        [
+            pytest.param(True, 200, id="with-AUDIT_LOGS_ALL-sees-row"),
+            pytest.param(False, 403, id="without-AUDIT_LOGS_ALL-forbidden"),
+        ],
+    )
+    def test_non_dag_row_is_gated_on_audit_logs_all(
+        self, test_client, setup, can_view_all_audit_logs, expected_status_code
+    ):
+        """A row with a NULL dag_id records an operation that is not tied to a 
Dag -- a
+        Connection, Variable or Pool change -- so it has no per-Dag key to 
authorize on.
+        Visibility is gated on the dedicated ``AUDIT_LOGS_ALL`` view rather 
than riding on
+        Dag-level audit log access, which every viewer holds.
+        """
+        event_log_id = setup[EVENT_NORMAL].id
+        with mock.patch(
+            
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_view",
+            return_value=can_view_all_audit_logs,
+        ) as mock_is_authorized_view:
+            response = test_client.get(f"/eventLogs/{event_log_id}")
+
+        assert response.status_code == expected_status_code
+        mock_is_authorized_view.assert_called_once_with(
+            access_view=AccessView.AUDIT_LOGS_ALL, user=mock.ANY, 
team_name=None
+        )
+
+    def test_unknown_id_stays_404_and_does_not_consult_audit_logs_all(self, 
test_client, setup):
+        """An id that matches no row must answer 404, not 403.
+
+        A missing row and a NULL dag_id both read back as ``None``, so the 
guard has to tell
+        them apart: turning an unknown id into a permission error would change 
the documented
+        contract of the endpoint for callers that are allowed to use it.
+        """
+        with mock.patch(
+            
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_view",
+            return_value=False,
+        ) as mock_is_authorized_view:
+            response = test_client.get(f"/eventLogs/{EVENT_NON_EXISTED_ID}")
+
+        assert response.status_code == 404
+        mock_is_authorized_view.assert_not_called()
+
     @provide_session
     def test_should_return_404_for_log_without_dttm(self, test_client, *, 
session: Session = NEW_SESSION):  # noqa: PT028
         event_log = Log(event=EVENT_WITHOUT_DTTM)
@@ -463,3 +510,43 @@ class TestGetEventLogs(TestEventLogsEndpoint):
     def test_should_raises_403_forbidden(self, unauthorized_test_client):
         response = unauthorized_test_client.get("/eventLogs")
         assert response.status_code == 403
+
+    @pytest.mark.parametrize(
+        ("can_view_all_audit_logs", "expected_events"),
+        [
+            pytest.param(
+                True,
+                [EVENT_NORMAL, EVENT_WITH_OWNER, TASK_INSTANCE_EVENT, 
EVENT_WITH_OWNER_AND_TASK_INSTANCE],
+                id="with-AUDIT_LOGS_ALL-sees-non-dag-rows",
+            ),
+            pytest.param(
+                False,
+                [TASK_INSTANCE_EVENT, EVENT_WITH_OWNER_AND_TASK_INSTANCE],
+                id="without-AUDIT_LOGS_ALL-only-dag-rows",
+            ),
+        ],
+    )
+    def test_non_dag_rows_are_gated_on_audit_logs_all(
+        self, test_client, can_view_all_audit_logs, expected_events
+    ):
+        """Rows with a NULL dag_id are returned only to callers holding 
``AUDIT_LOGS_ALL``.
+
+        Before this gate every caller that could read event logs at all 
received them, which
+        for the default auth manager is any viewer. ``EVENT_NORMAL`` and 
``EVENT_WITH_OWNER``
+        carry no dag_id; the other two are bound to a Dag and stay visible 
either way.
+        """
+        with mock.patch(
+            
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_view",
+            return_value=can_view_all_audit_logs,
+        ) as mock_is_authorized_view:
+            response = test_client.get("/eventLogs")
+
+        assert response.status_code == 200
+        resp_json = response.json()
+        # Filtered in the query, so the excluded rows are absent from the 
count and
+        # pagination too -- their existence does not leak through 
total_entries.
+        assert resp_json["total_entries"] == len(expected_events)
+        assert {event_log["event"] for event_log in resp_json["event_logs"]} 
== set(expected_events)
+        mock_is_authorized_view.assert_called_once_with(
+            access_view=AccessView.AUDIT_LOGS_ALL, user=mock.ANY, 
team_name=None
+        )
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 8824969f976..37750e30da9 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
@@ -27,6 +27,7 @@ from airflow import settings
 from airflow.api_fastapi.app import create_app
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.auth.managers.models.resource_details import (
+    AccessView,
     ConnectionDetails,
     DagAccessEntity,
     DagDetails,
@@ -468,7 +469,7 @@ class TestFastApiSecurity:
         mock_get_team_name.return_value = "team1"
 
         session = Mock()
-        session.scalar.return_value = "event_log_dag_id"
+        session.execute.return_value.one_or_none.return_value = Mock(id=42, 
dag_id="event_log_dag_id")
 
         request = Mock()
         request.path_params = {"event_log_id": "42"}
@@ -496,7 +497,7 @@ class TestFastApiSecurity:
         mock_get_team_name.return_value = None
 
         session = Mock()
-        session.scalar.return_value = "unauthorized_dag"
+        session.execute.return_value.one_or_none.return_value = Mock(id=1, 
dag_id="unauthorized_dag")
 
         request = Mock()
         request.path_params = {"event_log_id": "1"}
@@ -524,7 +525,7 @@ class TestFastApiSecurity:
         mock_get_auth_manager.return_value = auth_manager
 
         session = Mock()
-        session.scalar.return_value = None
+        session.execute.return_value.one_or_none.return_value = None
 
         request = Mock()
         request.path_params = {"event_log_id": "999"}
@@ -542,6 +543,56 @@ class TestFastApiSecurity:
         )
         mock_get_team_name.assert_not_called()
 
+    @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_non_dag_row_uses_audit_logs_all(
+        self, mock_get_auth_manager, mock_get_team_name
+    ):
+        """A row that exists with a NULL dag_id is gated on AUDIT_LOGS_ALL, 
not the Dag check.
+
+        Such a row records an operation that is not tied to a Dag, so there is 
no per-Dag key
+        to authorize on. It must not fall through to an unscoped 
``DagDetails(id=None)`` check,
+        which any caller holding Dag-level audit access would pass.
+        """
+        auth_manager = Mock()
+        auth_manager.authorize_view.return_value = True
+        mock_get_auth_manager.return_value = auth_manager
+
+        session = Mock()
+        session.execute.return_value.one_or_none.return_value = Mock(id=7, 
dag_id=None)
+
+        request = Mock()
+        request.path_params = {"event_log_id": "7"}
+        request.query_params = {}
+        user = Mock()
+
+        await requires_access_event_log("GET")(request, user, session)
+
+        
auth_manager.authorize_view.assert_called_once_with(access_view=AccessView.AUDIT_LOGS_ALL,
 user=user)
+        auth_manager.is_authorized_dag.assert_not_called()
+        mock_get_team_name.assert_not_called()
+
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+    async def test_requires_access_event_log_non_dag_row_denied(self, 
mock_get_auth_manager):
+        """Without AUDIT_LOGS_ALL, a non-Dag row is Forbidden rather than 
returned."""
+        auth_manager = Mock()
+        auth_manager.authorize_view.return_value = False
+        mock_get_auth_manager.return_value = auth_manager
+
+        session = Mock()
+        session.execute.return_value.one_or_none.return_value = Mock(id=7, 
dag_id=None)
+
+        request = Mock()
+        request.path_params = {"event_log_id": "7"}
+        request.query_params = {}
+
+        with pytest.raises(HTTPException, match="Forbidden"):
+            await requires_access_event_log("GET")(request, Mock(), session)
+
     @pytest.mark.db_test
     @pytest.mark.parametrize("bad_event_log_id", ["abc", "1.5", "1,2", ""])
     @patch("airflow.api_fastapi.core_api.security.requires_access_dag")
diff --git 
a/providers/common/compat/src/airflow/providers/common/compat/security/access_view.py
 
b/providers/common/compat/src/airflow/providers/common/compat/security/access_view.py
index 4c79c5a139c..23209b68ad9 100644
--- 
a/providers/common/compat/src/airflow/providers/common/compat/security/access_view.py
+++ 
b/providers/common/compat/src/airflow/providers/common/compat/security/access_view.py
@@ -16,14 +16,16 @@
 # under the License.
 from __future__ import annotations
 
-# ``AccessView.IMPORT_ERRORS_ALL`` was added in Airflow 3.4.0, and providers 
are
-# released independently of core. ``None`` signals the view is unavailable on 
the
-# running core and callers should skip mapping it.
+# ``AccessView.IMPORT_ERRORS_ALL`` and ``AccessView.AUDIT_LOGS_ALL`` were 
added in
+# Airflow 3.4.0, and providers are released independently of core. ``None`` 
signals the
+# view is unavailable on the running core and callers should skip mapping it.
 try:
     from airflow.api_fastapi.auth.managers.models.resource_details import 
AccessView
 
     IMPORT_ERRORS_ALL_ACCESS_VIEW: AccessView | None = getattr(AccessView, 
"IMPORT_ERRORS_ALL", None)
+    AUDIT_LOGS_ALL_ACCESS_VIEW: AccessView | None = getattr(AccessView, 
"AUDIT_LOGS_ALL", None)
 except ImportError:
     IMPORT_ERRORS_ALL_ACCESS_VIEW = None
+    AUDIT_LOGS_ALL_ACCESS_VIEW = None
 
-__all__ = ["IMPORT_ERRORS_ALL_ACCESS_VIEW"]
+__all__ = ["AUDIT_LOGS_ALL_ACCESS_VIEW", "IMPORT_ERRORS_ALL_ACCESS_VIEW"]
diff --git 
a/providers/common/compat/tests/unit/common/compat/security/test_access_view.py 
b/providers/common/compat/tests/unit/common/compat/security/test_access_view.py
index 1804ff96e24..9798ff9330a 100644
--- 
a/providers/common/compat/tests/unit/common/compat/security/test_access_view.py
+++ 
b/providers/common/compat/tests/unit/common/compat/security/test_access_view.py
@@ -21,40 +21,44 @@ import sys
 import types
 from unittest import mock
 
+import pytest
+
 RESOURCE_DETAILS_MODULE = 
"airflow.api_fastapi.auth.managers.models.resource_details"
 ACCESS_VIEW_SHIM_MODULE = 
"airflow.providers.common.compat.security.access_view"
 
 
-def test_resolves_to_the_core_access_view_member_or_none():
-    """The shim mirrors the running core: the ``AccessView.IMPORT_ERRORS_ALL``
-    member on a core that defines it (>= 3.4.0), otherwise ``None``. Kept
-    version-agnostic so it holds across the whole provider compatibility 
matrix,
-    including cores that predate the member or lack ``api_fastapi`` entirely.
[email protected]("member_name", ["IMPORT_ERRORS_ALL", 
"AUDIT_LOGS_ALL"])
+def test_resolves_to_the_core_access_view_member_or_none(member_name):
+    """The shim mirrors the running core: the ``AccessView`` member on a core 
that
+    defines it (>= 3.4.0), otherwise ``None``. Kept version-agnostic so it 
holds
+    across the whole provider compatibility matrix, including cores that 
predate the
+    member or lack ``api_fastapi`` entirely.
     """
-    from airflow.providers.common.compat.security.access_view import 
IMPORT_ERRORS_ALL_ACCESS_VIEW
+    shim = importlib.import_module(ACCESS_VIEW_SHIM_MODULE)
 
     try:
         from airflow.api_fastapi.auth.managers.models.resource_details import 
AccessView
 
-        expected = getattr(AccessView, "IMPORT_ERRORS_ALL", None)
+        expected = getattr(AccessView, member_name, None)
     except ImportError:
         expected = None
 
-    assert IMPORT_ERRORS_ALL_ACCESS_VIEW is expected
+    assert getattr(shim, f"{member_name}_ACCESS_VIEW") is expected
 
 
 def test_is_none_on_older_core_without_the_member():
-    """On a core that predates ``AccessView.IMPORT_ERRORS_ALL`` the shim 
resolves to ``None``."""
+    """On a core that predates the new ``AccessView`` members the shim 
resolves to ``None``."""
 
-    class _AccessViewWithoutImportErrorsAll:
-        """Stand-in for an older core AccessView that lacks the new member."""
+    class _AccessViewWithoutNewMembers:
+        """Stand-in for an older core AccessView that lacks the new members."""
 
     fake_resource_details = types.ModuleType(RESOURCE_DETAILS_MODULE)
-    fake_resource_details.AccessView = _AccessViewWithoutImportErrorsAll
+    fake_resource_details.AccessView = _AccessViewWithoutNewMembers
 
     with mock.patch.dict(sys.modules, {RESOURCE_DETAILS_MODULE: 
fake_resource_details}):
         reloaded = 
importlib.reload(importlib.import_module(ACCESS_VIEW_SHIM_MODULE))
         assert reloaded.IMPORT_ERRORS_ALL_ACCESS_VIEW is None
+        assert reloaded.AUDIT_LOGS_ALL_ACCESS_VIEW is None
 
     # Restore the module against the real core so later imports see the real 
value.
     importlib.reload(importlib.import_module(ACCESS_VIEW_SHIM_MODULE))
diff --git a/providers/fab/docs/auth-manager/access-control.rst 
b/providers/fab/docs/auth-manager/access-control.rst
index da6ad74648e..52658bd6e80 100644
--- a/providers/fab/docs/auth-manager/access-control.rst
+++ b/providers/fab/docs/auth-manager/access-control.rst
@@ -172,8 +172,10 @@ Endpoint
 /assets                                                                        
    GET    Assets.can_read                                                   
Viewer
 /assets/{uri}                                                                  
    GET    Assets.can_read                                                   
Viewer
 /assets/events                                                                 
    GET    Assets.can_read                                                   
Viewer
-/eventLogs                                                                     
    GET    Audit Logs.can_read                                               
Viewer
-/eventLogs/{event_log_id}                                                      
    GET    Audit Logs.can_read                                               
Viewer
+/eventLogs                                                                     
    GET    Audit Logs.can_read                                               
Admin
+                                                                               
           All Audit Logs.can_read (for rows not tied to a Dag)
+/eventLogs/{event_log_id}                                                      
    GET    Audit Logs.can_read                                               
Admin
+                                                                               
           All Audit Logs.can_read (for rows not tied to a Dag)
 /importErrors                                                                  
    GET    ImportError.can_read                                              
Viewer
 /importErrors/{import_error_id}                                                
    GET    ImportError.can_read                                              
Viewer
 /health                                                                        
    GET    None                                                              
Public
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
index 046278a0408..09e58424302 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
@@ -56,7 +56,10 @@ from airflow.api_fastapi.common.types import ExtraMenuItem, 
MenuItem
 from airflow.exceptions import AirflowConfigException, 
AirflowProviderDeprecationWarning
 from airflow.models import Connection, DagModel, Pool, Variable
 from airflow.providers.common.compat.sdk import AirflowException, conf
-from airflow.providers.common.compat.security.access_view import 
IMPORT_ERRORS_ALL_ACCESS_VIEW
+from airflow.providers.common.compat.security.access_view import (
+    AUDIT_LOGS_ALL_ACCESS_VIEW,
+    IMPORT_ERRORS_ALL_ACCESS_VIEW,
+)
 from airflow.providers.fab.auth_manager.models import Permission, Role, User
 from airflow.providers.fab.auth_manager.models.anonymous_user import 
AnonymousUser
 from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS
@@ -65,6 +68,7 @@ from airflow.providers.fab.www.security import permissions
 from airflow.providers.fab.www.security.permissions import (
     ACTION_CAN_READ,
     RESOURCE_AUDIT_LOG,
+    RESOURCE_AUDIT_LOG_ALL,
     RESOURCE_BACKFILL,
     RESOURCE_CLUSTER_ACTIVITY,
     RESOURCE_CONFIG,
@@ -148,10 +152,13 @@ _MAP_ACCESS_VIEW_TO_FAB_RESOURCE_TYPE = {
     AccessView.WEBSITE: RESOURCE_WEBSITE,
 }
 
-# ``AccessView.IMPORT_ERRORS_ALL`` only exists on core >= 3.4.0; the compat 
shim
-# yields ``None`` on older core so this provider still imports there.
+# ``AccessView.IMPORT_ERRORS_ALL`` and ``AccessView.AUDIT_LOGS_ALL`` only 
exist on
+# core >= 3.4.0; the compat shim yields ``None`` on older core so this 
provider still
+# imports there.
 if IMPORT_ERRORS_ALL_ACCESS_VIEW is not None:
     _MAP_ACCESS_VIEW_TO_FAB_RESOURCE_TYPE[IMPORT_ERRORS_ALL_ACCESS_VIEW] = 
RESOURCE_IMPORT_ERROR_ALL
+if AUDIT_LOGS_ALL_ACCESS_VIEW is not None:
+    _MAP_ACCESS_VIEW_TO_FAB_RESOURCE_TYPE[AUDIT_LOGS_ALL_ACCESS_VIEW] = 
RESOURCE_AUDIT_LOG_ALL
 
 _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE = {
     MenuItem.ASSETS: RESOURCE_ASSET,
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index 05a72235f7d..22ba8353119 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -332,6 +332,7 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
     ADMIN_PERMISSIONS = [
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_AUDIT_LOG),
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_AUDIT_LOG),
+        (permissions.ACTION_CAN_READ, permissions.RESOURCE_AUDIT_LOG_ALL),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_IMPORT_ERROR_ALL),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_RESCHEDULE),
         (permissions.ACTION_CAN_ACCESS_MENU, 
permissions.RESOURCE_TASK_RESCHEDULE),
diff --git 
a/providers/fab/src/airflow/providers/fab/www/security/permissions.py 
b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
index 10dfd6f45f2..7fcf1ea357a 100644
--- a/providers/fab/src/airflow/providers/fab/www/security/permissions.py
+++ b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
@@ -22,6 +22,7 @@ from typing import TypedDict
 RESOURCE_ACTION = "Permissions"
 RESOURCE_ADMIN_MENU = "Admin"
 RESOURCE_AUDIT_LOG = "Audit Logs"
+RESOURCE_AUDIT_LOG_ALL = "All Audit Logs"
 RESOURCE_BACKFILL = "Backfills"
 RESOURCE_BROWSE_MENU = "Browse"
 RESOURCE_CONFIG = "Configurations"

Reply via email to