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

vincbeck 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 0fb6385832a Fix HTTP 500 on /ui/teams endpoint when using Keycloak 
auth manager (#62471)
0fb6385832a is described below

commit 0fb6385832a1e91f889b443fe5c6d0acbb117d13
Author: Mathieu Monet <[email protected]>
AuthorDate: Wed Feb 25 19:22:40 2026 +0100

    Fix HTTP 500 on /ui/teams endpoint when using Keycloak auth manager (#62471)
---
 .../keycloak/auth_manager/cli/commands.py          | 14 +++++---
 .../keycloak/auth_manager/keycloak_auth_manager.py | 15 +++++++-
 .../providers/keycloak/auth_manager/resources.py   |  1 +
 .../keycloak/auth_manager/cli/test_commands.py     |  4 ++-
 .../auth_manager/test_keycloak_auth_manager.py     | 42 +++++++++++++++++++++-
 5 files changed, 69 insertions(+), 7 deletions(-)

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 997b90ae026..22bb187996d 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
@@ -48,10 +48,11 @@ except ImportError:
 
 log = logging.getLogger(__name__)
 TEAM_SCOPED_RESOURCE_NAMES = {
-    KeycloakResource.DAG.value,
     KeycloakResource.CONNECTION.value,
-    KeycloakResource.VARIABLE.value,
+    KeycloakResource.DAG.value,
     KeycloakResource.POOL.value,
+    KeycloakResource.TEAM.value,
+    KeycloakResource.VARIABLE.value,
 }
 GLOBAL_SCOPED_RESOURCE_NAMES = {
     KeycloakResource.ASSET.value,
@@ -403,6 +404,7 @@ def _get_permissions_to_create(
                         "scope_names": ["GET", "LIST"],
                         "resources": [
                             f"{KeycloakResource.DAG.value}:{team}",
+                            f"{KeycloakResource.TEAM.value}:{team}",
                         ],
                     },
                     {
@@ -716,6 +718,10 @@ def _attach_team_permissions(
     team_dag_resources = [
         f"{KeycloakResource.DAG.value}:{team}",
     ]
+    team_readable_resources = [
+        f"{KeycloakResource.DAG.value}:{team}",
+        f"{KeycloakResource.TEAM.value}:{team}",
+    ]
     team_scoped_resources = [f"{resource}:{team}" for resource in 
sorted(TEAM_SCOPED_RESOURCE_NAMES)]
 
     _attach_policy_to_scope_permission(
@@ -724,7 +730,7 @@ def _attach_team_permissions(
         permission_name=f"ReadOnly-{team}",
         policy_name=_team_role_policy_name(team, "Viewer"),
         scope_names=["GET", "LIST"],
-        resource_names=team_dag_resources,
+        resource_names=team_readable_resources,
         _dry_run=_dry_run,
     )
     for role_name in ("User", "Op", "Admin"):
@@ -734,7 +740,7 @@ def _attach_team_permissions(
             permission_name=f"ReadOnly-{team}",
             policy_name=_team_role_policy_name(team, role_name),
             scope_names=["GET", "LIST"],
-            resource_names=team_dag_resources,
+            resource_names=team_readable_resources,
             _dry_run=_dry_run,
         )
     _attach_policy_to_scope_permission(
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 d28c04da598..11f2c55b0f7 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
@@ -74,6 +74,7 @@ if TYPE_CHECKING:
         DagAccessEntity,
         DagDetails,
         PoolDetails,
+        TeamDetails,
         VariableDetails,
     )
     from airflow.cli.cli_config import CLICommand
@@ -83,9 +84,10 @@ log = logging.getLogger(__name__)
 RESOURCE_ID_ATTRIBUTE_NAME = "resource_id"
 TEAM_SCOPED_RESOURCES = frozenset(
     {
-        KeycloakResource.DAG,
         KeycloakResource.CONNECTION,
+        KeycloakResource.DAG,
         KeycloakResource.POOL,
+        KeycloakResource.TEAM,
         KeycloakResource.VARIABLE,
     }
 )
@@ -301,6 +303,17 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
             team_name=team_name,
         )
 
+    def is_authorized_team(
+        self, *, method: ResourceMethod, user: KeycloakAuthManagerUser, 
details: TeamDetails | None = None
+    ) -> bool:
+        team_name = details.name if details else None
+        return self._is_authorized(
+            method=method,
+            resource_type=KeycloakResource.TEAM,
+            user=user,
+            team_name=team_name,
+        )
+
     def is_authorized_view(self, *, access_view: AccessView, user: 
KeycloakAuthManagerUser) -> bool:
         return self._is_authorized(
             method="GET",
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 a2d1b1a5bbf..0f9068e993e 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
@@ -31,5 +31,6 @@ class KeycloakResource(Enum):
     DAG = "Dag"
     MENU = "Menu"
     POOL = "Pool"
+    TEAM = "Team"
     VARIABLE = "Variable"
     VIEW = "View"
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 d80649c32ca..07bb80c9448 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
@@ -485,7 +485,7 @@ class TestCommands:
             permission_name="ReadOnly-team-a",
             policy_name="Allow-Viewer-team-a",
             scope_names=["GET", "LIST"],
-            resource_names=["Dag:team-a"],
+            resource_names=["Dag:team-a", "Team:team-a"],
             _dry_run=False,
         )
         mock_attach_policy.assert_any_call(
@@ -498,6 +498,7 @@ class TestCommands:
                 "Connection:team-a",
                 "Dag:team-a",
                 "Pool:team-a",
+                "Team:team-a",
                 "Variable:team-a",
             ],
             _dry_run=False,
@@ -552,6 +553,7 @@ class TestCommands:
                 "Connection:team-a",
                 "Dag:team-a",
                 "Pool:team-a",
+                "Team:team-a",
                 "Variable:team-a",
             ],
             _dry_run=False,
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 ef90a603d53..4610b45bf63 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
@@ -37,6 +37,13 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import (
     PoolDetails,
     VariableDetails,
 )
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS, 
AIRFLOW_V_3_2_PLUS
+
+if AIRFLOW_V_3_2_PLUS:
+    from airflow.api_fastapi.auth.managers.models.resource_details import 
TeamDetails
+else:
+    TeamDetails = None  # type: ignore[assignment,misc]
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.exceptions import AirflowProviderDeprecationWarning
 
@@ -58,7 +65,6 @@ from 
airflow.providers.keycloak.auth_manager.keycloak_auth_manager import (
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
 
 from tests_common.test_utils.config import conf_vars
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS, 
AIRFLOW_V_3_2_PLUS
 
 
 def _build_access_token(payload: dict[str, object]) -> str:
@@ -265,6 +271,7 @@ class TestKeycloakAuthManager:
                 {RESOURCE_ID_ATTRIBUTE_NAME: "test"},
             ],
             ["is_authorized_pool", "GET", None, "Pool#LIST", {}],
+            ["is_authorized_team", "GET", None, "Team#LIST", {}],
         ],
     )
     @pytest.mark.parametrize(
@@ -310,6 +317,31 @@ class TestKeycloakAuthManager:
         )
         assert result == expected
 
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="TeamDetails not 
available before Airflow 3.2.0")
+    @pytest.mark.parametrize(
+        ("status_code", "expected"),
+        [
+            [200, True],
+            [401, False],
+            [403, False],
+        ],
+    )
+    def test_is_authorized_team_with_details(self, status_code, expected, 
auth_manager, user):
+        details = TeamDetails(name="team-a")
+        mock_response = Mock()
+        mock_response.status_code = status_code
+        auth_manager.http_session.post = Mock(return_value=mock_response)
+
+        result = auth_manager.is_authorized_team(method="GET", user=user, 
details=details)
+
+        token_url = auth_manager._get_token_url("server_url", "realm")
+        payload = auth_manager._get_payload("client_id", "Team#LIST", {})
+        headers = auth_manager._get_headers(user.access_token)
+        auth_manager.http_session.post.assert_called_once_with(
+            token_url, data=payload, headers=headers, timeout=5
+        )
+        assert result == expected
+
     @pytest.mark.parametrize(
         "function",
         [
@@ -321,6 +353,7 @@ class TestKeycloakAuthManager:
             "is_authorized_asset_alias",
             "is_authorized_variable",
             "is_authorized_pool",
+            "is_authorized_team",
         ],
     )
     def test_is_authorized_failure(self, function, auth_manager, user):
@@ -353,6 +386,7 @@ class TestKeycloakAuthManager:
             "is_authorized_asset_alias",
             "is_authorized_variable",
             "is_authorized_pool",
+            "is_authorized_team",
         ],
     )
     def test_is_authorized_invalid_request(self, function, auth_manager, user):
