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
« 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.
4``get_user_password`` retrieves the user's password from the FastAPI session
5password store.
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.
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"""
21from typing import Optional, Tuple
23from loguru import logger
26def get_user_password(username: str) -> Optional[str]:
27 """Retrieve the user's database password for the current request.
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.
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 )
45 current_session_id = get_current_session_id()
46 if not current_session_id:
47 return None
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
59 return session_password_store.get_session_password( # gitleaks:allow
60 username, current_session_id
61 )
64def resolve_user_password(username: str) -> Tuple[Optional[str], bool]:
65 """Resolve the user's DB password for starting a research run.
67 Returns ``(password, session_expired)``:
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``.
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
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