Coverage for src/local_deep_research/web/auth/password_utils.py: 100%
29 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2Shared utilities for resolving the current user's DB password.
4``get_user_password`` centralises the 3-source fallback chain (session
5password store → Flask g → temp auth store) so every route that needs the
6current user's DB password uses the same logic. Without this, each route
7reimplemented the chain independently, risking subtle divergence (e.g. one
8route forgetting to check temp_auth, or using a different method alias).
10``resolve_user_password`` builds on it with the encryption-aware guard the
11research entry points share: it returns ``(password, session_expired)`` so
12a caller can reject a run when an encrypted DB has no available password.
13"""
15from typing import Optional, Tuple
17from flask import g, session
18from loguru import logger
21def get_user_password(username: str) -> Optional[str]:
22 """Retrieve user password from available session sources.
24 Checks, in order:
25 1. SessionPasswordStore (persistent per-session passwords)
26 2. Flask ``g.user_password`` (set by middleware when temp_auth was used)
27 3. TempAuthStore (one-time tokens stored during login redirect)
29 Returns ``None`` when no password can be found — callers must decide
30 whether that is acceptable (e.g. non-encrypted databases) or an error
31 (encrypted databases → 401).
32 """
33 from ...database.session_passwords import session_password_store
35 session_id = session.get("session_id")
36 if session_id:
37 password = session_password_store.get_session_password(
38 username, session_id
39 )
40 if password:
41 return password
43 password = getattr(g, "user_password", None)
44 if password:
45 return password
47 from ...database.temp_auth import temp_auth_store
49 auth_token = session.get("temp_auth_token")
50 if auth_token:
51 auth_data = temp_auth_store.peek_auth(auth_token)
52 if auth_data and auth_data[0] == username:
53 return auth_data[1]
55 return None
58def resolve_user_password(username: str) -> Tuple[Optional[str], bool]:
59 """Resolve the user's DB password for starting a research run.
61 Returns ``(password, session_expired)``:
63 - ``session_expired`` is ``True`` only when the database is encrypted
64 and no password is available. The caller MUST reject the request
65 (e.g. a 401 telling the user to log back in) because the research's
66 background DB and metric writes would otherwise be silently dropped
67 (issue #4457) — research would appear to run while every metric write
68 fails. Trigger: session-password-store TTL expiry or a server/
69 container restart while the session cookie is still valid.
70 - For unencrypted databases ``session_expired`` is always ``False``;
71 the returned ``password`` may legitimately be ``None``.
73 Centralises the guard that the direct (``/start_research``), follow-up,
74 and chat research entry points all need, so the encryption-aware
75 decision and its logging live in one place instead of being copied
76 (and risking divergence) across routes. Each route still formats its
77 own error response, because their frontends expect different shapes.
78 """
79 from ...database.encrypted_db import db_manager
81 password = get_user_password(username) # gitleaks:allow
82 if not password and db_manager.has_encryption:
83 logger.error(
84 f"No password available for user {username} with encrypted "
85 "database - cannot start research (session password expired or "
86 "lost after server restart)"
87 )
88 return None, True
89 if not password:
90 logger.warning(
91 f"No password available for metrics access for user {username} "
92 "(unencrypted database)"
93 )
94 return password, False