Coverage for src/local_deep_research/web/auth/password_utils.py: 100%

21 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Shared utilities for resolving the current user's DB password under FastAPI. 

3 

4``get_user_password`` retrieves the user's password from the FastAPI session 

5password store. 

6 

7The Flask-era version of this helper had a 3-source fallback chain 

8(session_password_store → Flask g.user_password → temp_auth_store). 

9The Flask sources became dead after the FastAPI migration: 

10``has_request_context()`` always returns False without Flask running, 

11and there is no FastAPI middleware that mirrors password into a 

12contextvar-accessible "g". Under FastAPI, the password is written to 

13``session_password_store`` directly during login (auth.py) keyed by 

14``session_id``, so Source 1 covers every authenticated request path. 

15 

16``resolve_user_password`` builds on it with the encryption-aware guard the 

17research entry points share: it returns ``(password, session_expired)`` so 

18a caller can reject a run when an encrypted DB has no available password. 

19""" 

20 

21from typing import Optional, Tuple 

22 

23from loguru import logger 

24 

25 

26def get_user_password(username: str) -> Optional[str]: 

27 """Retrieve the user's database password for the current request. 

28 

29 Reads ``session_id`` from the contextvar populated by 

30 ``DatabaseMiddleware`` and looks up the password in 

31 ``session_password_store``. If the contextvar's username doesn't 

32 match the requested one (cross-user service call), returns None 

33 rather than widening to a different session. 

34 

35 Returns ``None`` when no password can be found — callers must decide 

36 whether that is acceptable (e.g. non-encrypted databases) or an 

37 error (encrypted databases → 401). 

38 """ 

39 from ...database.session_passwords import session_password_store 

40 from ...utilities.request_context import ( 

41 get_current_session_id, 

42 get_current_username, 

43 ) 

44 

45 current_session_id = get_current_session_id() 

46 if not current_session_id: 

47 return None 

48 

49 # If a service call passes a different username than what's in the 

50 # request context, don't use the contextvar's session_id — it 

51 # belongs to a different user. Returning None here is intentional: 

52 # widening to "any session for this user" would be a cross-session 

53 # leak (e.g. mid-password-change a stale password could be returned 

54 # for the wrong session). 

55 ctx_username = get_current_username() 

56 if ctx_username is not None and ctx_username != username: 

57 return None 

58 

59 return session_password_store.get_session_password( # gitleaks:allow 

60 username, current_session_id 

61 ) 

62 

63 

64def resolve_user_password(username: str) -> Tuple[Optional[str], bool]: 

65 """Resolve the user's DB password for starting a research run. 

66 

67 Returns ``(password, session_expired)``: 

68 

69 - ``session_expired`` is ``True`` only when the database is encrypted 

70 and no password is available. The caller MUST reject the request 

71 (e.g. a 401 telling the user to log back in) because the research's 

72 background DB and metric writes would otherwise be silently dropped 

73 (issue #4457) — research would appear to run while every metric write 

74 fails. Trigger: session-password-store TTL expiry or a server/ 

75 container restart while the session cookie is still valid. 

76 - For unencrypted databases ``session_expired`` is always ``False``; 

77 the returned ``password`` may legitimately be ``None``. 

78 

79 Centralises the guard that the direct (``/start_research``), follow-up, 

80 and chat research entry points all need, so the encryption-aware 

81 decision and its logging live in one place instead of being copied 

82 (and risking divergence) across routes. Each route still formats its 

83 own error response, because their frontends expect different shapes. 

84 """ 

85 from ...database.encrypted_db import db_manager 

86 

87 password = get_user_password(username) # gitleaks:allow 

88 if not password and db_manager.has_encryption: 

89 logger.error( 

90 f"No password available for user {username} with encrypted " 

91 "database - cannot start research (session password expired or " 

92 "lost after server restart)" 

93 ) 

94 return None, True 

95 if not password: 

96 logger.warning( 

97 f"No password available for metrics access for user {username} " 

98 "(unencrypted database)" 

99 ) 

100 return password, False