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

jasonliu 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 5c9171af1a5 Fix race condition in auth manager initialization (#62214)
5c9171af1a5 is described below

commit 5c9171af1a56b5b60bb121537159ceb9c4de9f73
Author: Young-Ki Kim <[email protected]>
AuthorDate: Sun Feb 22 23:06:38 2026 +0900

    Fix race condition in auth manager initialization (#62214)
    
    * Fix race condition in auth manager initialization
    
    Make create_auth_manager() thread-safe using double-checked locking
    to prevent concurrent requests from creating multiple auth manager
    instances. This fixes intermittent 500 errors on /auth/token when
    multiple requests arrive simultaneously.
    
    Closes: #61108
    
    * Handle auth manager class change in singleton cache
    
    The singleton check now also verifies the cached instance matches
    the currently configured auth manager class. This prevents stale
    instances when the config changes (e.g. switching between
    SimpleAuthManager and FabAuthManager during db upgrade).
    
    * Avoid calling get_auth_manager_cls on every create_auth_manager call
    
    Move get_auth_manager_cls() inside the lock so the fast path is just a None 
check. Add purge_cached_app() in test_upgradedb to ensure clean state between 
parametrized cases with different auth manager configs.
    
    * Reset auth manager singleton in get_application_builder
    
    Since get_application_builder creates a fresh Flask app each time, the 
cached auth manager singleton from a previous app context must be cleared to 
avoid stale state (e.g. KeyError on AUTH_USER_REGISTRATION).
    
    * Clear auth manager singleton in test fixtures using create_app
    
    Test fixtures that call application.create_app() or 
get_application_builder()
    can receive a stale auth manager singleton from a previous test, causing
    AirflowSecurityManagerV2 to be used instead of 
FabAirflowSecurityManagerOverride.
    
    Add purge_cached_app() calls to test fixtures across fab, google, and 
keycloak
    providers to ensure each test gets a fresh auth manager instance.
---
 airflow-core/src/airflow/api_fastapi/app.py        | 10 +++++--
 airflow-core/tests/unit/api_fastapi/test_app.py    | 35 ++++++++++++++++++++++
 airflow-core/tests/unit/utils/test_db.py           |  3 ++
 .../fab/auth_manager/cli_commands/utils.py         |  3 ++
 .../unit/fab/auth_manager/api_fastapi/conftest.py  |  2 ++
 .../fab/tests/unit/fab/auth_manager/conftest.py    |  5 ++++
 .../unit/fab/auth_manager/test_fab_auth_manager.py |  6 ++++
 .../tests/unit/fab/auth_manager/test_security.py   |  3 ++
 .../fab/auth_manager/views/test_permissions.py     |  3 ++
 .../unit/fab/auth_manager/views/test_roles_list.py |  3 ++
 .../tests/unit/fab/auth_manager/views/test_user.py |  3 ++
 .../unit/fab/auth_manager/views/test_user_edit.py  |  3 ++
 .../unit/fab/auth_manager/views/test_user_stats.py |  3 ++
 providers/fab/tests/unit/fab/www/test_auth.py      |  3 ++
 .../fab/www/views/test_views_custom_user_views.py  |  3 ++
 .../common/auth_backend/test_google_openid.py      |  7 +++++
 .../unit/keycloak/auth_manager/routes/conftest.py  |  3 +-
 17 files changed, 95 insertions(+), 3 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/app.py 
b/airflow-core/src/airflow/api_fastapi/app.py
index 30d151c0db7..7367bdc5d67 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import logging
+import threading
 from contextlib import AsyncExitStack, asynccontextmanager
 from functools import cache
 from typing import TYPE_CHECKING
@@ -57,6 +58,7 @@ log = logging.getLogger(__name__)
 
 class _AuthManagerState:
     instance: BaseAuthManager | None = None
+    _lock = threading.Lock()
 
 
 @asynccontextmanager
@@ -137,8 +139,12 @@ def get_auth_manager_cls() -> type[BaseAuthManager]:
 
 def create_auth_manager() -> BaseAuthManager:
     """Create the auth manager."""
-    auth_manager_cls = get_auth_manager_cls()
-    _AuthManagerState.instance = auth_manager_cls()
+    if _AuthManagerState.instance is not None:
+        return _AuthManagerState.instance
+    with _AuthManagerState._lock:
+        if _AuthManagerState.instance is None:
+            auth_manager_cls = get_auth_manager_cls()
+            _AuthManagerState.instance = auth_manager_cls()
     return _AuthManagerState.instance
 
 
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py 
b/airflow-core/tests/unit/api_fastapi/test_app.py
index 1eb692e1864..fa46a0d1f32 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import threading
 from unittest import mock
 
 import pytest
@@ -118,3 +119,37 @@ def test_plugin_with_invalid_url_prefix(caplog, 
fastapi_apps, expected_message,
 
     assert any(expected_message in rec.message for rec in caplog.records)
     assert not any(r.path == invalid_path for r in app.routes)
+
+
+def test_create_auth_manager_thread_safety():
+    """Concurrent calls to create_auth_manager must return the same singleton 
instance."""
+    call_count = 0
+    singleton = None
+
+    class FakeAuthManager:
+        def __init__(self):
+            nonlocal call_count, singleton
+            call_count += 1
+            singleton = self
+
+    app_module.purge_cached_app()
+
+    results = []
+    barrier = threading.Barrier(10)
+
+    def call_create_auth_manager():
+        barrier.wait()
+        results.append(app_module.create_auth_manager())
+
+    with mock.patch.object(app_module, "get_auth_manager_cls", 
return_value=FakeAuthManager):
+        threads = [threading.Thread(target=call_create_auth_manager) for _ in 
range(10)]
+        for t in threads:
+            t.start()
+        for t in threads:
+            t.join()
+
+    assert len(results) == 10
+    assert all(r is singleton for r in results)
+    assert call_count == 1
+
+    app_module.purge_cached_app()
diff --git a/airflow-core/tests/unit/utils/test_db.py 
b/airflow-core/tests/unit/utils/test_db.py
index ac1bf786d6c..97aa3941b3b 100644
--- a/airflow-core/tests/unit/utils/test_db.py
+++ b/airflow-core/tests/unit/utils/test_db.py
@@ -245,6 +245,9 @@ class TestDb:
 
         mock_upgrade = mocker.patch("alembic.command.upgrade")
 
+        from airflow.api_fastapi.app import purge_cached_app
+
+        purge_cached_app()
         with conf_vars(auth):
             upgradedb()
 
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/utils.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/utils.py
index 6d2ed506930..d3459709572 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/utils.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/utils.py
@@ -29,6 +29,7 @@ from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy.engine import make_url
 
 import airflow
+from airflow.api_fastapi.app import purge_cached_app
 from airflow.configuration import conf
 from airflow.exceptions import AirflowConfigException
 from airflow.providers.fab.www.extensions.init_appbuilder import 
init_appbuilder
@@ -50,6 +51,8 @@ def _return_appbuilder(app: Flask, db) -> AirflowAppBuilder:
 
 @contextmanager
 def get_application_builder() -> Generator[AirflowAppBuilder, None, None]:
+    _return_appbuilder.cache_clear()
+    purge_cached_app()
     static_folder = os.path.join(os.path.dirname(airflow.__file__), "www", 
"static")
     flask_app = Flask(__name__, static_folder=static_folder)
     webserver_config = conf.get_mandatory_value("fab", "config_file")
diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
index fb884b8ede7..27aafc3e117 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
@@ -22,12 +22,14 @@ from contextlib import contextmanager
 import pytest
 from fastapi.testclient import TestClient
 
+from airflow.api_fastapi.app import purge_cached_app
 from airflow.api_fastapi.core_api.security import get_user as get_user_dep
 from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
 
 
 @pytest.fixture(scope="module")
 def fab_auth_manager():
+    purge_cached_app()
     return FabAuthManager()
 
 
diff --git a/providers/fab/tests/unit/fab/auth_manager/conftest.py 
b/providers/fab/tests/unit/fab/auth_manager/conftest.py
index 2cad4b4db03..0def443a7f3 100644
--- a/providers/fab/tests/unit/fab/auth_manager/conftest.py
+++ b/providers/fab/tests/unit/fab/auth_manager/conftest.py
@@ -22,7 +22,9 @@ from pathlib import Path
 
 import pytest
 
+from airflow.api_fastapi.app import purge_cached_app
 from airflow.providers.fab.www import app
+from airflow.providers.fab.www.app import purge_cached_app as 
purge_fab_cached_app
 
 from tests_common.test_utils.config import conf_vars
 from unit.fab.decorators import dont_initialize_flask_app_submodules
@@ -30,6 +32,9 @@ from unit.fab.decorators import 
dont_initialize_flask_app_submodules
 
 @pytest.fixture(scope="session")
 def minimal_app_for_auth_api():
+    purge_cached_app()
+    purge_fab_cached_app()
+
     @dont_initialize_flask_app_submodules(
         skip_all_except=[
             "init_appbuilder",
diff --git a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py 
b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py
index 6f339204f41..c47d02dd5a4 100644
--- a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py
+++ b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py
@@ -160,6 +160,12 @@ def auth_manager():
 
 @pytest.fixture
 def flask_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
+    from airflow.providers.fab.www.app import purge_cached_app as 
purge_fab_cached_app
+
+    purge_fab_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/auth_manager/test_security.py 
b/providers/fab/tests/unit/fab/auth_manager/test_security.py
index 350101b832a..ce182dc6b4f 100644
--- a/providers/fab/tests/unit/fab/auth_manager/test_security.py
+++ b/providers/fab/tests/unit/fab/auth_manager/test_security.py
@@ -211,6 +211,9 @@ def clear_db_before_test():
 
 @pytest.fixture(scope="module")
 def app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py
index fc1af4dfb3a..f53f621e28e 100644
--- a/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py
@@ -30,6 +30,9 @@ from unit.fab.utils import client_with_login
 
 @pytest.fixture(scope="module")
 def fab_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py
index 66192f919ad..50c7b2f0d2e 100644
--- a/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py
@@ -30,6 +30,9 @@ from unit.fab.utils import client_with_login
 
 @pytest.fixture(scope="module")
 def fab_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_user.py
index 1ae942824c7..383e90e1b28 100644
--- a/providers/fab/tests/unit/fab/auth_manager/views/test_user.py
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user.py
@@ -30,6 +30,9 @@ from unit.fab.utils import client_with_login
 
 @pytest.fixture(scope="module")
 def fab_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py
index 926753a04c2..0138af2703c 100644
--- a/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py
@@ -30,6 +30,9 @@ from unit.fab.utils import client_with_login
 
 @pytest.fixture(scope="module")
 def fab_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py
index 9a68e9bc8fe..0758f122ebd 100644
--- a/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py
@@ -30,6 +30,9 @@ from unit.fab.utils import client_with_login
 
 @pytest.fixture(scope="module")
 def fab_app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git a/providers/fab/tests/unit/fab/www/test_auth.py 
b/providers/fab/tests/unit/fab/www/test_auth.py
index 7400ff2bf14..a2b1bf9f3a0 100644
--- a/providers/fab/tests/unit/fab/www/test_auth.py
+++ b/providers/fab/tests/unit/fab/www/test_auth.py
@@ -33,6 +33,9 @@ mock_call = Mock()
 
 @pytest.fixture
 def app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git 
a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py 
b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
index 2cfcb3f35fb..9663f14fff4 100644
--- a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
+++ b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
@@ -69,6 +69,9 @@ def delete_roles(app):
 
 @pytest.fixture
 def app():
+    from airflow.api_fastapi.app import purge_cached_app
+
+    purge_cached_app()
     with conf_vars(
         {
             (
diff --git 
a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py 
b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py
index fc91814441b..7183e248993 100644
--- 
a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py
+++ 
b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py
@@ -34,6 +34,8 @@ if not AIRFLOW_V_3_0_PLUS:
         allow_module_level=True,
     )
 
+from airflow.api_fastapi.app import purge_cached_app
+
 from tests_common.test_utils.config import conf_vars
 
 
@@ -42,6 +44,11 @@ def google_openid_app():
     if importlib.util.find_spec("flask_session") is None:
         return None
 
+    purge_cached_app()
+    from airflow.providers.fab.www.app import purge_cached_app as 
purge_fab_cached_app
+
+    purge_fab_cached_app()
+
     def factory():
         with conf_vars(
             {
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/conftest.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/conftest.py
index e5ea6131155..8706c569e0d 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/conftest.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/conftest.py
@@ -23,7 +23,7 @@ import pytest
 import time_machine
 from fastapi.testclient import TestClient
 
-from airflow.api_fastapi.app import create_app
+from airflow.api_fastapi.app import create_app, purge_cached_app
 from airflow.providers.keycloak.auth_manager.constants import (
     CONF_CLIENT_ID_KEY,
     CONF_CLIENT_SECRET_KEY,
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
 
 @pytest.fixture
 def client():
+    purge_cached_app()
     with conf_vars(
         {
             (

Reply via email to