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 fffbb5d07b8 Implement authorization methods of `KeycloakAuthManager` 
(#51403)
fffbb5d07b8 is described below

commit fffbb5d07b8b23eb5aebf2fd8747cbcbfd5e7fdc
Author: Vincent <[email protected]>
AuthorDate: Thu Jun 5 11:53:52 2025 -0400

    Implement authorization methods of `KeycloakAuthManager` (#51403)
---
 .../keycloak/auth_manager/keycloak_auth_manager.py | 145 +++++++++++--
 .../providers/keycloak/auth_manager/resources.py   |  35 +++
 .../auth_manager/test_keycloak_auth_manager.py     | 235 ++++++++++++++++++++-
 .../unit/keycloak/auth_manager/test_resources.py   |  24 +++
 4 files changed, 416 insertions(+), 23 deletions(-)

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 dee92d23304..9857e8d1fd2 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
@@ -16,16 +16,17 @@
 # under the License.
 from __future__ import annotations
 
-from functools import cached_property
 from typing import TYPE_CHECKING, Any
 from urllib.parse import urljoin
 
+import requests
 from fastapi import FastAPI
-from starlette.middleware.sessions import SessionMiddleware
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
BaseAuthManager, T
 from airflow.configuration import conf
+from airflow.exceptions import AirflowException
+from airflow.providers.keycloak.auth_manager.resources import KeycloakResource
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
 
 if TYPE_CHECKING:
@@ -52,10 +53,6 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
     Leverages Keycloak to perform authentication and authorization in Airflow.
     """
 
-    @cached_property
-    def api_server_endpoint(self) -> str:
-        return conf.get("api", "base_url", fallback="/")
-
     def deserialize_user(self, token: dict[str, Any]) -> 
KeycloakAuthManagerUser:
         return KeycloakAuthManagerUser(
             user_id=token.pop("user_id"),
@@ -73,7 +70,8 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         }
 
     def get_url_login(self, **kwargs) -> str:
-        return urljoin(self.api_server_endpoint, 
f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login")
+        base_url = conf.get("api", "base_url", fallback="/")
+        return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login")
 
     def is_authorized_configuration(
         self,
@@ -82,7 +80,18 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         user: KeycloakAuthManagerUser,
         details: ConfigurationDetails | None = None,
     ) -> bool:
-        return True
+        config_section = details.section if details else None
+        return self._is_authorized(
+            method=method, resource_type=KeycloakResource.CONFIGURATION, 
user=user
+        ) or (
+            config_section is not None
+            and self._is_authorized(
+                method=method,
+                resource_type=KeycloakResource.CONFIGURATION,
+                user=user,
+                resource_id=config_section,
+            )
+        )
 
     def is_authorized_connection(
         self,
@@ -91,7 +100,13 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         user: KeycloakAuthManagerUser,
         details: ConnectionDetails | None = None,
     ) -> bool:
-        return True
+        connection_id = details.conn_id if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.CONNECTION, user=user) or (
+            connection_id is not None
+            and self._is_authorized(
+                method=method, resource_type=KeycloakResource.CONNECTION, 
user=user, resource_id=connection_id
+            )
+        )
 
     def is_authorized_dag(
         self,
@@ -106,12 +121,24 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
     def is_authorized_backfill(
         self, *, method: ResourceMethod, user: KeycloakAuthManagerUser, 
details: BackfillDetails | None = None
     ) -> bool:
-        return True
+        backfill_id = str(details.id) if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.BACKFILL, user=user) or (
+            backfill_id is not None
+            and self._is_authorized(
+                method=method, resource_type=KeycloakResource.BACKFILL, 
user=user, resource_id=backfill_id
+            )
+        )
 
     def is_authorized_asset(
         self, *, method: ResourceMethod, user: KeycloakAuthManagerUser, 
details: AssetDetails | None = None
     ) -> bool:
-        return True
+        asset_id = details.id if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.ASSET, user=user) or (
+            asset_id is not None
+            and self._is_authorized(
+                method=method, resource_type=KeycloakResource.ASSET, 
user=user, resource_id=asset_id
+            )
+        )
 
     def is_authorized_asset_alias(
         self,
@@ -120,25 +147,57 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         user: KeycloakAuthManagerUser,
         details: AssetAliasDetails | None = None,
     ) -> bool:
-        return True
+        asset_alias_id = details.id if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.ASSET_ALIAS, user=user) or (
+            asset_alias_id is not None
+            and self._is_authorized(
+                method=method,
+                resource_type=KeycloakResource.ASSET_ALIAS,
+                user=user,
+                resource_id=asset_alias_id,
+            )
+        )
 
     def is_authorized_variable(
         self, *, method: ResourceMethod, user: KeycloakAuthManagerUser, 
details: VariableDetails | None = None
     ) -> bool:
-        return True
+        variable_key = details.key if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.VARIABLE, user=user) or (
+            variable_key is not None
+            and self._is_authorized(
+                method=method, resource_type=KeycloakResource.VARIABLE, 
user=user, resource_id=variable_key
+            )
+        )
 
     def is_authorized_pool(
         self, *, method: ResourceMethod, user: KeycloakAuthManagerUser, 
details: PoolDetails | None = None
     ) -> bool:
-        return True
+        pool_name = details.name if details else None
+        return self._is_authorized(method=method, 
resource_type=KeycloakResource.POOL, user=user) or (
+            pool_name is not None
+            and self._is_authorized(
+                method=method, resource_type=KeycloakResource.POOL, user=user, 
resource_id=pool_name
+            )
+        )
 
     def is_authorized_view(self, *, access_view: AccessView, user: 
KeycloakAuthManagerUser) -> bool:
-        return True
+        return self._is_authorized(
+            method="GET", resource_type=KeycloakResource.VIEW, user=user
+        ) or self._is_authorized(
+            method="GET",
+            resource_type=KeycloakResource.VIEW,
+            user=user,
+            resource_id=access_view.value,
+        )
 
     def is_authorized_custom_view(
         self, *, method: ResourceMethod | str, resource_name: str, user: 
KeycloakAuthManagerUser
     ) -> bool:
-        return True
+        return self._is_authorized(
+            method=method, resource_type=KeycloakResource.CUSTOM, user=user
+        ) or self._is_authorized(
+            method=method, resource_type=KeycloakResource.CUSTOM, user=user, 
resource_id=resource_name
+        )
 
     def filter_authorized_menu_items(
         self, menu_items: list[MenuItem], *, user: KeycloakAuthManagerUser
@@ -156,12 +215,54 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
                 "This sub application provides login routes."
             ),
         )
-        # authlib requires ``SessionMiddleware``
-        app.add_middleware(
-            SessionMiddleware,
-            secret_key=conf.get("api_auth", "jwt_secret"),
-            https_only=False,
-        )
         app.include_router(login_router)
 
         return app
+
+    def _is_authorized(
+        self,
+        *,
+        method: ResourceMethod | str,
+        resource_type: KeycloakResource,
+        user: KeycloakAuthManagerUser,
+        resource_id: str | None = None,
+    ) -> bool:
+        client_id = conf.get("keycloak_auth_manager", "client_id")
+        realm = conf.get("keycloak_auth_manager", "realm")
+        server_url = conf.get("keycloak_auth_manager", "server_url")
+
+        permission = (
+            f"{resource_type.value}:{resource_id}#{method}"
+            if resource_id
+            else f"{resource_type.value}#{method}"
+        )
+        resp = requests.post(
+            self._get_token_url(server_url, realm),
+            data=self._get_payload(client_id, permission),
+            headers=self._get_headers(user.access_token),
+        )
+
+        if resp.status_code == 200:
+            return True
+        if resp.status_code == 403:
+            return False
+        raise AirflowException(f"Unexpected error: {resp.status_code} - 
{resp.text}")
+
+    @staticmethod
+    def _get_token_url(server_url, realm):
+        return f"{server_url}/realms/{realm}/protocol/openid-connect/token"
+
+    @staticmethod
+    def _get_payload(client_id, permission):
+        return {
+            "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
+            "audience": client_id,
+            "permission": permission,
+        }
+
+    @staticmethod
+    def _get_headers(access_token):
+        return {
+            "Authorization": f"Bearer {access_token}",
+            "Content-Type": "application/x-www-form-urlencoded",
+        }
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
new file mode 100644
index 00000000000..a2d1b1a5bbf
--- /dev/null
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from enum import Enum
+
+
+class KeycloakResource(Enum):
+    """Enum of Keycloak resources."""
+
+    ASSET = "Asset"
+    ASSET_ALIAS = "AssetAlias"
+    BACKFILL = "Backfill"
+    CONFIGURATION = "Configuration"
+    CONNECTION = "Connection"
+    CUSTOM = "Custom"
+    DAG = "Dag"
+    MENU = "Menu"
+    POOL = "Pool"
+    VARIABLE = "Variable"
+    VIEW = "View"
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 ab63ef3d596..f9de61de79a 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
@@ -16,16 +16,45 @@
 # under the License.
 from __future__ import annotations
 
+from unittest.mock import Mock, call, patch
+
 import pytest
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
+from airflow.api_fastapi.auth.managers.models.resource_details import (
+    AccessView,
+    AssetAliasDetails,
+    AssetDetails,
+    BackfillDetails,
+    ConfigurationDetails,
+    ConnectionDetails,
+    PoolDetails,
+    VariableDetails,
+)
+from airflow.exceptions import AirflowException
 from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import 
KeycloakAuthManager
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
 
+from tests_common.test_utils.config import conf_vars
+
 
 @pytest.fixture
 def auth_manager():
-    return KeycloakAuthManager()
+    with conf_vars(
+        {
+            ("keycloak_auth_manager", "client_id"): "client_id",
+            ("keycloak_auth_manager", "realm"): "realm",
+            ("keycloak_auth_manager", "server_url"): "server_url",
+        }
+    ):
+        yield KeycloakAuthManager()
+
+
[email protected]
+def user():
+    user = Mock()
+    user.access_token = "access_token"
+    return user
 
 
 class TestKeycloakAuthManager:
@@ -59,3 +88,207 @@ class TestKeycloakAuthManager:
     def test_get_url_login(self, auth_manager):
         result = auth_manager.get_url_login()
         assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login"
+
+    @pytest.mark.parametrize(
+        "function, method, details, permission_type, permission_resource",
+        [
+            [
+                "is_authorized_configuration",
+                "GET",
+                ConfigurationDetails(section="test"),
+                "Configuration#GET",
+                "Configuration:test#GET",
+            ],
+            ["is_authorized_configuration", "GET", None, "Configuration#GET", 
None],
+            [
+                "is_authorized_configuration",
+                "PUT",
+                ConfigurationDetails(section="test"),
+                "Configuration#PUT",
+                "Configuration:test#PUT",
+            ],
+            [
+                "is_authorized_connection",
+                "DELETE",
+                ConnectionDetails(conn_id="test"),
+                "Connection#DELETE",
+                "Connection:test#DELETE",
+            ],
+            ["is_authorized_connection", "GET", None, "Connection#GET", None],
+            ["is_authorized_backfill", "POST", BackfillDetails(id=1), 
"Backfill#POST", "Backfill:1#POST"],
+            ["is_authorized_backfill", "GET", None, "Backfill#GET", None],
+            ["is_authorized_asset", "GET", AssetDetails(id="test"), 
"Asset#GET", "Asset:test#GET"],
+            ["is_authorized_asset", "GET", None, "Asset#GET", None],
+            [
+                "is_authorized_asset_alias",
+                "GET",
+                AssetAliasDetails(id="test"),
+                "AssetAlias#GET",
+                "AssetAlias:test#GET",
+            ],
+            ["is_authorized_asset_alias", "GET", None, "AssetAlias#GET", None],
+            [
+                "is_authorized_variable",
+                "PUT",
+                VariableDetails(key="test"),
+                "Variable#PUT",
+                "Variable:test#PUT",
+            ],
+            ["is_authorized_variable", "GET", None, "Variable#GET", None],
+            ["is_authorized_pool", "POST", PoolDetails(name="test"), 
"Pool#POST", "Pool:test#POST"],
+            ["is_authorized_pool", "GET", None, "Pool#GET", None],
+        ],
+    )
+    @pytest.mark.parametrize(
+        "status_code_type, status_code_resource, expected_one_call, 
expected_two_calls",
+        [
+            [200, 200, True, True],
+            [200, 403, True, True],
+            [403, 200, False, True],
+            [403, 403, False, False],
+        ],
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
+    def test_is_authorized(
+        self,
+        mock_requests,
+        function,
+        method,
+        details,
+        permission_type,
+        permission_resource,
+        status_code_type,
+        status_code_resource,
+        expected_one_call,
+        expected_two_calls,
+        auth_manager,
+        user,
+    ):
+        expected_num_calls = 1 if status_code_type == 200 or not 
permission_resource else 2
+        resp1 = Mock()
+        resp1.status_code = status_code_type
+        resp2 = Mock()
+        resp2.status_code = status_code_resource
+
+        mock_requests.post.side_effect = [resp1, resp2]
+
+        result = getattr(auth_manager, function)(method=method, user=user, 
details=details)
+
+        token_url = auth_manager._get_token_url("server_url", "realm")
+        payload = auth_manager._get_payload("client_id", permission_type)
+        headers = auth_manager._get_headers("access_token")
+        expected_calls = [call(token_url, data=payload, headers=headers)]
+        if expected_num_calls == 2:
+            expected_calls.append(
+                call(token_url, data={**payload, "permission": 
permission_resource}, headers=headers)
+            )
+        mock_requests.post.assert_has_calls(expected_calls)
+        expected = expected_two_calls if expected_num_calls == 2 else 
expected_one_call
+        assert result == expected
+
+    @pytest.mark.parametrize(
+        "function",
+        [
+            "is_authorized_configuration",
+            "is_authorized_connection",
+            "is_authorized_backfill",
+            "is_authorized_asset",
+            "is_authorized_asset_alias",
+            "is_authorized_variable",
+            "is_authorized_pool",
+        ],
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
+    def test_is_authorized_failure(self, mock_requests, function, 
auth_manager, user):
+        resp = Mock()
+        resp.status_code = 500
+        mock_requests.post.return_value = resp
+
+        with pytest.raises(AirflowException):
+            getattr(auth_manager, function)(method="GET", user=user)
+
+    @pytest.mark.parametrize(
+        "status_code_type, status_code_resource, expected_one_call, 
expected_two_calls",
+        [
+            [200, 200, True, True],
+            [200, 403, True, True],
+            [403, 200, False, True],
+            [403, 403, False, False],
+        ],
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
+    def test_is_authorized_view(
+        self,
+        mock_requests,
+        status_code_type,
+        status_code_resource,
+        expected_one_call,
+        expected_two_calls,
+        auth_manager,
+        user,
+    ):
+        expected_num_calls = 1 if status_code_type == 200 else 2
+        resp1 = Mock()
+        resp1.status_code = status_code_type
+        resp2 = Mock()
+        resp2.status_code = status_code_resource
+
+        mock_requests.post.side_effect = [resp1, resp2]
+
+        result = 
auth_manager.is_authorized_view(access_view=AccessView.CLUSTER_ACTIVITY, 
user=user)
+
+        token_url = auth_manager._get_token_url("server_url", "realm")
+        payload = auth_manager._get_payload("client_id", "View#GET")
+        headers = auth_manager._get_headers("access_token")
+        expected_calls = [call(token_url, data=payload, headers=headers)]
+        if expected_num_calls == 2:
+            expected_calls.append(
+                call(token_url, data={**payload, "permission": 
"View:CLUSTER_ACTIVITY#GET"}, headers=headers)
+            )
+
+        mock_requests.post.assert_has_calls(expected_calls)
+        expected = expected_two_calls if expected_num_calls == 2 else 
expected_one_call
+        assert result == expected
+
+    @pytest.mark.parametrize(
+        "status_code_type, status_code_resource, expected_one_call, 
expected_two_calls",
+        [
+            [200, 200, True, True],
+            [200, 403, True, True],
+            [403, 200, False, True],
+            [403, 403, False, False],
+        ],
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
+    def test_is_authorized_custom_view(
+        self,
+        mock_requests,
+        status_code_type,
+        status_code_resource,
+        expected_one_call,
+        expected_two_calls,
+        auth_manager,
+        user,
+    ):
+        expected_num_calls = 1 if status_code_type == 200 else 2
+        resp1 = Mock()
+        resp1.status_code = status_code_type
+        resp2 = Mock()
+        resp2.status_code = status_code_resource
+
+        mock_requests.post.side_effect = [resp1, resp2]
+
+        result = auth_manager.is_authorized_custom_view(method="GET", 
resource_name="test", user=user)
+
+        token_url = auth_manager._get_token_url("server_url", "realm")
+        payload = auth_manager._get_payload("client_id", "Custom#GET")
+        headers = auth_manager._get_headers("access_token")
+        expected_calls = [call(token_url, data=payload, headers=headers)]
+        if expected_num_calls == 2:
+            expected_calls.append(
+                call(token_url, data={**payload, "permission": 
"Custom:test#GET"}, headers=headers)
+            )
+
+        mock_requests.post.assert_has_calls(expected_calls)
+        expected = expected_two_calls if expected_num_calls == 2 else 
expected_one_call
+        assert result == expected
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_resources.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_resources.py
new file mode 100644
index 00000000000..a05a3182b4d
--- /dev/null
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_resources.py
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow.providers.keycloak.auth_manager.resources import KeycloakResource
+
+
+class TestKeycloakResource:
+    def test_asset(self):
+        assert KeycloakResource.ASSET.value == "Asset"

Reply via email to