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 9004b834746 Fix FAB ignoring create_metadata_engine from local 
settings (#71674)
9004b834746 is described below

commit 9004b8347461e69dd95570584dab13a5cc4db7ee
Author: rjgoyln <[email protected]>
AuthorDate: Thu Sep 10 05:37:17 2026 +0800

    Fix FAB ignoring create_metadata_engine from local settings (#71674)
    
    Deployments that authenticate to the metadata database with short-lived
    credentials override create_metadata_engine in airflow_local_settings.py so 
a
    do_connect handler can mint a token per physical connection. 
Flask-SQLAlchemy
    built a second engine straight from the connection URI, so those handlers 
never
    ran and `airflow users create` — the Helm chart's create-user job — 
connected
    without a password while the migrate job and the API server succeeded.
---
 .../fab/auth_manager/cli_commands/utils.py         |  9 ++-
 .../providers/fab/auth_manager/models/db.py        |  4 +-
 providers/fab/src/airflow/providers/fab/www/app.py |  4 +-
 providers/fab/src/airflow/providers/fab/www/db.py  | 50 +++++++++++++++
 .../fab/auth_manager/cli_commands/test_utils.py    | 15 +++++
 providers/fab/tests/unit/fab/www/test_app.py       | 38 +++++++++++
 providers/fab/tests/unit/fab/www/test_db.py        | 75 ++++++++++++++++++++++
 7 files changed, 188 insertions(+), 7 deletions(-)

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 a6a58c41525..2a3fb195146 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
@@ -25,12 +25,13 @@ from os.path import isabs
 from typing import TYPE_CHECKING
 
 from flask import Flask
-from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy.engine import make_url
 
 import airflow
+from airflow import settings
 from airflow.exceptions import AirflowConfigException
 from airflow.providers.common.compat.sdk import conf
+from airflow.providers.fab.www.db import AirflowSQLAlchemy
 from airflow.providers.fab.www.extensions.init_appbuilder import 
init_appbuilder
 from airflow.providers.fab.www.extensions.init_session import 
init_airflow_session_interface
 from airflow.providers.fab.www.extensions.init_views import init_plugins
@@ -65,6 +66,8 @@ def get_application_builder() -> Generator[AirflowAppBuilder, 
None, None]:
             )
         flask_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
 
-        db = SQLAlchemy(flask_app)
+        db = AirflowSQLAlchemy(flask_app)
         yield _return_appbuilder(flask_app, db)
-        db.engine.dispose(close=True)
+        # The shared metadata engine outlives this helper — only a locally 
built one may be disposed.
+        if db.engine is not settings.engine:
+            db.engine.dispose(close=True)
diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/models/db.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/models/db.py
index 10b83612339..04eb86cc633 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/models/db.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/models/db.py
@@ -43,14 +43,14 @@ def _release_metadata_locks_if_supported(manager: 
BaseDBManager) -> None:
 
 def _get_flask_db(sql_database_uri):
     from flask import Flask
-    from flask_sqlalchemy import SQLAlchemy
 
+    from airflow.providers.fab.www.db import AirflowSQLAlchemy
     from airflow.providers.fab.www.session import 
AirflowDatabaseSessionInterface
 
     flask_app = Flask(__name__)
     flask_app.config["SQLALCHEMY_DATABASE_URI"] = sql_database_uri
     flask_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
-    db = SQLAlchemy(flask_app)
+    db = AirflowSQLAlchemy(flask_app)
     AirflowDatabaseSessionInterface(app=flask_app, client=db, table="session", 
key_prefix="")
     return db, flask_app
 
diff --git a/providers/fab/src/airflow/providers/fab/www/app.py 
b/providers/fab/src/airflow/providers/fab/www/app.py
index f28cd29c0db..cf7658b69c9 100644
--- a/providers/fab/src/airflow/providers/fab/www/app.py
+++ b/providers/fab/src/airflow/providers/fab/www/app.py
@@ -21,7 +21,6 @@ from datetime import timedelta
 from os.path import isabs
 
 from flask import Flask
-from flask_sqlalchemy import SQLAlchemy
 from flask_wtf.csrf import CSRFProtect
 from sqlalchemy.engine.url import make_url
 
@@ -31,6 +30,7 @@ from airflow.exceptions import AirflowConfigException
 from airflow.logging_config import configure_logging
 from airflow.providers.common.compat.sdk import conf
 from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_8_PLUS
+from airflow.providers.fab.www.db import AirflowSQLAlchemy
 from airflow.providers.fab.www.extensions.init_appbuilder import 
init_appbuilder
 from airflow.providers.fab.www.extensions.init_jinja_globals import 
init_jinja_globals
 from airflow.providers.fab.www.extensions.init_manifest_files import 
configure_manifest_files
@@ -104,7 +104,7 @@ def create_app(enable_plugins: bool):
 
     csrf.init_app(flask_app)
 
-    db = SQLAlchemy(flask_app)
+    db = AirflowSQLAlchemy(flask_app)
     if settings.Session is None:
         raise RuntimeError("Session not configured. Call configure_orm() 
first.")
     db.session = settings.Session
diff --git a/providers/fab/src/airflow/providers/fab/www/db.py 
b/providers/fab/src/airflow/providers/fab/www/db.py
new file mode 100644
index 00000000000..d73e7d0c030
--- /dev/null
+++ b/providers/fab/src/airflow/providers/fab/www/db.py
@@ -0,0 +1,50 @@
+# 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 typing import TYPE_CHECKING, Any
+
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy.engine import make_url
+
+from airflow import settings
+
+if TYPE_CHECKING:
+    from flask import Flask
+    from sqlalchemy.engine import Engine
+
+
+def _points_at_metadata_db(options: dict[str, Any]) -> bool:
+    url = options.get("url")
+    conn = settings.SQL_ALCHEMY_CONN
+    return url is not None and conn is not None and make_url(url) == 
make_url(conn)
+
+
+class AirflowSQLAlchemy(SQLAlchemy):
+    """``Flask-SQLAlchemy`` extension bound to Airflow's metadata engine."""
+
+    def _make_engine(self, bind_key: str | None, options: dict[str, Any], app: 
Flask) -> Engine:
+        # A second engine built straight from the connection URI would skip 
the ``do_connect``
+        # handlers that a ``settings.create_metadata_engine`` override 
installs in
+        # ``airflow_local_settings.py`` to mint short-lived credentials per 
connection. Only the
+        # default bind aimed at the metadata database is Airflow's to take 
over — secondary binds
+        # and a ``webserver_config.py`` that points 
``SQLALCHEMY_DATABASE_URI`` at another database
+        # keep the engine Flask-SQLAlchemy builds for them.
+        engine = settings.engine
+        if bind_key is None and engine is not None and 
_points_at_metadata_db(options):
+            return engine
+        return super()._make_engine(bind_key, options, app)
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_utils.py 
b/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_utils.py
index 0acf228957c..4ec4e46b301 100644
--- a/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_utils.py
+++ b/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_utils.py
@@ -17,10 +17,12 @@
 from __future__ import annotations
 
 import os
+from unittest import mock
 
 import pytest
 
 import airflow
+from airflow import settings
 from airflow.exceptions import AirflowConfigException
 from airflow.providers.common.compat.sdk import conf
 from airflow.providers.fab.auth_manager.cli_commands.utils import 
get_application_builder
@@ -78,3 +80,16 @@ class TestCliUtils:
             flask_app = appbuilder.app
             # Ensure that the correct session interface is set (for 'database' 
auth backend)
             assert isinstance(flask_app.session_interface, 
AirflowDatabaseSessionInterface)
+
+    def test_flask_app_binds_to_the_metadata_engine(self):
+        """A second engine would bypass a ``create_metadata_engine`` override 
in local settings."""
+        with get_application_builder() as appbuilder:
+            db = appbuilder.app.extensions["sqlalchemy"]
+            assert db.engine is settings.engine
+
+    @mock.patch("airflow.settings.engine.dispose", autospec=True)
+    def test_metadata_engine_is_not_disposed(self, mock_dispose):
+        with get_application_builder():
+            pass
+
+        mock_dispose.assert_not_called()
diff --git a/providers/fab/tests/unit/fab/www/test_app.py 
b/providers/fab/tests/unit/fab/www/test_app.py
new file mode 100644
index 00000000000..6ee442c3d84
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/test_app.py
@@ -0,0 +1,38 @@
+# 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
+
+import pytest
+
+from airflow import settings
+from airflow.providers.fab.www.app import create_app
+
+from tests_common.test_utils.config import conf_vars
+
+
[email protected]_test
+class TestCreateApp:
+    def test_flask_app_binds_to_the_metadata_engine(self):
+        """A second engine would bypass a ``create_metadata_engine`` override 
in local settings."""
+        with conf_vars(
+            {("core", "auth_manager"): 
"airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager"}
+        ):
+            flask_app = create_app(enable_plugins=False)
+
+        with flask_app.app_context():
+            db = flask_app.extensions["sqlalchemy"]
+            assert db.engine is settings.engine
diff --git a/providers/fab/tests/unit/fab/www/test_db.py 
b/providers/fab/tests/unit/fab/www/test_db.py
new file mode 100644
index 00000000000..f28da79e89c
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/test_db.py
@@ -0,0 +1,75 @@
+# 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 unittest import mock
+
+import pytest
+from flask import Flask
+from sqlalchemy import create_engine
+
+from airflow.providers.fab.www.db import AirflowSQLAlchemy
+
+
[email protected]
+def flask_app():
+    app = Flask(__name__)
+    app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://"
+    app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
+    return app
+
+
[email protected]
+def metadata_engine():
+    engine = create_engine("sqlite://")
+    with (
+        mock.patch("airflow.settings.engine", engine),
+        mock.patch("airflow.settings.SQL_ALCHEMY_CONN", "sqlite://"),
+    ):
+        yield engine
+
+
+class TestAirflowSQLAlchemy:
+    def test_binds_to_the_airflow_metadata_engine(self, flask_app, 
metadata_engine):
+        db = AirflowSQLAlchemy(flask_app)
+
+        with flask_app.app_context():
+            assert db.engine is metadata_engine
+
+    def test_builds_its_own_engine_for_secondary_binds(self, flask_app, 
metadata_engine):
+        flask_app.config["SQLALCHEMY_BINDS"] = {"other": "sqlite://"}
+        db = AirflowSQLAlchemy(flask_app)
+
+        with flask_app.app_context():
+            assert db.engines[None] is metadata_engine
+            assert db.engines["other"] is not metadata_engine
+
+    def test_builds_its_own_engine_for_another_database(self, flask_app, 
metadata_engine):
+        """``webserver_config.py`` may aim ``SQLALCHEMY_DATABASE_URI`` at a 
database of its own."""
+        flask_app.config["SQLALCHEMY_DATABASE_URI"] = 
"sqlite:////var/lib/airflow/fab.db"
+        db = AirflowSQLAlchemy(flask_app)
+
+        with flask_app.app_context():
+            assert db.engine is not metadata_engine
+
+    @mock.patch("airflow.settings.engine", None)
+    def test_builds_its_own_engine_when_the_orm_is_not_configured(self, 
flask_app):
+        db = AirflowSQLAlchemy(flask_app)
+
+        with flask_app.app_context():
+            assert db.engine is not None
+            assert str(db.engine.url) == "sqlite://"

Reply via email to