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(