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 f2ccc0fe7db Implement bulk authorization methods in
KeycloakAuthManager (#70647)
f2ccc0fe7db is described below
commit f2ccc0fe7dbe2739e8dc2e0a6b1ea2079fd40a5b
Author: abhishekmauryaKsolves <[email protected]>
AuthorDate: Fri Jul 31 19:54:11 2026 +0530
Implement bulk authorization methods in KeycloakAuthManager (#70647)
---
.../keycloak/auth_manager/keycloak_auth_manager.py | 209 ++++++++++++++++++++-
.../auth_manager/test_keycloak_auth_manager.py | 120 ++++++++++++
2 files changed, 325 insertions(+), 4 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 9755d574279..a680be488f7 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
@@ -22,6 +22,7 @@ import logging
import time
import warnings
from base64 import urlsafe_b64decode
+from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Annotated, Any
from urllib.parse import urljoin
@@ -35,7 +36,12 @@ from urllib3.util import Retry
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
-from airflow.api_fastapi.auth.managers.models.resource_details import
DagDetails
+from airflow.api_fastapi.auth.managers.models.resource_details import (
+ ConnectionDetails,
+ DagDetails,
+ PoolDetails,
+ VariableDetails,
+)
from airflow.exceptions import AirflowProviderDeprecationWarning
try:
@@ -62,17 +68,20 @@ from airflow.utils.helpers import prune_dict
if TYPE_CHECKING:
from airflow.api_fastapi.auth.managers.base_auth_manager import
ResourceMethod
+ from airflow.api_fastapi.auth.managers.models.batch_apis import (
+ IsAuthorizedConnectionRequest,
+ IsAuthorizedDagRequest,
+ IsAuthorizedPoolRequest,
+ IsAuthorizedVariableRequest,
+ )
from airflow.api_fastapi.auth.managers.models.resource_details import (
AccessView,
AssetAliasDetails,
AssetDetails,
BackfillDetails,
ConfigurationDetails,
- ConnectionDetails,
DagAccessEntity,
- PoolDetails,
TeamDetails,
- VariableDetails,
)
from airflow.cli.cli_config import CLICommand
@@ -492,6 +501,198 @@ class
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
return single_flight(cache_key, query_keycloak)
+ def batch_is_authorized_connection(
+ self,
+ requests: Sequence[IsAuthorizedConnectionRequest],
+ *,
+ user: KeycloakAuthManagerUser,
+ ) -> bool:
+ if not requests:
+ return True
+ max_workers = min(
+ len(requests), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(request: IsAuthorizedConnectionRequest) -> bool:
+ return self.is_authorized_connection(
+ method=request["method"],
+ details=request.get("details"),
+ user=user,
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, requests)
+ return all(results)
+
+ def batch_is_authorized_dag(
+ self,
+ requests: Sequence[IsAuthorizedDagRequest],
+ *,
+ user: KeycloakAuthManagerUser,
+ ) -> bool:
+ if not requests:
+ return True
+ max_workers = min(
+ len(requests), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(request: IsAuthorizedDagRequest) -> bool:
+ return self.is_authorized_dag(
+ method=request["method"],
+ access_entity=request.get("access_entity"),
+ details=request.get("details"),
+ user=user,
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, requests)
+ return all(results)
+
+ def batch_is_authorized_pool(
+ self,
+ requests: Sequence[IsAuthorizedPoolRequest],
+ *,
+ user: KeycloakAuthManagerUser,
+ ) -> bool:
+ if not requests:
+ return True
+ max_workers = min(
+ len(requests), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(request: IsAuthorizedPoolRequest) -> bool:
+ return self.is_authorized_pool(
+ method=request["method"],
+ details=request.get("details"),
+ user=user,
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, requests)
+ return all(results)
+
+ def batch_is_authorized_variable(
+ self,
+ requests: Sequence[IsAuthorizedVariableRequest],
+ *,
+ user: KeycloakAuthManagerUser,
+ ) -> bool:
+ if not requests:
+ return True
+ max_workers = min(
+ len(requests), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(request: IsAuthorizedVariableRequest) -> bool:
+ return self.is_authorized_variable(
+ method=request["method"],
+ details=request.get("details"),
+ user=user,
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, requests)
+ return all(results)
+
+ def filter_authorized_connections(
+ self,
+ *,
+ conn_ids: set[str],
+ user: KeycloakAuthManagerUser,
+ method: ResourceMethod = "GET",
+ team_name: str | None = None,
+ ) -> set[str]:
+ cache_key = (user.get_id(), method, team_name, frozenset(conn_ids))
+
+ def query_keycloak() -> set[str]:
+ if not conn_ids:
+ return set()
+ max_workers = min(
+ len(conn_ids), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(conn_id: str) -> tuple[str, bool]:
+ details_kwargs: dict[str, Any] = {"conn_id": conn_id}
+ if team_name is not None:
+ details_kwargs["team_name"] = team_name
+ return conn_id, self.is_authorized_connection(
+ method=method,
+ user=user,
+ details=ConnectionDetails(**details_kwargs),
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, conn_ids)
+ return {conn_id for conn_id, authorized in results if authorized}
+
+ return single_flight(cache_key, query_keycloak)
+
+ def filter_authorized_pools(
+ self,
+ *,
+ pool_names: set[str],
+ user: KeycloakAuthManagerUser,
+ method: ResourceMethod = "GET",
+ team_name: str | None = None,
+ ) -> set[str]:
+ cache_key = (user.get_id(), method, team_name, frozenset(pool_names))
+
+ def query_keycloak() -> set[str]:
+ if not pool_names:
+ return set()
+ max_workers = min(
+ len(pool_names), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(pool_name: str) -> tuple[str, bool]:
+ details_kwargs: dict[str, Any] = {"name": pool_name}
+ if team_name is not None:
+ details_kwargs["team_name"] = team_name
+ return pool_name, self.is_authorized_pool(
+ method=method,
+ user=user,
+ details=PoolDetails(**details_kwargs),
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, pool_names)
+ return {pool_name for pool_name, authorized in results if
authorized}
+
+ return single_flight(cache_key, query_keycloak)
+
+ def filter_authorized_variables(
+ self,
+ *,
+ variable_keys: set[str],
+ user: KeycloakAuthManagerUser,
+ method: ResourceMethod = "GET",
+ team_name: str | None = None,
+ ) -> set[str]:
+ cache_key = (user.get_id(), method, team_name,
frozenset(variable_keys))
+
+ def query_keycloak() -> set[str]:
+ if not variable_keys:
+ return set()
+ max_workers = min(
+ len(variable_keys), conf.getint(CONF_SECTION_NAME,
CONF_REQUESTS_POOL_SIZE_KEY, fallback=10)
+ )
+
+ def check(variable_key: str) -> tuple[str, bool]:
+ details_kwargs: dict[str, Any] = {"key": variable_key}
+ if team_name is not None:
+ details_kwargs["team_name"] = team_name
+ return variable_key, self.is_authorized_variable(
+ method=method,
+ user=user,
+ details=VariableDetails(**details_kwargs),
+ )
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ results = executor.map(check, variable_keys)
+ return {variable_key for variable_key, authorized in results if
authorized}
+
+ return single_flight(cache_key, query_keycloak)
+
def _is_batch_authorized(
self,
*,
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 58698292bc9..d3f075d2a46 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
@@ -1162,6 +1162,126 @@ class TestKeycloakAuthManager:
# is_authorized_dag should only be called for the first invocation (2
dag_ids × 1 call)
assert mock_is_authorized.call_count == 2
+ @patch.object(
+ KeycloakAuthManager,
+ "is_authorized_connection",
+ side_effect=lambda *, details, **kw: {"conn_0": True, "conn_1": False,
"conn_2": True}[
+ details.conn_id
+ ],
+ )
+ def test_filter_authorized_connections(self, mock_is_authorized,
auth_manager, user):
+ result = auth_manager.filter_authorized_connections(
+ conn_ids={"conn_0", "conn_1", "conn_2"}, user=user, method="GET"
+ )
+
+ assert result == {"conn_0", "conn_2"}
+ assert mock_is_authorized.call_count == 3
+
+ def test_filter_authorized_connections_empty(self, auth_manager, user):
+ result = auth_manager.filter_authorized_connections(conn_ids=set(),
user=user, method="GET")
+ assert result == set()
+
+ @patch.object(KeycloakAuthManager, "is_authorized_connection",
return_value=False)
+ def test_filter_authorized_connections_all_denied(self,
mock_is_authorized, auth_manager, user):
+ result = auth_manager.filter_authorized_connections(
+ conn_ids={"conn_0", "conn_1"}, user=user, method="GET"
+ )
+
+ assert result == set()
+ assert mock_is_authorized.call_count == 2
+
+ @patch.object(KeycloakAuthManager, "is_authorized_connection",
return_value=True)
+ def test_filter_authorized_connections_cache_hit(self, mock_is_authorized,
auth_manager, user):
+ """Second call with same args should return cached result without
hitting Keycloak."""
+ conn_ids = {"conn_0", "conn_1"}
+
+ result1 =
auth_manager.filter_authorized_connections(conn_ids=conn_ids, user=user,
method="GET")
+ result2 =
auth_manager.filter_authorized_connections(conn_ids=conn_ids, user=user,
method="GET")
+
+ assert result1 == conn_ids
+ assert result2 == conn_ids
+ assert mock_is_authorized.call_count == 2
+
+ @patch.object(
+ KeycloakAuthManager,
+ "is_authorized_pool",
+ side_effect=lambda *, details, **kw: {"pool_0": True, "pool_1": False,
"pool_2": True}[details.name],
+ )
+ def test_filter_authorized_pools(self, mock_is_authorized, auth_manager,
user):
+ result = auth_manager.filter_authorized_pools(
+ pool_names={"pool_0", "pool_1", "pool_2"}, user=user, method="GET"
+ )
+
+ assert result == {"pool_0", "pool_2"}
+ assert mock_is_authorized.call_count == 3
+
+ def test_filter_authorized_pools_empty(self, auth_manager, user):
+ result = auth_manager.filter_authorized_pools(pool_names=set(),
user=user, method="GET")
+ assert result == set()
+
+ @patch.object(KeycloakAuthManager, "is_authorized_pool",
return_value=False)
+ def test_filter_authorized_pools_all_denied(self, mock_is_authorized,
auth_manager, user):
+ result = auth_manager.filter_authorized_pools(
+ pool_names={"pool_0", "pool_1"}, user=user, method="GET"
+ )
+
+ assert result == set()
+ assert mock_is_authorized.call_count == 2
+
+ @patch.object(KeycloakAuthManager, "is_authorized_pool", return_value=True)
+ def test_filter_authorized_pools_cache_hit(self, mock_is_authorized,
auth_manager, user):
+ """Second call with same args should return cached result without
hitting Keycloak."""
+ pool_names = {"pool_0", "pool_1"}
+
+ result1 = auth_manager.filter_authorized_pools(pool_names=pool_names,
user=user, method="GET")
+ result2 = auth_manager.filter_authorized_pools(pool_names=pool_names,
user=user, method="GET")
+
+ assert result1 == pool_names
+ assert result2 == pool_names
+ assert mock_is_authorized.call_count == 2
+
+ @patch.object(
+ KeycloakAuthManager,
+ "is_authorized_variable",
+ side_effect=lambda *, details, **kw: {"var_0": True, "var_1": False,
"var_2": True}[details.key],
+ )
+ def test_filter_authorized_variables(self, mock_is_authorized,
auth_manager, user):
+ result = auth_manager.filter_authorized_variables(
+ variable_keys={"var_0", "var_1", "var_2"}, user=user, method="GET"
+ )
+
+ assert result == {"var_0", "var_2"}
+ assert mock_is_authorized.call_count == 3
+
+ def test_filter_authorized_variables_empty(self, auth_manager, user):
+ result = auth_manager.filter_authorized_variables(variable_keys=set(),
user=user, method="GET")
+ assert result == set()
+
+ @patch.object(KeycloakAuthManager, "is_authorized_variable",
return_value=False)
+ def test_filter_authorized_variables_all_denied(self, mock_is_authorized,
auth_manager, user):
+ result = auth_manager.filter_authorized_variables(
+ variable_keys={"var_0", "var_1"}, user=user, method="GET"
+ )
+
+ assert result == set()
+ assert mock_is_authorized.call_count == 2
+
+ @patch.object(KeycloakAuthManager, "is_authorized_variable",
return_value=True)
+ def test_filter_authorized_variables_cache_hit(self, mock_is_authorized,
auth_manager, user):
+ """Second call with same args should return cached result without
hitting Keycloak."""
+ variable_keys = {"var_0", "var_1"}
+
+ result1 = auth_manager.filter_authorized_variables(
+ variable_keys=variable_keys, user=user, method="GET"
+ )
+ result2 = auth_manager.filter_authorized_variables(
+ variable_keys=variable_keys, user=user, method="GET"
+ )
+
+ assert result1 == variable_keys
+ assert result2 == variable_keys
+ assert mock_is_authorized.call_count == 2
+
@pytest.mark.parametrize(
("dag_count", "pool_size", "expected_max_workers"),
[