@@ -463,6 +497,7 @@ class TestKeycloakAuthManager:
                 "Variable#PUT",
             ),
             ("is_authorized_pool", "POST", PoolDetails, {"name": "test", 
"team_name": "team-a"}, "Pool#POST"),
+            ("is_authorized_team", "GET", TeamDetails, {"name": "team-a"}, 
"Team#LIST"),
         ],
     )
     def test_team_name_ignored_when_multi_team_disabled(
@@ -496,6 +531,7 @@ class TestKeycloakAuthManager:
                 "Variable:team-a#GET",
             ),
             ("is_authorized_pool", PoolDetails, {"name": "test", "team_name": 
"team-a"}, "Pool:team-a#GET"),
+            ("is_authorized_team", TeamDetails, {"name": "team-a"}, 
"Team:team-a#LIST"),
         ],
     )
     def test_with_team_name_uses_team_scoped_permission(
@@ -518,6 +554,7 @@ class TestKeycloakAuthManager:
             ("is_authorized_connection", ConnectionDetails(conn_id="test"), 
"Connection#GET"),
             ("is_authorized_variable", VariableDetails(key="test"), 
"Variable#GET"),
             ("is_authorized_pool", PoolDetails(name="test"), "Pool#GET"),
+            ("is_authorized_team", None, "Team#LIST"),
         ],
     )
     def test_without_team_name_uses_global_permission(
@@ -539,6 +576,7 @@ class TestKeycloakAuthManager:
             ("is_authorized_connection", "Connection#LIST"),
             ("is_authorized_variable", "Variable#LIST"),
             ("is_authorized_pool", "Pool#LIST"),
+            ("is_authorized_team", "Team#LIST"),
         ],
     )
     def test_list_without_team_name_uses_global_permission(
@@ -566,6 +604,7 @@ class TestKeycloakAuthManager:
             ),
             ("is_authorized_variable", VariableDetails, {"team_name": 
"team-a"}, "Variable:team-a#LIST"),
             ("is_authorized_pool", PoolDetails, {"team_name": "team-a"}, 
"Pool:team-a#LIST"),
+            ("is_authorized_team", TeamDetails, {"name": "team-a"}, 
"Team:team-a#LIST"),
         ],
     )
     def test_list_with_team_name_uses_team_scoped_permission(
@@ -622,6 +661,7 @@ class TestKeycloakAuthManager:
             ("is_authorized_connection", ConnectionDetails, {"team_name": 
"team-b"}),
             ("is_authorized_variable", VariableDetails, {"team_name": 
"team-b"}),
             ("is_authorized_pool", PoolDetails, {"team_name": "team-b"}),
+            ("is_authorized_team", TeamDetails, {"name": "team-b"}),
         ],
     )
     def test_list_with_mismatched_team_delegates_to_keycloak(

Reply via email to