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 a3abc237355 Check admin-only views against a dedicated Keycloak 
resource in multi-team mode (#73696)
a3abc237355 is described below

commit a3abc237355d9db99cdcf6669e95bd42ded93064
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Sep 27 16:24:08 2026 +0200

    Check admin-only views against a dedicated Keycloak resource in multi-team 
mode (#73696)
    
    * Check admin-only views against a dedicated Keycloak resource in 
multi-team mode
    
    The Keycloak auth manager checked every AccessView against the single
    ``View`` resource, and ``create-team`` grants ``ViewAccess`` (GET on
    ``View``) to every team role. That made the admin-by-default views --
    ``AUDIT_LOGS_ALL``, ``IMPORT_ERRORS_ALL`` and ``REPARSE_ALL``, which cover
    records not tied to a Dag or a team -- readable by any team member, across
    teams.
    
    In multi-team mode these views are now checked against a new ``AdminView``
    resource. The CLI creates it with an ``AdminViewAccess`` permission that is
    granted to ``SuperAdmin`` only; team roles keep ``ViewAccess`` on ``View``
    for the other views. A client that does not have the ``AdminView`` resource
    yet denies these views instead of raising, until ``create-team`` is run
    again. Without ``[core] multi_team`` the checks are unchanged.
    
    Generated-by: Claude Opus 5 following the guidelines at
    
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    * Add reparsing to the spelling wordlist
    
    Generated-by: Claude Opus 5
---
 docs/spelling_wordlist.txt                         |  1 +
 .../docs/auth-manager/manage/permissions.rst       |  4 ++
 providers/keycloak/docs/changelog.rst              | 10 +++++
 .../keycloak/auth_manager/cli/commands.py          | 21 ++++++++++
 .../keycloak/auth_manager/keycloak_auth_manager.py | 18 +++++++-
 .../providers/keycloak/auth_manager/resources.py   |  5 +++
 .../keycloak/auth_manager/cli/test_commands.py     | 35 ++++++++++++++--
 .../auth_manager/test_keycloak_auth_manager.py     | 48 ++++++++++++++++++++++
 8 files changed, 137 insertions(+), 5 deletions(-)

diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 03876053a19..c31da3d2400 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1448,6 +1448,7 @@ renderers
 renewer
 reparse
 reparsed
+reparsing
 replicaSet
 repo
 repos
diff --git a/providers/keycloak/docs/auth-manager/manage/permissions.rst 
b/providers/keycloak/docs/auth-manager/manage/permissions.rst
index 15b0b074079..ed323942cdb 100644
--- a/providers/keycloak/docs/auth-manager/manage/permissions.rst
+++ b/providers/keycloak/docs/auth-manager/manage/permissions.rst
@@ -119,6 +119,10 @@ Note: the CLI creates groups, resources, permissions, and 
policies, but **does n
 You must assign the appropriate Keycloak roles (Admin/Op/User/Viewer or 
SuperAdmin) to each user separately.
 In multi-team mode, the ``Admin`` role is **team-scoped** (group + role). Only 
``SuperAdmin`` grants global
 admin access across all teams.
+Views over records that are not tied to a Dag or a team -- audit log entries 
not tied to a Dag,
+import errors for files with no registered Dag and reparsing such files -- are 
checked against the
+``AdminView`` resource, which the CLI grants to ``SuperAdmin`` only. The other 
views are checked against
+the ``View`` resource, which every team role can read.
 
 More resources about permissions can be found in the official documentation of 
Keycloak:
 
diff --git a/providers/keycloak/docs/changelog.rst 
b/providers/keycloak/docs/changelog.rst
index 9494ec79bcc..891ad6d9193 100644
--- a/providers/keycloak/docs/changelog.rst
+++ b/providers/keycloak/docs/changelog.rst
@@ -25,6 +25,16 @@
 Changelog
 ---------
 
+.. note::
+    In multi-team mode, the views covering records that are not tied to a Dag 
or a team -- audit log
+    entries not tied to a Dag, import errors for files with no registered Dag 
and reparsing such
+    files -- are now checked against a new ``AdminView`` Keycloak resource 
instead of ``View``.
+    Only ``SuperAdmin`` is granted access to it; team roles keep access to the 
other views through
+    ``View``. Until the Keycloak client is updated, access to these views is 
denied to every user.
+    Run ``airflow keycloak-auth-manager create-team <team>`` again for an 
existing team to create the
+    resource and the ``AdminViewAccess`` permission and grant it to 
``SuperAdmin``. Deployments
+    without ``[core] multi_team`` are not affected.
+
 0.11.0
 ......
 
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
index ca1e8081dc9..84274b220e5 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
@@ -555,6 +555,14 @@ def _get_permissions_to_create(
                 "resources": [KeycloakResource.VIEW.value],
             }
         )
+        perm_configs.append(
+            {
+                "name": "AdminViewAccess",
+                "type": "scope-based",
+                "scope_names": ["GET"],
+                "resources": [KeycloakResource.ADMIN_VIEW.value],
+            }
+        )
         perm_configs.append(
             {
                 "name": "MenuAccess",
@@ -884,6 +892,9 @@ def _attach_team_permissions(
         ],
         _dry_run=_dry_run,
     )
+    # ``View`` only covers the views every team role may read. Views over 
records that are not tied
+    # to a team (e.g. audit log rows not tied to a Dag) live on ``AdminView``, 
which team roles do not
+    # get -- see ``_attach_superadmin_permissions``.
     for role_name in TEAM_ROLE_NAMES:
         _attach_policy_to_scope_permission(
             client,
@@ -990,6 +1001,16 @@ def _attach_superadmin_permissions(
         decision_strategy="AFFIRMATIVE",
         _dry_run=_dry_run,
     )
+    _attach_policy_to_scope_permission(
+        client,
+        client_uuid,
+        permission_name="AdminViewAccess",
+        policy_name=_role_policy_name(SUPER_ADMIN_ROLE_NAME),
+        scope_names=["GET"],
+        resource_names=[KeycloakResource.ADMIN_VIEW.value],
+        decision_strategy="AFFIRMATIVE",
+        _dry_run=_dry_run,
+    )
     _attach_policy_to_scope_permission(
         client,
         client_uuid,
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
index 1b8e6d7e630..c195170ca04 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
@@ -91,6 +91,10 @@ log = logging.getLogger(__name__)
 
 RESOURCE_ID_ATTRIBUTE_NAME = "resource_id"
 
+# ``AccessView`` values covering records that carry no per-Dag or per-team key 
to authorize on. They
+# are admin-by-default in core. Compared by value because older Airflow 
versions lack some members.
+ADMIN_ACCESS_VIEW_NAMES = frozenset({"AUDIT_LOGS_ALL", "IMPORT_ERRORS_ALL", 
"REPARSE_ALL"})
+
 
 TEAM_SCOPED_RESOURCES = frozenset(
     {
@@ -394,9 +398,16 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
     def is_authorized_view(
         self, *, access_view: AccessView, user: KeycloakAuthManagerUser, 
team_name: str | None = None
     ) -> bool:
+        resource_type = KeycloakResource.VIEW
+        if access_view.value in ADMIN_ACCESS_VIEW_NAMES and conf.getboolean(
+            "core", "multi_team", fallback=False
+        ):
+            # Every team role can read ``VIEW`` in multi-team mode, so these 
views are checked
+            # against a separate resource that the CLI grants to 
``SuperAdmin`` only.
+            resource_type = KeycloakResource.ADMIN_VIEW
         return self._is_authorized(
             method="GET",
-            resource_type=KeycloakResource.VIEW,
+            resource_type=resource_type,
             user=user,
             resource_id=access_view.value,
             team_name=team_name,
@@ -528,8 +539,11 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
             if error.get("error") == "invalid_grant":
                 log.debug("Received invalid_grant from Keycloak: %s", 
resp.text)
                 return False
-            if is_team_resource and error.get("error") == "invalid_resource":
+            if (is_team_resource or resource_type == 
KeycloakResource.ADMIN_VIEW) and error.get(
+                "error"
+            ) == "invalid_resource":
                 # filter_authorized_dag_ids will return this error if team 
resources have not been added to the Keycloak Client.
+                # The same applies to the ``AdminView`` resource on a client 
provisioned by an older CLI.
                 log.warning(
                     "Keycloak authorization resource is missing; denying 
access. Response: %s", resp.text
                 )
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
index 0f9068e993e..9bc239fb084 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
@@ -22,6 +22,11 @@ from enum import Enum
 class KeycloakResource(Enum):
     """Enum of Keycloak resources."""
 
+    # Views over records that carry no per-Dag or per-team key to authorize on 
(audit log rows not
+    # tied to a Dag, import errors for files with no registered Dag, ...). In 
multi-team mode they
+    # are checked against this resource rather than ``VIEW`` so that they are 
not granted along
+    # with the views every team role can read.
+    ADMIN_VIEW = "AdminView"
     ASSET = "Asset"
     ASSET_ALIAS = "AssetAlias"
     BACKFILL = "Backfill"
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
index b0973573a0a..418db54d1ec 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
@@ -353,6 +353,7 @@ class TestCommands:
             {"_id": "r10", "name": "Asset"},
             {"_id": "r11", "name": "AssetAlias"},
             {"_id": "r12", "name": "Configuration"},
+            {"_id": "r13", "name": "AdminView"},
         ]
 
         client.get_clients.return_value = [
@@ -417,6 +418,17 @@ class TestCommands:
                 "resources": ["r5"],
             },
         )
+        client.create_client_authz_scope_permission.assert_any_call(
+            client_id="test-id",
+            payload={
+                "name": "AdminViewAccess",
+                "type": "scope",
+                "logic": "POSITIVE",
+                "decisionStrategy": "UNANIMOUS",
+                "scopes": ["1"],
+                "resources": ["r13"],
+            },
+        )
         client.create_client_authz_scope_permission.assert_any_call(
             client_id="test-id",
             payload={
@@ -746,16 +758,33 @@ class TestCommands:
             decision_strategy="AFFIRMATIVE",
             _dry_run=False,
         )
+        for role_name in TEAM_ROLE_NAMES:
+            mock_attach_policy.assert_any_call(
+                client,
+                "test-id",
+                permission_name="ViewAccess",
+                policy_name=f"Allow-{role_name}-team-a",
+                scope_names=["GET"],
+                resource_names=["View"],
+                decision_strategy="AFFIRMATIVE",
+                _dry_run=False,
+            )
         mock_attach_policy.assert_any_call(
             client,
             "test-id",
-            permission_name="ViewAccess",
-            policy_name="Allow-Viewer-team-a",
+            permission_name="AdminViewAccess",
+            policy_name="Allow-SuperAdmin",
             scope_names=["GET"],
-            resource_names=["View"],
+            resource_names=["AdminView"],
             decision_strategy="AFFIRMATIVE",
             _dry_run=False,
         )
+        admin_view_policies = [
+            c.kwargs["policy_name"]
+            for c in mock_attach_policy.call_args_list
+            if c.kwargs["permission_name"] == "AdminViewAccess" or "AdminView" 
in c.kwargs["resource_names"]
+        ]
+        assert admin_view_policies == ["Allow-SuperAdmin"]
         mock_ensure_scope_permission.assert_any_call(
             client,
             "test-id",
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
index d4d3a192887..a129322ac58 100644
--- 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
+++ 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
@@ -1027,6 +1027,54 @@ class TestKeycloakAuthManager:
         )
         assert result == expected
 
+    @pytest.mark.parametrize(
+        ("view_name", "multi_team", "expected_permission"),
+        [
+            ("AUDIT_LOGS_ALL", "True", "AdminView#GET"),
+            ("IMPORT_ERRORS_ALL", "True", "AdminView#GET"),
+            ("REPARSE_ALL", "True", "AdminView#GET"),
+            ("AUDIT_LOGS_ALL", "False", "View#GET"),
+            ("IMPORT_ERRORS", "True", "View#GET"),
+            ("PLUGINS", "True", "View#GET"),
+        ],
+    )
+    def test_is_authorized_view_resource(
+        self, view_name, multi_team, expected_permission, auth_manager, user
+    ):
+        access_view = getattr(AccessView, view_name, None)
+        if access_view is None:
+            pytest.skip(f"AccessView.{view_name} is not available in this 
Airflow version")
+        mock_response = Mock()
+        mock_response.status_code = 200
+        auth_manager.http_session.post = Mock(return_value=mock_response)
+
+        with conf_vars({("core", "multi_team"): multi_team}):
+            assert auth_manager.is_authorized_view(access_view=access_view, 
user=user) is True
+
+        payload = auth_manager._get_payload(
+            "client_id", expected_permission, {RESOURCE_ID_ATTRIBUTE_NAME: 
view_name}
+        )
+        auth_manager.http_session.post.assert_called_once_with(
+            auth_manager._get_token_url("server_url", "realm"),
+            data=payload,
+            headers=auth_manager._get_headers(user.access_token),
+            timeout=5,
+        )
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_4_PLUS, 
reason="AccessView.AUDIT_LOGS_ALL not available")
+    @conf_vars({("core", "multi_team"): "True"})
+    def test_is_authorized_view_missing_admin_view_resource(self, 
auth_manager, user, caplog):
+        resp = Mock()
+        resp.status_code = 400
+        resp.text = '{"error": "invalid_resource", "error_description": 
"Resource with id [AdminView] does not exist."}'
+        auth_manager.http_session.post = Mock(return_value=resp)
+        caplog.set_level("WARNING", 
logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager")
+
+        result = 
auth_manager.is_authorized_view(access_view=AccessView.AUDIT_LOGS_ALL, 
user=user)
+
+        assert result is False
+        assert "Keycloak authorization resource is missing; denying access" in 
caplog.text
+
     @pytest.mark.parametrize(
         ("status_code", "expected"),
         [

Reply via email to