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

potiuk 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 188a744c0b9 Reject tokens naming a deactivated account in the FAB auth 
manager (#72199)
188a744c0b9 is described below

commit 188a744c0b94010e15329cec81ff73248ec43fa1
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri Aug 28 22:05:08 2026 +0200

    Reject tokens naming a deactivated account in the FAB auth manager (#72199)
    
    deserialize_user resolved the token subject by id alone, so a bearer issued
    before an account was deactivated continued to resolve to that user. The
    password path already refuses an inactive account in auth_user_db; the token
    path did not. The account state is now re-checked when the user is loaded,
    and a null 'active' column is treated as inactive to match auth_user_db.
    
    The check runs when the user is loaded, so it is bounded by the existing
    [fab] cache_ttl window (30s by default) rather than being immediate.
    
    test_is_logged_in_with_inactive_user set is_active via return_value, but
    is_active is a property, so the mock stayed truthy and the assertion held
    regardless of the state under test. It now sets the attribute, and an
    active-user counterpart was added alongside it.
---
 .../providers/fab/auth_manager/fab_auth_manager.py | 10 ++++-
 .../unit/fab/auth_manager/test_fab_auth_manager.py | 46 +++++++++++++++++++++-
 2 files changed, 53 insertions(+), 3 deletions(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
index 09e58424302..4bb6d8abe4f 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
@@ -292,9 +292,17 @@ class FabAuthManager(BaseAuthManager[User]):
         def _fetch_user() -> User:
             with create_session() as session:
                 try:
-                    return session.scalars(select(User).where(User.id == 
user_id)).one()
+                    user = session.scalars(select(User).where(User.id == 
user_id)).one()
                 except NoResultFound:
                     raise ValueError(f"User with id {token['sub']} not found")
+                # A token stays syntactically valid until it expires, so the 
account it
+                # names has to be re-checked on every request rather than 
trusted from
+                # the signature alone. ``is_active`` reads the nullable 
``active``
+                # column, and a null is treated as inactive here for the same 
reason it
+                # is on the password path in ``auth_user_db``.
+                if not user.is_active:
+                    raise ValueError(f"User with id {token['sub']} is not 
active")
+                return user
 
         try:
             return _fetch_user()
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 62f98567012..43841959cd1 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
@@ -221,6 +221,36 @@ class TestFabAuthManager:
 
         assert user.get_id() == result.get_id()
 
+    def test_deserialize_user_rejects_inactive_user(self, flask_app, 
auth_manager_with_appbuilder):
+        """A token naming a deactivated account must not resolve to a user."""
+        user = create_user(flask_app, "test_inactive")
+        auth_manager_with_appbuilder.cache.clear()
+
+        user.active = False
+        auth_manager_with_appbuilder.session.commit()
+        auth_manager_with_appbuilder.cache.clear()
+
+        with pytest.raises(ValueError, match=f"User with id {user.id} is not 
active"):
+            auth_manager_with_appbuilder.deserialize_user({"sub": 
str(user.id)})
+
+    def test_deserialize_user_rejects_null_active(self, flask_app, 
auth_manager_with_appbuilder):
+        """``active`` is nullable; a null is treated as inactive, as on the 
password path."""
+        user = create_user(flask_app, "test_null_active")
+        user.active = None
+        auth_manager_with_appbuilder.session.commit()
+        auth_manager_with_appbuilder.cache.clear()
+
+        with pytest.raises(ValueError, match=f"User with id {user.id} is not 
active"):
+            auth_manager_with_appbuilder.deserialize_user({"sub": 
str(user.id)})
+
+    def test_deserialize_user_accepts_active_user(self, flask_app, 
auth_manager_with_appbuilder):
+        user = create_user(flask_app, "test_still_active")
+        auth_manager_with_appbuilder.cache.clear()
+
+        result = auth_manager_with_appbuilder.deserialize_user({"sub": 
str(user.id)})
+
+        assert result.get_id() == user.get_id()
+
     def test_deserialize_user_not_found(self, flask_app, 
auth_manager_with_appbuilder):
         """Test that deserialize_user raises ValueError when the user does not 
exist."""
         non_existent_id = "99999"
@@ -256,13 +286,25 @@ class TestFabAuthManager:
 
     @mock.patch.object(FabAuthManager, "get_user")
     def test_is_logged_in_with_inactive_user(self, mock_get_user, 
auth_manager_with_appbuilder):
+        # ``is_anonymous`` and ``is_active`` are properties on the real model, 
so the
+        # mock has to set attributes rather than ``return_value``; setting the 
latter
+        # leaves a truthy Mock in place and the assertion passes regardless of 
state.
         user = Mock()
-        user.is_anonymous.return_value = False
-        user.is_active.return_value = True
+        user.is_anonymous = False
+        user.is_active = False
         mock_get_user.return_value = user
 
         assert auth_manager_with_appbuilder.is_logged_in() is False
 
+    @mock.patch.object(FabAuthManager, "get_user")
+    def test_is_logged_in_with_active_user(self, mock_get_user, 
auth_manager_with_appbuilder):
+        user = Mock()
+        user.is_anonymous = False
+        user.is_active = True
+        mock_get_user.return_value = user
+
+        assert auth_manager_with_appbuilder.is_logged_in() is True
+
     @mock.patch.object(FabAuthManager, "get_user")
     def test_is_logged_in_with_auth_role_public(self, mock_get_user, 
flask_app, auth_manager_with_appbuilder):
         """When ``AUTH_ROLE_PUBLIC`` is set on the Flask app, anonymous users 
are 'logged in'."""

Reply via email to