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 930770211e5 Invalidate a user's sessions when their password is 
changed through the API (#72657)
930770211e5 is described below

commit 930770211e5990b29ed86adc53b784372e52525c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Sep 9 15:19:05 2026 +0200

    Invalidate a user's sessions when their password is changed through the API 
(#72657)
    
    Changing a password through PATCH /auth/fab/v1/users/{username} set the new 
hash
    and saved the user, without ending the sessions the old password had
    established. A session captured before the change kept authenticating as 
that
    user, so an administrator changing a password during recovery or incident
    response did not evict whoever held it. The old password was correctly 
rejected,
    which is what makes the surviving session easy to miss.
    
    FAB already has a lifecycle-aware path: reset_password sets the hash, calls
    reset_user_sessions, then saves. The user-management API bypassed that and
    performed only the hash update.
    
    Call reset_user_sessions when, and only when, the password actually 
changed. A
    password supplied in the body but excluded by update_mask is not applied and
    invalidates nothing, and changes to other fields leave sessions alone.
    
    Ordered *after* persistence, unlike reset_password. reset_user_sessions 
commits
    its deletions immediately, and security_manager.update_user rolls back and
    returns False on failure -- a return value this service previously 
discarded.
    Invalidating first would have logged the user out even when the password 
update
    then failed, leaving the old password working, the user evicted, and the API
    reporting success. That return value is now checked and surfaced.
    
    Scope worth stating plainly: this ends FAB server-side sessions on the 
database
    session backend, which is where the issue was reported. reset_user_sessions 
is a
    no-op on other session backends, and no session mechanism here revokes 
JWTs, so
    a token issued before the change remains valid until it expires. Making a
    password change invalidate outstanding tokens needs a per-user token 
version or
    an equivalent, which is a broader change than this one.
    
    Against unpatched sources the new test fails with 'Expected 
reset_user_sessions
    to be called once. Called 0 times.'
---
 .../fab/auth_manager/api_fastapi/services/users.py |  24 +++-
 .../api_fastapi/services/test_users.py             | 133 ++++++++++++++++++++-
 2 files changed, 155 insertions(+), 2 deletions(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/users.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/users.py
index c84f1083404..e63577ba453 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/users.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/users.py
@@ -187,8 +187,10 @@ class FABAuthManagerUsers:
                 )
             user.roles = roles_to_update
 
+        password_changed = False
         if "password" in fields_to_update and body.password is not None:
             user.password = 
generate_password_hash(body.password.get_secret_value())
+            password_changed = True
 
         if "username" in fields_to_update and body.username is not None:
             user.username = body.username
@@ -199,7 +201,27 @@ class FABAuthManagerUsers:
         if "last_name" in fields_to_update and body.last_name is not None:
             user.last_name = body.last_name
 
-        security_manager.update_user(user)
+        if not security_manager.update_user(user):
+            # `update_user` rolls back and returns False on failure. Ignoring 
it would
+            # report success for a change that was not persisted.
+            raise HTTPException(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                detail=f"Failed to update user `{username}`",
+            )
+
+        if password_changed:
+            # Changing a password has to end the sessions the old password 
established,
+            # or a session captured beforehand keeps authenticating as this 
user and the
+            # change does not evict whoever holds it. `reset_password` -- the 
other
+            # supported way to change a password -- already does this; going 
through the
+            # user-management API must not silently skip it.
+            #
+            # Deliberately ordered *after* persistence, unlike 
`reset_password`.
+            # `reset_user_sessions` commits its deletions immediately, so 
invalidating
+            # first would log the user out even when the password update then 
fails,
+            # leaving the old password working and the user evicted for 
nothing.
+            security_manager.reset_user_sessions(user)
+
         return UserResponse.model_validate(user)
 
     @classmethod
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_users.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_users.py
index eec5054d2b1..15511a3c2b8 100644
--- 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_users.py
+++ 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_users.py
@@ -17,10 +17,11 @@
 from __future__ import annotations
 
 import types
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, call, patch
 
 import pytest
 from fastapi import HTTPException
+from pydantic import SecretStr
 
 from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import 
Role
 from airflow.providers.fab.auth_manager.api_fastapi.services.users import 
FABAuthManagerUsers
@@ -305,6 +306,136 @@ class TestUsersService:
         assert out.last_name == "Updated"
         security_manager.update_user.assert_called_once()
 
+    def test_update_user_password_invalidates_existing_sessions(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """Changing a password through the user API must end the old sessions.
+
+        A session captured before the change otherwise keeps authenticating as 
this
+        user, so the password change does not evict whoever holds it. 
`reset_password`
+        already invalidates; going through this API must not silently skip it.
+        """
+        user_obj = _make_user_obj(
+            username="alice",
+            email="[email protected]",
+            first_name="Alice",
+            last_name="Liddell",
+            roles=["User"],
+        )
+        security_manager.find_user.return_value = user_obj
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        patch_body = types.SimpleNamespace(
+            username=None,
+            email=None,
+            first_name=None,
+            last_name=None,
+            roles=None,
+            password=SecretStr("new-password"),
+        )
+
+        FABAuthManagerUsers.update_user("alice", patch_body, 
update_mask="password")
+
+        security_manager.reset_user_sessions.assert_called_once_with(user_obj)
+        # Ordered *after* persistence: invalidating first would evict the user 
even when
+        # the password update then fails.
+        assert security_manager.mock_calls.index(
+            call.update_user(user_obj)
+        ) < 
security_manager.mock_calls.index(call.reset_user_sessions(user_obj))
+
+    def test_update_user_without_password_does_not_touch_sessions(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """An unrelated field change must not log the user out."""
+        user_obj = _make_user_obj(
+            username="alice",
+            email="[email protected]",
+            first_name="Alice",
+            last_name="Liddell",
+            roles=["User"],
+        )
+        security_manager.find_user.return_value = user_obj
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        patch_body = types.SimpleNamespace(
+            username=None,
+            email=None,
+            first_name=None,
+            last_name="Updated",
+            roles=None,
+            password=None,
+        )
+
+        FABAuthManagerUsers.update_user("alice", patch_body, 
update_mask="last_name")
+
+        security_manager.reset_user_sessions.assert_not_called()
+
+    def test_update_user_password_outside_the_mask_does_not_invalidate(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """A password present in the body but excluded by the mask is not 
applied."""
+        user_obj = _make_user_obj(
+            username="alice",
+            email="[email protected]",
+            first_name="Alice",
+            last_name="Liddell",
+            roles=["User"],
+        )
+        security_manager.find_user.return_value = user_obj
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        patch_body = types.SimpleNamespace(
+            username=None,
+            email=None,
+            first_name=None,
+            last_name="Updated",
+            roles=None,
+            password=SecretStr("new-password"),
+        )
+
+        FABAuthManagerUsers.update_user("alice", patch_body, 
update_mask="last_name")
+
+        security_manager.reset_user_sessions.assert_not_called()
+
+    def test_update_user_failed_persistence_does_not_evict_the_user(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """A password change that does not persist must not log the user out.
+
+        `update_user` rolls back and returns False on failure. Invalidating 
first would
+        leave the old password working *and* the user evicted, with the API 
reporting
+        success.
+        """
+        user_obj = _make_user_obj(
+            username="alice",
+            email="[email protected]",
+            first_name="Alice",
+            last_name="Liddell",
+            roles=["User"],
+        )
+        security_manager.find_user.return_value = user_obj
+        security_manager.update_user.return_value = False
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        patch_body = types.SimpleNamespace(
+            username=None,
+            email=None,
+            first_name=None,
+            last_name=None,
+            roles=None,
+            password=SecretStr("new-password"),
+        )
+
+        with pytest.raises(HTTPException) as exc:
+            FABAuthManagerUsers.update_user("alice", patch_body, 
update_mask="password")
+
+        assert exc.value.status_code == 500
+        security_manager.reset_user_sessions.assert_not_called()
+
     def test_update_user_not_found(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
         security_manager.find_user.return_value = None
         fab_auth_manager.security_manager = security_manager

Reply via email to