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(
{
(