Coverage for src/local_deep_research/database/session_passwords.py: 92%

49 statements  

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

1""" 

2Session-based password storage for metrics access. 

3 

4This module provides a way to temporarily store passwords in memory 

5for the duration of a user's session, allowing background threads 

6to access encrypted databases for metrics writing. 

7 

8SECURITY NOTES: 

91. Passwords are only stored in memory, never on disk 

102. Passwords are stored in plain text in memory (encryption removed as it 

11 provided no real security benefit - see issue #593) 

123. Passwords are automatically cleared on logout 

134. This is only used for metrics/logging, not user data access 

14""" 

15 

16from typing import Optional 

17 

18from loguru import logger 

19 

20from .credential_store_base import CredentialStoreBase 

21 

22 

23class SessionPasswordStore(CredentialStoreBase): 

24 """ 

25 Stores passwords temporarily for active sessions. 

26 Used to allow background threads to write metrics to encrypted databases. 

27 """ 

28 

29 def __init__(self, ttl_hours: int = 24): 

30 """ 

31 Initialize the session password store. 

32 

33 Args: 

34 ttl_hours: How long to keep passwords (default 24 hours) 

35 """ 

36 super().__init__(ttl_hours * 3600) # Convert to seconds 

37 

38 def store_session_password( 

39 self, username: str, session_id: str, password: str 

40 ) -> None: 

41 """ 

42 Store a password for an active session. 

43 

44 Args: 

45 username: The username 

46 session_id: The Flask session ID 

47 password: The password to store 

48 """ 

49 key = (username, session_id) 

50 self._store_credentials( 

51 key, {"username": username, "password": password} 

52 ) 

53 logger.debug(f"Stored session password for {username}") 

54 

55 def get_session_password( 

56 self, username: str, session_id: str 

57 ) -> Optional[str]: 

58 """ 

59 Retrieve a password for an active session. 

60 

61 Args: 

62 username: The username 

63 session_id: The Flask session ID 

64 

65 Returns: 

66 The decrypted password or None if not found/expired 

67 """ 

68 key = (username, session_id) 

69 result = self._retrieve_credentials(key, remove=False) 

70 return result[1] if result else None 

71 

72 def clear_session(self, username: str, session_id: str) -> None: 

73 """ 

74 Clear password for a specific session (on logout). 

75 

76 Args: 

77 username: The username 

78 session_id: The Flask session ID 

79 """ 

80 key = (username, session_id) 

81 self.clear_entry(key) 

82 logger.debug(f"Cleared session password for {username}") 

83 

84 def get_any_session_password(self, username: str) -> Optional[str]: 

85 """ 

86 Retrieve a password from any active session for this user. 

87 

88 Used by FastAPI routes where the session_id is not easily 

89 accessible (no Flask request context). Checks all stored 

90 sessions for the user and returns the first valid password. 

91 

92 Args: 

93 username: The username 

94 

95 Returns: 

96 The decrypted password or None if not found/expired 

97 """ 

98 import time 

99 

100 with self._lock: 

101 for key in list(self._store.keys()): 

102 if key[0] == username: 

103 entry = self._store.get(key) 

104 if entry and time.time() <= entry["expires_at"]: 104 ↛ 106line 104 didn't jump to line 106 because the condition on line 104 was always true

105 return entry["password"] 

106 if entry: 

107 # Expired — clean up 

108 del self._store[key] 

109 return None 

110 

111 def clear_all_for_user(self, username: str) -> None: 

112 """Remove all stored passwords for a user (idle connection cleanup).""" 

113 with self._lock: 

114 keys_to_remove = [k for k in self._store if k[0] == username] 

115 for k in keys_to_remove: 

116 del self._store[k] 

117 

118 # Implement abstract methods for compatibility 

119 def store(self, username: str, session_id: str, password: str) -> None: 

120 """Alias for store_session_password.""" 

121 self.store_session_password(username, session_id, password) 

122 

123 def retrieve(self, username: str, session_id: str) -> Optional[str]: 

124 """Alias for get_session_password.""" 

125 return self.get_session_password(username, session_id) 

126 

127 

128# Global instance 

129session_password_store = SessionPasswordStore() 

130 

131 

132def capture_request_db_password(username: str) -> Optional[str]: 

133 """Best-effort capture of the encrypted-DB password from the request 

134 thread. 

135 

136 ``ThreadPoolExecutor.submit`` does NOT copy the calling thread's 

137 ``ContextVar`` snapshot, so background workers cannot recover the 

138 password through the usual request-context probes. Callers that need 

139 to hand a password to a worker (or to a service like the RAG factory 

140 that must open the encrypted DB to read index config) capture it here 

141 while still on the request thread and pass it explicitly. 

142 

143 Returns ``None`` if the password cannot be located — on encrypted-DB 

144 installs the caller should then skip the DB-touching work rather than 

145 fail loudly. 

146 """ 

147 try: 

148 from ..utilities.request_context import get_current_session_id 

149 

150 session_id = get_current_session_id() 

151 if not session_id: 

152 # No request context on this thread. Main returned None here, 

153 # and that contract matters: falling back to a username-wide 

154 # scan would hand a caller with no session whatsoever a live 

155 # password scavenged from any other open session. Callers that 

156 # legitimately need a password off-request must capture it on 

157 # the request thread and pass it explicitly. 

158 return None 

159 return session_password_store.get_session_password(username, session_id) 

160 except Exception: 

161 logger.debug( 

162 "Failed to capture DB password from request context", 

163 exc_info=True, 

164 ) 

165 return None