Skip to content

Commit 188a744

Browse files
authored
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.
1 parent cdc5d9f commit 188a744

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,17 @@ def deserialize_user(self, token: dict[str, Any]) -> User:
292292
def _fetch_user() -> User:
293293
with create_session() as session:
294294
try:
295-
return session.scalars(select(User).where(User.id == user_id)).one()
295+
user = session.scalars(select(User).where(User.id == user_id)).one()
296296
except NoResultFound:
297297
raise ValueError(f"User with id {token['sub']} not found")
298+
# A token stays syntactically valid until it expires, so the account it
299+
# names has to be re-checked on every request rather than trusted from
300+
# the signature alone. ``is_active`` reads the nullable ``active``
301+
# column, and a null is treated as inactive here for the same reason it
302+
# is on the password path in ``auth_user_db``.
303+
if not user.is_active:
304+
raise ValueError(f"User with id {token['sub']} is not active")
305+
return user
298306

299307
try:
300308
return _fetch_user()

providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,36 @@ def test_deserialize_user(self, flask_app, auth_manager_with_appbuilder):
221221

222222
assert user.get_id() == result.get_id()
223223

224+
def test_deserialize_user_rejects_inactive_user(self, flask_app, auth_manager_with_appbuilder):
225+
"""A token naming a deactivated account must not resolve to a user."""
226+
user = create_user(flask_app, "test_inactive")
227+
auth_manager_with_appbuilder.cache.clear()
228+
229+
user.active = False
230+
auth_manager_with_appbuilder.session.commit()
231+
auth_manager_with_appbuilder.cache.clear()
232+
233+
with pytest.raises(ValueError, match=f"User with id {user.id} is not active"):
234+
auth_manager_with_appbuilder.deserialize_user({"sub": str(user.id)})
235+
236+
def test_deserialize_user_rejects_null_active(self, flask_app, auth_manager_with_appbuilder):
237+
"""``active`` is nullable; a null is treated as inactive, as on the password path."""
238+
user = create_user(flask_app, "test_null_active")
239+
user.active = None
240+
auth_manager_with_appbuilder.session.commit()
241+
auth_manager_with_appbuilder.cache.clear()
242+
243+
with pytest.raises(ValueError, match=f"User with id {user.id} is not active"):
244+
auth_manager_with_appbuilder.deserialize_user({"sub": str(user.id)})
245+
246+
def test_deserialize_user_accepts_active_user(self, flask_app, auth_manager_with_appbuilder):
247+
user = create_user(flask_app, "test_still_active")
248+
auth_manager_with_appbuilder.cache.clear()
249+
250+
result = auth_manager_with_appbuilder.deserialize_user({"sub": str(user.id)})
251+
252+
assert result.get_id() == user.get_id()
253+
224254
def test_deserialize_user_not_found(self, flask_app, auth_manager_with_appbuilder):
225255
"""Test that deserialize_user raises ValueError when the user does not exist."""
226256
non_existent_id = "99999"
@@ -256,13 +286,25 @@ def test_is_logged_in(self, mock_get_user, auth_manager_with_appbuilder):
256286

257287
@mock.patch.object(FabAuthManager, "get_user")
258288
def test_is_logged_in_with_inactive_user(self, mock_get_user, auth_manager_with_appbuilder):
289+
# ``is_anonymous`` and ``is_active`` are properties on the real model, so the
290+
# mock has to set attributes rather than ``return_value``; setting the latter
291+
# leaves a truthy Mock in place and the assertion passes regardless of state.
259292
user = Mock()
260-
user.is_anonymous.return_value = False
261-
user.is_active.return_value = True
293+
user.is_anonymous = False
294+
user.is_active = False
262295
mock_get_user.return_value = user
263296

264297
assert auth_manager_with_appbuilder.is_logged_in() is False
265298

299+
@mock.patch.object(FabAuthManager, "get_user")
300+
def test_is_logged_in_with_active_user(self, mock_get_user, auth_manager_with_appbuilder):
301+
user = Mock()
302+
user.is_anonymous = False
303+
user.is_active = True
304+
mock_get_user.return_value = user
305+
306+
assert auth_manager_with_appbuilder.is_logged_in() is True
307+
266308
@mock.patch.object(FabAuthManager, "get_user")
267309
def test_is_logged_in_with_auth_role_public(self, mock_get_user, flask_app, auth_manager_with_appbuilder):
268310
"""When ``AUTH_ROLE_PUBLIC`` is set on the Flask app, anonymous users are 'logged in'."""

0 commit comments

Comments
 (0)